返回 DeepSeek-Reasonix
complete.go
根目录 / internal / cli / complete.go
1 package cli
2
3 import (
4 "fmt"
5 "slices"
6 "strings"
7
8 "charm.land/lipgloss/v2"
9 rw "github.com/mattn/go-runewidth"
10
11 "reasonix/internal/config"
12 "reasonix/internal/control"
13 "reasonix/internal/i18n"
14 "reasonix/internal/plugin"
15 "reasonix/internal/skill"
16 )
17
18 // compKind distinguishes the two completion menus.
19 type compKind int
20
21 const (
22 compSlash compKind = iota // slash command names, while the line is a bare "/word"
23 compSlashArg // a structured argument of a slash command (e.g. "/mcp remove <name>")
24 compAt // @-references (files / MCP resources)
25 )
26
27 // compItem is one menu row: label shown, insert applied on accept, hint dimmed.
28 // descend marks a directory entry — accepting it fills the input and re-opens
29 // the menu one level deeper instead of closing.
30 type compItem struct {
31 label string
32 insert string
33 hint string
34 descend bool
35 }
36
37 // completion is the live autocomplete menu state. Empty value = inactive.
38 // replaceFrom/replaceTo are byte offsets of the token span that accept replaces
39 // (half-open [replaceFrom, replaceTo)). For a bare slash name, replaceFrom is 0
40 // and replaceTo is len(value). For @-refs, replaceFrom is the '@' and replaceTo
41 // is the first unescaped whitespace after the token (or end of input).
42 type completion struct {
43 active bool
44 kind compKind
45 items []compItem
46 sel int
47 replaceFrom int
48 replaceTo int
49 }
50
51 const (
52 // maxCompRows caps how many menu rows show at once; the list windows around
53 // the selection when longer.
54 maxCompRows = 8
55 // maxCompItems caps how many entries a single directory contributes, so a
56 // pathologically large directory can't blow up the menu — we read only one
57 // level (os.ReadDir), never the whole tree.
58 maxCompItems = 200
59 // maxFileSearchItems caps basename search results for bare @tokens.
60 maxFileSearchItems = 20
61 )
62
63 // refreshHostAndInvalidateSlashCatalog reloads m.host from the controller and
64 // drops the slash catalog so MCP prompts (and any host-backed menu entries)
65 // rebuild on the next slashItems call. Use after connect/disconnect/remove/
66 // import, MCPSurfaceReady, auth clear, and every other host mutation path.
67 func (m *chatTUI) refreshHostAndInvalidateSlashCatalog() {
68 if m.ctrl != nil {
69 m.host = m.ctrl.Host()
70 }
71 m.invalidateSlashCatalog()
72 }
73
74 // setHostAndInvalidateSlashCatalog assigns a host pointer (e.g. from a model-
75 // switch message) and invalidates the slash catalog.
76 func (m *chatTUI) setHostAndInvalidateSlashCatalog(host *plugin.Host) {
77 m.host = host
78 m.invalidateSlashCatalog()
79 }
80
81 // buildSlashCatalog constructs the full slash menu from current sources.
82 func (m *chatTUI) buildSlashCatalog() []compItem {
83 docsOwner := control.ResolveSlashCommandOwner(control.DocsSlashName, m.commands, m.skills)
84 docsBuiltin := "/" + control.ResolvedBuiltinSlashName(control.DocsSlashName, m.commands, m.skills)
85 items := renameSlashItem(builtinSlashItems(), "/docs", docsBuiltin)
86 for _, c := range m.commands {
87 if c.Hidden {
88 continue
89 }
90 items = append(items, compItem{label: "/" + c.Name, insert: "/" + c.Name + " ", hint: customCommandHint(c)})
91 }
92 for _, s := range m.skills {
93 if docsOwner == control.SlashOwnerCustom && s.SlashName() == control.DocsSlashName {
94 continue
95 }
96 hint := s.Description
97 if s.RunAs == skill.RunSubagent {
98 hint = "🧬 " + hint
99 }
100 items = append(items, compItem{label: "/" + s.SlashName(), insert: "/" + s.SlashName() + " ", hint: skillCommandHint(s, hint)})
101 }
102 for _, p := range m.prompts() {
103 items = append(items, compItem{label: "/" + p.Name, insert: "/" + p.Name + " ", hint: p.Description})
104 }
105 if m.ctrl != nil {
106 for _, a := range m.ctrl.ExtensionActions() {
107 items = append(items, compItem{label: a.Slash, insert: a.Slash + " ", hint: extensionActionHint(a)})
108 }
109 }
110 return items
111 }
112
113 func renameSlashItem(items []compItem, oldLabel, newLabel string) []compItem {
114 if oldLabel == newLabel {
115 return items
116 }
117 for i := range items {
118 if items[i].label != oldLabel {
119 continue
120 }
121 items[i].label = newLabel
122 if after, ok := strings.CutPrefix(items[i].insert, oldLabel); ok {
123 items[i].insert = newLabel + after
124 }
125 break
126 }
127 return items
128 }
129
130 func removeSlashItems(items []compItem, label string) []compItem {
131 out := make([]compItem, 0, len(items))
132 for _, item := range items {
133 if item.label != label {
134 out = append(out, item)
135 }
136 }
137 return out
138 }
139
140 // updateCompletion recomputes the menu from the current input: a slash menu
141 // while the line is a single "/word" token, or an @-reference menu while the
142 // token under the cursor is "@…".
143 func (m *chatTUI) updateCompletion() {
144 val := m.input.Value()
145 cursor := m.inputCursorByteOffset()
146
147 // An @-reference token under the cursor wins — it can appear mid-line, even
148 // inside a slash command's arguments (e.g. "/review @file").
149 if at, end, token, ok := activeAtToken(val, cursor); ok {
150 if items := m.atItems(token); len(items) > 0 {
151 m.setCompletion(compAt, items, at, end)
152 return
153 }
154 }
155
156 // Slash completion only when the line is a pure slash command being typed
157 // from the start (not mid-line after free text). Use the full value so a
158 // mid-token cursor still filters the catalog without rewriting the line.
159 if strings.HasPrefix(val, "/") {
160 if items, from, ok := m.explicitSubcommandItems(val); ok && len(items) > 0 {
161 m.setCompletion(compSlashArg, items, from, tokenEnd(val, from))
162 return
163 }
164 if !strings.ContainsAny(val, " \t\n") {
165 // Still naming the command itself. Catalog is cached; filter is cheap.
166 if items := fuzzyFilterSlash(m.slashItems(), val); len(items) > 0 {
167 m.setCompletion(compSlash, items, 0, len(val))
168 return
169 }
170 } else if m.bareSubcommandSpace(val) {
171 m.endSlashArgSnapshot()
172 m.completion = completion{}
173 return
174 } else if items, from, ok := m.slashArgItems(val); ok && len(items) > 0 {
175 // Past the command word — complete its structured arguments.
176 m.setCompletion(compSlashArg, items, from, tokenEnd(val, from))
177 return
178 }
179 }
180
181 m.completion = completion{}
182 }
183
184 // inputCursorByteOffset returns the byte offset of the insertion caret in
185 // input.Value(). Falls back to len(Value) when layout is unavailable so
186 // completion still works in unit tests that never size the window.
187 func (m *chatTUI) inputCursorByteOffset() int {
188 val := m.input.Value()
189 if val == "" {
190 return 0
191 }
192 // Prefer the visual-row model used by mouse selection: it maps the caret
193 // to a stable rune offset into Value().
194 if m.width > 0 {
195 rows := m.composerRows()
196 if len(rows) > 0 {
197 if cur := m.input.Cursor(); cur != nil {
198 absRow := m.input.ScrollYOffset() + cur.Y
199 if absRow >= 0 && absRow < len(rows) {
200 row := rows[absRow]
201 // cur.X is screen-relative and includes the "❯ " prompt
202 // gutter (composerPromptWidth columns). Subtract it so
203 // we measure content columns only.
204 col := max(cur.X-composerPromptWidth, 0)
205 visual := 0
206 for _, cell := range row.cells {
207 w := rw.RuneWidth(cell.r)
208 if visual+w > col {
209 if cell.offset >= 0 {
210 // cell.offset is a rune index into Value.
211 return runeOffsetToByte(val, cell.offset)
212 }
213 break
214 }
215 visual += w
216 }
217 if row.endOffset >= 0 {
218 return runeOffsetToByte(val, row.endOffset)
219 }
220 }
221 }
222 }
223 }
224 return len(val)
225 }
226
227 // runeOffsetToByte converts a rune index into Value() into a byte index.
228 func runeOffsetToByte(val string, runeOff int) int {
229 if runeOff <= 0 {
230 return 0
231 }
232 i := 0
233 for ri := range val {
234 if i == runeOff {
235 return ri
236 }
237 i++
238 }
239 return len(val)
240 }
241
242 // command word). It returns the menu items, the byte offset where the current
243 // token begins (replaceFrom, so accept replaces just that token), and whether
244 // anything applied. Custom commands and MCP prompts yield nothing.
245 func (m *chatTUI) slashArgItems(val string) ([]compItem, int, bool) {
246 if items, from, ok := m.branchArgItems(val); ok {
247 m.endSlashArgSnapshot()
248 return items, from, len(items) > 0
249 }
250 if items, from, ok := m.resumeArgItems(val); ok {
251 m.endSlashArgSnapshot()
252 return items, from, len(items) > 0
253 }
254 if items, from, ok := m.themeArgItems(val); ok {
255 m.endSlashArgSnapshot()
256 return items, from, len(items) > 0
257 }
258 // Delegate to the shared completion logic so the chat TUI and the desktop
259 // offer identical sub-command hints. We supply the data from the TUI's own
260 // cached lists (no live controller needed), build the items, and adapt them
261 // to compItem.
262 items, from, applies := m.cachedSlashArgItems(val)
263 if !applies || len(items) == 0 {
264 return nil, 0, false
265 }
266 return slashItemsToComps(items), from, true
267 }
268
269 func (m *chatTUI) slashArgData() control.ArgData {
270 curProvider := ""
271 if parts := strings.SplitN(m.modelRef, "/", 2); len(parts) == 2 {
272 curProvider = parts[0]
273 }
274 data := control.ArgData{
275 Skills: m.skills,
276 ModelRefs: modelRefs(),
277 CurrentModel: m.modelRef,
278 ProviderNames: providerNames(),
279 CurrentProvider: curProvider,
280 PluginNames: pluginArgNames(),
281 }
282 if strings.TrimSpace(m.modelRef) != "" {
283 if entry, _, err := m.currentConfigProvider(); err == nil {
284 data.EffortLevels = slices.Clone(config.EffortCapabilityForEntry(entry).Levels)
285 }
286 }
287 if m.ctrl != nil {
288 data.DisabledSkills = m.ctrl.DisabledSkills()
289 data.ConfiguredMCP = m.ctrl.ConfiguredMCPNames()
290 data.DisconnectedMCP = m.ctrl.DisconnectedMCPNames()
291 data.MemoryRefs, data.MemoryArchives = control.MemoryCompletionData(m.ctrl.Memory())
292 }
293 if m.host != nil {
294 data.ServerNames = m.host.ServerNames()
295 }
296 return data
297 }
298
299 func (m *chatTUI) explicitSubcommandItems(val string) ([]compItem, int, bool) {
300 cmd, ok := strings.CutSuffix(val, "?")
301 if !ok {
302 return nil, 0, false
303 }
304 switch cmd {
305 case "/mcp", "/skill", "/skills", "/plugin", "/plugins", "/memory":
306 default:
307 return nil, 0, false
308 }
309 // These question-mark overlays only list static root subcommands. Dynamic
310 // data is resolved after the user descends into an argument that needs it.
311 items, _ := control.SlashArgItems(cmd+" ", control.ArgData{})
312 if len(items) == 0 {
313 return nil, 0, false
314 }
315 out := slashItemsToComps(items)
316 for i := range out {
317 out[i].insert = " " + out[i].insert
318 }
319 return out, len(cmd), true
320 }
321
322 func (m *chatTUI) bareSubcommandSpace(val string) bool {
323 if !strings.ContainsAny(val, " \t") || strings.TrimRight(val, " \t") == val {
324 return false
325 }
326 fields := strings.Fields(val)
327 if len(fields) != 1 {
328 return false
329 }
330 switch fields[0] {
331 case "/mcp", "/skill", "/skills", "/plugin", "/plugins", "/memory":
332 return true
333 default:
334 return false
335 }
336 }
337
338 func slashItemsToComps(items []control.SlashItem) []compItem {
339 out := make([]compItem, len(items))
340 for i, it := range items {
341 out[i] = compItem{label: it.Label, insert: it.Insert, hint: it.Hint, descend: it.Descend}
342 }
343 return out
344 }
345
346 func (m *chatTUI) branchArgItems(val string) ([]compItem, int, bool) {
347 cmdEnd := strings.IndexAny(val, " \t")
348 if cmdEnd < 0 || val[:cmdEnd] != "/switch" {
349 return nil, 0, false
350 }
351 from := strings.LastIndexAny(val, " \t") + 1
352 prior := strings.Fields(val[:from])
353 if len(prior) != 1 || m.ctrl == nil {
354 return nil, from, true
355 }
356 branches, err := m.ctrl.Branches()
357 // Branches snapshots first, which can retarget the controller to a
358 // recovery branch; keep the lease on whatever the controller now owns.
359 m.followSessionLease()
360 if err != nil {
361 return nil, from, true
362 }
363 cur := strings.ToLower(val[from:])
364 var out []compItem
365 for _, b := range branches {
366 label := b.ID
367 if cur != "" && !strings.HasPrefix(strings.ToLower(label), cur) &&
368 !strings.HasPrefix(strings.ToLower(b.Name), cur) {
369 continue
370 }
371 hint := b.Name
372 if hint == "" {
373 hint = b.Preview
374 }
375 if hint != "" {
376 hint = fmt.Sprintf("%d turns · %s", b.Turns, hint)
377 }
378 out = append(out, compItem{label: label, insert: label, hint: hint})
379 }
380 return out, from, true
381 }
382
383 // setCompletion installs items, preserving the selection index only while the
384 // same menu kind stays open. replaceFrom/replaceTo form a half-open byte span
385 // of the token that acceptCompletion will replace.
386 func (m *chatTUI) setCompletion(kind compKind, items []compItem, replaceFrom, replaceTo int) {
387 sel := 0
388 if m.completion.active && m.completion.kind == kind && m.completion.sel < len(items) {
389 sel = m.completion.sel
390 }
391 if replaceTo < replaceFrom {
392 replaceTo = replaceFrom
393 }
394 m.completion = completion{
395 active: true, kind: kind, items: items, sel: sel,
396 replaceFrom: replaceFrom, replaceTo: replaceTo,
397 }
398 }
399
400 func (m *chatTUI) dismissCompletion() {
401 m.completion = completion{}
402 m.endSlashArgSnapshot()
403 }
404
405 // fuzzyFilterSlash returns the slash-menu items that match query as a
406 // case-insensitive subsequence of their label, with prefix hits ranked first
407 // (each group preserved in the input order from slashItems). An empty query
408 // matches everything — the same behavior the old prefix filter had, since
409 // every label trivially starts with "". A query that matches nothing returns
410 // nil so the caller can fall through and close the menu.
411 func fuzzyFilterSlash(items []compItem, query string) []compItem {
412 if query == "" {
413 out := make([]compItem, len(items))
414 copy(out, items)
415 return out
416 }
417 lq := strings.ToLower(query)
418 var prefix, rest []compItem
419 for _, it := range items {
420 l := strings.ToLower(it.label)
421 switch {
422 case strings.HasPrefix(l, lq):
423 prefix = append(prefix, it)
424 case subsequenceMatch(l, lq):
425 rest = append(rest, it)
426 }
427 }
428 if len(prefix) == 0 && len(rest) == 0 {
429 return nil
430 }
431 out := make([]compItem, 0, len(prefix)+len(rest))
432 out = append(out, prefix...)
433 out = append(out, rest...)
434 return out
435 }
436
437 // subsequenceMatch reports whether query appears in target as a case-folded
438 // subsequence (each rune of query in order, not necessarily contiguous). It is
439 // the matcher behind the slash-menu fuzzy filter: typing "/modl" matches
440 // "/model", "/memory", or any other label where m-o-d-l appear in that order.
441 // Callers must pass already case-folded strings; an empty query matches
442 // every target, so callers that want a "no match" signal on the empty input
443 // should check that first.
444 func subsequenceMatch(target, query string) bool {
445 if query == "" {
446 return true
447 }
448 qr := []rune(query)
449 ti := 0
450 for _, r := range target {
451 if r == qr[ti] {
452 ti++
453 if ti == len(qr) {
454 return true
455 }
456 }
457 }
458 return false
459 }
460
461 // moveCompletion advances the selection by delta, wrapping around.
462 func (m *chatTUI) moveCompletion(delta int) {
463 n := len(m.completion.items)
464 if n == 0 {
465 return
466 }
467 m.completion.sel = ((m.completion.sel+delta)%n + n) % n
468 }
469
470 func (m *chatTUI) completionExactLabel() bool {
471 if !m.completion.active || m.completion.sel >= len(m.completion.items) {
472 return false
473 }
474 val := strings.TrimSpace(m.input.Value())
475 return val == m.completion.items[m.completion.sel].label
476 }
477
478 func (m *chatTUI) completionBareOverlayCommand() bool {
479 switch strings.TrimSpace(m.input.Value()) {
480 case "/mcp", "/skills":
481 return true
482 default:
483 return false
484 }
485 }
486
487 func (m *chatTUI) completionSelectedInsertPresent() bool {
488 if !m.completion.active || m.completion.sel >= len(m.completion.items) {
489 return false
490 }
491 val := m.input.Value()
492 rf, rt := m.completion.replaceFrom, m.completion.replaceTo
493 if rf < 0 || rf > len(val) {
494 return false
495 }
496 if rt < rf || rt > len(val) {
497 rt = len(val)
498 }
499 return val[rf:rt] == m.completion.items[m.completion.sel].insert
500 }
501
502 // acceptCompletion applies the selected item to the input, then recomputes the
503 // menu from the new value: it re-opens one level deeper (a descended directory
504 // or a freshly completed command's arguments) or closes when nothing applies.
505 // Cursor moves to the end of the inserted token only on accept — ordinary
506 // keystrokes never call this path, so mid-line typing keeps its caret.
507 func (m *chatTUI) acceptCompletion() {
508 if m.completion.sel >= len(m.completion.items) {
509 m.dismissCompletion()
510 return
511 }
512 it := m.completion.items[m.completion.sel]
513 val := m.input.Value()
514 rf := m.completion.replaceFrom
515 rt := m.completion.replaceTo
516 if rf < 0 || rf > len(val) {
517 rf = 0
518 }
519 // replaceTo must be set by setCompletion. Hand-built test completions may
520 // leave it at 0; treat inverted/empty whole-line spans as "to end".
521 if rt < rf || rt > len(val) {
522 rt = len(val)
523 } else if rt == rf && rf == 0 && len(val) > 0 {
524 // Bare slash replace with unset replaceTo: replace the whole line.
525 rt = len(val)
526 }
527 // Replace the full token span [rf, rt); keep any suffix after the token
528 // so "see @foo and more" + accept @foobar.md becomes
529 // "see @foobar.md and more" (not "@foobar.mdand" or "@foobar.mdfoo").
530 newVal := val[:rf] + it.insert + val[rt:]
531 insertEnd := rf + len(it.insert)
532 m.input.SetValue(newVal)
533 // Place caret at the end of the inserted completion only. Fall back to
534 // CursorEnd when the layout has no width yet (unit tests).
535 if m.width > 0 {
536 m.setComposerCursor(len([]rune(newVal[:min(insertEnd, len(newVal))])))
537 } else {
538 m.input.CursorEnd()
539 }
540 if it.descend || strings.HasSuffix(it.insert, " ") {
541 m.updateCompletion()
542 return
543 }
544 m.updateCompletion() // re-filter for arg completion (e.g. /resume → numbered sessions)
545 if !m.completion.active {
546 m.endSlashArgSnapshot()
547 return
548 }
549 // If the completion re-opened with the same single item the user just
550 // selected (i.e. the token was already typed), close it so the next Enter
551 // submits the command rather than being captured again by acceptCompletion.
552 if m.completion.active && len(m.completion.items) == 1 {
553 rf, rt := m.completion.replaceFrom, m.completion.replaceTo
554 val := m.input.Value()
555 if rf >= 0 && rf <= len(val) && rt >= rf && rt <= len(val) {
556 if val[rf:rt] == m.completion.items[0].insert {
557 m.dismissCompletion()
558 }
559 }
560 }
561 }
562
563 var compSelStyle lipgloss.Style
564
565 const completionPadCell = "\u00a0"
566
567 // padCompletionLine pads completion rows with NBSPs instead of ASCII spaces.
568 // Ultraviolet treats trailing ASCII spaces as clearable cells and may emit EL
569 // or ECH erase sequences; mintty can leave stale CJK glyph cells after those
570 // erases. NBSP is visually blank but forces the renderer to overwrite cells.
571 func padCompletionLine(s string, w int) string {
572 pad := w - visibleWidth(s)
573 if pad <= 0 {
574 return s
575 }
576 return s + strings.Repeat(completionPadCell, pad)
577 }
578
579 // renderCompletion draws the menu above the input box: matching items, windowed
580 // around the selection, the current row highlighted, hints dimmed. Every line is
581 // padded to m.width with non-clearable blank cells so bubbletea's delta renderer
582 // has no ordinary trailing-space run to collapse into EL/ECH erase sequences.
583 // That avoids ghost cells on terminals (mintty) with unreliable erases after
584 // wide CJK glyphs.
585 func (m chatTUI) renderCompletion() string {
586 if !m.completion.active || len(m.completion.items) == 0 {
587 return ""
588 }
589 items := m.completion.items
590 start := 0
591 if len(items) > maxCompRows {
592 start = min(max(m.completion.sel-maxCompRows/2, 0), len(items)-maxCompRows)
593 }
594 end := min(start+maxCompRows, len(items))
595
596 var b strings.Builder
597 for i := start; i < end; i++ {
598 it := items[i]
599 var line string
600 if i == m.completion.sel {
601 line = accent("› ") + compSelStyle.Render(it.label)
602 } else {
603 line = " " + it.label
604 }
605 if it.hint != "" {
606 line += " " + dim(it.hint)
607 }
608 b.WriteString(padCompletionLine(line, m.width))
609 b.WriteByte('\n')
610 }
611 // A key-hint footer so users discover Tab — many won't know it accepts a
612 // completion, let alone descends into a folder.
613 hint := i18n.M.CompHintSlash
614 if m.completion.kind == compAt {
615 hint = i18n.M.CompHintFile
616 }
617 b.WriteString(padCompletionLine(dim(hint), m.width))
618 return b.String()
619 }
620
620 lines GO