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