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}