1package modes
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/tui"
12)
13
14type sessionLoadEventKind uint8
15
16const (
17 sessionLoadStarted sessionLoadEventKind = iota
18 sessionLoadEntry
19 sessionLoadFinished
20)
21
22type sessionLoadEvent struct {
23 kind sessionLoadEventKind
24 generation uint64
25 total int
26 index int
27 summary core.SessionSummary
28}
29
30type sessionLoadSlot struct {
31 loaded bool
32 summary core.SessionSummary
33}
34
35type sessionLoadJob struct {
36 index int
37 path string
38}
39
40const maxSessionLoadWorkers = 8
41
42// sessionDialog is the inline picker shown when the user runs /sessions.
43type sessionDialog struct {
44 active bool
45 sessions []core.SessionSummary
46 cursor int
47 renaming bool
48 rename string
49
50 loading bool
51 loadingDone int
52 loadingTotal int
53 loadingStartedAt time.Time
54 loadGeneration uint64
55 loadCancel context.CancelFunc
56 loadDone chan struct{}
57 // loadReadyThrough is the contiguous path prefix whose summaries have
58 // arrived. It prevents an older worker result from appearing before a
59 // newer session that is still being read.
60 loadSlots []sessionLoadSlot
61 loadReadyThrough int
62
63 // describeSession is replaceable in tests so cancellation can be
64 // exercised without relying on file-size-dependent timing.
65 describeSession func(context.Context, string) core.SessionSummary
66
67 // MaxRows is the maximum number of session rows the dialog
68 // will render in a single frame. Set by the host right before
69 // Render based on the available chat space; if 0, the dialog
70 // falls back to rendering every row (original behaviour).
71 // When the list is longer than MaxRows the dialog scrolls so
72 // the cursor stays visible and tags the first/last visible
73 // entry with a muted "↑ N more" / "↓ N more" row so the user
74 // knows there's offscreen content.
75 MaxRows int
76
77 // viewTop is the index of the first session currently drawn.
78 // Adjusted to follow the cursor on up/down moves.
79 viewTop int
80}
81
82// sessionDialogAction is returned by HandleKey.
83type sessionDialogAction struct {
84 Select bool
85 Path string
86 Close bool
87 Renamed bool
88 RenameTitle string
89 Err error
90}
91
92func newSessionDialog() *sessionDialog { return &sessionDialog{} }
93
94// Open shows the dialog immediately and loads session entries on bounded
95// background workers. Results are delivered to the caller so the UI goroutine
96// remains responsible for mutating dialog state and rendering it.
97func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent {
98 if d.loadCancel != nil {
99 d.loadCancel()
100 }
101 previousDone := d.loadDone
102 if parent == nil {
103 parent = context.Background()
104 }
105 ctx, cancel := context.WithCancel(parent)
106 d.loadCancel = cancel
107 d.loadGeneration++
108 generation := d.loadGeneration
109 done := make(chan struct{})
110 d.loadDone = done
111 d.sessions = nil
112 d.cursor = 0
113 d.viewTop = 0
114 d.renaming = false
115 d.rename = ""
116 d.loading = true
117 d.loadingDone = 0
118 d.loadingTotal = 0
119 d.loadingStartedAt = time.Now()
120 d.loadSlots = nil
121 d.loadReadyThrough = 0
122 d.active = true
123
124 if ctxErr := ctx.Err(); ctxErr != nil {
125 cancel()
126 d.loadCancel = nil
127 d.loading = false
128 d.active = false
129 d.loadDone = previousDone
130 events := make(chan sessionLoadEvent)
131 close(events)
132 close(done)
133 return events
134 }
135
136 events := make(chan sessionLoadEvent, 1)
137 send := func(event sessionLoadEvent) bool {
138 select {
139 case events <- event:
140 return true
141 case <-ctx.Done():
142 return false
143 }
144 }
145 describeSession := d.describeSession
146 if describeSession == nil {
147 describeSession = core.DescribeSessionContext
148 }
149 go func() {
150 defer close(done)
151 defer close(events)
152 if previousDone != nil {
153 <-previousDone
154 }
155 select {
156 case <-ctx.Done():
157 return
158 default:
159 }
160 paths := core.ListSessionsContext(ctx, root, cwd)
161 if !send(sessionLoadEvent{
162 kind: sessionLoadStarted,
163 generation: generation,
164 total: len(paths),
165 }) {
166 return
167 }
168
169 workerCount := len(paths)
170 if workerCount > maxSessionLoadWorkers {
171 workerCount = maxSessionLoadWorkers
172 }
173 jobs := make(chan sessionLoadJob)
174 var wg sync.WaitGroup
175 wg.Add(workerCount)
176 for worker := 0; worker < workerCount; worker++ {
177 go func() {
178 defer wg.Done()
179 for {
180 select {
181 case <-ctx.Done():
182 return
183 case job, ok := <-jobs:
184 if !ok {
185 return
186 }
187 summary := describeSession(ctx, job.path)
188 select {
189 case <-ctx.Done():
190 return
191 default:
192 }
193 if !send(sessionLoadEvent{
194 kind: sessionLoadEntry,
195 generation: generation,
196 index: job.index,
197 summary: summary,
198 }) {
199 return
200 }
201 }
202 }
203 }()
204 }
205 dispatch:
206 for index, path := range paths {
207 select {
208 case jobs <- sessionLoadJob{index: index, path: path}:
209 case <-ctx.Done():
210 break dispatch
211 }
212 }
213 close(jobs)
214 wg.Wait()
215 if ctx.Err() != nil {
216 return
217 }
218 send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation})
219 }()
220 return events
221}
222
223// ApplyLoad incorporates one result from Open. It must be called by the UI
224// goroutine so Render and HandleKey never race with background file reads.
225func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
226 if !d.active || event.generation != d.loadGeneration {
227 return
228 }
229 switch event.kind {
230 case sessionLoadStarted:
231 d.loadingTotal = event.total
232 d.loadingDone = 0
233 d.loadSlots = make([]sessionLoadSlot, event.total)
234 d.loadReadyThrough = 0
235 d.sessions = nil
236 case sessionLoadEntry:
237 if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
238 return
239 }
240 d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}
241 d.loadingDone++
242 previousReady := d.loadReadyThrough
243 for d.loadReadyThrough < len(d.loadSlots) && d.loadSlots[d.loadReadyThrough].loaded {
244 d.loadReadyThrough++
245 }
246 d.appendLoadedSessions(previousReady, d.loadReadyThrough)
247 case sessionLoadFinished:
248 d.finishLoad(true)
249 }
250}
251
252// ApplyLoadClosed finalizes a load whose producer stopped without delivering a
253// terminal event, such as when its parent context is canceled.
254func (d *sessionDialog) ApplyLoadClosed() {
255 if !d.active || !d.loading {
256 return
257 }
258 d.finishLoad(false)
259 d.active = false
260}
261
262func (d *sessionDialog) finishLoad(complete bool) {
263 if complete {
264 d.rebuildLoadedSessions(len(d.loadSlots))
265 } else {
266 d.sessions = nil
267 }
268 d.loading = false
269 d.loadSlots = nil
270 d.loadReadyThrough = 0
271 if d.loadCancel != nil {
272 d.loadCancel()
273 d.loadCancel = nil
274 }
275}
276
277// appendLoadedSessions exposes the newly contiguous portion of the path
278// list. Paths are already newest-first, so this keeps the first visible entry
279// stable while workers finish out of order without rescanning old slots.
280func (d *sessionDialog) appendLoadedSessions(start, end int) {
281 limit := len(d.loadSlots)
282 if start < 0 {
283 start = 0
284 } else if start > limit {
285 start = limit
286 }
287 if end < 0 {
288 end = 0
289 } else if end > limit {
290 end = limit
291 }
292 if start >= end {
293 return
294 }
295 for _, slot := range d.loadSlots[start:end] {
296 if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
297 continue
298 }
299 d.sessions = append(d.sessions, slot.summary)
300 }
301}
302
303func (d *sessionDialog) rebuildLoadedSessions(limit int) {
304 if limit > len(d.loadSlots) {
305 limit = len(d.loadSlots)
306 }
307 filtered := make([]core.SessionSummary, 0, limit)
308 for _, slot := range d.loadSlots[:limit] {
309 if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
310 continue
311 }
312 filtered = append(filtered, slot.summary)
313 }
314 d.sessions = filtered
315 if d.cursor >= len(d.sessions) {
316 d.cursor = len(d.sessions) - 1
317 }
318 if d.cursor < 0 {
319 d.cursor = 0
320 }
321}
322
323// CursorPos returns the row/col for the terminal cursor when in
324// rename mode. Returns -1, -1 otherwise.
325func (d *sessionDialog) CursorPos() (row, col int) {
326 if !d.renaming {
327 return -1, -1
328 }
329 // Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed).
330 // Col: " ▌ " prefix + text length.
331 return 3, 4 + len([]rune(d.rename))
332}
333
334// Close hides the dialog and cancels any in-flight session reads.
335func (d *sessionDialog) Close() {
336 if d.loadCancel != nil {
337 d.loadCancel()
338 d.loadCancel = nil
339 }
340 d.loading = false
341 d.loadSlots = nil
342 d.loadReadyThrough = 0
343 d.active = false
344}
345
346// Active reports whether the dialog is visible and consumes input.
347func (d *sessionDialog) Active() bool { return d != nil && d.active }
348
349// Loading reports whether the dialog still has session entries in flight.
350func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading }
351
352// Render returns the dialog lines.
353func (d *sessionDialog) Render(th tui.Theme, width int) []string {
354 if !d.Active() {
355 return nil
356 }
357 var lines []string
358 lines = append(lines, frameHeader(th, "sessions", width))
359 if d.loading {
360 lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))
361 if len(d.sessions) == 0 {
362 lines = append(lines, th.FGColor(th.Muted, "session entries appear as they load"))
363 lines = append(lines, frameRule(th, width))
364 return lines
365 }
366 } else if len(d.sessions) == 0 {
367 lines = append(lines, th.FGColor(th.Muted, "no previous sessions for this directory"))
368 lines = append(lines, th.FGColor(th.Muted, "press esc to close"))
369 lines = append(lines, frameRule(th, width))
370 return lines
371 }
372 if d.renaming {
373 lines = append(lines, th.FGColor(th.Muted, "rename session (enter save, esc cancel):"))
374 text := d.rename
375 if text == "" {
376 text = th.FGColor(th.Muted, "session name")
377 } else {
378 text = th.FGColor(th.FG, text)
379 }
380 lines = append(lines, " "+th.FGColor(th.Accent, "▌ ")+text)
381 lines = append(lines, frameRule(th, width))
382 return lines
383 }
384 hint := "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"
385 if d.loading {
386 hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)"
387 }
388 lines = append(lines, th.FGColor(th.Muted, hint))
389
390 // Viewport: windowed slice of d.sessions around d.cursor so a
391 // list taller than the terminal still scrolls. Caller sets
392 // MaxRows to the number of rows available for session entries
393 // (i.e. excluding the header, hint, chrome). When it's zero or
394 // bigger than the list, we draw everything.
395 total := len(d.sessions)
396 window := d.MaxRows
397 if window <= 0 || window >= total {
398 window = total
399 }
400 d.viewTop = clampViewTop(d.viewTop, d.cursor, window, total)
401 viewBot := d.viewTop + window
402 if viewBot > total {
403 viewBot = total
404 }
405
406 // Top indicator: how many rows are above the viewport.
407 if d.viewTop > 0 {
408 hidden := d.viewTop
409 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↑ %d more above", hidden)))
410 }
411 for i := d.viewTop; i < viewBot; i++ {
412 s := d.sessions[i]
413 plain := " " + formatSessionRowPlain(s, width-2)
414 if i == d.cursor {
415 lines = append(lines, th.PadHighlight(plain, width))
416 } else {
417 lines = append(lines, th.FGColor(th.Muted, plain))
418 }
419 }
420 // Bottom indicator: how many rows are below the viewport.
421 if viewBot < total {
422 hidden := total - viewBot
423 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
424 }
425 lines = append(lines, frameRule(th, width))
426 return lines
427}
428
429// clampViewTop returns a viewTop that keeps cursor visible in a
430// window of the given size over a list of `total` rows. Leaves one
431// row of padding above/below where possible so moving the cursor
432// doesn't land right on the top/bottom edge — easier to see what
433// direction you're moving.
434func clampViewTop(viewTop, cursor, window, total int) int {
435 if window <= 0 || total <= 0 {
436 return 0
437 }
438 if window >= total {
439 return 0
440 }
441 pad := 2
442 if window < 6 {
443 pad = 0
444 }
445 if cursor < viewTop+pad {
446 viewTop = cursor - pad
447 }
448 if cursor >= viewTop+window-pad {
449 viewTop = cursor - window + pad + 1
450 }
451 if viewTop < 0 {
452 viewTop = 0
453 }
454 if viewTop+window > total {
455 viewTop = total - window
456 }
457 return viewTop
458}
459
460// formatSessionRowPlain returns the session row body without any ANSI
461// styling so the caller can wrap it in either a plain mute color or a
462// full-row selection highlight. The returned string is guaranteed to
463// fit within maxWidth visible characters so the terminal never soft-
464// wraps it into the next row.
465func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
466 when := formatRelative(s.Started)
467 summary := strings.TrimSpace(s.Title)
468 if summary == "" {
469 summary = strings.TrimSpace(s.FirstUserText)
470 }
471 if summary == "" {
472 summary = "(empty)"
473 }
474 provider := sanitizeSessionTreeText(s.Provider)
475 model := sanitizeSessionTreeText(s.Model)
476 summary = sanitizeSessionTreeText(summary)
477 left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ",
478 when, provider, model, s.MessageCount, s.TotalCost)
479 room := maxWidth - len([]rune(left))
480 if room < 4 {
481 room = 4
482 }
483 runes := []rune(summary)
484 if len(runes) > room {
485 summary = string(runes[:room-3]) + "..."
486 }
487 row := left + summary
488 // Hard clamp: ensure the full row never exceeds maxWidth.
489 rowRunes := []rune(row)
490 if len(rowRunes) > maxWidth {
491 if maxWidth <= 3 {
492 row = strings.Repeat(".", maxWidth)
493 } else {
494 row = string(rowRunes[:maxWidth-3]) + "..."
495 }
496 }
497 return row
498}
499
500func (d *sessionDialog) loadingMessage(th tui.Theme) string {
501 frames := th.SpinnerFrames
502 if len(frames) == 0 {
503 frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
504 }
505 interval := th.SpinnerIntervalMS
506 if interval <= 0 {
507 interval = 80
508 }
509 idx := 0
510 if !d.loadingStartedAt.IsZero() {
511 elapsed := time.Since(d.loadingStartedAt)
512 if elapsed < 0 {
513 elapsed = 0
514 }
515 idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)
516 }
517 progress := "loading sessions"
518 if d.loadingTotal > 0 {
519 progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
520 }
521 return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)")
522}
523
524func formatRelative(t time.Time) string {
525 if t.IsZero() {
526 return "unknown"
527 }
528 d := time.Since(t)
529 switch {
530 case d < time.Minute:
531 return "just now"
532 case d < time.Hour:
533 return fmt.Sprintf("%d min ago", int(d.Minutes()))
534 case d < 24*time.Hour:
535 return fmt.Sprintf("%d h ago", int(d.Hours()))
536 case d < 7*24*time.Hour:
537 return fmt.Sprintf("%d d ago", int(d.Hours()/24))
538 default:
539 return t.Local().Format("2006-01-02")
540 }
541}
542
543// HandleKey advances the dialog and returns an action to apply, if any.
544func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
545 // Rename mode: type the new name.
546 if d.renaming {
547 switch k.Kind {
548 case tui.KeyEnter:
549 title := core.NormalizeSessionTitle(d.rename)
550 path := ""
551 renamed := false
552 var renameErr error
553 if title != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
554 path = d.sessions[d.cursor].Path
555 if err := core.RenameSession(path, title); err != nil {
556 renameErr = err
557 } else {
558 d.sessions[d.cursor].Title = title
559 renamed = true
560 }
561 }
562 d.renaming = false
563 d.rename = ""
564 return sessionDialogAction{Renamed: renamed, Path: path, RenameTitle: title, Err: renameErr}
565 case tui.KeyEsc:
566 d.renaming = false
567 d.rename = ""
568 return sessionDialogAction{}
569 case tui.KeyBackspace:
570 if len(d.rename) > 0 {
571 r := []rune(d.rename)
572 d.rename = string(r[:len(r)-1])
573 }
574 return sessionDialogAction{}
575 case tui.KeyPaste:
576 d.rename += k.Paste
577 return sessionDialogAction{}
578 case tui.KeyRune:
579 if k.Rune != 0 {
580 d.rename += string(k.Rune)
581 }
582 return sessionDialogAction{}
583 }
584 return sessionDialogAction{}
585 }
586
587 if d.loading && len(d.sessions) == 0 {
588 if k.Kind == tui.KeyEsc {
589 d.Close()
590 return sessionDialogAction{Close: true}
591 }
592 return sessionDialogAction{}
593 }
594
595 page := d.MaxRows
596 if page <= 0 {
597 page = 10
598 }
599 if page > 1 {
600 page--
601 }
602 switch k.Kind {
603 case tui.KeyUp:
604 if d.cursor > 0 {
605 d.cursor--
606 }
607 case tui.KeyDown:
608 if d.cursor < len(d.sessions)-1 {
609 d.cursor++
610 }
611 case tui.KeyPageUp:
612 d.cursor -= page
613 if d.cursor < 0 {
614 d.cursor = 0
615 }
616 case tui.KeyPageDown:
617 d.cursor += page
618 if d.cursor >= len(d.sessions) {
619 d.cursor = len(d.sessions) - 1
620 if d.cursor < 0 {
621 d.cursor = 0
622 }
623 }
624 case tui.KeyHome:
625 d.cursor = 0
626 case tui.KeyEnd:
627 if len(d.sessions) > 0 {
628 d.cursor = len(d.sessions) - 1
629 }
630 case tui.KeyEsc:
631 d.Close()
632 return sessionDialogAction{Close: true}
633 case tui.KeyEnter:
634 if len(d.sessions) == 0 {
635 d.Close()
636 return sessionDialogAction{Close: true}
637 }
638 s := d.sessions[d.cursor]
639 d.Close()
640 return sessionDialogAction{Select: true, Path: s.Path}
641 case tui.KeyRune:
642 if k.Rune == 'r' && !d.loading && len(d.sessions) > 0 {
643 s := d.sessions[d.cursor]
644 d.renaming = true
645 if s.Title != "" {
646 d.rename = s.Title
647 } else {
648 d.rename = ""
649 }
650 return sessionDialogAction{}
651 }
652 }
653 return sessionDialogAction{}
654}