v0.1.21 b2fa0e3

jump_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"strings"
6
7	"github.com/patriceckhart/zot/internal/provider"
8	"github.com/patriceckhart/zot/internal/tui"
9)
10
11// jumpTarget describes one "turn" in the current session — a user
12// prompt plus the preview metadata the picker renders. MessageIdx
13// maps back into view.Messages so we can resolve the row offset via
14// view.BuildWithAnchors when the user picks a target.
15type jumpTarget struct {
16	MessageIdx int    // index into view.Messages
17	TurnNo     int    // 1-based turn number in session order
18	Preview    string // first ~60 chars of the user prompt (one line)
19	ToolCount  int    // tools invoked by the assistant in this turn
20}
21
22// jumpDialog is the picker shown when the user runs /jump. Rows are
23// turns: one per user message. Filtering happens as the user types;
24// arrow keys move within the filtered set.
25type jumpDialog struct {
26	active  bool
27	all     []jumpTarget
28	visible []jumpTarget // filtered subset
29	cursor  int
30	filter  string
31}
32
33// jumpDialogAction is returned by HandleKey.
34type jumpDialogAction struct {
35	Select     bool
36	MessageIdx int
37	TurnNo     int
38	Close      bool
39}
40
41func newJumpDialog() *jumpDialog { return &jumpDialog{} }
42
43// Open scans the current transcript for user-message anchor points,
44// applies the optional initial filter, and makes the dialog visible.
45// If the filter already narrows the list to exactly one match the
46// caller should check len(d.visible)==1 and treat that as an
47// immediate select rather than opening the full picker.
48func (d *jumpDialog) Open(msgs []provider.Message, initialFilter string) {
49	d.all = buildJumpTargets(msgs)
50	d.filter = initialFilter
51	d.applyFilter()
52	// Start on the last (most recent) target so enter-without-typing
53	// goes to the newest turn, which is almost never what you want
54	// for /jump — flip to the oldest filtered match instead so the
55	// picker opens at the top of the list.
56	d.cursor = 0
57	d.active = true
58}
59
60// Close hides the dialog.
61func (d *jumpDialog) Close() { d.active = false }
62
63// Active reports whether the dialog is visible and consumes input.
64func (d *jumpDialog) Active() bool { return d != nil && d.active }
65
66// Targets returns the current filtered target slice. Interactive uses
67// this right after Open() to detect the "one unique match" shortcut
68// and jump without showing the picker.
69func (d *jumpDialog) Targets() []jumpTarget {
70	if d == nil {
71		return nil
72	}
73	return d.visible
74}
75
76// applyFilter re-computes d.visible from d.all + d.filter. Matching
77// is case-insensitive substring on the preview. An empty filter
78// returns all targets.
79func (d *jumpDialog) applyFilter() {
80	if d.filter == "" {
81		d.visible = append(d.visible[:0], d.all...)
82	} else {
83		q := strings.ToLower(d.filter)
84		d.visible = d.visible[:0]
85		for _, t := range d.all {
86			if strings.Contains(strings.ToLower(t.Preview), q) {
87				d.visible = append(d.visible, t)
88			}
89		}
90	}
91	if d.cursor >= len(d.visible) {
92		d.cursor = len(d.visible) - 1
93	}
94	if d.cursor < 0 {
95		d.cursor = 0
96	}
97}
98
99// Render draws the dialog.
100func (d *jumpDialog) Render(th tui.Theme, width int) []string {
101	if !d.Active() {
102		return nil
103	}
104	var lines []string
105	lines = append(lines, frameHeader(th, "jump to turn", width))
106	if len(d.all) == 0 {
107		lines = append(lines, th.FG256(th.Muted, "no turns in this session yet"))
108		lines = append(lines, th.FG256(th.Muted, "press esc to close"))
109		lines = append(lines, frameRule(th, width))
110		return lines
111	}
112
113	// Status line: shows the active filter, visible count, and hints.
114	hint := "↑/↓ pick - enter jump - esc cancel - type to filter"
115	if d.filter != "" {
116		hint = fmt.Sprintf("filter: %q - %d match - ", d.filter, len(d.visible)) + hint
117	}
118	lines = append(lines, th.FG256(th.Muted, hint))
119
120	if len(d.visible) == 0 {
121		lines = append(lines, th.FG256(th.Muted, "  (nothing matches; backspace to widen)"))
122		lines = append(lines, frameRule(th, width))
123		return lines
124	}
125
126	// Cap the visible window so a 200-turn session doesn't push the
127	// editor off screen. Center around the cursor.
128	const maxRows = 12
129	start := 0
130	end := len(d.visible)
131	if end > maxRows {
132		start = d.cursor - maxRows/2
133		if start < 0 {
134			start = 0
135		}
136		end = start + maxRows
137		if end > len(d.visible) {
138			end = len(d.visible)
139			start = end - maxRows
140		}
141	}
142	if start > 0 {
143		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("  \u2191 %d more above", start)))
144	}
145	for i := start; i < end; i++ {
146		t := d.visible[i]
147		plain := "  " + formatJumpRowPlain(t, width-2)
148		if i == d.cursor {
149			lines = append(lines, th.PadHighlight(plain, width))
150		} else {
151			lines = append(lines, th.FG256(th.Muted, plain))
152		}
153	}
154	if end < len(d.visible) {
155		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("  \u2193 %d more below", len(d.visible)-end)))
156	}
157
158	lines = append(lines, frameRule(th, width))
159	return lines
160}
161
162// formatJumpRowPlain renders one target as a single line with turn
163// number, tool count, and the prompt preview trimmed to fit.
164func formatJumpRowPlain(t jumpTarget, maxWidth int) string {
165	left := fmt.Sprintf("#%-3d  %s  ", t.TurnNo, toolBadge(t.ToolCount))
166	room := maxWidth - len(left)
167	if room < 10 {
168		room = 10
169	}
170	preview := t.Preview
171	if len(preview) > room {
172		preview = preview[:room-1] + "\u2026"
173	}
174	return left + preview
175}
176
177func toolBadge(n int) string {
178	if n <= 0 {
179		return "      "
180	}
181	if n > 99 {
182		return " 99+t "
183	}
184	return fmt.Sprintf(" %2dt  ", n)
185}
186
187// HandleKey advances the dialog state and returns an action to apply.
188// Runes are added to the filter; backspace removes the last rune.
189func (d *jumpDialog) HandleKey(k tui.Key) jumpDialogAction {
190	switch k.Kind {
191	case tui.KeyUp:
192		if d.cursor > 0 {
193			d.cursor--
194		}
195	case tui.KeyDown:
196		if d.cursor < len(d.visible)-1 {
197			d.cursor++
198		}
199	case tui.KeyPageUp:
200		d.cursor -= 5
201		if d.cursor < 0 {
202			d.cursor = 0
203		}
204	case tui.KeyPageDown:
205		d.cursor += 5
206		if d.cursor >= len(d.visible) {
207			d.cursor = len(d.visible) - 1
208		}
209	case tui.KeyBackspace:
210		if len(d.filter) > 0 {
211			runes := []rune(d.filter)
212			d.filter = string(runes[:len(runes)-1])
213			d.applyFilter()
214		}
215	case tui.KeyRune:
216		// Any printable rune extends the filter.
217		d.filter += string(k.Rune)
218		d.applyFilter()
219	case tui.KeyEsc:
220		d.Close()
221		return jumpDialogAction{Close: true}
222	case tui.KeyEnter:
223		if len(d.visible) == 0 {
224			return jumpDialogAction{}
225		}
226		t := d.visible[d.cursor]
227		d.Close()
228		return jumpDialogAction{Select: true, MessageIdx: t.MessageIdx, TurnNo: t.TurnNo}
229	}
230	return jumpDialogAction{}
231}
232
233// buildJumpTargets walks the session transcript and produces one
234// jumpTarget per user message, enriched with the tool count from
235// the following assistant messages up to the next user boundary.
236func buildJumpTargets(msgs []provider.Message) []jumpTarget {
237	var out []jumpTarget
238	turn := 0
239	for i, m := range msgs {
240		if m.Role != provider.RoleUser {
241			continue
242		}
243		turn++
244		t := jumpTarget{
245			MessageIdx: i,
246			TurnNo:     turn,
247			Preview:    firstLineOfUserMessage(m),
248			ToolCount:  countToolsUntilNextUser(msgs, i),
249		}
250		out = append(out, t)
251	}
252	return out
253}
254
255// firstLineOfUserMessage returns the first non-empty text line of a
256// user message, trimmed to a single visible line, for use as a row
257// preview. Non-text blocks (images) are summarised.
258func firstLineOfUserMessage(m provider.Message) string {
259	for _, c := range m.Content {
260		switch b := c.(type) {
261		case provider.TextBlock:
262			for _, line := range strings.Split(b.Text, "\n") {
263				line = strings.TrimSpace(line)
264				if line != "" {
265					return line
266				}
267			}
268		case provider.ImageBlock:
269			return fmt.Sprintf("[image - %s - %d bytes]", b.MimeType, len(b.Data))
270		}
271	}
272	return "(empty)"
273}
274
275// countToolsUntilNextUser totals the tool calls emitted by assistant
276// messages between msgs[i] (exclusive) and the next user message
277// (exclusive). Tool-result messages are ignored because they mirror
278// the call count 1:1.
279func countToolsUntilNextUser(msgs []provider.Message, i int) int {
280	n := 0
281	for j := i + 1; j < len(msgs); j++ {
282		if msgs[j].Role == provider.RoleUser {
283			break
284		}
285		if msgs[j].Role == provider.RoleAssistant {
286			for _, c := range msgs[j].Content {
287				if _, ok := c.(provider.ToolCallBlock); ok {
288					n++
289				}
290			}
291		}
292	}
293	return n
294}

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 476

↑↓ moveenter openesc close