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