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}