| 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 |