1package modes
2
3import (
4 "strings"
5
6 "github.com/patriceckhart/zot/internal/tui"
7)
8
9// slashCommand is one entry in the autocomplete popup.
10type slashCommand struct {
11 Name string // with leading "/"
12 Desc string
13}
14
15// slashCancelsTurn reports whether the named slash command, when run
16// while a turn is in flight, requires the active turn to be cancelled
17// first. The destructive commands (those that mutate the transcript
18// or rebuild the agent) need a quiet state; the rest run alongside
19// the streaming response without trouble.
20func slashCancelsTurn(head string) bool {
21 switch head {
22 case "/clear", "/compact", "/logout", "/login", "/model":
23 return true
24 }
25 return false
26}
27
28// slashCatalog lists every slash command the interactive mode handles.
29// Keep in sync with runSlash().
30var slashCatalog = []slashCommand{
31 {"/help", "show key bindings and commands"},
32 {"/login", "log in via api key or subscription"},
33 {"/logout", "clear a provider's credentials"},
34 {"/model", "pick a model (or /model <id>)"},
35 {"/sessions", "resume a previous session for this directory"},
36 {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},
37 {"/btw", "side-chat that doesn't add to the main thread (saves tokens)"},
38 {"/skills", "list discovered skills (SKILL.md files)"},
39 {"/compact", "summarize and replace the transcript to free up context"},
40 {"/lock", "confine tools to the current directory"},
41 {"/unlock", "allow tools to touch paths outside this directory"},
42 {"/clear", "clear the chat transcript"},
43 {"/exit", "exit zot"},
44}
45
46// slashSuggester renders the popup that appears when the editor starts
47// with "/". It does not own any input state — the editor drives.
48type slashSuggester struct {
49 cursor int
50
51 // extra are commands contributed by extensions, refreshed each
52 // frame from the extension manager. Empty when no extensions
53 // have registered any.
54 extra []slashCommand
55}
56
57// SetExtra updates the extension-contributed command list. Called
58// once per render with the live snapshot from the extension manager.
59func (s *slashSuggester) SetExtra(cmds []slashCommand) { s.extra = cmds }
60
61// allCatalog returns slashCatalog plus the current extra commands
62// (extension-registered). Extra entries are only kept if they don't
63// collide with a built-in name; the built-in always wins.
64func (s *slashSuggester) allCatalog() []slashCommand {
65 if len(s.extra) == 0 {
66 return slashCatalog
67 }
68 out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra))
69 out = append(out, slashCatalog...)
70 for _, c := range s.extra {
71 dup := false
72 for _, b := range slashCatalog {
73 if b.Name == c.Name {
74 dup = true
75 break
76 }
77 }
78 if !dup {
79 out = append(out, c)
80 }
81 }
82 return out
83}
84
85// looksLikeSlashCommand reports whether text is an attempt at a slash
86// command (valid or not). Returns true for things like "/foo" or
87// "/bar baz" but false for paths ("/Users/pat/...") and regexes
88// ("/foo.bar/") so those can be sent to the model as-is.
89//
90// The head after "/" must be a single simple word: only letters,
91// digits, hyphens, and underscores. That excludes paths (contain "/"),
92// regexes (contain "."), and URLs.
93func looksLikeSlashCommand(text string) bool {
94 text = strings.TrimSpace(text)
95 if len(text) < 2 || text[0] != '/' {
96 return false
97 }
98 head := text[1:]
99 if i := strings.IndexAny(head, " \t\n"); i >= 0 {
100 head = head[:i]
101 }
102 if head == "" {
103 return false
104 }
105 for _, r := range head {
106 if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') &&
107 !(r >= '0' && r <= '9') && r != '-' && r != '_' {
108 return false
109 }
110 }
111 return true
112}
113
114// isKnownSlashCommand reports whether text's head matches a registered
115// slash command name in slashCatalog. Built-in only; extension
116// commands are looked up separately by the dispatcher (which
117// consults the extension manager).
118func isKnownSlashCommand(text string) bool {
119 text = strings.TrimSpace(text)
120 if text == "" || text[0] != '/' {
121 return false
122 }
123 head := text
124 if i := strings.IndexAny(text, " \t\n"); i >= 0 {
125 head = text[:i]
126 }
127 for _, c := range slashCatalog {
128 if c.Name == head {
129 return true
130 }
131 }
132 return false
133}
134
135func newSlashSuggester() *slashSuggester { return &slashSuggester{} }
136
137// matches returns the commands whose name has input as a prefix.
138// If input is just "/", everything is shown.
139func (s *slashSuggester) matches(input string) []slashCommand {
140 input = strings.TrimRight(input, " ")
141 if input == "" || !strings.HasPrefix(input, "/") {
142 return nil
143 }
144 // If there is a space, the user has moved past the command name.
145 if idx := strings.IndexByte(input, ' '); idx >= 0 {
146 return nil
147 }
148 var out []slashCommand
149 for _, c := range s.allCatalog() {
150 if strings.HasPrefix(c.Name, input) {
151 out = append(out, c)
152 }
153 }
154 return out
155}
156
157// clampCursor keeps the cursor inside the current match list.
158func (s *slashSuggester) clampCursor(n int) {
159 if n <= 0 {
160 s.cursor = 0
161 return
162 }
163 if s.cursor < 0 {
164 s.cursor = 0
165 }
166 if s.cursor >= n {
167 s.cursor = n - 1
168 }
169}
170
171// Up / Down navigate the suggestion list.
172func (s *slashSuggester) Up() { s.cursor-- }
173func (s *slashSuggester) Down() { s.cursor++ }
174
175// Active reports whether the popup is visible for the given input.
176func (s *slashSuggester) Active(input string) bool {
177 return len(s.matches(input)) > 0
178}
179
180// Selection returns the currently highlighted command for input, or "".
181func (s *slashSuggester) Selection(input string) string {
182 m := s.matches(input)
183 if len(m) == 0 {
184 return ""
185 }
186 s.clampCursor(len(m))
187 return m[s.cursor].Name
188}
189
190// Render returns the popup lines or nil.
191func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string {
192 m := s.matches(input)
193 if len(m) == 0 {
194 return nil
195 }
196 s.clampCursor(len(m))
197 var lines []string
198 for i, c := range m {
199 name := c.Name
200 if len(name) < 10 {
201 name = name + strings.Repeat(" ", 10-len(name))
202 }
203 plain := " " + name + " " + c.Desc
204 if i == s.cursor {
205 lines = append(lines, th.PadHighlight(plain, width))
206 } else {
207 lines = append(lines, th.FG256(th.Muted, plain))
208 }
209 }
210 lines = append(lines, th.FG256(th.Muted, " ↑/↓ navigate · tab complete · enter run"))
211 return lines
212}
213
214// Reset puts the cursor back to the first match. Call this whenever the
215// input changes in a way that reshapes the match list.
216func (s *slashSuggester) Reset() { s.cursor = 0 }