v0.26.0 7e66a81

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

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