683 lines
22 KiB
Go
683 lines
22 KiB
Go
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 <name>")
|
||
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 "/<name>"), 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()
|
||
}
|