| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | |
| 6 | "reasonix/internal/control" |
| 7 | ) |
| 8 | |
| 9 | // slashCompletionCache memoizes the two expensive completion snapshots: the |
| 10 | // slash catalog (commands, skills, prompts) and the arg-completion data |
| 11 | // (config models/providers, plugin state, MCP names, memory refs). Assembling |
| 12 | // the arg data runs several config loads plus a plugin-state read per keystroke |
| 13 | // otherwise, which reads as visible input lag on cold Linux caches (#9503). |
| 14 | type slashCompletionCache struct { |
| 15 | items []compItem |
| 16 | // argData is the memoized snapshot; argBuilt separates "not built" from a |
| 17 | // snapshot taken while modelRef was still "". |
| 18 | argData control.ArgData |
| 19 | argModel string |
| 20 | // argContext identifies the structured command whose editing session owns |
| 21 | // argData. It survives an empty filter result but is cleared by an explicit |
| 22 | // dismissal, input reset, invalidation, or a different command. |
| 23 | argContext string |
| 24 | argBuilt bool |
| 25 | } |
| 26 | |
| 27 | func (m *chatTUI) ensureSlashCache() *slashCompletionCache { |
| 28 | if m.slashCache == nil { |
| 29 | m.slashCache = &slashCompletionCache{} |
| 30 | } |
| 31 | return m.slashCache |
| 32 | } |
| 33 | |
| 34 | // slashItems returns the cached slash catalog. Rebuilds only after |
| 35 | // invalidateSlashCatalog — never on ordinary keystrokes. |
| 36 | func (m *chatTUI) slashItems() []compItem { |
| 37 | if c := m.slashCache; c != nil && c.items != nil { |
| 38 | return c.items |
| 39 | } |
| 40 | // Immutable snapshot so keystroke filtering never mutates shared state. |
| 41 | items := m.buildSlashCatalog() |
| 42 | out := make([]compItem, len(items)) |
| 43 | copy(out, items) |
| 44 | m.ensureSlashCache().items = out |
| 45 | return out |
| 46 | } |
| 47 | |
| 48 | // slashArgDataSnapshot returns the memoized arg-completion data. It rebuilds |
| 49 | // when the active model changed or the cache was invalidated; filtering stays |
| 50 | // in-memory, so keystrokes inside an open arg popup cost no I/O. |
| 51 | func (m *chatTUI) slashArgDataSnapshot() control.ArgData { |
| 52 | if c := m.slashCache; c != nil && c.argBuilt && c.argModel == m.modelRef { |
| 53 | return c.argData |
| 54 | } |
| 55 | c := m.ensureSlashCache() |
| 56 | c.argData = m.slashArgData() |
| 57 | c.argModel = m.modelRef |
| 58 | c.argBuilt = true |
| 59 | return c.argData |
| 60 | } |
| 61 | |
| 62 | func (m *chatTUI) cachedSlashArgItems(line string) ([]control.SlashItem, int, bool) { |
| 63 | context := "" |
| 64 | if end := strings.IndexAny(line, " \t"); end >= 0 { |
| 65 | context = line[:end] |
| 66 | } |
| 67 | usedData := false |
| 68 | items, from, applies := control.SlashArgItemsLazy(line, func() control.ArgData { |
| 69 | usedData = true |
| 70 | m.prepareSlashArgSnapshot(context) |
| 71 | return m.slashArgDataSnapshot() |
| 72 | }) |
| 73 | if !usedData { |
| 74 | m.endSlashArgSnapshot() |
| 75 | } |
| 76 | return items, from, applies |
| 77 | } |
| 78 | |
| 79 | // prepareSlashArgSnapshot keeps one stable snapshot for a structured argument |
| 80 | // editing session. Candidate filtering may temporarily hide the popup, so the |
| 81 | // command context—not menu visibility—owns the generation boundary. |
| 82 | func (m *chatTUI) prepareSlashArgSnapshot(context string) { |
| 83 | c := m.ensureSlashCache() |
| 84 | if context != "" && c.argContext == context { |
| 85 | return |
| 86 | } |
| 87 | c.clearArgData() |
| 88 | c.argContext = context |
| 89 | } |
| 90 | |
| 91 | func (c *slashCompletionCache) clearArgData() { |
| 92 | c.argData = control.ArgData{} |
| 93 | c.argModel = "" |
| 94 | c.argBuilt = false |
| 95 | } |
| 96 | |
| 97 | func (m *chatTUI) endSlashArgSnapshot() { |
| 98 | if c := m.slashCache; c != nil { |
| 99 | c.clearArgData() |
| 100 | c.argContext = "" |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func (m *chatTUI) endSlashArgSnapshotForKey(key string) string { |
| 105 | switch key { |
| 106 | case "esc", "ctrl+c", "super+c", "meta+c", "ctrl+enter", "enter": |
| 107 | m.endSlashArgSnapshot() |
| 108 | } |
| 109 | return key |
| 110 | } |
| 111 | |
| 112 | func (m *chatTUI) resetComposerInput() { |
| 113 | m.input.Reset() |
| 114 | m.endSlashArgSnapshot() |
| 115 | } |
| 116 | |
| 117 | // invalidateSlashCatalog drops the cached catalog and arg data so the next |
| 118 | // slashItems/slashArgDataSnapshot call rebuilds them. Call from model switch, |
| 119 | // skill rescan, /reload-cmd, and any path that mutates |
| 120 | // commands/skills/host/extension actions. |
| 121 | func (m *chatTUI) invalidateSlashCatalog() { |
| 122 | m.slashCache = nil |
| 123 | } |
| 124 |