返回 DeepSeek-Reasonix
complete.go
根目录 / internal / cli / complete.go
1 package cli
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "sort"
8 "strings"
9
10 "charm.land/lipgloss/v2"
11 rw "github.com/mattn/go-runewidth"
12
13 "reasonix/internal/control"
14 "reasonix/internal/fileref"
15 "reasonix/internal/i18n"
16 "reasonix/internal/plugin"
17 "reasonix/internal/skill"
18 )
19
20 // compKind distinguishes the two completion menus.
21 type compKind int
22
23 const (
24 compSlash compKind = iota // slash command names, while the line is a bare "/word"
25 compSlashArg // a structured argument of a slash command (e.g. "/mcp remove <name>")
26 compAt // @-references (files / MCP resources)
27 )
28
29 // compItem is one menu row: label shown, insert applied on accept, hint dimmed.
30 // descend marks a directory entry — accepting it fills the input and re-opens
31 // the menu one level deeper instead of closing.
32 type compItem struct {
33 label string
34 insert string
35 hint string
36 descend bool
37 }
38
39 // completion is the live autocomplete menu state. Empty value = inactive.
40 // replaceFrom/replaceTo are byte offsets of the token span that accept replaces
41 // (half-open [replaceFrom, replaceTo)). For a bare slash name, replaceFrom is 0
42 // and replaceTo is len(value). For @-refs, replaceFrom is the '@' and replaceTo
43 // is the first unescaped whitespace after the token (or end of input).
44 type completion struct {
45 active bool
46 kind compKind
47 items []compItem
48 sel int
49 replaceFrom int
50 replaceTo int
51 }
52
53 const (
54 // maxCompRows caps how many menu rows show at once; the list windows around
55 // the selection when longer.
56 maxCompRows = 8
57 // maxCompItems caps how many entries a single directory contributes, so a
58 // pathologically large directory can't blow up the menu — we read only one
59 // level (os.ReadDir), never the whole tree.
60 maxCompItems = 200
61 // maxFileSearchItems caps basename search results for bare @tokens.
62 maxFileSearchItems = 20
63 )
64
65 // slashItems returns the cached slash catalog. Rebuilds only after
66 // invalidateSlashCatalog — never on ordinary keystrokes.
67 func (m *chatTUI) slashItems() []compItem {
68 if m.slashCatalogOnce && m.slashCatalog != nil {
69 return m.slashCatalog
70 }
71 items := m.buildSlashCatalog()
72 // Immutable snapshot so keystroke filtering never mutates shared state.
73 out := make([]compItem, len(items))
74 copy(out, items)
75 m.slashCatalog = out
76 m.slashCatalogOnce = true
77 return m.slashCatalog
78 }
79
80 // invalidateSlashCatalog drops the cached catalog so the next slashItems call
81 // rebuilds it. Call from model switch, skill rescan, /reload-cmd, and any path
82 // that mutates commands/skills/host/extension actions.
83 func (m *chatTUI) invalidateSlashCatalog() {
84 m.slashCatalogOnce = false
85 m.slashCatalog = nil
86 }
87
88 // refreshHostAndInvalidateSlashCatalog reloads m.host from the controller and
89 // drops the slash catalog so MCP prompts (and any host-backed menu entries)
90 // rebuild on the next slashItems call. Use after connect/disconnect/remove/
91 // import, MCPSurfaceReady, auth clear, and every other host mutation path.
92 func (m *chatTUI) refreshHostAndInvalidateSlashCatalog() {
93 if m.ctrl != nil {
94 m.host = m.ctrl.Host()
95 }
96 m.invalidateSlashCatalog()
97 }
98
99 // setHostAndInvalidateSlashCatalog assigns a host pointer (e.g. from a model-
100 // switch message) and invalidates the slash catalog.
101 func (m *chatTUI) setHostAndInvalidateSlashCatalog(host *plugin.Host) {
102 m.host = host
103 m.invalidateSlashCatalog()
104 }
105
106 // buildSlashCatalog constructs the full slash menu from current sources.
107 func (m *chatTUI) buildSlashCatalog() []compItem {
108 docsOwner := control.ResolveSlashCommandOwner(control.DocsSlashName, m.commands, m.skills)
109 docsBuiltin := "/" + control.ResolvedBuiltinSlashName(control.DocsSlashName, m.commands, m.skills)
110 items := renameSlashItem(builtinSlashItems(), "/docs", docsBuiltin)
111 for _, c := range m.commands {
112 if c.Hidden {
113 continue
114 }
115 items = append(items, compItem{label: "/" + c.Name, insert: "/" + c.Name + " ", hint: customCommandHint(c)})
116 }
117 for _, s := range m.skills {
118 if docsOwner == control.SlashOwnerCustom && s.SlashName() == control.DocsSlashName {
119 continue
120 }
121 hint := s.Description
122 if s.RunAs == skill.RunSubagent {
123 hint = "🧬 " + hint
124 }
125 items = append(items, compItem{label: "/" + s.SlashName(), insert: "/" + s.SlashName() + " ", hint: skillCommandHint(s, hint)})
126 }
127 for _, p := range m.prompts() {
128 items = append(items, compItem{label: "/" + p.Name, insert: "/" + p.Name + " ", hint: p.Description})
129 }
130 if m.ctrl != nil {
131 for _, a := range m.ctrl.ExtensionActions() {
132 items = append(items, compItem{label: a.Slash, insert: a.Slash + " ", hint: extensionActionHint(a)})
133 }
134 }
135 return items
136 }
137
138 func renameSlashItem(items []compItem, oldLabel, newLabel string) []compItem {
139 if oldLabel == newLabel {
140 return items
141 }
142 for i := range items {
143 if items[i].label != oldLabel {
144 continue
145 }
146 items[i].label = newLabel
147 if strings.HasPrefix(items[i].insert, oldLabel) {
148 items[i].insert = newLabel + strings.TrimPrefix(items[i].insert, oldLabel)
149 }
150 break
151 }
152 return items
153 }
154
155 func removeSlashItems(items []compItem, label string) []compItem {
156 out := make([]compItem, 0, len(items))
157 for _, item := range items {
158 if item.label != label {
159 out = append(out, item)
160 }
161 }
162 return out
163 }
164
165 // updateCompletion recomputes the menu from the current input: a slash menu
166 // while the line is a single "/word" token, or an @-reference menu while the
167 // token under the cursor is "@…".
168 func (m *chatTUI) updateCompletion() {
169 val := m.input.Value()
170 cursor := m.inputCursorByteOffset()
171
172 // An @-reference token under the cursor wins — it can appear mid-line, even
173 // inside a slash command's arguments (e.g. "/review @file").
174 if at, end, token, ok := activeAtToken(val, cursor); ok {
175 if items := m.atItems(token); len(items) > 0 {
176 m.setCompletion(compAt, items, at, end)
177 return
178 }
179 }
180
181 // Slash completion only when the line is a pure slash command being typed
182 // from the start (not mid-line after free text). Use the full value so a
183 // mid-token cursor still filters the catalog without rewriting the line.
184 if strings.HasPrefix(val, "/") {
185 if items, from, ok := m.explicitSubcommandItems(val); ok && len(items) > 0 {
186 m.setCompletion(compSlashArg, items, from, tokenEnd(val, from))
187 return
188 }
189 if !strings.ContainsAny(val, " \t\n") {
190 // Still naming the command itself. Catalog is cached; filter is cheap.
191 if items := fuzzyFilterSlash(m.slashItems(), val); len(items) > 0 {
192 m.setCompletion(compSlash, items, 0, len(val))
193 return
194 }
195 } else if m.bareSubcommandSpace(val) {
196 m.completion = completion{}
197 return
198 } else if items, from, ok := m.slashArgItems(val); ok && len(items) > 0 {
199 // Past the command word — complete its structured arguments.
200 m.setCompletion(compSlashArg, items, from, tokenEnd(val, from))
201 return
202 }
203 }
204
205 m.completion = completion{}
206 }
207
208 // inputCursorByteOffset returns the byte offset of the insertion caret in
209 // input.Value(). Falls back to len(Value) when layout is unavailable so
210 // completion still works in unit tests that never size the window.
211 func (m *chatTUI) inputCursorByteOffset() int {
212 val := m.input.Value()
213 if val == "" {
214 return 0
215 }
216 // Prefer the visual-row model used by mouse selection: it maps the caret
217 // to a stable rune offset into Value().
218 if m.width > 0 {
219 rows := m.composerRows()
220 if len(rows) > 0 {
221 if cur := m.input.Cursor(); cur != nil {
222 absRow := m.input.ScrollYOffset() + cur.Y
223 if absRow >= 0 && absRow < len(rows) {
224 row := rows[absRow]
225 // cur.X is screen-relative and includes the "❯ " prompt
226 // gutter (composerPromptWidth columns). Subtract it so
227 // we measure content columns only.
228 col := cur.X - composerPromptWidth
229 if col < 0 {
230 col = 0
231 }
232 visual := 0
233 for _, cell := range row.cells {
234 w := rw.RuneWidth(cell.r)
235 if visual+w > col {
236 if cell.offset >= 0 {
237 // cell.offset is a rune index into Value.
238 return runeOffsetToByte(val, cell.offset)
239 }
240 break
241 }
242 visual += w
243 }
244 if row.endOffset >= 0 {
245 return runeOffsetToByte(val, row.endOffset)
246 }
247 }
248 }
249 }
250 }
251 return len(val)
252 }
253
254 // runeOffsetToByte converts a rune index into Value() into a byte index.
255 func runeOffsetToByte(val string, runeOff int) int {
256 if runeOff <= 0 {
257 return 0
258 }
259 i := 0
260 for ri := range val {
261 if i == runeOff {
262 return ri
263 }
264 i++
265 }
266 return len(val)
267 }
268
269 // slashArgItems completes the arguments of a slash command (everything after the
270 // command word). It returns the menu items, the byte offset where the current
271 // token begins (replaceFrom, so accept replaces just that token), and whether
272 // anything applied. Only commands with structured arguments participate —
273 // currently /mcp; custom commands and MCP prompts take free-form template args,
274 // so they yield nothing.
275 func (m *chatTUI) slashArgItems(val string) ([]compItem, int, bool) {
276 if items, from, ok := m.workModeArgItems(val); ok {
277 return items, from, len(items) > 0
278 }
279 if items, from, ok := m.branchArgItems(val); ok {
280 return items, from, len(items) > 0
281 }
282 if items, from, ok := m.resumeArgItems(val); ok {
283 return items, from, len(items) > 0
284 }
285 if items, from, ok := m.themeArgItems(val); ok {
286 return items, from, len(items) > 0
287 }
288 // Delegate to the shared completion logic so the chat TUI and the desktop
289 // offer identical sub-command hints. We supply the data from the TUI's own
290 // cached lists (no live controller needed), build the items, and adapt them
291 // to compItem.
292 items, from := control.SlashArgItems(val, m.slashArgData())
293 if len(items) == 0 {
294 return nil, 0, false
295 }
296 return slashItemsToComps(items), from, true
297 }
298
299 func (m *chatTUI) slashArgData() control.ArgData {
300 curProvider := ""
301 if parts := strings.SplitN(m.modelRef, "/", 2); len(parts) == 2 {
302 curProvider = parts[0]
303 }
304 data := control.ArgData{
305 Skills: m.skills,
306 ModelRefs: modelRefs(),
307 CurrentModel: m.modelRef,
308 ProviderNames: providerNames(),
309 CurrentProvider: curProvider,
310 PluginNames: pluginArgNames(),
311 }
312 if m.ctrl != nil {
313 data.DisabledSkills = m.ctrl.DisabledSkills()
314 data.ConfiguredMCP = m.ctrl.ConfiguredMCPNames()
315 data.DisconnectedMCP = m.ctrl.DisconnectedMCPNames()
316 data.MemoryRefs, data.MemoryArchives = control.MemoryCompletionData(m.ctrl.Memory())
317 }
318 if m.host != nil {
319 data.ServerNames = m.host.ServerNames()
320 }
321 return data
322 }
323
324 func (m *chatTUI) explicitSubcommandItems(val string) ([]compItem, int, bool) {
325 cmd, ok := strings.CutSuffix(val, "?")
326 if !ok {
327 return nil, 0, false
328 }
329 switch cmd {
330 case "/mcp", "/skill", "/skills", "/plugin", "/plugins", "/memory":
331 default:
332 return nil, 0, false
333 }
334 items, _ := control.SlashArgItems(cmd+" ", m.slashArgData())
335 if len(items) == 0 {
336 return nil, 0, false
337 }
338 out := slashItemsToComps(items)
339 for i := range out {
340 out[i].insert = " " + out[i].insert
341 }
342 return out, len(cmd), true
343 }
344
345 func (m *chatTUI) bareSubcommandSpace(val string) bool {
346 if !strings.ContainsAny(val, " \t") || strings.TrimRight(val, " \t") == val {
347 return false
348 }
349 fields := strings.Fields(val)
350 if len(fields) != 1 {
351 return false
352 }
353 switch fields[0] {
354 case "/mcp", "/skill", "/skills", "/plugin", "/plugins", "/memory":
355 return true
356 default:
357 return false
358 }
359 }
360
361 func slashItemsToComps(items []control.SlashItem) []compItem {
362 out := make([]compItem, len(items))
363 for i, it := range items {
364 out[i] = compItem{label: it.Label, insert: it.Insert, hint: it.Hint, descend: it.Descend}
365 }
366 return out
367 }
368
369 func (m *chatTUI) branchArgItems(val string) ([]compItem, int, bool) {
370 cmdEnd := strings.IndexAny(val, " \t")
371 if cmdEnd < 0 || val[:cmdEnd] != "/switch" {
372 return nil, 0, false
373 }
374 from := strings.LastIndexAny(val, " \t") + 1
375 prior := strings.Fields(val[:from])
376 if len(prior) != 1 || m.ctrl == nil {
377 return nil, from, true
378 }
379 branches, err := m.ctrl.Branches()
380 // Branches snapshots first, which can retarget the controller to a
381 // recovery branch; keep the lease on whatever the controller now owns.
382 m.followSessionLease()
383 if err != nil {
384 return nil, from, true
385 }
386 cur := strings.ToLower(val[from:])
387 var out []compItem
388 for _, b := range branches {
389 label := b.ID
390 if cur != "" && !strings.HasPrefix(strings.ToLower(label), cur) &&
391 !strings.HasPrefix(strings.ToLower(b.Name), cur) {
392 continue
393 }
394 hint := b.Name
395 if hint == "" {
396 hint = b.Preview
397 }
398 if hint != "" {
399 hint = fmt.Sprintf("%d turns · %s", b.Turns, hint)
400 }
401 out = append(out, compItem{label: label, insert: label, hint: hint})
402 }
403 return out, from, true
404 }
405
406 // setCompletion installs items, preserving the selection index only while the
407 // same menu kind stays open. replaceFrom/replaceTo form a half-open byte span
408 // of the token that acceptCompletion will replace.
409 func (m *chatTUI) setCompletion(kind compKind, items []compItem, replaceFrom, replaceTo int) {
410 sel := 0
411 if m.completion.active && m.completion.kind == kind && m.completion.sel < len(items) {
412 sel = m.completion.sel
413 }
414 if replaceTo < replaceFrom {
415 replaceTo = replaceFrom
416 }
417 m.completion = completion{
418 active: true, kind: kind, items: items, sel: sel,
419 replaceFrom: replaceFrom, replaceTo: replaceTo,
420 }
421 }
422
423 // fuzzyFilterSlash returns the slash-menu items that match query as a
424 // case-insensitive subsequence of their label, with prefix hits ranked first
425 // (each group preserved in the input order from slashItems). An empty query
426 // matches everything — the same behavior the old prefix filter had, since
427 // every label trivially starts with "". A query that matches nothing returns
428 // nil so the caller can fall through and close the menu.
429 func fuzzyFilterSlash(items []compItem, query string) []compItem {
430 if query == "" {
431 out := make([]compItem, len(items))
432 copy(out, items)
433 return out
434 }
435 lq := strings.ToLower(query)
436 var prefix, rest []compItem
437 for _, it := range items {
438 l := strings.ToLower(it.label)
439 switch {
440 case strings.HasPrefix(l, lq):
441 prefix = append(prefix, it)
442 case subsequenceMatch(l, lq):
443 rest = append(rest, it)
444 }
445 }
446 if len(prefix) == 0 && len(rest) == 0 {
447 return nil
448 }
449 out := make([]compItem, 0, len(prefix)+len(rest))
450 out = append(out, prefix...)
451 out = append(out, rest...)
452 return out
453 }
454
455 // subsequenceMatch reports whether query appears in target as a case-folded
456 // subsequence (each rune of query in order, not necessarily contiguous). It is
457 // the matcher behind the slash-menu fuzzy filter: typing "/modl" matches
458 // "/model", "/memory", or any other label where m-o-d-l appear in that order.
459 // Callers must pass already case-folded strings; an empty query matches
460 // every target, so callers that want a "no match" signal on the empty input
461 // should check that first.
462 func subsequenceMatch(target, query string) bool {
463 if query == "" {
464 return true
465 }
466 qr := []rune(query)
467 ti := 0
468 for _, r := range target {
469 if r == qr[ti] {
470 ti++
471 if ti == len(qr) {
472 return true
473 }
474 }
475 }
476 return false
477 }
478
479 // activeAtToken finds the @-reference token under the cursor. cursor is a byte
480 // offset into val; when out of range the scan uses the end of the string.
481 // The '@' must start the line or follow whitespace, so emails like "a@b" don't
482 // trigger it. A backslash-escaped space or tab is part of the token.
483 //
484 // Returns (at, end, query, ok):
485 // - [at, end) is the full token span to replace on accept (including '@'),
486 // extending past the caret to the next unescaped whitespace so mid-token
487 // accept never leaves a dangling suffix ("@foo|bar" → "@file.md ", not
488 // "@file.mdbar").
489 // - query is only the text after '@' up to the caret, used for menu filtering
490 // ("@fo|o" filters as "fo", not "foo").
491 func activeAtToken(val string, cursor int) (at, end int, query string, ok bool) {
492 if cursor < 0 || cursor > len(val) {
493 cursor = len(val)
494 }
495 for i := cursor - 1; i >= 0; i-- {
496 switch val[i] {
497 case ' ', '\t':
498 if i > 0 && val[i-1] == '\\' {
499 i-- // escaped whitespace stays inside the token
500 continue
501 }
502 return 0, 0, "", false
503 case '\n':
504 return 0, 0, "", false
505 case '@':
506 if i == 0 || val[i-1] == ' ' || val[i-1] == '\t' || val[i-1] == '\n' {
507 end = tokenEnd(val, i+1)
508 queryEnd := cursor
509 if queryEnd < i+1 {
510 queryEnd = i + 1
511 }
512 if queryEnd > end {
513 queryEnd = end
514 }
515 return i, end, val[i+1 : queryEnd], true
516 }
517 return 0, 0, "", false
518 }
519 }
520 return 0, 0, "", false
521 }
522
523 // tokenEnd returns the exclusive byte end of a path/ref token starting at from
524 // (just after '@'). Stops at unescaped whitespace or newline.
525 func tokenEnd(val string, from int) int {
526 for i := from; i < len(val); i++ {
527 switch val[i] {
528 case ' ', '\t':
529 if i > 0 && val[i-1] == '\\' {
530 continue
531 }
532 return i
533 case '\n':
534 return i
535 }
536 }
537 return len(val)
538 }
539
540 // atItems builds the @-reference menu for a token. A "server:uri" token whose
541 // server is connected lists that server's MCP resources; otherwise the token is
542 // a path and we list one directory level (never a recursive walk), plus — at the
543 // top level — any matching MCP resources.
544 func (m *chatTUI) atItems(token string) []compItem {
545 if i := strings.Index(token, ":"); i > 0 && m.isMCPServer(token[:i]) {
546 return m.resourceItems(token[:i], token[i+1:])
547 }
548 return m.fileItems(token)
549 }
550
551 // fileItems lists one directory level for a path token. dir is the part up to
552 // the last '/', frag the part after; entries of dir starting with frag are
553 // offered (directories descend, files complete). Hidden entries are skipped
554 // unless frag starts with '.'. Top-level tokens also surface MCP resources.
555 func (m *chatTUI) fileItems(token string) []compItem {
556 dir, frag := splitPathToken(token)
557 // The typed token may carry backslash-escaped spaces (the form completion
558 // itself inserts); filesystem lookups need the real path while inserts keep
559 // the escaped grammar.
560 fsFrag := control.UnescapeRefPath(frag)
561 workspaceRoot := ""
562 if m.ctrl != nil {
563 workspaceRoot = m.ctrl.WorkspaceRoot()
564 }
565 readDir := control.UnescapeRefPath(dir)
566 if workspaceRoot != "" {
567 if readDir == "" {
568 readDir = workspaceRoot
569 } else if !filepath.IsAbs(readDir) {
570 readDir = filepath.Join(workspaceRoot, filepath.FromSlash(readDir))
571 }
572 } else if readDir == "" {
573 readDir = "."
574 }
575 entries, err := os.ReadDir(readDir)
576 if err != nil {
577 entries = nil
578 }
579 // Directories first, then files; ReadDir is already name-sorted.
580 sort.SliceStable(entries, func(i, j int) bool {
581 return entries[i].IsDir() && !entries[j].IsDir()
582 })
583
584 showHidden := strings.HasPrefix(fsFrag, ".")
585 var items []compItem
586 for _, e := range entries {
587 name := e.Name()
588 if !strings.HasPrefix(name, fsFrag) {
589 continue
590 }
591 if !showHidden && strings.HasPrefix(name, ".") {
592 continue
593 }
594 if e.IsDir() {
595 items = append(items, compItem{label: name + "/", insert: "@" + dir + control.EscapeRefPath(name) + "/", hint: "dir", descend: true})
596 } else {
597 items = append(items, compItem{label: name, insert: "@" + dir + control.EscapeRefPath(name)})
598 }
599 if len(items) >= maxCompItems {
600 break
601 }
602 }
603
604 // At the top level (still naming the first segment) MCP resources share the
605 // '@' namespace, so offer the matching ones too.
606 if !strings.Contains(token, "/") {
607 seen := map[string]bool{}
608 for _, it := range items {
609 seen[strings.TrimPrefix(it.insert, "@")] = true
610 }
611 remaining := maxCompItems - len(items)
612 if remaining > maxFileSearchItems {
613 remaining = maxFileSearchItems
614 }
615 results := m.searchFileRefs(fsFrag)
616 if len(results) > remaining {
617 results = results[:remaining]
618 }
619 for _, path := range results {
620 escaped := control.EscapeRefPath(path)
621 if seen[escaped] {
622 continue
623 }
624 items = append(items, compItem{label: path, insert: "@" + escaped, hint: "file"})
625 if len(items) >= maxCompItems {
626 break
627 }
628 }
629 items = append(items, m.resourceItems("", token)...)
630 }
631 return items
632 }
633
634 // searchFileRefs memoizes the bounded basename walk so re-rendering the menu
635 // for an unchanged @token fragment doesn't re-walk the workspace each keystroke.
636 func (m *chatTUI) searchFileRefs(frag string) []string {
637 if m.fileSearchCache == nil {
638 m.fileSearchCache = map[string][]string{}
639 }
640 if r, ok := m.fileSearchCache[frag]; ok {
641 return r
642 }
643 searchRoot := "."
644 if m.ctrl != nil {
645 if wr := m.ctrl.WorkspaceRoot(); wr != "" {
646 searchRoot = wr
647 }
648 }
649 results := fileref.Search(searchRoot, frag, maxFileSearchItems)
650 paths := make([]string, 0, len(results))
651 for _, r := range results {
652 paths = append(paths, r.Path)
653 }
654 m.fileSearchCache[frag] = paths
655 return paths
656 }
657
658 // splitPathToken splits a path token into (dir, frag): dir keeps its trailing
659 // slash ("internal/" ), frag is the segment being typed.
660 func splitPathToken(token string) (dir, frag string) {
661 if i := strings.LastIndex(token, "/"); i >= 0 {
662 return token[:i+1], token[i+1:]
663 }
664 return "", token
665 }
666
667 // isMCPServer reports whether name is a connected MCP server.
668 func (m *chatTUI) isMCPServer(name string) bool {
669 if m.host == nil {
670 return false
671 }
672 for _, s := range m.host.ServerNames() {
673 if s == name {
674 return true
675 }
676 }
677 return false
678 }
679
680 // resourceItems lists MCP resources as @server:uri completions. When server is
681 // "" (top level) it matches by the whole "server:uri" prefix; otherwise it lists
682 // the named server's resources filtered by the uri prefix.
683 func (m *chatTUI) resourceItems(server, frag string) []compItem {
684 if m.host == nil {
685 return nil
686 }
687 var items []compItem
688 for _, r := range m.host.Resources() {
689 ref := r.Server + ":" + r.URI
690 switch {
691 case server == "":
692 if !strings.HasPrefix(ref, frag) {
693 continue
694 }
695 case r.Server == server:
696 if !strings.HasPrefix(r.URI, frag) {
697 continue
698 }
699 default:
700 continue
701 }
702 label := r.Name
703 if label == "" {
704 label = "resource"
705 }
706 items = append(items, compItem{label: "@" + ref, insert: "@" + ref, hint: label})
707 }
708 return items
709 }
710
711 // moveCompletion advances the selection by delta, wrapping around.
712 func (m *chatTUI) moveCompletion(delta int) {
713 n := len(m.completion.items)
714 if n == 0 {
715 return
716 }
717 m.completion.sel = ((m.completion.sel+delta)%n + n) % n
718 }
719
720 func (m *chatTUI) completionExactLabel() bool {
721 if !m.completion.active || m.completion.sel >= len(m.completion.items) {
722 return false
723 }
724 val := strings.TrimSpace(m.input.Value())
725 return val == m.completion.items[m.completion.sel].label
726 }
727
728 func (m *chatTUI) completionBareOverlayCommand() bool {
729 switch strings.TrimSpace(m.input.Value()) {
730 case "/mcp", "/skills":
731 return true
732 default:
733 return false
734 }
735 }
736
737 func (m *chatTUI) completionSelectedInsertPresent() bool {
738 if !m.completion.active || m.completion.sel >= len(m.completion.items) {
739 return false
740 }
741 val := m.input.Value()
742 rf, rt := m.completion.replaceFrom, m.completion.replaceTo
743 if rf < 0 || rf > len(val) {
744 return false
745 }
746 if rt < rf || rt > len(val) {
747 rt = len(val)
748 }
749 return val[rf:rt] == m.completion.items[m.completion.sel].insert
750 }
751
752 // acceptCompletion applies the selected item to the input, then recomputes the
753 // menu from the new value: it re-opens one level deeper (a descended directory
754 // or a freshly completed command's arguments) or closes when nothing applies.
755 // Cursor moves to the end of the inserted token only on accept — ordinary
756 // keystrokes never call this path, so mid-line typing keeps its caret.
757 func (m *chatTUI) acceptCompletion() {
758 if m.completion.sel >= len(m.completion.items) {
759 m.completion = completion{}
760 return
761 }
762 it := m.completion.items[m.completion.sel]
763 val := m.input.Value()
764 rf := m.completion.replaceFrom
765 rt := m.completion.replaceTo
766 if rf < 0 || rf > len(val) {
767 rf = 0
768 }
769 // replaceTo must be set by setCompletion. Hand-built test completions may
770 // leave it at 0; treat inverted/empty whole-line spans as "to end".
771 if rt < rf || rt > len(val) {
772 rt = len(val)
773 } else if rt == rf && rf == 0 && len(val) > 0 {
774 // Bare slash replace with unset replaceTo: replace the whole line.
775 rt = len(val)
776 }
777 // Replace the full token span [rf, rt); keep any suffix after the token
778 // so "see @foo and more" + accept @foobar.md becomes
779 // "see @foobar.md and more" (not "@foobar.mdand" or "@foobar.mdfoo").
780 newVal := val[:rf] + it.insert + val[rt:]
781 insertEnd := rf + len(it.insert)
782 m.input.SetValue(newVal)
783 // Place caret at the end of the inserted completion only. Fall back to
784 // CursorEnd when the layout has no width yet (unit tests).
785 if m.width > 0 {
786 m.setComposerCursor(len([]rune(newVal[:min(insertEnd, len(newVal))])))
787 } else {
788 m.input.CursorEnd()
789 }
790 if it.descend || strings.HasSuffix(it.insert, " ") {
791 m.updateCompletion()
792 return
793 }
794 m.updateCompletion() // re-filter for arg completion (e.g. /resume → numbered sessions)
795 // If the completion re-opened with the same single item the user just
796 // selected (i.e. the token was already typed), close it so the next Enter
797 // submits the command rather than being captured again by acceptCompletion.
798 if m.completion.active && len(m.completion.items) == 1 {
799 rf, rt := m.completion.replaceFrom, m.completion.replaceTo
800 val := m.input.Value()
801 if rf >= 0 && rf <= len(val) && rt >= rf && rt <= len(val) {
802 if val[rf:rt] == m.completion.items[0].insert {
803 m.completion = completion{}
804 }
805 }
806 }
807 }
808
809 var compSelStyle lipgloss.Style
810
811 const completionPadCell = "\u00a0"
812
813 // padCompletionLine pads completion rows with NBSPs instead of ASCII spaces.
814 // Ultraviolet treats trailing ASCII spaces as clearable cells and may emit EL
815 // or ECH erase sequences; mintty can leave stale CJK glyph cells after those
816 // erases. NBSP is visually blank but forces the renderer to overwrite cells.
817 func padCompletionLine(s string, w int) string {
818 pad := w - visibleWidth(s)
819 if pad <= 0 {
820 return s
821 }
822 return s + strings.Repeat(completionPadCell, pad)
823 }
824
825 // renderCompletion draws the menu above the input box: matching items, windowed
826 // around the selection, the current row highlighted, hints dimmed. Every line is
827 // padded to m.width with non-clearable blank cells so bubbletea's delta renderer
828 // has no ordinary trailing-space run to collapse into EL/ECH erase sequences.
829 // That avoids ghost cells on terminals (mintty) with unreliable erases after
830 // wide CJK glyphs.
831 func (m chatTUI) renderCompletion() string {
832 if !m.completion.active || len(m.completion.items) == 0 {
833 return ""
834 }
835 items := m.completion.items
836 start := 0
837 if len(items) > maxCompRows {
838 start = m.completion.sel - maxCompRows/2
839 if start < 0 {
840 start = 0
841 }
842 if start > len(items)-maxCompRows {
843 start = len(items) - maxCompRows
844 }
845 }
846 end := start + maxCompRows
847 if end > len(items) {
848 end = len(items)
849 }
850
851 var b strings.Builder
852 for i := start; i < end; i++ {
853 it := items[i]
854 var line string
855 if i == m.completion.sel {
856 line = accent("› ") + compSelStyle.Render(it.label)
857 } else {
858 line = " " + it.label
859 }
860 if it.hint != "" {
861 line += " " + dim(it.hint)
862 }
863 b.WriteString(padCompletionLine(line, m.width))
864 b.WriteByte('\n')
865 }
866 // A key-hint footer so users discover Tab — many won't know it accepts a
867 // completion, let alone descends into a folder.
868 hint := i18n.M.CompHintSlash
869 if m.completion.kind == compAt {
870 hint = i18n.M.CompHintFile
871 }
872 b.WriteString(padCompletionLine(dim(hint), m.width))
873 return b.String()
874 }
875
875 lines GO