1package modes
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/patriceckhart/zot/packages/core"
9 "github.com/patriceckhart/zot/packages/tui"
10)
11
12// sessionDialog is the inline picker shown when the user runs /sessions.
13type sessionDialog struct {
14 active bool
15 sessions []core.SessionSummary
16 cursor int
17 renaming bool
18 rename string
19 deleting bool
20
21 // MaxRows is the maximum number of session rows the dialog
22 // will render in a single frame. Set by the host right before
23 // Render based on the available chat space; if 0, the dialog
24 // falls back to rendering every row (original behaviour).
25 // When the list is longer than MaxRows the dialog scrolls so
26 // the cursor stays visible and tags the first/last visible
27 // entry with a muted "↑ N more" / "↓ N more" row so the user
28 // knows there's offscreen content.
29 MaxRows int
30
31 // viewTop is the index of the first session currently drawn.
32 // Adjusted to follow the cursor on up/down moves.
33 viewTop int
34}
35
36// sessionDialogAction is returned by HandleKey.
37type sessionDialogAction struct {
38 Select bool
39 Delete bool
40 Path string
41 Close bool
42 Renamed bool
43}
44
45func newSessionDialog() *sessionDialog { return &sessionDialog{} }
46
47// Open populates the dialog from root + cwd and shows it. Empty
48// sessions (zero messages) are filtered out so the currently-running
49// session, a freshly-opened one that hasn't received a prompt yet,
50// and any stale empties that haven't been pruned yet all stay out
51// of the picker. Resuming an empty session is a no-op anyway.
52func (d *sessionDialog) Open(root, cwd string) {
53 all := core.DescribeSessions(root, cwd)
54 filtered := make([]core.SessionSummary, 0, len(all))
55 for _, s := range all {
56 if s.MessageCount == 0 {
57 continue
58 }
59 filtered = append(filtered, s)
60 }
61 d.sessions = filtered
62 d.cursor = 0
63 d.viewTop = 0
64 d.renaming = false
65 d.rename = ""
66 d.deleting = false
67 d.active = true
68}
69
70// CursorPos returns the row/col for the terminal cursor when in
71// rename mode. Returns -1, -1 otherwise.
72func (d *sessionDialog) CursorPos() (row, col int) {
73 if !d.renaming {
74 return -1, -1
75 }
76 // Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed).
77 // Col: " ▌ " prefix + text length.
78 return 3, 4 + len([]rune(d.rename))
79}
80
81// Close hides the dialog and abandons any pending edit or deletion.
82func (d *sessionDialog) Close() {
83 d.active = false
84 d.renaming = false
85 d.rename = ""
86 d.deleting = false
87}
88
89// Active reports whether the dialog is visible and consumes input.
90func (d *sessionDialog) Active() bool { return d != nil && d.active }
91
92// Render returns the dialog lines.
93func (d *sessionDialog) Render(th tui.Theme, width int) []string {
94 if !d.Active() {
95 return nil
96 }
97 var lines []string
98 lines = append(lines, frameHeader(th, "sessions", width))
99 if len(d.sessions) == 0 {
100 lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))
101 lines = append(lines, th.FG256(th.Muted, "press esc to close"))
102 lines = append(lines, frameRule(th, width))
103 return lines
104 }
105 if d.renaming {
106 lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):"))
107 text := d.rename
108 if text == "" {
109 text = th.FG256(th.Muted, "session name")
110 } else {
111 text = th.FG256(th.FG, text)
112 }
113 lines = append(lines, " "+th.FG256(th.Accent, "▌ ")+text)
114 lines = append(lines, frameRule(th, width))
115 return lines
116 }
117 if d.deleting {
118 s := d.sessions[d.cursor]
119 lines = append(lines, th.FG256(th.Muted, "permanently delete this session?"))
120 lines = append(lines, " "+formatSessionRowPlain(s, width-2))
121 lines = append(lines, th.FG256(th.Muted, "press y to delete, enter/esc to cancel"))
122 lines = append(lines, frameRule(th, width))
123 return lines
124 }
125 lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, d delete, esc cancel)"))
126
127 // Viewport: windowed slice of d.sessions around d.cursor so a
128 // list taller than the terminal still scrolls. Caller sets
129 // MaxRows to the number of rows available for session entries
130 // (i.e. excluding the header, hint, chrome). When it's zero or
131 // bigger than the list, we draw everything.
132 total := len(d.sessions)
133 window := d.MaxRows
134 if window <= 0 || window >= total {
135 window = total
136 }
137 d.viewTop = clampViewTop(d.viewTop, d.cursor, window, total)
138 viewBot := d.viewTop + window
139 if viewBot > total {
140 viewBot = total
141 }
142
143 // Top indicator: how many rows are above the viewport.
144 if d.viewTop > 0 {
145 hidden := d.viewTop
146 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", hidden)))
147 }
148 for i := d.viewTop; i < viewBot; i++ {
149 s := d.sessions[i]
150 plain := " " + formatSessionRowPlain(s, width-2)
151 if i == d.cursor {
152 lines = append(lines, th.PadHighlight(plain, width))
153 } else {
154 lines = append(lines, th.FG256(th.Muted, plain))
155 }
156 }
157 // Bottom indicator: how many rows are below the viewport.
158 if viewBot < total {
159 hidden := total - viewBot
160 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
161 }
162 lines = append(lines, frameRule(th, width))
163 return lines
164}
165
166// clampViewTop returns a viewTop that keeps cursor visible in a
167// window of the given size over a list of `total` rows. Leaves one
168// row of padding above/below where possible so moving the cursor
169// doesn't land right on the top/bottom edge — easier to see what
170// direction you're moving.
171func clampViewTop(viewTop, cursor, window, total int) int {
172 if window <= 0 || total <= 0 {
173 return 0
174 }
175 if window >= total {
176 return 0
177 }
178 pad := 2
179 if window < 6 {
180 pad = 0
181 }
182 if cursor < viewTop+pad {
183 viewTop = cursor - pad
184 }
185 if cursor >= viewTop+window-pad {
186 viewTop = cursor - window + pad + 1
187 }
188 if viewTop < 0 {
189 viewTop = 0
190 }
191 if viewTop+window > total {
192 viewTop = total - window
193 }
194 return viewTop
195}
196
197// formatSessionRowPlain returns the session row body without any ANSI
198// styling so the caller can wrap it in either a plain mute color or a
199// full-row selection highlight. The returned string is guaranteed to
200// fit within maxWidth visible characters so the terminal never soft-
201// wraps it into the next row.
202func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
203 when := formatRelative(s.Started)
204 summary := strings.TrimSpace(s.Title)
205 if summary == "" {
206 summary = strings.TrimSpace(s.FirstUserText)
207 }
208 if summary == "" {
209 summary = "(empty)"
210 }
211 summary = strings.ReplaceAll(summary, "\n", " ")
212 left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ",
213 when, s.Provider, s.Model, s.MessageCount, s.TotalCost)
214 room := maxWidth - len([]rune(left))
215 if room < 4 {
216 room = 4
217 }
218 runes := []rune(summary)
219 if len(runes) > room {
220 summary = string(runes[:room-3]) + "..."
221 }
222 row := left + summary
223 // Hard clamp: ensure the full row never exceeds maxWidth.
224 rowRunes := []rune(row)
225 if len(rowRunes) > maxWidth {
226 if maxWidth <= 3 {
227 row = strings.Repeat(".", maxWidth)
228 } else {
229 row = string(rowRunes[:maxWidth-3]) + "..."
230 }
231 }
232 return row
233}
234
235func formatRelative(t time.Time) string {
236 if t.IsZero() {
237 return "unknown"
238 }
239 d := time.Since(t)
240 switch {
241 case d < time.Minute:
242 return "just now"
243 case d < time.Hour:
244 return fmt.Sprintf("%d min ago", int(d.Minutes()))
245 case d < 24*time.Hour:
246 return fmt.Sprintf("%d h ago", int(d.Hours()))
247 case d < 7*24*time.Hour:
248 return fmt.Sprintf("%d d ago", int(d.Hours()/24))
249 default:
250 return t.Local().Format("2006-01-02")
251 }
252}
253
254// HandleKey advances the dialog and returns an action to apply, if any.
255func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
256 if d.deleting {
257 switch k.Kind {
258 case tui.KeyRune:
259 if (k.Rune == 'y' || k.Rune == 'Y') && d.cursor < len(d.sessions) {
260 path := d.sessions[d.cursor].Path
261 d.deleting = false
262 return sessionDialogAction{Delete: true, Path: path}
263 }
264 if k.Rune == 'n' || k.Rune == 'N' {
265 d.deleting = false
266 }
267 case tui.KeyEnter, tui.KeyEsc:
268 d.deleting = false
269 }
270 return sessionDialogAction{}
271 }
272
273 // Rename mode: type the new name.
274 if d.renaming {
275 switch k.Kind {
276 case tui.KeyEnter:
277 title := strings.TrimSpace(d.rename)
278 if title != "" && d.cursor < len(d.sessions) {
279 path := d.sessions[d.cursor].Path
280 _ = core.RenameSession(path, title)
281 d.sessions[d.cursor].Title = title
282 }
283 d.renaming = false
284 d.rename = ""
285 return sessionDialogAction{Renamed: true}
286 case tui.KeyEsc:
287 d.renaming = false
288 d.rename = ""
289 return sessionDialogAction{}
290 case tui.KeyBackspace:
291 if len(d.rename) > 0 {
292 r := []rune(d.rename)
293 d.rename = string(r[:len(r)-1])
294 }
295 return sessionDialogAction{}
296 case tui.KeyPaste:
297 d.rename += k.Paste
298 return sessionDialogAction{}
299 case tui.KeyRune:
300 if k.Rune != 0 {
301 d.rename += string(k.Rune)
302 }
303 return sessionDialogAction{}
304 }
305 return sessionDialogAction{}
306 }
307
308 page := d.MaxRows
309 if page <= 0 {
310 page = 10
311 }
312 if page > 1 {
313 page--
314 }
315 switch k.Kind {
316 case tui.KeyUp:
317 if d.cursor > 0 {
318 d.cursor--
319 }
320 case tui.KeyDown:
321 if d.cursor < len(d.sessions)-1 {
322 d.cursor++
323 }
324 case tui.KeyPageUp:
325 d.cursor -= page
326 if d.cursor < 0 {
327 d.cursor = 0
328 }
329 case tui.KeyPageDown:
330 d.cursor += page
331 if d.cursor >= len(d.sessions) {
332 d.cursor = len(d.sessions) - 1
333 if d.cursor < 0 {
334 d.cursor = 0
335 }
336 }
337 case tui.KeyHome:
338 d.cursor = 0
339 case tui.KeyEnd:
340 if len(d.sessions) > 0 {
341 d.cursor = len(d.sessions) - 1
342 }
343 case tui.KeyEsc:
344 d.Close()
345 return sessionDialogAction{Close: true}
346 case tui.KeyEnter:
347 if len(d.sessions) == 0 {
348 d.Close()
349 return sessionDialogAction{Close: true}
350 }
351 s := d.sessions[d.cursor]
352 d.Close()
353 return sessionDialogAction{Select: true, Path: s.Path}
354 case tui.KeyRune:
355 if k.Rune == 'r' && len(d.sessions) > 0 {
356 s := d.sessions[d.cursor]
357 d.renaming = true
358 if s.Title != "" {
359 d.rename = s.Title
360 } else {
361 d.rename = ""
362 }
363 return sessionDialogAction{}
364 }
365 if k.Rune == 'd' && len(d.sessions) > 0 {
366 d.deleting = true
367 }
368 }
369 return sessionDialogAction{}
370}
371
372// Remove deletes path from the visible rows after the host has removed the
373// corresponding file. The next row stays selected when possible.
374func (d *sessionDialog) Remove(path string) {
375 for idx := range d.sessions {
376 if d.sessions[idx].Path != path {
377 continue
378 }
379 d.sessions = append(d.sessions[:idx], d.sessions[idx+1:]...)
380 if d.cursor >= len(d.sessions) {
381 d.cursor = len(d.sessions) - 1
382 }
383 if d.cursor < 0 {
384 d.cursor = 0
385 }
386 d.viewTop = clampViewTop(d.viewTop, d.cursor, d.MaxRows, len(d.sessions))
387 return
388 }
389}