v0.22.0 fae6c20

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

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 477

↑↓ moveenter openesc close