v0.0.25 5dbbcb9

slash_suggest.go RawHistoryFolder

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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close