返回 DeepSeek-Reasonix
complete_at.go
根目录 / internal / cli / complete_at.go
1 package cli
2
3 import (
4 "os"
5 "path/filepath"
6 "slices"
7 "sort"
8 "strings"
9
10 "reasonix/internal/control"
11 "reasonix/internal/fileref"
12 )
13
14 // activeAtToken finds the @-reference token under the cursor. cursor is a byte
15 // offset into val; when out of range the scan uses the end of the string.
16 // The '@' must start the line or follow whitespace, so emails like "a@b" don't
17 // trigger it. A backslash-escaped space or tab is part of the token.
18 //
19 // Returns (at, end, query, ok):
20 // - [at, end) is the full token span to replace on accept (including '@'),
21 // extending past the caret to the next unescaped whitespace so mid-token
22 // accept never leaves a dangling suffix ("@foo|bar" → "@file.md ", not
23 // "@file.mdbar").
24 // - query is only the text after '@' up to the caret, used for menu filtering
25 // ("@fo|o" filters as "fo", not "foo").
26 func activeAtToken(val string, cursor int) (at, end int, query string, ok bool) {
27 if cursor < 0 || cursor > len(val) {
28 cursor = len(val)
29 }
30 for i := cursor - 1; i >= 0; i-- {
31 switch val[i] {
32 case ' ', '\t':
33 if i > 0 && val[i-1] == '\\' {
34 i-- // escaped whitespace stays inside the token
35 continue
36 }
37 return 0, 0, "", false
38 case '\n':
39 return 0, 0, "", false
40 case '@':
41 if i == 0 || val[i-1] == ' ' || val[i-1] == '\t' || val[i-1] == '\n' {
42 end = tokenEnd(val, i+1)
43 queryEnd := min(max(cursor, i+1), end)
44 return i, end, val[i+1 : queryEnd], true
45 }
46 return 0, 0, "", false
47 }
48 }
49 return 0, 0, "", false
50 }
51
52 // tokenEnd returns the exclusive byte end of a path/ref token starting at from
53 // (just after '@'). Stops at unescaped whitespace or newline.
54 func tokenEnd(val string, from int) int {
55 for i := from; i < len(val); i++ {
56 switch val[i] {
57 case ' ', '\t':
58 if i > 0 && val[i-1] == '\\' {
59 continue
60 }
61 return i
62 case '\n':
63 return i
64 }
65 }
66 return len(val)
67 }
68
69 // atItems builds the @-reference menu for a token. A "server:uri" token whose
70 // server is connected lists that server's MCP resources; otherwise the token is
71 // a path and we list one directory level (never a recursive walk), plus — at the
72 // top level — any matching MCP resources.
73 func (m *chatTUI) atItems(token string) []compItem {
74 if i := strings.Index(token, ":"); i > 0 && m.isMCPServer(token[:i]) {
75 return m.resourceItems(token[:i], token[i+1:])
76 }
77 return m.fileItems(token)
78 }
79
80 // fileItems lists one directory level for a path token. dir is the part up to
81 // the last '/', frag the part after; entries of dir starting with frag are
82 // offered (directories descend, files complete). Hidden entries are skipped
83 // unless frag starts with '.'. Top-level tokens also surface MCP resources.
84 func (m *chatTUI) fileItems(token string) []compItem {
85 dir, frag := splitPathToken(token)
86 // The typed token may carry backslash-escaped spaces (the form completion
87 // itself inserts); filesystem lookups need the real path while inserts keep
88 // the escaped grammar.
89 fsFrag := control.UnescapeRefPath(frag)
90 workspaceRoot := ""
91 if m.ctrl != nil {
92 workspaceRoot = m.ctrl.WorkspaceRoot()
93 }
94 readDir := control.UnescapeRefPath(dir)
95 if workspaceRoot != "" {
96 if readDir == "" {
97 readDir = workspaceRoot
98 } else if !filepath.IsAbs(readDir) {
99 readDir = filepath.Join(workspaceRoot, filepath.FromSlash(readDir))
100 }
101 } else if readDir == "" {
102 readDir = "."
103 }
104 entries, err := os.ReadDir(readDir)
105 if err != nil {
106 entries = nil
107 }
108 // Directories first, then files; both groups sorted naturally.
109 sort.Slice(entries, func(i, j int) bool {
110 if entries[i].IsDir() != entries[j].IsDir() {
111 return entries[i].IsDir()
112 }
113 return fileref.NaturalLess(entries[i].Name(), entries[j].Name())
114 })
115
116 showHidden := strings.HasPrefix(fsFrag, ".")
117 var items []compItem
118 for _, e := range entries {
119 name := e.Name()
120 if !strings.HasPrefix(name, fsFrag) {
121 continue
122 }
123 if !showHidden && strings.HasPrefix(name, ".") {
124 continue
125 }
126 if e.IsDir() {
127 items = append(items, compItem{label: name + "/", insert: "@" + dir + control.EscapeRefPath(name) + "/", hint: "dir", descend: true})
128 } else {
129 items = append(items, compItem{label: name, insert: "@" + dir + control.EscapeRefPath(name)})
130 }
131 if len(items) >= maxCompItems {
132 break
133 }
134 }
135
136 // At the top level (still naming the first segment) MCP resources share the
137 // '@' namespace, so offer the matching ones too.
138 if !strings.Contains(token, "/") {
139 seen := map[string]bool{}
140 for _, it := range items {
141 seen[strings.TrimPrefix(it.insert, "@")] = true
142 }
143 remaining := min(maxCompItems-len(items), maxFileSearchItems)
144 results := m.searchFileRefs(fsFrag)
145 if len(results) > remaining {
146 results = results[:remaining]
147 }
148 for _, path := range results {
149 escaped := control.EscapeRefPath(path)
150 if seen[escaped] {
151 continue
152 }
153 items = append(items, compItem{label: path, insert: "@" + escaped, hint: "file"})
154 if len(items) >= maxCompItems {
155 break
156 }
157 }
158 items = append(items, m.resourceItems("", token)...)
159 }
160 return items
161 }
162
163 // searchFileRefs memoizes the bounded basename walk so re-rendering the menu
164 // for an unchanged @token fragment doesn't re-walk the workspace each keystroke.
165 func (m *chatTUI) searchFileRefs(frag string) []string {
166 if m.fileSearchCache == nil {
167 m.fileSearchCache = map[string][]string{}
168 }
169 if r, ok := m.fileSearchCache[frag]; ok {
170 return r
171 }
172 searchRoot := "."
173 if m.ctrl != nil {
174 if wr := m.ctrl.WorkspaceRoot(); wr != "" {
175 searchRoot = wr
176 }
177 }
178 results := fileref.Search(searchRoot, frag, maxFileSearchItems)
179 paths := make([]string, 0, len(results))
180 for _, r := range results {
181 paths = append(paths, r.Path)
182 }
183 m.fileSearchCache[frag] = paths
184 return paths
185 }
186
187 // splitPathToken splits a path token into (dir, frag): dir keeps its trailing
188 // slash ("internal/" ), frag is the segment being typed.
189 func splitPathToken(token string) (dir, frag string) {
190 if i := strings.LastIndex(token, "/"); i >= 0 {
191 return token[:i+1], token[i+1:]
192 }
193 return "", token
194 }
195
196 // isMCPServer reports whether name is a connected MCP server.
197 func (m *chatTUI) isMCPServer(name string) bool {
198 if m.host == nil {
199 return false
200 }
201 return slices.Contains(m.host.ServerNames(), name)
202 }
203
204 // resourceItems lists MCP resources as @server:uri completions. When server is
205 // "" (top level) it matches by the whole "server:uri" prefix; otherwise it lists
206 // the named server's resources filtered by the uri prefix.
207 func (m *chatTUI) resourceItems(server, frag string) []compItem {
208 if m.host == nil {
209 return nil
210 }
211 var items []compItem
212 for _, r := range m.host.Resources() {
213 ref := r.Server + ":" + r.URI
214 switch {
215 case server == "":
216 if !strings.HasPrefix(ref, frag) {
217 continue
218 }
219 case r.Server == server:
220 if !strings.HasPrefix(r.URI, frag) {
221 continue
222 }
223 default:
224 continue
225 }
226 label := r.Name
227 if label == "" {
228 label = "resource"
229 }
230 items = append(items, compItem{label: "@" + ref, insert: "@" + ref, hint: label})
231 }
232 return items
233 }
234
234 lines GO