v0.11.2 619aabb

session_dialog.go RawHistoryFolder

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}

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