package cli import ( "fmt" "os" "path/filepath" "sort" "strings" "charm.land/lipgloss/v2" "reasonix/internal/control" "reasonix/internal/fileref" "reasonix/internal/i18n" "reasonix/internal/skill" ) // compKind distinguishes the two completion menus. type compKind int const ( compSlash compKind = iota // slash command names, while the line is a bare "/word" compSlashArg // a structured argument of a slash command (e.g. "/mcp remove ") compAt // @-references (files / MCP resources) ) // compItem is one menu row: label shown, insert applied on accept, hint dimmed. // descend marks a directory entry โ€” accepting it fills the input and re-opens // the menu one level deeper instead of closing. type compItem struct { label string insert string hint string descend bool } // completion is the live autocomplete menu state. Empty value = inactive. // replaceFrom is the byte offset in the input where the completed token starts // (0 for a slash line, the '@' index for an @-reference). type completion struct { active bool kind compKind items []compItem sel int replaceFrom int } const ( // maxCompRows caps how many menu rows show at once; the list windows around // the selection when longer. maxCompRows = 8 // maxCompItems caps how many entries a single directory contributes, so a // pathologically large directory can't blow up the menu โ€” we read only one // level (os.ReadDir), never the whole tree. maxCompItems = 200 // maxFileSearchItems caps basename search results for bare @tokens. maxFileSearchItems = 20 ) // slashItems is the full set of slash commands offered for completion: the // built-in verbs, custom commands, skills (each as "/"), and MCP prompts. func (m *chatTUI) slashItems() []compItem { items := []compItem{ {label: "/compact", insert: "/compact ", hint: i18n.M.CmdCompact}, {label: "/new", insert: "/new ", hint: i18n.M.CmdNew}, {label: "/clear", insert: "/clear", hint: i18n.M.CmdClear}, {label: "/cls", insert: "/cls", hint: i18n.M.CmdCls}, {label: "/resume", insert: "/resume ", hint: i18n.M.CmdResume}, {label: "/rename", insert: "/rename ", hint: i18n.M.CmdRename}, {label: "/rewind", insert: "/rewind", hint: i18n.M.CmdRewind}, {label: "/tree", insert: "/tree", hint: i18n.M.CmdTree}, {label: "/branch", insert: "/branch ", hint: i18n.M.CmdBranch}, {label: "/switch", insert: "/switch ", hint: i18n.M.CmdSwitchBranch}, {label: "/mcp", insert: "/mcp", hint: i18n.M.CmdMcp}, {label: "/plugins", insert: "/plugins", hint: i18n.M.CmdPlugins}, {label: "/model", insert: "/model ", hint: i18n.M.CmdModel, descend: true}, {label: "/work-mode", insert: "/work-mode ", hint: i18n.M.CmdWorkMode, descend: true}, {label: "/provider", insert: "/provider ", hint: i18n.M.CmdProvider, descend: true}, {label: "/skills", insert: "/skills", hint: i18n.M.CmdSkill}, {label: "/reload-cmd", insert: "/reload-cmd", hint: i18n.M.CmdReloadCmd}, {label: "/hooks", insert: "/hooks ", hint: i18n.M.CmdHooks, descend: true}, {label: "/paste-image", insert: "/paste-image", hint: i18n.M.CmdPasteImage}, {label: "/output-style", insert: "/output-style", hint: i18n.M.CmdOutputStyle}, {label: "/verbose", insert: "/verbose", hint: i18n.M.CmdVerbose}, {label: "/mouse", insert: "/mouse", hint: i18n.M.CmdMouse}, {label: "/diff-fold", insert: "/diff-fold", hint: i18n.M.CmdDiffFold}, {label: "/sandbox", insert: "/sandbox", hint: i18n.M.CmdSandbox}, {label: "/effort", insert: "/effort ", hint: i18n.M.CmdEffort, descend: true}, {label: "/auto-plan", insert: "/auto-plan ", hint: i18n.M.CmdAutoPlan, descend: true}, {label: "/reasoning-language", insert: "/reasoning-language ", hint: i18n.M.CmdReasonLang, descend: true}, {label: "/memory-v5", insert: "/memory-v5 ", hint: i18n.M.CmdMemoryV5, descend: true}, {label: "/theme", insert: "/theme ", hint: i18n.M.CmdTheme, descend: true}, {label: "/language", insert: "/language ", hint: i18n.M.CmdLanguage, descend: true}, {label: "/help", insert: "/help ", hint: i18n.M.CmdHelp}, {label: "/memory", insert: "/memory ", hint: i18n.M.CmdMemory}, {label: "/migrate", insert: "/migrate", hint: i18n.M.CmdMigrate}, {label: "/goal", insert: "/goal ", hint: i18n.M.CmdGoal, descend: true}, {label: "/remember", insert: "/remember ", hint: i18n.M.CmdRemember}, {label: "/forget", insert: "/forget ", hint: i18n.M.CmdForget}, {label: "/quit", insert: "/quit", hint: i18n.M.CmdQuit}, {label: "/copy", insert: "/copy", hint: i18n.M.CmdCopy}, {label: "/export", insert: "/export", hint: i18n.M.CmdExport}, } for _, c := range m.commands { if c.Hidden { continue } items = append(items, compItem{label: "/" + c.Name, insert: "/" + c.Name + " ", hint: customCommandHint(c)}) } for _, s := range m.skills { hint := s.Description if s.RunAs == skill.RunSubagent { hint = "๐Ÿงฌ " + hint } items = append(items, compItem{label: "/" + s.SlashName(), insert: "/" + s.SlashName() + " ", hint: skillCommandHint(s, hint)}) } for _, p := range m.prompts() { items = append(items, compItem{label: "/" + p.Name, insert: "/" + p.Name + " ", hint: p.Description}) } return items } // updateCompletion recomputes the menu from the current input: a slash menu // while the line is a single "/word" token, or an @-reference menu while the // token under the cursor is "@โ€ฆ". func (m *chatTUI) updateCompletion() { val := m.input.Value() // An @-reference token under the cursor wins โ€” it can appear mid-line, even // inside a slash command's arguments (e.g. "/review @file"). if at, token, ok := activeAtToken(val); ok { if items := m.atItems(token); len(items) > 0 { m.setCompletion(compAt, items, at) return } } if strings.HasPrefix(val, "/") { if items, from, ok := m.explicitSubcommandItems(val); ok && len(items) > 0 { m.setCompletion(compSlashArg, items, from) return } if !strings.ContainsAny(val, " \t\n") { // Still naming the command itself. if items := fuzzyFilterSlash(m.slashItems(), val); len(items) > 0 { m.setCompletion(compSlash, items, 0) return } } else if m.bareSubcommandSpace(val) { m.completion = completion{} return } else if items, from, ok := m.slashArgItems(val); ok && len(items) > 0 { // Past the command word โ€” complete its structured arguments. m.setCompletion(compSlashArg, items, from) return } } m.completion = completion{} } // slashArgItems completes the arguments of a slash command (everything after the // command word). It returns the menu items, the byte offset where the current // token begins (replaceFrom, so accept replaces just that token), and whether // anything applied. Only commands with structured arguments participate โ€” // currently /mcp; custom commands and MCP prompts take free-form template args, // so they yield nothing. func (m *chatTUI) slashArgItems(val string) ([]compItem, int, bool) { if items, from, ok := m.workModeArgItems(val); ok { return items, from, len(items) > 0 } if items, from, ok := m.branchArgItems(val); ok { return items, from, len(items) > 0 } if items, from, ok := m.resumeArgItems(val); ok { return items, from, len(items) > 0 } if items, from, ok := m.themeArgItems(val); ok { return items, from, len(items) > 0 } // Delegate to the shared completion logic so the chat TUI and the desktop // offer identical sub-command hints. We supply the data from the TUI's own // cached lists (no live controller needed), build the items, and adapt them // to compItem. items, from := control.SlashArgItems(val, m.slashArgData()) if len(items) == 0 { return nil, 0, false } return slashItemsToComps(items), from, true } func (m *chatTUI) slashArgData() control.ArgData { curProvider := "" if parts := strings.SplitN(m.modelRef, "/", 2); len(parts) == 2 { curProvider = parts[0] } data := control.ArgData{ Skills: m.skills, ModelRefs: modelRefs(), CurrentModel: m.modelRef, ProviderNames: providerNames(), CurrentProvider: curProvider, PluginNames: pluginArgNames(), } if m.ctrl != nil { data.DisabledSkills = m.ctrl.DisabledSkills() data.ConfiguredMCP = m.ctrl.ConfiguredMCPNames() data.DisconnectedMCP = m.ctrl.DisconnectedMCPNames() } if m.host != nil { data.ServerNames = m.host.ServerNames() } return data } func (m *chatTUI) explicitSubcommandItems(val string) ([]compItem, int, bool) { cmd, ok := strings.CutSuffix(val, "?") if !ok { return nil, 0, false } switch cmd { case "/mcp", "/skill", "/skills", "/plugin", "/plugins": default: return nil, 0, false } items, _ := control.SlashArgItems(cmd+" ", m.slashArgData()) if len(items) == 0 { return nil, 0, false } out := slashItemsToComps(items) for i := range out { out[i].insert = " " + out[i].insert } return out, len(cmd), true } func (m *chatTUI) bareSubcommandSpace(val string) bool { if !strings.ContainsAny(val, " \t") || strings.TrimRight(val, " \t") == val { return false } fields := strings.Fields(val) if len(fields) != 1 { return false } switch fields[0] { case "/mcp", "/skill", "/skills", "/plugin", "/plugins": return true default: return false } } func slashItemsToComps(items []control.SlashItem) []compItem { out := make([]compItem, len(items)) for i, it := range items { out[i] = compItem{label: it.Label, insert: it.Insert, hint: it.Hint, descend: it.Descend} } return out } func (m *chatTUI) branchArgItems(val string) ([]compItem, int, bool) { cmdEnd := strings.IndexAny(val, " \t") if cmdEnd < 0 || val[:cmdEnd] != "/switch" { return nil, 0, false } from := strings.LastIndexAny(val, " \t") + 1 prior := strings.Fields(val[:from]) if len(prior) != 1 || m.ctrl == nil { return nil, from, true } branches, err := m.ctrl.Branches() // Branches snapshots first, which can retarget the controller to a // recovery branch; keep the lease on whatever the controller now owns. m.followSessionLease() if err != nil { return nil, from, true } cur := strings.ToLower(val[from:]) var out []compItem for _, b := range branches { label := b.ID if cur != "" && !strings.HasPrefix(strings.ToLower(label), cur) && !strings.HasPrefix(strings.ToLower(b.Name), cur) { continue } hint := b.Name if hint == "" { hint = b.Preview } if hint != "" { hint = fmt.Sprintf("%d turns ยท %s", b.Turns, hint) } out = append(out, compItem{label: label, insert: label, hint: hint}) } return out, from, true } // setCompletion installs items, preserving the selection index only while the // same menu kind stays open. func (m *chatTUI) setCompletion(kind compKind, items []compItem, replaceFrom int) { sel := 0 if m.completion.active && m.completion.kind == kind && m.completion.sel < len(items) { sel = m.completion.sel } m.completion = completion{active: true, kind: kind, items: items, sel: sel, replaceFrom: replaceFrom} } // fuzzyFilterSlash returns the slash-menu items that match query as a // case-insensitive subsequence of their label, with prefix hits ranked first // (each group preserved in the input order from slashItems). An empty query // matches everything โ€” the same behavior the old prefix filter had, since // every label trivially starts with "". A query that matches nothing returns // nil so the caller can fall through and close the menu. func fuzzyFilterSlash(items []compItem, query string) []compItem { if query == "" { out := make([]compItem, len(items)) copy(out, items) return out } lq := strings.ToLower(query) var prefix, rest []compItem for _, it := range items { l := strings.ToLower(it.label) switch { case strings.HasPrefix(l, lq): prefix = append(prefix, it) case subsequenceMatch(l, lq): rest = append(rest, it) } } if len(prefix) == 0 && len(rest) == 0 { return nil } out := make([]compItem, 0, len(prefix)+len(rest)) out = append(out, prefix...) out = append(out, rest...) return out } // subsequenceMatch reports whether query appears in target as a case-folded // subsequence (each rune of query in order, not necessarily contiguous). It is // the matcher behind the slash-menu fuzzy filter: typing "/modl" matches // "/model", "/memory", or any other label where m-o-d-l appear in that order. // Callers must pass already case-folded strings; an empty query matches // every target, so callers that want a "no match" signal on the empty input // should check that first. func subsequenceMatch(target, query string) bool { if query == "" { return true } qr := []rune(query) ti := 0 for _, r := range target { if r == qr[ti] { ti++ if ti == len(qr) { return true } } } return false } // activeAtToken finds the @-reference token ending at the cursor (assumed at the // input's end). The '@' must start the line or follow whitespace, so emails // like "a@b" don't trigger it. Returns the '@' offset and the text after it. func activeAtToken(val string) (int, string, bool) { for i := len(val) - 1; i >= 0; i-- { switch val[i] { case ' ', '\t', '\n': return 0, "", false // hit whitespace before an '@' โ†’ no active token case '@': if i == 0 || val[i-1] == ' ' || val[i-1] == '\t' || val[i-1] == '\n' { return i, val[i+1:], true } return 0, "", false } } return 0, "", false } // atItems builds the @-reference menu for a token. A "server:uri" token whose // server is connected lists that server's MCP resources; otherwise the token is // a path and we list one directory level (never a recursive walk), plus โ€” at the // top level โ€” any matching MCP resources. func (m *chatTUI) atItems(token string) []compItem { if i := strings.Index(token, ":"); i > 0 && m.isMCPServer(token[:i]) { return m.resourceItems(token[:i], token[i+1:]) } return m.fileItems(token) } // fileItems lists one directory level for a path token. dir is the part up to // the last '/', frag the part after; entries of dir starting with frag are // offered (directories descend, files complete). Hidden entries are skipped // unless frag starts with '.'. Top-level tokens also surface MCP resources. func (m *chatTUI) fileItems(token string) []compItem { dir, frag := splitPathToken(token) workspaceRoot := "" if m.ctrl != nil { workspaceRoot = m.ctrl.WorkspaceRoot() } readDir := dir if workspaceRoot != "" { if readDir == "" { readDir = workspaceRoot } else if !filepath.IsAbs(readDir) { readDir = filepath.Join(workspaceRoot, filepath.FromSlash(readDir)) } } else if readDir == "" { readDir = "." } entries, err := os.ReadDir(readDir) if err != nil { entries = nil } // Directories first, then files; ReadDir is already name-sorted. sort.SliceStable(entries, func(i, j int) bool { return entries[i].IsDir() && !entries[j].IsDir() }) showHidden := strings.HasPrefix(frag, ".") var items []compItem for _, e := range entries { name := e.Name() if !strings.HasPrefix(name, frag) { continue } if !showHidden && strings.HasPrefix(name, ".") { continue } if e.IsDir() { items = append(items, compItem{label: name + "/", insert: "@" + dir + name + "/", hint: "dir", descend: true}) } else { items = append(items, compItem{label: name, insert: "@" + dir + name}) } if len(items) >= maxCompItems { break } } // At the top level (still naming the first segment) MCP resources share the // '@' namespace, so offer the matching ones too. if !strings.Contains(token, "/") { seen := map[string]bool{} for _, it := range items { seen[strings.TrimPrefix(it.insert, "@")] = true } remaining := maxCompItems - len(items) if remaining > maxFileSearchItems { remaining = maxFileSearchItems } results := m.searchFileRefs(frag) if len(results) > remaining { results = results[:remaining] } for _, path := range results { if seen[path] { continue } items = append(items, compItem{label: path, insert: "@" + path, hint: "file"}) if len(items) >= maxCompItems { break } } items = append(items, m.resourceItems("", token)...) } return items } // searchFileRefs memoizes the bounded basename walk so re-rendering the menu // for an unchanged @token fragment doesn't re-walk the workspace each keystroke. func (m *chatTUI) searchFileRefs(frag string) []string { if m.fileSearchCache == nil { m.fileSearchCache = map[string][]string{} } if r, ok := m.fileSearchCache[frag]; ok { return r } searchRoot := "." if m.ctrl != nil { if wr := m.ctrl.WorkspaceRoot(); wr != "" { searchRoot = wr } } results := fileref.Search(searchRoot, frag, maxFileSearchItems) paths := make([]string, 0, len(results)) for _, r := range results { paths = append(paths, r.Path) } m.fileSearchCache[frag] = paths return paths } // splitPathToken splits a path token into (dir, frag): dir keeps its trailing // slash ("internal/" ), frag is the segment being typed. func splitPathToken(token string) (dir, frag string) { if i := strings.LastIndex(token, "/"); i >= 0 { return token[:i+1], token[i+1:] } return "", token } // isMCPServer reports whether name is a connected MCP server. func (m *chatTUI) isMCPServer(name string) bool { if m.host == nil { return false } for _, s := range m.host.ServerNames() { if s == name { return true } } return false } // resourceItems lists MCP resources as @server:uri completions. When server is // "" (top level) it matches by the whole "server:uri" prefix; otherwise it lists // the named server's resources filtered by the uri prefix. func (m *chatTUI) resourceItems(server, frag string) []compItem { if m.host == nil { return nil } var items []compItem for _, r := range m.host.Resources() { ref := r.Server + ":" + r.URI switch { case server == "": if !strings.HasPrefix(ref, frag) { continue } case r.Server == server: if !strings.HasPrefix(r.URI, frag) { continue } default: continue } label := r.Name if label == "" { label = "resource" } items = append(items, compItem{label: "@" + ref, insert: "@" + ref, hint: label}) } return items } // moveCompletion advances the selection by delta, wrapping around. func (m *chatTUI) moveCompletion(delta int) { n := len(m.completion.items) if n == 0 { return } m.completion.sel = ((m.completion.sel+delta)%n + n) % n } func (m *chatTUI) completionExactLabel() bool { if !m.completion.active || m.completion.sel >= len(m.completion.items) { return false } val := strings.TrimSpace(m.input.Value()) return val == m.completion.items[m.completion.sel].label } func (m *chatTUI) completionBareOverlayCommand() bool { switch strings.TrimSpace(m.input.Value()) { case "/mcp", "/skills": return true default: return false } } func (m *chatTUI) completionSelectedInsertPresent() bool { if !m.completion.active || m.completion.sel >= len(m.completion.items) { return false } val := m.input.Value() if m.completion.replaceFrom > len(val) { return false } return val[m.completion.replaceFrom:] == m.completion.items[m.completion.sel].insert } // acceptCompletion applies the selected item to the input, then recomputes the // menu from the new value: it re-opens one level deeper (a descended directory // or a freshly completed command's arguments) or closes when nothing applies. func (m *chatTUI) acceptCompletion() { if m.completion.sel >= len(m.completion.items) { m.completion = completion{} return } it := m.completion.items[m.completion.sel] val := m.input.Value() rf := m.completion.replaceFrom if rf > len(val) { rf = len(val) } m.input.SetValue(val[:rf] + it.insert) m.input.CursorEnd() if it.descend || strings.HasSuffix(it.insert, " ") { m.updateCompletion() return } m.updateCompletion() // re-filter for arg completion (e.g. /resume โ†’ numbered sessions) // If the completion re-opened with the same single item the user just // selected (i.e. the token was already typed), close it so the next Enter // submits the command rather than being captured again by acceptCompletion. if m.completion.active && len(m.completion.items) == 1 { tok := m.input.Value()[m.completion.replaceFrom:] if tok == m.completion.items[0].insert { m.completion = completion{} } } } var compSelStyle lipgloss.Style const completionPadCell = "\u00a0" // padCompletionLine pads completion rows with NBSPs instead of ASCII spaces. // Ultraviolet treats trailing ASCII spaces as clearable cells and may emit EL // or ECH erase sequences; mintty can leave stale CJK glyph cells after those // erases. NBSP is visually blank but forces the renderer to overwrite cells. func padCompletionLine(s string, w int) string { pad := w - visibleWidth(s) if pad <= 0 { return s } return s + strings.Repeat(completionPadCell, pad) } // renderCompletion draws the menu above the input box: matching items, windowed // around the selection, the current row highlighted, hints dimmed. Every line is // padded to m.width with non-clearable blank cells so bubbletea's delta renderer // has no ordinary trailing-space run to collapse into EL/ECH erase sequences. // That avoids ghost cells on terminals (mintty) with unreliable erases after // wide CJK glyphs. func (m chatTUI) renderCompletion() string { if !m.completion.active || len(m.completion.items) == 0 { return "" } items := m.completion.items start := 0 if len(items) > maxCompRows { start = m.completion.sel - maxCompRows/2 if start < 0 { start = 0 } if start > len(items)-maxCompRows { start = len(items) - maxCompRows } } end := start + maxCompRows if end > len(items) { end = len(items) } var b strings.Builder for i := start; i < end; i++ { it := items[i] var line string if i == m.completion.sel { line = accent("โ€บ ") + compSelStyle.Render(it.label) } else { line = " " + it.label } if it.hint != "" { line += " " + dim(it.hint) } b.WriteString(padCompletionLine(line, m.width)) b.WriteByte('\n') } // A key-hint footer so users discover Tab โ€” many won't know it accepts a // completion, let alone descends into a folder. hint := i18n.M.CompHintSlash if m.completion.kind == compAt { hint = i18n.M.CompHintFile } b.WriteString(padCompletionLine(dim(hint), m.width)) return b.String() }