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}