v0.38.0 58133ea

session_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"sync"
8	"time"
9	"unicode/utf8"
10
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/tui"
13)
14
15type sessionLoadEventKind uint8
16
17const (
18	sessionLoadStarted sessionLoadEventKind = iota
19	sessionLoadEntry
20	sessionLoadFinished
21)
22
23type sessionSearchEventKind uint8
24
25const (
26	sessionSearchCorpusReady sessionSearchEventKind = iota
27	sessionSearchMatchesReady
28)
29
30type sessionSearchEvent struct {
31	kind            sessionSearchEventKind
32	generation      uint64
33	matchGeneration uint64
34	query           string
35	segments        []core.SessionSearchSegment
36	matches         map[string]sessionSearchMatch
37	skipped         int
38}
39
40type sessionSearchMatch struct {
41	count   int
42	excerpt string
43	indexes []int
44}
45
46type sessionLoadEvent struct {
47	kind       sessionLoadEventKind
48	generation uint64
49	total      int
50	paths      []string
51	index      int
52	summary    core.SessionSummary
53}
54
55type sessionLoadSlot struct {
56	loaded  bool
57	summary core.SessionSummary
58}
59
60type sessionLoadJob struct {
61	index int
62	path  string
63}
64
65const (
66	maxSessionLoadWorkers      = 8
67	minSessionSearchQueryRunes = 2
68)
69
70// sessionDialog is the inline picker shown when the user runs /sessions.
71type sessionDialog struct {
72	active       bool
73	sessions     []core.SessionSummary
74	baseSessions []core.SessionSummary
75	cursor       int
76	renaming     bool
77	rename       string
78	deleting     bool
79	// removedPaths remembers picker deletions for the dialog lifetime so
80	// in-flight load/search generations cannot resurrect a deleted row
81	// when they rebuild the visible list.
82	removedPaths map[string]bool
83
84	allScope         bool
85	root             string
86	cwd              string
87	paths            []string
88	query            string
89	searchRequested  bool
90	searching        bool
91	matching         bool
92	searchPending    bool
93	searchReady      bool
94	searchSpinner    *spinner
95	searchGeneration uint64
96	matchGeneration  uint64
97	searchContext    context.Context
98	searchCancel     context.CancelFunc
99	matchCancel      context.CancelFunc
100	searchEvents     chan sessionSearchEvent
101	searchSegments   []core.SessionSearchSegment
102	searchMatches    map[string]sessionSearchMatch
103	searchErr        string
104
105	loading          bool
106	loadingDone      int
107	loadingTotal     int
108	loadingStartedAt time.Time
109	loadGeneration   uint64
110	loadCancel       context.CancelFunc
111	loadDone         chan struct{}
112	// loadReadyThrough is the contiguous path prefix whose summaries have
113	// arrived. It prevents an older worker result from appearing before a
114	// newer session that is still being read.
115	loadSlots        []sessionLoadSlot
116	loadReadyThrough int
117
118	// describeSession is replaceable in tests so cancellation can be
119	// exercised without relying on file-size-dependent timing.
120	describeSession func(context.Context, string) core.SessionSummary
121
122	// MaxRows is the maximum number of session rows the dialog
123	// will render in a single frame. Set by the host right before
124	// Render based on the available chat space; if 0, the dialog
125	// falls back to rendering every row (original behaviour).
126	// When the list is longer than MaxRows the dialog scrolls so
127	// the cursor stays visible and tags the first/last visible
128	// entry with a muted "↑ N more" / "↓ N more" row so the user
129	// knows there's offscreen content.
130	MaxRows int
131
132	// viewTop is the index of the first session currently drawn.
133	// Adjusted to follow the cursor on up/down moves.
134	viewTop int
135}
136
137// sessionDialogAction is returned by HandleKey.
138type sessionDialogAction struct {
139	Select      bool
140	Delete      bool
141	Path        string
142	Close       bool
143	Renamed     bool
144	RenameTitle string
145	ToggleScope bool
146	StartSearch bool
147	Err         error
148}
149
150func newSessionDialog() *sessionDialog { return &sessionDialog{} }
151
152// Open shows the dialog immediately and loads session entries on bounded
153// background workers. Results are delivered to the caller so the UI goroutine
154// remains responsible for mutating dialog state and rendering it.
155func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope ...bool) <-chan sessionLoadEvent {
156	if d.loadCancel != nil {
157		d.loadCancel()
158	}
159	if d.searchCancel != nil {
160		d.searchCancel()
161		d.searchCancel = nil
162	}
163	if d.matchCancel != nil {
164		d.matchCancel()
165		d.matchCancel = nil
166	}
167	previousDone := d.loadDone
168	if parent == nil {
169		parent = context.Background()
170	}
171	ctx, cancel := context.WithCancel(parent)
172	d.loadCancel = cancel
173	d.loadGeneration++
174	generation := d.loadGeneration
175	done := make(chan struct{})
176	d.loadDone = done
177	d.sessions = nil
178	d.baseSessions = nil
179	d.cursor = 0
180	d.viewTop = 0
181	d.renaming = false
182	d.rename = ""
183	d.deleting = false
184	d.removedPaths = nil
185	d.root = root
186	d.cwd = cwd
187	d.allScope = len(allScope) > 0 && allScope[0]
188	d.paths = nil
189	d.query = ""
190	d.searchRequested = false
191	d.searching = false
192	d.matching = false
193	d.searchPending = false
194	d.searchSpinner = nil
195	d.searchReady = false
196	d.matchGeneration = 0
197	d.searchSegments = nil
198	d.searchMatches = nil
199	d.searchErr = ""
200	d.searchEvents = nil
201	d.loading = true
202	d.loadingDone = 0
203	d.loadingTotal = 0
204	d.loadingStartedAt = time.Now()
205	d.loadSlots = nil
206	d.loadReadyThrough = 0
207	d.active = true
208
209	if ctxErr := ctx.Err(); ctxErr != nil {
210		cancel()
211		d.loadCancel = nil
212		d.loading = false
213		d.active = false
214		d.loadDone = previousDone
215		events := make(chan sessionLoadEvent)
216		close(events)
217		close(done)
218		return events
219	}
220
221	events := make(chan sessionLoadEvent, 1)
222	send := func(event sessionLoadEvent) bool {
223		select {
224		case events <- event:
225			return true
226		case <-ctx.Done():
227			return false
228		}
229	}
230	describeSession := d.describeSession
231	if describeSession == nil {
232		describeSession = core.DescribeSessionContext
233	}
234	go func() {
235		defer close(done)
236		defer close(events)
237		if previousDone != nil {
238			<-previousDone
239		}
240		select {
241		case <-ctx.Done():
242			return
243		default:
244		}
245		paths := core.ListSessionPathsContext(ctx, root, cwd, len(allScope) > 0 && allScope[0])
246		if !send(sessionLoadEvent{
247			kind:       sessionLoadStarted,
248			generation: generation,
249			total:      len(paths),
250			paths:      paths,
251		}) {
252			return
253		}
254
255		workerCount := len(paths)
256		if workerCount > maxSessionLoadWorkers {
257			workerCount = maxSessionLoadWorkers
258		}
259		jobs := make(chan sessionLoadJob)
260		var wg sync.WaitGroup
261		wg.Add(workerCount)
262		for worker := 0; worker < workerCount; worker++ {
263			go func() {
264				defer wg.Done()
265				for {
266					select {
267					case <-ctx.Done():
268						return
269					case job, ok := <-jobs:
270						if !ok {
271							return
272						}
273						summary := describeSession(ctx, job.path)
274						select {
275						case <-ctx.Done():
276							return
277						default:
278						}
279						if !send(sessionLoadEvent{
280							kind:       sessionLoadEntry,
281							generation: generation,
282							index:      job.index,
283							summary:    summary,
284						}) {
285							return
286						}
287					}
288				}
289			}()
290		}
291	dispatch:
292		for index, path := range paths {
293			select {
294			case jobs <- sessionLoadJob{index: index, path: path}:
295			case <-ctx.Done():
296				break dispatch
297			}
298		}
299		close(jobs)
300		wg.Wait()
301		if ctx.Err() != nil {
302			return
303		}
304		send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation})
305	}()
306	return events
307}
308
309// ApplyLoad incorporates one result from Open. It must be called by the UI
310// goroutine so Render and HandleKey never race with background file reads.
311func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
312	if !d.active || event.generation != d.loadGeneration {
313		return
314	}
315	switch event.kind {
316	case sessionLoadStarted:
317		d.loadingTotal = event.total
318		d.loadingDone = 0
319		d.paths = append([]string(nil), event.paths...)
320		if d.searchPending {
321			d.searchPending = false
322			d.startSearchReader()
323		}
324		d.loadSlots = make([]sessionLoadSlot, event.total)
325		d.loadReadyThrough = 0
326		d.sessions = nil
327		d.baseSessions = nil
328	case sessionLoadEntry:
329		if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
330			return
331		}
332		d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}
333		d.loadingDone++
334		previousReady := d.loadReadyThrough
335		for d.loadReadyThrough < len(d.loadSlots) && d.loadSlots[d.loadReadyThrough].loaded {
336			d.loadReadyThrough++
337		}
338		d.appendLoadedSessions(previousReady, d.loadReadyThrough)
339	case sessionLoadFinished:
340		d.finishLoad(true)
341	}
342}
343
344// ApplyLoadClosed finalizes a load whose producer stopped without delivering a
345// terminal event, such as when its parent context is canceled.
346func (d *sessionDialog) ApplyLoadClosed() {
347	if !d.active || !d.loading {
348		return
349	}
350	d.finishLoad(false)
351	d.active = false
352}
353
354func (d *sessionDialog) finishLoad(complete bool) {
355	if complete {
356		d.rebuildLoadedSessions(len(d.loadSlots))
357	} else {
358		d.sessions = nil
359	}
360	d.loading = false
361	d.loadSlots = nil
362	d.loadReadyThrough = 0
363	if d.loadCancel != nil {
364		d.loadCancel()
365		d.loadCancel = nil
366	}
367}
368
369// appendLoadedSessions exposes the newly contiguous portion of the path
370// list. Paths are already newest-first, so this keeps the first visible entry
371// stable while workers finish out of order without rescanning old slots.
372func (d *sessionDialog) appendLoadedSessions(start, end int) {
373	limit := len(d.loadSlots)
374	if start < 0 {
375		start = 0
376	} else if start > limit {
377		start = limit
378	}
379	if end < 0 {
380		end = 0
381	} else if end > limit {
382		end = limit
383	}
384	if start >= end {
385		return
386	}
387	for _, slot := range d.loadSlots[start:end] {
388		if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 || d.removedPaths[slot.summary.Path] {
389			continue
390		}
391		d.baseSessions = append(d.baseSessions, slot.summary)
392		if !sessionSearchQuery(d.query) || !d.searchReady {
393			d.sessions = append(d.sessions, slot.summary)
394		}
395	}
396}
397
398func (d *sessionDialog) rebuildLoadedSessions(limit int) {
399	if limit > len(d.loadSlots) {
400		limit = len(d.loadSlots)
401	}
402	filtered := make([]core.SessionSummary, 0, limit)
403	for _, slot := range d.loadSlots[:limit] {
404		if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 || d.removedPaths[slot.summary.Path] {
405			continue
406		}
407		filtered = append(filtered, slot.summary)
408	}
409	d.baseSessions = filtered
410	if d.query == "" {
411		d.sessions = append([]core.SessionSummary(nil), filtered...)
412	} else {
413		d.applySearchFilter()
414	}
415	if d.cursor >= len(d.sessions) {
416		d.cursor = len(d.sessions) - 1
417	}
418	if d.cursor < 0 {
419		d.cursor = 0
420	}
421}
422
423// CursorPos returns the row/col for the terminal cursor when in
424// rename mode. Returns -1, -1 otherwise.
425func (d *sessionDialog) CursorPos() (row, col int) {
426	if !d.renaming {
427		return -1, -1
428	}
429	// Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed).
430	// Col: "  ▌ " prefix + text length.
431	return 3, 4 + len([]rune(d.rename))
432}
433
434// Close hides the dialog, cancels any in-flight session reads, and abandons
435// any pending edit or deletion.
436func (d *sessionDialog) Close() {
437	if d.loadCancel != nil {
438		d.loadCancel()
439		d.loadCancel = nil
440	}
441	if d.searchCancel != nil {
442		d.searchCancel()
443		d.searchCancel = nil
444	}
445	if d.matchCancel != nil {
446		d.matchCancel()
447		d.matchCancel = nil
448	}
449	d.loading = false
450	d.searchRequested = false
451	d.searching = false
452	d.matching = false
453	d.searchSpinner = nil
454	d.loadSlots = nil
455	d.loadReadyThrough = 0
456	d.searchEvents = nil
457	d.searchSegments = nil
458	d.searchMatches = nil
459	d.deleting = false
460	d.active = false
461}
462
463// StartSearch starts one background corpus read after the query reaches the
464// minimum length. Query edits reuse the retained corpus and never reopen a
465// session file.
466func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearchEvent {
467	if !sessionSearchQuery(d.query) {
468		return nil
469	}
470	if d.searchEvents != nil {
471		return d.searchEvents
472	}
473	if parent == nil {
474		parent = context.Background()
475	}
476	ctx, cancel := context.WithCancel(parent)
477	d.searchContext = ctx
478	d.searchCancel = cancel
479	d.searching = true
480	d.matching = false
481	d.searchSpinner = &spinner{startedAt: time.Now()}
482	d.searchGeneration++
483	events := make(chan sessionSearchEvent, 1)
484	d.searchEvents = events
485	if len(d.paths) == 0 && d.loading {
486		d.searchPending = true
487		return events
488	}
489	d.startSearchReader()
490	return events
491}
492
493func (d *sessionDialog) startSearchReader() {
494	if d.searchEvents == nil || d.searchCancel == nil {
495		return
496	}
497	ctx := d.searchContext
498	paths := append([]string(nil), d.paths...)
499	generation := d.searchGeneration
500	events := d.searchEvents
501	go func() {
502		segments := make([]core.SessionSearchSegment, 0)
503		skipped := 0
504		for _, path := range paths {
505			if ctx.Err() != nil {
506				return
507			}
508			read, err := core.ReadSessionSearchSegments(ctx, path)
509			if err != nil {
510				if ctx.Err() != nil {
511					return
512				}
513				skipped++
514				continue
515			}
516			segments = append(segments, read...)
517		}
518		select {
519		case events <- sessionSearchEvent{kind: sessionSearchCorpusReady, generation: generation, segments: segments, skipped: skipped}:
520		case <-ctx.Done():
521		}
522	}()
523}
524
525// ApplySearch incorporates corpus and match results on the UI goroutine.
526func (d *sessionDialog) ApplySearch(event sessionSearchEvent) {
527	if !d.active || event.generation != d.searchGeneration {
528		return
529	}
530	switch event.kind {
531	case sessionSearchCorpusReady:
532		d.searching = false
533		d.searchReady = true
534		if event.skipped > 0 {
535			d.searchErr = fmt.Sprintf("search skipped %d unreadable session(s)", event.skipped)
536		}
537		d.searchSegments = event.segments
538		d.scheduleSearchMatch()
539	case sessionSearchMatchesReady:
540		if event.query != d.query || event.matchGeneration != d.matchGeneration {
541			return
542		}
543		d.matching = false
544		d.searchMatches = event.matches
545		d.applySearchFilter()
546	}
547}
548
549func (d *sessionDialog) scheduleSearchMatch() {
550	if !d.searchReady || !sessionSearchQuery(d.query) || d.searchEvents == nil || d.searchCancel == nil {
551		return
552	}
553	if d.matchCancel != nil {
554		d.matchCancel()
555	}
556	d.matching = true
557	d.matchGeneration++
558	query := d.query
559	segments := d.searchSegments
560	generation := d.searchGeneration
561	matchGeneration := d.matchGeneration
562	events := d.searchEvents
563	ctx, cancel := context.WithCancel(d.searchContext)
564	d.matchCancel = cancel
565	go func() {
566		matches := matchSessionSearchSegments(ctx, query, segments)
567		if ctx.Err() != nil {
568			return
569		}
570		select {
571		case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, matchGeneration: matchGeneration, query: query, matches: matches}:
572		case <-ctx.Done():
573		}
574	}()
575}
576
577func matchSessionSearchSegments(ctx context.Context, query string, segments []core.SessionSearchSegment) map[string]sessionSearchMatch {
578	query = core.NormalizeSessionSearchText(query)
579	if !sessionSearchQuery(query) || ctx.Err() != nil {
580		return nil
581	}
582	matches := make(map[string]sessionSearchMatch)
583	for _, segment := range segments {
584		if ctx.Err() != nil {
585			return matches
586		}
587		text := segment.Normalized
588		if text == "" {
589			text = core.NormalizeSessionSearchText(segment.Text)
590		}
591		count, indexes := sessionSearchSubstringMatches(text, query)
592		if count == 0 {
593			continue
594		}
595		current := matches[segment.Path]
596		current.count += count
597		if current.excerpt == "" {
598			current.excerpt = text
599			current.indexes = indexes
600		}
601		matches[segment.Path] = current
602	}
603	return matches
604}
605
606func sessionSearchQuery(query string) bool {
607	return len([]rune(core.NormalizeSessionSearchText(query))) >= minSessionSearchQueryRunes
608}
609
610func sessionSearchSubstringMatches(text, query string) (int, []int) {
611	count := 0
612	var firstIndexes []int
613	for start := 0; start < len(text); {
614		offset := strings.Index(text[start:], query)
615		if offset < 0 {
616			break
617		}
618		index := start + offset
619		count++
620		if firstIndexes == nil {
621			firstIndexes = make([]int, 0, utf8.RuneCountInString(query))
622			for end := index; end < index+len(query); {
623				firstIndexes = append(firstIndexes, end)
624				_, size := utf8.DecodeRuneInString(text[end:])
625				end += size
626			}
627		}
628		start = index + len(query)
629	}
630	return count, firstIndexes
631}
632
633func (d *sessionDialog) applySearchFilter() {
634	if !sessionSearchQuery(d.query) || !d.searchReady {
635		d.sessions = append(d.sessions[:0], d.baseSessions...)
636	} else {
637		filtered := make([]core.SessionSummary, 0, len(d.baseSessions))
638		for _, summary := range d.baseSessions {
639			if _, ok := d.searchMatches[summary.Path]; ok {
640				filtered = append(filtered, summary)
641			}
642		}
643		d.sessions = filtered
644	}
645	// Belt and suspenders: a deletion confirmed while a search match or
646	// load event is being applied must not reappear in the visible rows.
647	if len(d.removedPaths) > 0 && len(d.sessions) > 0 {
648		kept := d.sessions[:0]
649		for _, summary := range d.sessions {
650			if !d.removedPaths[summary.Path] {
651				kept = append(kept, summary)
652			}
653		}
654		d.sessions = kept
655	}
656	if d.cursor >= len(d.sessions) {
657		d.cursor = max(0, len(d.sessions)-1)
658	}
659}
660
661// Active reports whether the dialog is visible and consumes input.
662func (d *sessionDialog) Active() bool { return d != nil && d.active }
663
664// Loading reports whether the dialog still has session entries in flight.
665func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading }
666
667// SearchLoading reports whether the dialog is reading or matching session text.
668func (d *sessionDialog) SearchLoading() bool {
669	return d != nil && d.active && (d.searching || d.matching)
670}
671
672// Render returns the dialog lines.
673func (d *sessionDialog) Render(th tui.Theme, width int) []string {
674	if !d.Active() {
675		return nil
676	}
677	var lines []string
678	title := "sessions · this directory"
679	if d.allScope {
680		title = "sessions · all"
681	}
682	lines = append(lines, frameHeader(th, title, width))
683	if d.SearchLoading() {
684		lines = append(lines, d.searchLoadingMessage(th))
685	}
686	if d.loading {
687		lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))
688		if len(d.sessions) == 0 {
689			lines = append(lines, th.FGColor(th.Muted, "session entries appear as they load"))
690			lines = append(lines, frameRule(th, width))
691			return lines
692		}
693	} else if len(d.sessions) == 0 {
694		message := "no previous sessions for this directory"
695		if d.allScope {
696			message = "no previous sessions in this namespace"
697		}
698		if sessionSearchQuery(d.query) && d.searchReady {
699			message = "no matching sessions"
700		}
701		lines = append(lines, th.FGColor(th.Muted, message))
702		lines = append(lines, th.FGColor(th.Muted, "tab changes scope · esc closes"))
703		lines = append(lines, frameRule(th, width))
704		return lines
705	}
706	if d.deleting && d.cursor >= 0 && d.cursor < len(d.sessions) {
707		s := d.sessions[d.cursor]
708		lines = append(lines, th.FGColor(th.Muted, "permanently delete this session?"))
709		lines = append(lines, "  "+formatSessionRowPlain(s, width-2))
710		lines = append(lines, th.FGColor(th.Muted, "press y to delete, enter/esc to cancel"))
711		lines = append(lines, frameRule(th, width))
712		return lines
713	}
714	if d.renaming {
715		lines = append(lines, th.FGColor(th.Muted, "rename session (enter save, esc cancel):"))
716		text := d.rename
717		if text == "" {
718			text = th.FGColor(th.Muted, "session name")
719		} else {
720			text = th.FGColor(th.FG, text)
721		}
722		lines = append(lines, "  "+th.FGColor(th.Accent, "▌ ")+text)
723		lines = append(lines, frameRule(th, width))
724		return lines
725	}
726	hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · d delete · esc cancel"
727	if d.searchRequested {
728		hint = "search: " + d.query + "  (esc clear · tab scope)"
729		if !sessionSearchQuery(d.query) {
730			hint += " · type 2+ characters"
731		}
732	}
733	if d.loading {
734		hint += " · loading"
735	}
736	lines = append(lines, th.FGColor(th.Muted, hint))
737	if d.searchErr != "" {
738		lines = append(lines, th.FGColor(th.Warning, d.searchErr))
739	}
740
741	// Viewport: windowed slice of d.sessions around d.cursor so a
742	// list taller than the terminal still scrolls. Caller sets
743	// MaxRows to the number of rows available for session entries
744	// (i.e. excluding the header, hint, chrome). When it's zero or
745	// bigger than the list, we draw everything.
746	total := len(d.sessions)
747	window := d.MaxRows
748	if window <= 0 || window >= total {
749		window = total
750	}
751	d.viewTop = clampViewTop(d.viewTop, d.cursor, window, total)
752	viewBot := d.viewTop + window
753	if viewBot > total {
754		viewBot = total
755	}
756
757	// Top indicator: how many rows are above the viewport.
758	if d.viewTop > 0 {
759		hidden := d.viewTop
760		lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf("  ↑ %d more above", hidden)))
761	}
762	for i := d.viewTop; i < viewBot; i++ {
763		s := d.sessions[i]
764		matchCount := 0
765		if sessionSearchQuery(d.query) {
766			matchCount = d.searchMatches[s.Path].count
767		}
768		plain := "  " + formatSessionSearchRowPlain(s, width-2, matchCount)
769		if i == d.cursor {
770			lines = append(lines, th.PadHighlight(plain, width))
771		} else {
772			lines = append(lines, th.FGColor(th.Muted, plain))
773		}
774	}
775	// Bottom indicator: how many rows are below the viewport.
776	if viewBot < total {
777		hidden := total - viewBot
778		lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf("  ↓ %d more below", hidden)))
779	}
780	if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" {
781		cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD))
782		lines = append(lines, th.FGColor(th.Muted, "  cwd: "+cwd))
783	}
784	if sessionSearchQuery(d.query) && d.cursor >= 0 && d.cursor < len(d.sessions) {
785		if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {
786			lines = append(lines, "  "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2))
787		}
788	}
789	lines = append(lines, frameRule(th, width))
790	return lines
791}
792
793// clampViewTop returns a viewTop that keeps cursor visible in a
794// window of the given size over a list of `total` rows. Leaves one
795// row of padding above/below where possible so moving the cursor
796// doesn't land right on the top/bottom edge — easier to see what
797// direction you're moving.
798func clampViewTop(viewTop, cursor, window, total int) int {
799	if window <= 0 || total <= 0 {
800		return 0
801	}
802	if window >= total {
803		return 0
804	}
805	pad := 2
806	if window < 6 {
807		pad = 0
808	}
809	if cursor < viewTop+pad {
810		viewTop = cursor - pad
811	}
812	if cursor >= viewTop+window-pad {
813		viewTop = cursor - window + pad + 1
814	}
815	if viewTop < 0 {
816		viewTop = 0
817	}
818	if viewTop+window > total {
819		viewTop = total - window
820	}
821	return viewTop
822}
823
824// formatSessionRowPlain returns the session row body without any ANSI
825// styling so the caller can wrap it in either a plain mute color or a
826// full-row selection highlight. The returned string is guaranteed to
827// fit within maxWidth visible characters so the terminal never soft-
828// wraps it into the next row.
829func formatSessionSearchRowPlain(s core.SessionSummary, maxWidth, matchCount int) string {
830	row := formatSessionRowPlain(s, maxWidth)
831	if matchCount == 0 {
832		return row
833	}
834	count := fmt.Sprintf("  %d matches", matchCount)
835	if len([]rune(row))+len([]rune(count)) > maxWidth {
836		return row
837	}
838	return row + count
839}
840
841func highlightSessionSearchExcerpt(th tui.Theme, excerpt string, indexes []int, maxWidth int) string {
842	limit := 240
843	if maxWidth > 0 && maxWidth < limit {
844		limit = maxWidth
845	}
846	excerpt, indexes = windowSessionSearchExcerpt(excerpt, indexes, limit)
847	if len(indexes) == 0 {
848		return th.FGColor(th.Muted, excerpt)
849	}
850	matched := make(map[int]bool, len(indexes))
851	for _, index := range indexes {
852		matched[index] = true
853	}
854	var out strings.Builder
855	for index := 0; index < len(excerpt); {
856		r, size := utf8.DecodeRuneInString(excerpt[index:])
857		part := string(r)
858		if matched[index] {
859			out.WriteString(th.FGColor(th.Accent, part))
860		} else {
861			out.WriteString(th.FGColor(th.Muted, part))
862		}
863		index += size
864	}
865	return out.String()
866}
867
868func windowSessionSearchExcerpt(text string, indexes []int, limit int) (string, []int) {
869	runes := []rune(text)
870	if limit <= 0 || len(runes) <= limit {
871		return text, indexes
872	}
873	matchRune := 0
874	if len(indexes) > 0 {
875		for runeIndex, byteIndex := range runeByteOffsets(text) {
876			if byteIndex <= indexes[0] {
877				matchRune = runeIndex
878			} else {
879				break
880			}
881		}
882	}
883	room := limit - 3
884	if room < 1 {
885		return string(runes[:limit]), nil
886	}
887	start := max(0, matchRune-room/2)
888	if start+room > len(runes) {
889		start = len(runes) - room
890	}
891	end := min(len(runes), start+room)
892	prefix := ""
893	if start > 0 {
894		prefix = "..."
895	}
896	suffix := ""
897	if end < len(runes) {
898		suffix = "..."
899	}
900	base := len(string(runes[:start]))
901	stop := len(string(runes[:end]))
902	rebased := make([]int, 0, len(indexes))
903	for _, index := range indexes {
904		if index >= base && index < stop {
905			rebased = append(rebased, len(prefix)+index-base)
906		}
907	}
908	return prefix + string(runes[start:end]) + suffix, rebased
909}
910
911func runeByteOffsets(text string) []int {
912	offsets := make([]int, 0, len(text))
913	for index := range text {
914		offsets = append(offsets, index)
915	}
916	return offsets
917}
918
919func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
920	when := formatRelative(s.Started)
921	summary := strings.TrimSpace(s.Title)
922	if summary == "" {
923		summary = strings.TrimSpace(s.FirstUserText)
924	}
925	if summary == "" {
926		summary = "(empty)"
927	}
928	provider := sanitizeSessionTreeText(s.Provider)
929	model := sanitizeSessionTreeText(s.Model)
930	summary = sanitizeSessionTreeText(summary)
931	left := fmt.Sprintf("%-14s  %s/%s  %d msgs  $%.4f  ",
932		when, provider, model, s.MessageCount, s.TotalCost)
933	room := maxWidth - len([]rune(left))
934	if room < 4 {
935		room = 4
936	}
937	runes := []rune(summary)
938	if len(runes) > room {
939		summary = string(runes[:room-3]) + "..."
940	}
941	row := left + summary
942	// Hard clamp: ensure the full row never exceeds maxWidth.
943	rowRunes := []rune(row)
944	if len(rowRunes) > maxWidth {
945		if maxWidth <= 3 {
946			row = strings.Repeat(".", maxWidth)
947		} else {
948			row = string(rowRunes[:maxWidth-3]) + "..."
949		}
950	}
951	return row
952}
953
954func (d *sessionDialog) searchLoadingMessage(th tui.Theme) string {
955	if d.searchSpinner == nil {
956		d.searchSpinner = &spinner{startedAt: time.Now()}
957	}
958	d.searchSpinner.Configure(th)
959	message := "Searching session text"
960	if d.searching {
961		message = "Indexing session text"
962	}
963	return th.FGColor(th.Spinner, d.searchSpinner.Frame()) + " " + th.FGColor(th.Muted, message+"…")
964}
965
966func (d *sessionDialog) loadingMessage(th tui.Theme) string {
967	frames := th.SpinnerFrames
968	if len(frames) == 0 {
969		frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
970	}
971	interval := th.SpinnerIntervalMS
972	if interval <= 0 {
973		interval = 80
974	}
975	idx := 0
976	if !d.loadingStartedAt.IsZero() {
977		elapsed := time.Since(d.loadingStartedAt)
978		if elapsed < 0 {
979			elapsed = 0
980		}
981		idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)
982	}
983	progress := "Loading sessions"
984	if d.loadingTotal > 0 {
985		progress = fmt.Sprintf("Loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
986	}
987	return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)")
988}
989
990func formatRelative(t time.Time) string {
991	if t.IsZero() {
992		return "unknown"
993	}
994	d := time.Since(t)
995	switch {
996	case d < time.Minute:
997		return "just now"
998	case d < time.Hour:
999		return fmt.Sprintf("%d min ago", int(d.Minutes()))
1000	case d < 24*time.Hour:
1001		return fmt.Sprintf("%d h ago", int(d.Hours()))
1002	case d < 7*24*time.Hour:
1003		return fmt.Sprintf("%d d ago", int(d.Hours()/24))
1004	default:
1005		return t.Local().Format("2006-01-02")
1006	}
1007}
1008
1009// HandleKey advances the dialog and returns an action to apply, if any.
1010func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
1011	// Deletion confirmation: y deletes, anything else (including enter and
1012	// esc) cancels without closing the picker.
1013	if d.deleting {
1014		switch k.Kind {
1015		case tui.KeyRune:
1016			if (k.Rune == 'y' || k.Rune == 'Y') && d.cursor >= 0 && d.cursor < len(d.sessions) {
1017				path := d.sessions[d.cursor].Path
1018				d.deleting = false
1019				return sessionDialogAction{Delete: true, Path: path}
1020			}
1021			d.deleting = false
1022		case tui.KeyEnter, tui.KeyEsc:
1023			d.deleting = false
1024		}
1025		return sessionDialogAction{}
1026	}
1027
1028	// Rename mode: type the new name.
1029	if d.renaming {
1030		switch k.Kind {
1031		case tui.KeyEnter:
1032			title := core.NormalizeSessionTitle(d.rename)
1033			path := ""
1034			renamed := false
1035			var renameErr error
1036			if title != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
1037				path = d.sessions[d.cursor].Path
1038				if err := core.RenameSession(path, title); err != nil {
1039					renameErr = err
1040				} else {
1041					d.sessions[d.cursor].Title = title
1042					for index := range d.baseSessions {
1043						if d.baseSessions[index].Path == path {
1044							d.baseSessions[index].Title = title
1045							break
1046						}
1047					}
1048					renamed = true
1049				}
1050			}
1051			d.renaming = false
1052			d.rename = ""
1053			return sessionDialogAction{Renamed: renamed, Path: path, RenameTitle: title, Err: renameErr}
1054		case tui.KeyEsc:
1055			d.renaming = false
1056			d.rename = ""
1057			return sessionDialogAction{}
1058		case tui.KeyBackspace:
1059			if len(d.rename) > 0 {
1060				r := []rune(d.rename)
1061				d.rename = string(r[:len(r)-1])
1062			}
1063			return sessionDialogAction{}
1064		case tui.KeyPaste:
1065			d.rename += k.Paste
1066			return sessionDialogAction{}
1067		case tui.KeyRune:
1068			if k.Rune != 0 {
1069				d.rename += string(k.Rune)
1070			}
1071			return sessionDialogAction{}
1072		}
1073		return sessionDialogAction{}
1074	}
1075
1076	if k.Kind == tui.KeyTab {
1077		return sessionDialogAction{ToggleScope: true}
1078	}
1079	if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" && !d.searchRequested {
1080		d.searchRequested = true
1081		return sessionDialogAction{}
1082	}
1083	if d.searchRequested {
1084		switch k.Kind {
1085		case tui.KeyEsc:
1086			if d.query == "" {
1087				d.Close()
1088				return sessionDialogAction{Close: true}
1089			}
1090			d.query = ""
1091			if d.matchCancel != nil {
1092				d.matchCancel()
1093				d.matchCancel = nil
1094			}
1095			d.matching = false
1096			d.searchMatches = nil
1097			d.applySearchFilter()
1098			return sessionDialogAction{}
1099		case tui.KeyBackspace:
1100			runes := []rune(d.query)
1101			if len(runes) > 0 {
1102				d.query = string(runes[:len(runes)-1])
1103			}
1104			if !sessionSearchQuery(d.query) {
1105				if d.matchCancel != nil {
1106					d.matchCancel()
1107					d.matchCancel = nil
1108				}
1109				d.matching = false
1110				d.searchMatches = nil
1111			}
1112			d.applySearchFilter()
1113			d.scheduleSearchMatch()
1114			return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
1115		case tui.KeyPaste:
1116			d.query += k.Paste
1117		case tui.KeyRune:
1118			if k.Rune != 0 {
1119				d.query += string(k.Rune)
1120			}
1121		default:
1122			// Navigation and selection operate on the current filtered rows.
1123			goto navigate
1124		}
1125		d.query = sanitizeSessionTreeText(d.query)
1126		if len([]rune(d.query)) > 256 {
1127			d.query = string([]rune(d.query)[:256])
1128		}
1129		d.applySearchFilter()
1130		d.scheduleSearchMatch()
1131		return sessionDialogAction{StartSearch: d.searchEvents == nil && sessionSearchQuery(d.query)}
1132	}
1133
1134	if d.loading && len(d.sessions) == 0 {
1135		if k.Kind == tui.KeyEsc {
1136			d.Close()
1137			return sessionDialogAction{Close: true}
1138		}
1139		return sessionDialogAction{}
1140	}
1141
1142navigate:
1143	page := d.MaxRows
1144	if page <= 0 {
1145		page = 10
1146	}
1147	if page > 1 {
1148		page--
1149	}
1150	switch k.Kind {
1151	case tui.KeyUp:
1152		if d.cursor > 0 {
1153			d.cursor--
1154		}
1155	case tui.KeyDown:
1156		if d.cursor < len(d.sessions)-1 {
1157			d.cursor++
1158		}
1159	case tui.KeyPageUp:
1160		d.cursor -= page
1161		if d.cursor < 0 {
1162			d.cursor = 0
1163		}
1164	case tui.KeyPageDown:
1165		d.cursor += page
1166		if d.cursor >= len(d.sessions) {
1167			d.cursor = len(d.sessions) - 1
1168			if d.cursor < 0 {
1169				d.cursor = 0
1170			}
1171		}
1172	case tui.KeyHome:
1173		d.cursor = 0
1174	case tui.KeyEnd:
1175		if len(d.sessions) > 0 {
1176			d.cursor = len(d.sessions) - 1
1177		}
1178	case tui.KeyEsc:
1179		d.Close()
1180		return sessionDialogAction{Close: true}
1181	case tui.KeyEnter:
1182		if len(d.sessions) == 0 {
1183			d.Close()
1184			return sessionDialogAction{Close: true}
1185		}
1186		s := d.sessions[d.cursor]
1187		d.Close()
1188		return sessionDialogAction{Select: true, Path: s.Path}
1189	case tui.KeyRune:
1190		if k.Rune == 'r' && !d.loading && len(d.sessions) > 0 {
1191			s := d.sessions[d.cursor]
1192			d.renaming = true
1193			if s.Title != "" {
1194				d.rename = s.Title
1195			} else {
1196				d.rename = ""
1197			}
1198			return sessionDialogAction{}
1199		}
1200		if k.Rune == 'd' && !d.loading && !d.searchRequested && len(d.sessions) > 0 {
1201			d.deleting = true
1202		}
1203	}
1204	return sessionDialogAction{}
1205}
1206
1207// Remove deletes path from every picker projection after the host has
1208// removed the corresponding file. The next row stays selected when
1209// possible and the dialog stays open. The path is remembered so an
1210// in-flight load or search generation cannot resurrect the row.
1211func (d *sessionDialog) Remove(path string) {
1212	if d.removedPaths == nil {
1213		d.removedPaths = map[string]bool{}
1214	}
1215	d.removedPaths[path] = true
1216	d.sessions = removeSessionSummary(d.sessions, path)
1217	d.baseSessions = removeSessionSummary(d.baseSessions, path)
1218	if d.cursor >= len(d.sessions) {
1219		d.cursor = len(d.sessions) - 1
1220	}
1221	if d.cursor < 0 {
1222		d.cursor = 0
1223	}
1224	d.viewTop = clampViewTop(d.viewTop, d.cursor, d.MaxRows, len(d.sessions))
1225}
1226
1227func removeSessionSummary(summaries []core.SessionSummary, path string) []core.SessionSummary {
1228	for idx := range summaries {
1229		if summaries[idx].Path != path {
1230			continue
1231		}
1232		return append(summaries[:idx], summaries[idx+1:]...)
1233	}
1234	return summaries
1235}

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