detached ff2ddd8

Merge pull request #24 from bnema/fix/async-session-picker

fix(agent): load session picker asynchronously
commit
ff2ddd8f4c5a1636e6f9a9cbd1c1a5d6b120a005
author
bnema · · 2026-08-05 09:07 UTC
committer
GitHub · · 2026-08-05 09:07 UTC
merge of
1513f55 + e7279a4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +670 -45
M README.md +1 -1
@@ -314,7 +314,7 @@ Type `!` followed by a command to run it directly without going through the mode
314314
315315 ### `/sessions`
316316
317-Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
317+Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Session entries load in the background while the picker stays responsive and shows a spinner until loading completes. While loading, `esc` cancels and closes the picker; other keys, including `up`, `down`, and `enter`, are ignored until loading finishes. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
318318
319319 ### `/session`
320320
@@ -314,7 +314,7 @@ Type `!` followed by a command to run it directly without going through the mode
314 314
315 ### `/sessions`315 ### `/sessions`
316 316
317-Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.317+Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Session entries load in the background while the picker stays responsive and shows a spinner until loading completes. While loading, `esc` cancels and closes the picker; other keys, including `up`, `down`, and `enter`, are ignored until loading finishes. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
318 318
319 ### `/session`319 ### `/session`
320 320
M packages/agent/modes/interactive.go +58 -12
@@ -696,6 +696,11 @@ type Interactive struct {
696696 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
697697 sessionLoading bool
698698
699+ // sessionLoads carries per-entry results for the /sessions picker. The
700+ // picker owns the load lifecycle; the interactive loop applies results so
701+ // rendering never races with background file reads.
702+ sessionLoads <-chan sessionLoadEvent
703+
699704 // pendingRescuePrompt / pendingRescueImages stash the prompt and
700705 // images that should be re-run after the user picks a model in
701706 // the rescue dialog. Cleared once applyRescueSelection consumes
@@ -848,6 +853,7 @@ func (i *Interactive) Run(ctx context.Context) error {
848853 defer restore()
849854 i.markInteractiveStarted()
850855 defer i.cancelSessionTitle()
856+ defer i.sessionDialog.Close()
851857 defer func() {
852858 if i.telegramBridge != nil {
853859 i.telegramBridge.Stop()
@@ -1071,6 +1077,18 @@ func (i *Interactive) Run(ctx context.Context) error {
10711077 i.invalidate()
10721078 }
10731079 changelog = nil // single-shot
1080+ case event, ok := <-i.sessionLoads:
1081+ i.mu.Lock()
1082+ if !ok {
1083+ i.sessionLoads = nil
1084+ i.sessionDialog.ApplyLoadClosed()
1085+ i.mu.Unlock()
1086+ i.invalidate()
1087+ break
1088+ }
1089+ i.sessionDialog.ApplyLoad(event)
1090+ i.mu.Unlock()
1091+ i.invalidate()
10741092 case <-i.dirty:
10751093 requestRedraw()
10761094 case <-tick.C:
@@ -1084,10 +1102,12 @@ func (i *Interactive) Run(ctx context.Context) error {
10841102 // Only force a periodic redraw when something is actually
10851103 // animating (the main spinner during a busy turn, or the
10861104 // btw side-chat spinner while it's awaiting a response).
1087- // Static pickers (model, session, jump, etc.) don't need
1088- // the tick and firing it cancels the terminal's cursor
1105+ // Static pickers (model, completed session, jump, etc.) don't
1106+ // need the tick and firing it cancels the terminal's cursor
10891107 // blink inside dialogs that host their own editor (btw),
10901108 // because each frame re-emits hide-cursor + show-cursor.
1109+ // A session picker that is still loading is an exception so
1110+ // its dialog-local spinner can advance.
10911111 //
10921112 // The subagent dashboard is also animated: its rows reflect
10931113 // background subprocesses whose activity / age change
@@ -1097,7 +1117,11 @@ func (i *Interactive) Run(ctx context.Context) error {
10971117 // of its inline editors (spawn task or prompt composer)
10981118 // is active so the cursor blink in those editors works
10991119 // the same way it does inside btw.
1100- if i.busy || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1120+ i.mu.Lock()
1121+ busy := i.busy
1122+ sessionLoading := i.sessionDialog.Loading()
1123+ i.mu.Unlock()
1124+ if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
11011125 requestRedraw()
11021126 }
11031127 }
@@ -1647,7 +1671,7 @@ func (i *Interactive) redraw() {
16471671 // in runSlash already handles the busy case per-command: safe
16481672 // ones run immediately, destructive ones cancel the turn first.
16491673 i.fileSuggest.SetCWD(i.cfg.CWD)
1650- mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive())
1674+ mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
16511675 if mainInputFocused && i.suggest.Active(currentInput) {
16521676 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
16531677 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
@@ -2322,6 +2346,12 @@ func (i *Interactive) toggleBtwToolExpansion() {
23222346 // confirmChildActive reports whether an interaction opened from slash input
23232347 // currently owns the keyboard while a tool confirmation remains pending.
23242348 func (i *Interactive) confirmChildActive() bool {
2349+ i.mu.Lock()
2350+ defer i.mu.Unlock()
2351+ return i.confirmChildActiveLocked()
2352+}
2353+
2354+func (i *Interactive) confirmChildActiveLocked() bool {
23252355 return len(i.helpBlock) > 0 ||
23262356 len(i.extNotes) > 0 ||
23272357 i.dialog.Active() ||
@@ -2501,20 +2531,34 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
25012531 i.invalidate()
25022532 return false
25032533 }
2504- if i.sessionDialog.Active() {
2534+ i.mu.Lock()
2535+ sessionDialogActive := i.sessionDialog.Active()
2536+ i.mu.Unlock()
2537+ if sessionDialogActive {
25052538 if k.Kind == tui.KeyCtrlC {
2539+ i.mu.Lock()
25062540 i.sessionDialog.Close()
2541+ i.sessionLoads = nil
2542+ i.mu.Unlock()
2543+ i.invalidate()
25072544 return false
25082545 }
2509- manualRenameCurrent := false
2546+ manualRenamePath := ""
2547+ i.mu.Lock()
25102548 if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {
2511- path := i.sessionDialog.sessions[i.sessionDialog.cursor].Path
2512- manualRenameCurrent = i.cfg.CurrentSessionPath() == path
2513- if manualRenameCurrent {
2514- i.markSessionTitleSwitching()
2515- }
2549+ manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
25162550 }
2551+ i.mu.Unlock()
2552+ manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
2553+ if manualRenameCurrent {
2554+ i.markSessionTitleSwitching()
2555+ }
2556+ i.mu.Lock()
25172557 act := i.sessionDialog.HandleKey(k)
2558+ if act.Select || act.Close {
2559+ i.sessionLoads = nil
2560+ }
2561+ i.mu.Unlock()
25182562 if act.Select {
25192563 i.applySessionSelection(act.Path)
25202564 }
@@ -5223,7 +5267,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
52235267 case "/settings":
52245268 i.openSettingsDialog()
52255269 case "/sessions":
5226- i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
5270+ i.mu.Lock()
5271+ i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
5272+ i.mu.Unlock()
52275273 case "/fork":
52285274 i.doSessionFork()
52295275 case "/jump":
@@ -696,6 +696,11 @@ type Interactive struct {
696 // lets ctrl+c/exit remain responsive for very large JSONL sessions.696 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
697 sessionLoading bool697 sessionLoading bool
698 698
699+ // sessionLoads carries per-entry results for the /sessions picker. The
700+ // picker owns the load lifecycle; the interactive loop applies results so
701+ // rendering never races with background file reads.
702+ sessionLoads <-chan sessionLoadEvent
703+
699 // pendingRescuePrompt / pendingRescueImages stash the prompt and704 // pendingRescuePrompt / pendingRescueImages stash the prompt and
700 // images that should be re-run after the user picks a model in705 // images that should be re-run after the user picks a model in
701 // the rescue dialog. Cleared once applyRescueSelection consumes706 // the rescue dialog. Cleared once applyRescueSelection consumes
@@ -848,6 +853,7 @@ func (i *Interactive) Run(ctx context.Context) error {
848 defer restore()853 defer restore()
849 i.markInteractiveStarted()854 i.markInteractiveStarted()
850 defer i.cancelSessionTitle()855 defer i.cancelSessionTitle()
856+ defer i.sessionDialog.Close()
851 defer func() {857 defer func() {
852 if i.telegramBridge != nil {858 if i.telegramBridge != nil {
853 i.telegramBridge.Stop()859 i.telegramBridge.Stop()
@@ -1071,6 +1077,18 @@ func (i *Interactive) Run(ctx context.Context) error {
1071 i.invalidate()1077 i.invalidate()
1072 }1078 }
1073 changelog = nil // single-shot1079 changelog = nil // single-shot
1080+ case event, ok := <-i.sessionLoads:
1081+ i.mu.Lock()
1082+ if !ok {
1083+ i.sessionLoads = nil
1084+ i.sessionDialog.ApplyLoadClosed()
1085+ i.mu.Unlock()
1086+ i.invalidate()
1087+ break
1088+ }
1089+ i.sessionDialog.ApplyLoad(event)
1090+ i.mu.Unlock()
1091+ i.invalidate()
1074 case <-i.dirty:1092 case <-i.dirty:
1075 requestRedraw()1093 requestRedraw()
1076 case <-tick.C:1094 case <-tick.C:
@@ -1084,10 +1102,12 @@ func (i *Interactive) Run(ctx context.Context) error {
1084 // Only force a periodic redraw when something is actually1102 // Only force a periodic redraw when something is actually
1085 // animating (the main spinner during a busy turn, or the1103 // animating (the main spinner during a busy turn, or the
1086 // btw side-chat spinner while it's awaiting a response).1104 // btw side-chat spinner while it's awaiting a response).
1087- // Static pickers (model, session, jump, etc.) don't need1105+ // Static pickers (model, completed session, jump, etc.) don't
1088- // the tick and firing it cancels the terminal's cursor1106+ // need the tick and firing it cancels the terminal's cursor
1089 // blink inside dialogs that host their own editor (btw),1107 // blink inside dialogs that host their own editor (btw),
1090 // because each frame re-emits hide-cursor + show-cursor.1108 // because each frame re-emits hide-cursor + show-cursor.
1109+ // A session picker that is still loading is an exception so
1110+ // its dialog-local spinner can advance.
1091 //1111 //
1092 // The subagent dashboard is also animated: its rows reflect1112 // The subagent dashboard is also animated: its rows reflect
1093 // background subprocesses whose activity / age change1113 // background subprocesses whose activity / age change
@@ -1097,7 +1117,11 @@ func (i *Interactive) Run(ctx context.Context) error {
1097 // of its inline editors (spawn task or prompt composer)1117 // of its inline editors (spawn task or prompt composer)
1098 // is active so the cursor blink in those editors works1118 // is active so the cursor blink in those editors works
1099 // the same way it does inside btw.1119 // the same way it does inside btw.
1100- if i.busy || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {1120+ i.mu.Lock()
1121+ busy := i.busy
1122+ sessionLoading := i.sessionDialog.Loading()
1123+ i.mu.Unlock()
1124+ if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1101 requestRedraw()1125 requestRedraw()
1102 }1126 }
1103 }1127 }
@@ -1647,7 +1671,7 @@ func (i *Interactive) redraw() {
1647 // in runSlash already handles the busy case per-command: safe1671 // in runSlash already handles the busy case per-command: safe
1648 // ones run immediately, destructive ones cancel the turn first.1672 // ones run immediately, destructive ones cancel the turn first.
1649 i.fileSuggest.SetCWD(i.cfg.CWD)1673 i.fileSuggest.SetCWD(i.cfg.CWD)
1650- mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive())1674+ mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
1651 if mainInputFocused && i.suggest.Active(currentInput) {1675 if mainInputFocused && i.suggest.Active(currentInput) {
1652 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)1676 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
1653 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {1677 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
@@ -2322,6 +2346,12 @@ func (i *Interactive) toggleBtwToolExpansion() {
2322 // confirmChildActive reports whether an interaction opened from slash input2346 // confirmChildActive reports whether an interaction opened from slash input
2323 // currently owns the keyboard while a tool confirmation remains pending.2347 // currently owns the keyboard while a tool confirmation remains pending.
2324 func (i *Interactive) confirmChildActive() bool {2348 func (i *Interactive) confirmChildActive() bool {
2349+ i.mu.Lock()
2350+ defer i.mu.Unlock()
2351+ return i.confirmChildActiveLocked()
2352+}
2353+
2354+func (i *Interactive) confirmChildActiveLocked() bool {
2325 return len(i.helpBlock) > 0 ||2355 return len(i.helpBlock) > 0 ||
2326 len(i.extNotes) > 0 ||2356 len(i.extNotes) > 0 ||
2327 i.dialog.Active() ||2357 i.dialog.Active() ||
@@ -2501,20 +2531,34 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2501 i.invalidate()2531 i.invalidate()
2502 return false2532 return false
2503 }2533 }
2504- if i.sessionDialog.Active() {2534+ i.mu.Lock()
2535+ sessionDialogActive := i.sessionDialog.Active()
2536+ i.mu.Unlock()
2537+ if sessionDialogActive {
2505 if k.Kind == tui.KeyCtrlC {2538 if k.Kind == tui.KeyCtrlC {
2539+ i.mu.Lock()
2506 i.sessionDialog.Close()2540 i.sessionDialog.Close()
2541+ i.sessionLoads = nil
2542+ i.mu.Unlock()
2543+ i.invalidate()
2507 return false2544 return false
2508 }2545 }
2509- manualRenameCurrent := false2546+ manualRenamePath := ""
2547+ i.mu.Lock()
2510 if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {2548 if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {
2511- path := i.sessionDialog.sessions[i.sessionDialog.cursor].Path2549+ manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
2512- manualRenameCurrent = i.cfg.CurrentSessionPath() == path
2513- if manualRenameCurrent {
2514- i.markSessionTitleSwitching()
2515- }
2516 }2550 }
2551+ i.mu.Unlock()
2552+ manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
2553+ if manualRenameCurrent {
2554+ i.markSessionTitleSwitching()
2555+ }
2556+ i.mu.Lock()
2517 act := i.sessionDialog.HandleKey(k)2557 act := i.sessionDialog.HandleKey(k)
2558+ if act.Select || act.Close {
2559+ i.sessionLoads = nil
2560+ }
2561+ i.mu.Unlock()
2518 if act.Select {2562 if act.Select {
2519 i.applySessionSelection(act.Path)2563 i.applySessionSelection(act.Path)
2520 }2564 }
@@ -5223,7 +5267,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5223 case "/settings":5267 case "/settings":
5224 i.openSettingsDialog()5268 i.openSettingsDialog()
5225 case "/sessions":5269 case "/sessions":
5226- i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)5270+ i.mu.Lock()
5271+ i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
5272+ i.mu.Unlock()
5227 case "/fork":5273 case "/fork":
5228 i.doSessionFork()5274 i.doSessionFork()
5229 case "/jump":5275 case "/jump":
M packages/agent/modes/session_agent_root_test.go +4 -0
@@ -33,6 +33,10 @@ func TestSessionsSlashUsesAgentSessionRoot(t *testing.T) {
3333 CWD: cwd,
3434 })
3535 i.runSlash(context.Background(), "/sessions")
36+ defer i.sessionDialog.Close()
37+ for event := range i.sessionLoads {
38+ i.sessionDialog.ApplyLoad(event)
39+ }
3640
3741 if got := len(i.sessionDialog.sessions); got != 1 {
3842 t.Fatalf("session picker entries = %d, want 1 from agent session root", got)
@@ -33,6 +33,10 @@ func TestSessionsSlashUsesAgentSessionRoot(t *testing.T) {
33 CWD: cwd,33 CWD: cwd,
34 })34 })
35 i.runSlash(context.Background(), "/sessions")35 i.runSlash(context.Background(), "/sessions")
36+ defer i.sessionDialog.Close()
37+ for event := range i.sessionLoads {
38+ i.sessionDialog.ApplyLoad(event)
39+ }
36 40
37 if got := len(i.sessionDialog.sessions); got != 1 {41 if got := len(i.sessionDialog.sessions); got != 1 {
38 t.Fatalf("session picker entries = %d, want 1 from agent session root", got)42 t.Fatalf("session picker entries = %d, want 1 from agent session root", got)
M packages/agent/modes/session_dialog.go +275 -20
@@ -1,14 +1,44 @@
11 package modes
22
33 import (
4+ "context"
45 "fmt"
56 "strings"
7+ "sync"
68 "time"
79
810 "github.com/patriceckhart/zot/packages/core"
911 "github.com/patriceckhart/zot/packages/tui"
1012 )
1113
14+type sessionLoadEventKind uint8
15+
16+const (
17+ sessionLoadStarted sessionLoadEventKind = iota
18+ sessionLoadEntry
19+ sessionLoadFinished
20+)
21+
22+type sessionLoadEvent struct {
23+ kind sessionLoadEventKind
24+ generation uint64
25+ total int
26+ index int
27+ summary core.SessionSummary
28+}
29+
30+type sessionLoadSlot struct {
31+ loaded bool
32+ summary core.SessionSummary
33+}
34+
35+type sessionLoadJob struct {
36+ index int
37+ path string
38+}
39+
40+const maxSessionLoadWorkers = 8
41+
1242 // sessionDialog is the inline picker shown when the user runs /sessions.
1343 type sessionDialog struct {
1444 active bool
@@ -17,6 +47,15 @@ type sessionDialog struct {
1747 renaming bool
1848 rename string
1949
50+ loading bool
51+ loadingDone int
52+ loadingTotal int
53+ loadingStartedAt time.Time
54+ loadGeneration uint64
55+ loadCancel context.CancelFunc
56+ loadDone chan struct{}
57+ loadSlots []sessionLoadSlot
58+
2059 // MaxRows is the maximum number of session rows the dialog
2160 // will render in a single frame. Set by the host right before
2261 // Render based on the available chat space; if 0, the dialog
@@ -44,24 +83,186 @@ type sessionDialogAction struct {
4483
4584 func newSessionDialog() *sessionDialog { return &sessionDialog{} }
4685
47-// Open populates the dialog from root + cwd and shows it. Empty
48-// sessions (zero messages) are filtered out so the currently-running
49-// session, a freshly-opened one that hasn't received a prompt yet,
50-// and any stale empties that haven't been pruned yet all stay out
51-// of the picker. Resuming an empty session is a no-op anyway.
52-func (d *sessionDialog) Open(root, cwd string) {
53- all := core.DescribeSessions(root, cwd)
54- filtered := make([]core.SessionSummary, 0, len(all))
55- for _, s := range all {
56- if s.MessageCount == 0 {
57- continue
58- }
59- filtered = append(filtered, s)
86+// Open shows the dialog immediately and loads session entries on bounded
87+// background workers. Results are delivered to the caller so the UI goroutine
88+// remains responsible for mutating dialog state and rendering it.
89+func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent {
90+ if d.loadCancel != nil {
91+ d.loadCancel()
6092 }
61- d.sessions = filtered
93+ previousDone := d.loadDone
94+ if parent == nil {
95+ parent = context.Background()
96+ }
97+ ctx, cancel := context.WithCancel(parent)
98+ d.loadCancel = cancel
99+ d.loadGeneration++
100+ generation := d.loadGeneration
101+ done := make(chan struct{})
102+ d.loadDone = done
103+ d.sessions = nil
62104 d.cursor = 0
63105 d.viewTop = 0
106+ d.renaming = false
107+ d.rename = ""
108+ d.loading = true
109+ d.loadingDone = 0
110+ d.loadingTotal = 0
111+ d.loadingStartedAt = time.Now()
112+ d.loadSlots = nil
64113 d.active = true
114+
115+ if ctxErr := ctx.Err(); ctxErr != nil {
116+ cancel()
117+ d.loadCancel = nil
118+ d.loading = false
119+ events := make(chan sessionLoadEvent)
120+ close(events)
121+ close(done)
122+ return events
123+ }
124+
125+ events := make(chan sessionLoadEvent, 1)
126+ send := func(event sessionLoadEvent) bool {
127+ select {
128+ case events <- event:
129+ return true
130+ case <-ctx.Done():
131+ return false
132+ }
133+ }
134+ go func() {
135+ defer close(done)
136+ defer close(events)
137+ if previousDone != nil {
138+ <-previousDone
139+ }
140+ select {
141+ case <-ctx.Done():
142+ return
143+ default:
144+ }
145+ paths := core.ListSessionsContext(ctx, root, cwd)
146+ if !send(sessionLoadEvent{
147+ kind: sessionLoadStarted,
148+ generation: generation,
149+ total: len(paths),
150+ }) {
151+ return
152+ }
153+
154+ workerCount := len(paths)
155+ if workerCount > maxSessionLoadWorkers {
156+ workerCount = maxSessionLoadWorkers
157+ }
158+ jobs := make(chan sessionLoadJob)
159+ var wg sync.WaitGroup
160+ wg.Add(workerCount)
161+ for worker := 0; worker < workerCount; worker++ {
162+ go func() {
163+ defer wg.Done()
164+ for {
165+ select {
166+ case <-ctx.Done():
167+ return
168+ case job, ok := <-jobs:
169+ if !ok {
170+ return
171+ }
172+ summary := core.DescribeSessionContext(ctx, job.path)
173+ select {
174+ case <-ctx.Done():
175+ return
176+ default:
177+ }
178+ if !send(sessionLoadEvent{
179+ kind: sessionLoadEntry,
180+ generation: generation,
181+ index: job.index,
182+ summary: summary,
183+ }) {
184+ return
185+ }
186+ }
187+ }
188+ }()
189+ }
190+ dispatch:
191+ for index, path := range paths {
192+ select {
193+ case jobs <- sessionLoadJob{index: index, path: path}:
194+ case <-ctx.Done():
195+ break dispatch
196+ }
197+ }
198+ close(jobs)
199+ wg.Wait()
200+ send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation})
201+ }()
202+ return events
203+}
204+
205+// ApplyLoad incorporates one result from Open. It must be called by the UI
206+// goroutine so Render and HandleKey never race with background file reads.
207+func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
208+ if !d.active || event.generation != d.loadGeneration {
209+ return
210+ }
211+ switch event.kind {
212+ case sessionLoadStarted:
213+ d.loadingTotal = event.total
214+ d.loadingDone = 0
215+ d.loadSlots = make([]sessionLoadSlot, event.total)
216+ d.sessions = nil
217+ case sessionLoadEntry:
218+ if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
219+ return
220+ }
221+ d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}
222+ d.loadingDone++
223+ case sessionLoadFinished:
224+ d.finishLoad(true)
225+ }
226+}
227+
228+// ApplyLoadClosed finalizes a load whose producer stopped without delivering a
229+// terminal event, such as when its parent context is canceled.
230+func (d *sessionDialog) ApplyLoadClosed() {
231+ if !d.active || !d.loading {
232+ return
233+ }
234+ d.finishLoad(false)
235+}
236+
237+func (d *sessionDialog) finishLoad(complete bool) {
238+ if complete {
239+ d.rebuildLoadedSessions()
240+ } else {
241+ d.sessions = nil
242+ }
243+ d.loading = false
244+ d.loadSlots = nil
245+ if d.loadCancel != nil {
246+ d.loadCancel()
247+ d.loadCancel = nil
248+ }
249+}
250+
251+func (d *sessionDialog) rebuildLoadedSessions() {
252+ filtered := make([]core.SessionSummary, 0, len(d.loadSlots))
253+ for _, slot := range d.loadSlots {
254+ if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
255+ continue
256+ }
257+ filtered = append(filtered, slot.summary)
258+ }
259+ d.sessions = filtered
260+ if d.cursor >= len(d.sessions) {
261+ d.cursor = len(d.sessions) - 1
262+ }
263+ if d.cursor < 0 {
264+ d.cursor = 0
265+ }
65266 }
66267
67268 // CursorPos returns the row/col for the terminal cursor when in
@@ -75,12 +276,23 @@ func (d *sessionDialog) CursorPos() (row, col int) {
75276 return 3, 4 + len([]rune(d.rename))
76277 }
77278
78-// Close hides the dialog.
79-func (d *sessionDialog) Close() { d.active = false }
279+// Close hides the dialog and cancels any in-flight session reads.
280+func (d *sessionDialog) Close() {
281+ if d.loadCancel != nil {
282+ d.loadCancel()
283+ d.loadCancel = nil
284+ }
285+ d.loading = false
286+ d.loadSlots = nil
287+ d.active = false
288+}
80289
81290 // Active reports whether the dialog is visible and consumes input.
82291 func (d *sessionDialog) Active() bool { return d != nil && d.active }
83292
293+// Loading reports whether the dialog still has session entries in flight.
294+func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading }
295+
84296 // Render returns the dialog lines.
85297 func (d *sessionDialog) Render(th tui.Theme, width int) []string {
86298 if !d.Active() {
@@ -88,7 +300,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
88300 }
89301 var lines []string
90302 lines = append(lines, frameHeader(th, "sessions", width))
91- if len(d.sessions) == 0 {
303+ if d.loading {
304+ lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th)))
305+ if len(d.sessions) == 0 {
306+ lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes"))
307+ lines = append(lines, frameRule(th, width))
308+ return lines
309+ }
310+ } else if len(d.sessions) == 0 {
92311 lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))
93312 lines = append(lines, th.FG256(th.Muted, "press esc to close"))
94313 lines = append(lines, frameRule(th, width))
@@ -106,7 +325,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
106325 lines = append(lines, frameRule(th, width))
107326 return lines
108327 }
109- lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))
328+ if !d.loading {
329+ lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))
330+ }
110331
111332 // Viewport: windowed slice of d.sessions around d.cursor so a
112333 // list taller than the terminal still scrolls. Caller sets
@@ -192,9 +413,11 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
192413 if summary == "" {
193414 summary = "(empty)"
194415 }
195- summary = strings.ReplaceAll(summary, "\n", " ")
416+ provider := sanitizeSessionTreeText(s.Provider)
417+ model := sanitizeSessionTreeText(s.Model)
418+ summary = sanitizeSessionTreeText(summary)
196419 left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ",
197- when, s.Provider, s.Model, s.MessageCount, s.TotalCost)
420+ when, provider, model, s.MessageCount, s.TotalCost)
198421 room := maxWidth - len([]rune(left))
199422 if room < 4 {
200423 room = 4
@@ -216,6 +439,30 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
216439 return row
217440 }
218441
442+func (d *sessionDialog) loadingMessage(th tui.Theme) string {
443+ frames := th.SpinnerFrames
444+ if len(frames) == 0 {
445+ frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
446+ }
447+ interval := th.SpinnerIntervalMS
448+ if interval <= 0 {
449+ interval = 80
450+ }
451+ idx := 0
452+ if !d.loadingStartedAt.IsZero() {
453+ elapsed := time.Since(d.loadingStartedAt)
454+ if elapsed < 0 {
455+ elapsed = 0
456+ }
457+ idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)
458+ }
459+ progress := "loading sessions"
460+ if d.loadingTotal > 0 {
461+ progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
462+ }
463+ return th.FG256(th.Spinner, frames[idx]) + " " + th.FG256(th.Muted, progress+" (esc cancel)")
464+}
465+
219466 func formatRelative(t time.Time) string {
220467 if t.IsZero() {
221468 return "unknown"
@@ -279,6 +526,14 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
279526 return sessionDialogAction{}
280527 }
281528
529+ if d.loading {
530+ if k.Kind == tui.KeyEsc {
531+ d.Close()
532+ return sessionDialogAction{Close: true}
533+ }
534+ return sessionDialogAction{}
535+ }
536+
282537 page := d.MaxRows
283538 if page <= 0 {
284539 page = 10
@@ -1,14 +1,44 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "strings"6 "strings"
7+ "sync"
6 "time"8 "time"
7 9
8 "github.com/patriceckhart/zot/packages/core"10 "github.com/patriceckhart/zot/packages/core"
9 "github.com/patriceckhart/zot/packages/tui"11 "github.com/patriceckhart/zot/packages/tui"
10 )12 )
11 13
14+type sessionLoadEventKind uint8
15+
16+const (
17+ sessionLoadStarted sessionLoadEventKind = iota
18+ sessionLoadEntry
19+ sessionLoadFinished
20+)
21+
22+type sessionLoadEvent struct {
23+ kind sessionLoadEventKind
24+ generation uint64
25+ total int
26+ index int
27+ summary core.SessionSummary
28+}
29+
30+type sessionLoadSlot struct {
31+ loaded bool
32+ summary core.SessionSummary
33+}
34+
35+type sessionLoadJob struct {
36+ index int
37+ path string
38+}
39+
40+const maxSessionLoadWorkers = 8
41+
12 // sessionDialog is the inline picker shown when the user runs /sessions.42 // sessionDialog is the inline picker shown when the user runs /sessions.
13 type sessionDialog struct {43 type sessionDialog struct {
14 active bool44 active bool
@@ -17,6 +47,15 @@ type sessionDialog struct {
17 renaming bool47 renaming bool
18 rename string48 rename string
19 49
50+ loading bool
51+ loadingDone int
52+ loadingTotal int
53+ loadingStartedAt time.Time
54+ loadGeneration uint64
55+ loadCancel context.CancelFunc
56+ loadDone chan struct{}
57+ loadSlots []sessionLoadSlot
58+
20 // MaxRows is the maximum number of session rows the dialog59 // MaxRows is the maximum number of session rows the dialog
21 // will render in a single frame. Set by the host right before60 // will render in a single frame. Set by the host right before
22 // Render based on the available chat space; if 0, the dialog61 // Render based on the available chat space; if 0, the dialog
@@ -44,24 +83,186 @@ type sessionDialogAction struct {
44 83
45 func newSessionDialog() *sessionDialog { return &sessionDialog{} }84 func newSessionDialog() *sessionDialog { return &sessionDialog{} }
46 85
47-// Open populates the dialog from root + cwd and shows it. Empty86+// Open shows the dialog immediately and loads session entries on bounded
48-// sessions (zero messages) are filtered out so the currently-running87+// background workers. Results are delivered to the caller so the UI goroutine
49-// session, a freshly-opened one that hasn't received a prompt yet,88+// remains responsible for mutating dialog state and rendering it.
50-// and any stale empties that haven't been pruned yet all stay out89+func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent {
51-// of the picker. Resuming an empty session is a no-op anyway.90+ if d.loadCancel != nil {
52-func (d *sessionDialog) Open(root, cwd string) {91+ d.loadCancel()
53- all := core.DescribeSessions(root, cwd)
54- filtered := make([]core.SessionSummary, 0, len(all))
55- for _, s := range all {
56- if s.MessageCount == 0 {
57- continue
58- }
59- filtered = append(filtered, s)
60 }92 }
61- d.sessions = filtered93+ previousDone := d.loadDone
94+ if parent == nil {
95+ parent = context.Background()
96+ }
97+ ctx, cancel := context.WithCancel(parent)
98+ d.loadCancel = cancel
99+ d.loadGeneration++
100+ generation := d.loadGeneration
101+ done := make(chan struct{})
102+ d.loadDone = done
103+ d.sessions = nil
62 d.cursor = 0104 d.cursor = 0
63 d.viewTop = 0105 d.viewTop = 0
106+ d.renaming = false
107+ d.rename = ""
108+ d.loading = true
109+ d.loadingDone = 0
110+ d.loadingTotal = 0
111+ d.loadingStartedAt = time.Now()
112+ d.loadSlots = nil
64 d.active = true113 d.active = true
114+
115+ if ctxErr := ctx.Err(); ctxErr != nil {
116+ cancel()
117+ d.loadCancel = nil
118+ d.loading = false
119+ events := make(chan sessionLoadEvent)
120+ close(events)
121+ close(done)
122+ return events
123+ }
124+
125+ events := make(chan sessionLoadEvent, 1)
126+ send := func(event sessionLoadEvent) bool {
127+ select {
128+ case events <- event:
129+ return true
130+ case <-ctx.Done():
131+ return false
132+ }
133+ }
134+ go func() {
135+ defer close(done)
136+ defer close(events)
137+ if previousDone != nil {
138+ <-previousDone
139+ }
140+ select {
141+ case <-ctx.Done():
142+ return
143+ default:
144+ }
145+ paths := core.ListSessionsContext(ctx, root, cwd)
146+ if !send(sessionLoadEvent{
147+ kind: sessionLoadStarted,
148+ generation: generation,
149+ total: len(paths),
150+ }) {
151+ return
152+ }
153+
154+ workerCount := len(paths)
155+ if workerCount > maxSessionLoadWorkers {
156+ workerCount = maxSessionLoadWorkers
157+ }
158+ jobs := make(chan sessionLoadJob)
159+ var wg sync.WaitGroup
160+ wg.Add(workerCount)
161+ for worker := 0; worker < workerCount; worker++ {
162+ go func() {
163+ defer wg.Done()
164+ for {
165+ select {
166+ case <-ctx.Done():
167+ return
168+ case job, ok := <-jobs:
169+ if !ok {
170+ return
171+ }
172+ summary := core.DescribeSessionContext(ctx, job.path)
173+ select {
174+ case <-ctx.Done():
175+ return
176+ default:
177+ }
178+ if !send(sessionLoadEvent{
179+ kind: sessionLoadEntry,
180+ generation: generation,
181+ index: job.index,
182+ summary: summary,
183+ }) {
184+ return
185+ }
186+ }
187+ }
188+ }()
189+ }
190+ dispatch:
191+ for index, path := range paths {
192+ select {
193+ case jobs <- sessionLoadJob{index: index, path: path}:
194+ case <-ctx.Done():
195+ break dispatch
196+ }
197+ }
198+ close(jobs)
199+ wg.Wait()
200+ send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation})
201+ }()
202+ return events
203+}
204+
205+// ApplyLoad incorporates one result from Open. It must be called by the UI
206+// goroutine so Render and HandleKey never race with background file reads.
207+func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
208+ if !d.active || event.generation != d.loadGeneration {
209+ return
210+ }
211+ switch event.kind {
212+ case sessionLoadStarted:
213+ d.loadingTotal = event.total
214+ d.loadingDone = 0
215+ d.loadSlots = make([]sessionLoadSlot, event.total)
216+ d.sessions = nil
217+ case sessionLoadEntry:
218+ if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
219+ return
220+ }
221+ d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}
222+ d.loadingDone++
223+ case sessionLoadFinished:
224+ d.finishLoad(true)
225+ }
226+}
227+
228+// ApplyLoadClosed finalizes a load whose producer stopped without delivering a
229+// terminal event, such as when its parent context is canceled.
230+func (d *sessionDialog) ApplyLoadClosed() {
231+ if !d.active || !d.loading {
232+ return
233+ }
234+ d.finishLoad(false)
235+}
236+
237+func (d *sessionDialog) finishLoad(complete bool) {
238+ if complete {
239+ d.rebuildLoadedSessions()
240+ } else {
241+ d.sessions = nil
242+ }
243+ d.loading = false
244+ d.loadSlots = nil
245+ if d.loadCancel != nil {
246+ d.loadCancel()
247+ d.loadCancel = nil
248+ }
249+}
250+
251+func (d *sessionDialog) rebuildLoadedSessions() {
252+ filtered := make([]core.SessionSummary, 0, len(d.loadSlots))
253+ for _, slot := range d.loadSlots {
254+ if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
255+ continue
256+ }
257+ filtered = append(filtered, slot.summary)
258+ }
259+ d.sessions = filtered
260+ if d.cursor >= len(d.sessions) {
261+ d.cursor = len(d.sessions) - 1
262+ }
263+ if d.cursor < 0 {
264+ d.cursor = 0
265+ }
65 }266 }
66 267
67 // CursorPos returns the row/col for the terminal cursor when in268 // CursorPos returns the row/col for the terminal cursor when in
@@ -75,12 +276,23 @@ func (d *sessionDialog) CursorPos() (row, col int) {
75 return 3, 4 + len([]rune(d.rename))276 return 3, 4 + len([]rune(d.rename))
76 }277 }
77 278
78-// Close hides the dialog.279+// Close hides the dialog and cancels any in-flight session reads.
79-func (d *sessionDialog) Close() { d.active = false }280+func (d *sessionDialog) Close() {
281+ if d.loadCancel != nil {
282+ d.loadCancel()
283+ d.loadCancel = nil
284+ }
285+ d.loading = false
286+ d.loadSlots = nil
287+ d.active = false
288+}
80 289
81 // Active reports whether the dialog is visible and consumes input.290 // Active reports whether the dialog is visible and consumes input.
82 func (d *sessionDialog) Active() bool { return d != nil && d.active }291 func (d *sessionDialog) Active() bool { return d != nil && d.active }
83 292
293+// Loading reports whether the dialog still has session entries in flight.
294+func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading }
295+
84 // Render returns the dialog lines.296 // Render returns the dialog lines.
85 func (d *sessionDialog) Render(th tui.Theme, width int) []string {297 func (d *sessionDialog) Render(th tui.Theme, width int) []string {
86 if !d.Active() {298 if !d.Active() {
@@ -88,7 +300,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
88 }300 }
89 var lines []string301 var lines []string
90 lines = append(lines, frameHeader(th, "sessions", width))302 lines = append(lines, frameHeader(th, "sessions", width))
91- if len(d.sessions) == 0 {303+ if d.loading {
304+ lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th)))
305+ if len(d.sessions) == 0 {
306+ lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes"))
307+ lines = append(lines, frameRule(th, width))
308+ return lines
309+ }
310+ } else if len(d.sessions) == 0 {
92 lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))311 lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))
93 lines = append(lines, th.FG256(th.Muted, "press esc to close"))312 lines = append(lines, th.FG256(th.Muted, "press esc to close"))
94 lines = append(lines, frameRule(th, width))313 lines = append(lines, frameRule(th, width))
@@ -106,7 +325,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
106 lines = append(lines, frameRule(th, width))325 lines = append(lines, frameRule(th, width))
107 return lines326 return lines
108 }327 }
109- lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))328+ if !d.loading {
329+ lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))
330+ }
110 331
111 // Viewport: windowed slice of d.sessions around d.cursor so a332 // Viewport: windowed slice of d.sessions around d.cursor so a
112 // list taller than the terminal still scrolls. Caller sets333 // list taller than the terminal still scrolls. Caller sets
@@ -192,9 +413,11 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
192 if summary == "" {413 if summary == "" {
193 summary = "(empty)"414 summary = "(empty)"
194 }415 }
195- summary = strings.ReplaceAll(summary, "\n", " ")416+ provider := sanitizeSessionTreeText(s.Provider)
417+ model := sanitizeSessionTreeText(s.Model)
418+ summary = sanitizeSessionTreeText(summary)
196 left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ",419 left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ",
197- when, s.Provider, s.Model, s.MessageCount, s.TotalCost)420+ when, provider, model, s.MessageCount, s.TotalCost)
198 room := maxWidth - len([]rune(left))421 room := maxWidth - len([]rune(left))
199 if room < 4 {422 if room < 4 {
200 room = 4423 room = 4
@@ -216,6 +439,30 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
216 return row439 return row
217 }440 }
218 441
442+func (d *sessionDialog) loadingMessage(th tui.Theme) string {
443+ frames := th.SpinnerFrames
444+ if len(frames) == 0 {
445+ frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
446+ }
447+ interval := th.SpinnerIntervalMS
448+ if interval <= 0 {
449+ interval = 80
450+ }
451+ idx := 0
452+ if !d.loadingStartedAt.IsZero() {
453+ elapsed := time.Since(d.loadingStartedAt)
454+ if elapsed < 0 {
455+ elapsed = 0
456+ }
457+ idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)
458+ }
459+ progress := "loading sessions"
460+ if d.loadingTotal > 0 {
461+ progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
462+ }
463+ return th.FG256(th.Spinner, frames[idx]) + " " + th.FG256(th.Muted, progress+" (esc cancel)")
464+}
465+
219 func formatRelative(t time.Time) string {466 func formatRelative(t time.Time) string {
220 if t.IsZero() {467 if t.IsZero() {
221 return "unknown"468 return "unknown"
@@ -279,6 +526,14 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
279 return sessionDialogAction{}526 return sessionDialogAction{}
280 }527 }
281 528
529+ if d.loading {
530+ if k.Kind == tui.KeyEsc {
531+ d.Close()
532+ return sessionDialogAction{Close: true}
533+ }
534+ return sessionDialogAction{}
535+ }
536+
282 page := d.MaxRows537 page := d.MaxRows
283 if page <= 0 {538 if page <= 0 {
284 page = 10539 page = 10
A packages/agent/modes/session_dialog_test.go +191 -0
@@ -0,0 +1,191 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "unicode"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+ "github.com/patriceckhart/zot/packages/tui"
12+)
13+
14+func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
15+ row := formatSessionRowPlain(core.SessionSummary{
16+ Provider: "test\x1b]0;bad\a",
17+ Model: "model\x1b[31m",
18+ MessageCount: 1,
19+ FirstUserText: "hello\x1b[2J\nworld",
20+ }, 120)
21+ if strings.IndexFunc(row, unicode.IsControl) >= 0 {
22+ t.Fatalf("session row contains control characters: %q", row)
23+ }
24+ if !strings.Contains(row, "hello world") {
25+ t.Fatalf("sanitized session text missing: %q", row)
26+ }
27+}
28+
29+func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
30+ root := t.TempDir()
31+ cwd := t.TempDir()
32+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := session.AppendMessage(provider.Message{
37+ Role: provider.RoleUser,
38+ Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
39+ }); err != nil {
40+ t.Fatal(err)
41+ }
42+ if err := session.Close(); err != nil {
43+ t.Fatal(err)
44+ }
45+
46+ d := newSessionDialog()
47+ events := d.Open(context.Background(), root, cwd)
48+ t.Cleanup(d.Close)
49+ if !d.Active() || !d.Loading() {
50+ t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
51+ }
52+ loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
53+ if !strings.Contains(loadingText, "loading sessions") {
54+ t.Fatalf("loading render = %q, want spinner status", loadingText)
55+ }
56+ act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
57+ if act.Select || !d.Active() {
58+ t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
59+ }
60+
61+ for event := range events {
62+ d.ApplyLoad(event)
63+ }
64+
65+ if d.Loading() {
66+ t.Fatal("dialog still loading after final result")
67+ }
68+ if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
69+ t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
70+ }
71+ loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
72+ if strings.Contains(loadedText, "loading sessions") {
73+ t.Fatalf("completed render still shows loading spinner: %q", loadedText)
74+ }
75+ if !strings.Contains(loadedText, "load this session") {
76+ t.Fatalf("completed render missing session text: %q", loadedText)
77+ }
78+}
79+
80+func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
81+ ctx, cancel := context.WithCancel(context.Background())
82+ cancel()
83+
84+ d := newSessionDialog()
85+ events := d.Open(ctx, t.TempDir(), t.TempDir())
86+ if d.Loading() {
87+ t.Fatal("dialog remained loading with an already-canceled parent")
88+ }
89+ if _, ok := <-events; ok {
90+ t.Fatal("canceled dialog emitted a load event")
91+ }
92+}
93+
94+func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
95+ ctx, cancel := context.WithCancel(context.Background())
96+ d := newSessionDialog()
97+ events := d.Open(ctx, t.TempDir(), t.TempDir())
98+ cancel()
99+ for range events {
100+ }
101+ d.ApplyLoadClosed()
102+ if d.Loading() {
103+ t.Fatal("dialog remained loading after parent cancellation")
104+ }
105+}
106+
107+func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
108+ d := newSessionDialog()
109+ events := d.Open(context.Background(), t.TempDir(), t.TempDir())
110+ act := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
111+ if !act.Close || d.Active() || d.Loading() {
112+ t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading())
113+ }
114+ for range events {
115+ }
116+}
117+
118+func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
119+ d := newSessionDialog()
120+ d.active = true
121+ d.loading = true
122+ d.loadGeneration = 7
123+
124+ d.ApplyLoad(sessionLoadEvent{
125+ kind: sessionLoadStarted,
126+ generation: 7,
127+ total: 4,
128+ })
129+ d.ApplyLoad(sessionLoadEvent{
130+ kind: sessionLoadEntry,
131+ generation: 7,
132+ index: 2,
133+ summary: core.SessionSummary{Path: "third", MessageCount: 1},
134+ })
135+ d.ApplyLoad(sessionLoadEvent{
136+ kind: sessionLoadEntry,
137+ generation: 7,
138+ index: 1,
139+ summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
140+ })
141+ d.ApplyLoad(sessionLoadEvent{
142+ kind: sessionLoadEntry,
143+ generation: 7,
144+ index: 3,
145+ summary: core.SessionSummary{Path: "empty"},
146+ })
147+ d.ApplyLoad(sessionLoadEvent{
148+ kind: sessionLoadEntry,
149+ generation: 7,
150+ index: 0,
151+ summary: core.SessionSummary{Path: "first", MessageCount: 1},
152+ })
153+ d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
154+
155+ if d.Loading() {
156+ t.Fatal("dialog still loading after final result")
157+ }
158+ if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
159+ t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
160+ }
161+}
162+
163+func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
164+ first := newSessionDialog()
165+ firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
166+ firstGeneration := first.loadGeneration
167+
168+ secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
169+ if first.loadGeneration == firstGeneration {
170+ t.Fatal("reopening picker did not advance load generation")
171+ }
172+
173+ first.ApplyLoad(sessionLoadEvent{
174+ kind: sessionLoadEntry,
175+ generation: firstGeneration,
176+ index: 0,
177+ summary: core.SessionSummary{
178+ Path: "stale",
179+ MessageCount: 1,
180+ },
181+ })
182+ if len(first.sessions) != 0 {
183+ t.Fatalf("stale results repopulated picker: %+v", first.sessions)
184+ }
185+
186+ first.Close()
187+ for range firstEvents {
188+ }
189+ for range secondEvents {
190+ }
191+}
@@ -0,0 +1,191 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "unicode"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+ "github.com/patriceckhart/zot/packages/tui"
12+)
13+
14+func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
15+ row := formatSessionRowPlain(core.SessionSummary{
16+ Provider: "test\x1b]0;bad\a",
17+ Model: "model\x1b[31m",
18+ MessageCount: 1,
19+ FirstUserText: "hello\x1b[2J\nworld",
20+ }, 120)
21+ if strings.IndexFunc(row, unicode.IsControl) >= 0 {
22+ t.Fatalf("session row contains control characters: %q", row)
23+ }
24+ if !strings.Contains(row, "hello world") {
25+ t.Fatalf("sanitized session text missing: %q", row)
26+ }
27+}
28+
29+func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
30+ root := t.TempDir()
31+ cwd := t.TempDir()
32+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := session.AppendMessage(provider.Message{
37+ Role: provider.RoleUser,
38+ Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
39+ }); err != nil {
40+ t.Fatal(err)
41+ }
42+ if err := session.Close(); err != nil {
43+ t.Fatal(err)
44+ }
45+
46+ d := newSessionDialog()
47+ events := d.Open(context.Background(), root, cwd)
48+ t.Cleanup(d.Close)
49+ if !d.Active() || !d.Loading() {
50+ t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
51+ }
52+ loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
53+ if !strings.Contains(loadingText, "loading sessions") {
54+ t.Fatalf("loading render = %q, want spinner status", loadingText)
55+ }
56+ act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
57+ if act.Select || !d.Active() {
58+ t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
59+ }
60+
61+ for event := range events {
62+ d.ApplyLoad(event)
63+ }
64+
65+ if d.Loading() {
66+ t.Fatal("dialog still loading after final result")
67+ }
68+ if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
69+ t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
70+ }
71+ loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
72+ if strings.Contains(loadedText, "loading sessions") {
73+ t.Fatalf("completed render still shows loading spinner: %q", loadedText)
74+ }
75+ if !strings.Contains(loadedText, "load this session") {
76+ t.Fatalf("completed render missing session text: %q", loadedText)
77+ }
78+}
79+
80+func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
81+ ctx, cancel := context.WithCancel(context.Background())
82+ cancel()
83+
84+ d := newSessionDialog()
85+ events := d.Open(ctx, t.TempDir(), t.TempDir())
86+ if d.Loading() {
87+ t.Fatal("dialog remained loading with an already-canceled parent")
88+ }
89+ if _, ok := <-events; ok {
90+ t.Fatal("canceled dialog emitted a load event")
91+ }
92+}
93+
94+func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
95+ ctx, cancel := context.WithCancel(context.Background())
96+ d := newSessionDialog()
97+ events := d.Open(ctx, t.TempDir(), t.TempDir())
98+ cancel()
99+ for range events {
100+ }
101+ d.ApplyLoadClosed()
102+ if d.Loading() {
103+ t.Fatal("dialog remained loading after parent cancellation")
104+ }
105+}
106+
107+func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
108+ d := newSessionDialog()
109+ events := d.Open(context.Background(), t.TempDir(), t.TempDir())
110+ act := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
111+ if !act.Close || d.Active() || d.Loading() {
112+ t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading())
113+ }
114+ for range events {
115+ }
116+}
117+
118+func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
119+ d := newSessionDialog()
120+ d.active = true
121+ d.loading = true
122+ d.loadGeneration = 7
123+
124+ d.ApplyLoad(sessionLoadEvent{
125+ kind: sessionLoadStarted,
126+ generation: 7,
127+ total: 4,
128+ })
129+ d.ApplyLoad(sessionLoadEvent{
130+ kind: sessionLoadEntry,
131+ generation: 7,
132+ index: 2,
133+ summary: core.SessionSummary{Path: "third", MessageCount: 1},
134+ })
135+ d.ApplyLoad(sessionLoadEvent{
136+ kind: sessionLoadEntry,
137+ generation: 7,
138+ index: 1,
139+ summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
140+ })
141+ d.ApplyLoad(sessionLoadEvent{
142+ kind: sessionLoadEntry,
143+ generation: 7,
144+ index: 3,
145+ summary: core.SessionSummary{Path: "empty"},
146+ })
147+ d.ApplyLoad(sessionLoadEvent{
148+ kind: sessionLoadEntry,
149+ generation: 7,
150+ index: 0,
151+ summary: core.SessionSummary{Path: "first", MessageCount: 1},
152+ })
153+ d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
154+
155+ if d.Loading() {
156+ t.Fatal("dialog still loading after final result")
157+ }
158+ if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
159+ t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
160+ }
161+}
162+
163+func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
164+ first := newSessionDialog()
165+ firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
166+ firstGeneration := first.loadGeneration
167+
168+ secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
169+ if first.loadGeneration == firstGeneration {
170+ t.Fatal("reopening picker did not advance load generation")
171+ }
172+
173+ first.ApplyLoad(sessionLoadEvent{
174+ kind: sessionLoadEntry,
175+ generation: firstGeneration,
176+ index: 0,
177+ summary: core.SessionSummary{
178+ Path: "stale",
179+ MessageCount: 1,
180+ },
181+ })
182+ if len(first.sessions) != 0 {
183+ t.Fatalf("stale results repopulated picker: %+v", first.sessions)
184+ }
185+
186+ first.Close()
187+ for range firstEvents {
188+ }
189+ for range secondEvents {
190+ }
191+}
M packages/core/session.go +98 -12
@@ -3,6 +3,7 @@ package core
33 import (
44 "bufio"
55 "bytes"
6+ "context"
67 "crypto/sha256"
78 "encoding/hex"
89 "encoding/json"
@@ -190,8 +191,15 @@ func newSessionAt(p, cwd, providerName, model, version string) (*Session, error)
190191 }
191192
192193 func forEachJSONLLine(r io.Reader, fn func([]byte) error) error {
194+ return forEachJSONLLineContext(context.Background(), r, fn)
195+}
196+
197+func forEachJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte) error) error {
193198 br := bufio.NewReader(r)
194199 for {
200+ if err := contextErr(ctx); err != nil {
201+ return err
202+ }
195203 line, err := br.ReadBytes('\n')
196204 if len(line) > 0 {
197205 line = bytes.TrimRight(line, "\r\n")
@@ -210,6 +218,18 @@ func forEachJSONLLine(r io.Reader, fn func([]byte) error) error {
210218 }
211219 }
212220
221+func contextErr(ctx context.Context) error {
222+ if ctx == nil {
223+ return nil
224+ }
225+ select {
226+ case <-ctx.Done():
227+ return ctx.Err()
228+ default:
229+ return nil
230+ }
231+}
232+
213233 // SessionUsage returns the most recent cumulative usage row stored in
214234 // a session file. Sessions append one usage row per completed turn; the
215235 // latest row's cumulative field is the session total. Missing usage rows
@@ -268,6 +288,13 @@ func nonNegDelta(cur, prev int) int {
268288 // listing helpers: a caller that is about to resume, render, or branch a
269289 // session must not receive a plausible-looking partial transcript.
270290 func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
291+ return readSessionSnapshot(context.Background(), path)
292+}
293+
294+func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, error) {
295+ if err := contextErr(ctx); err != nil {
296+ return SessionSnapshot{}, err
297+ }
271298 f, err := os.Open(path)
272299 if err != nil {
273300 return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
@@ -286,7 +313,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
286313 titleFromRename := false
287314 extensionState := map[string]json.RawMessage{}
288315
289- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
316+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
290317 var head sessionLineHead
291318 if err := json.Unmarshal(line, &head); err != nil {
292319 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
@@ -538,9 +565,16 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
538565 // forEachJSONLLine. It rejects malformed and blank rows and reports the
539566 // line number so callers cannot accidentally proceed with a partial file.
540567 func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {
568+ return forEachStrictJSONLLineContext(context.Background(), r, fn)
569+}
570+
571+func forEachStrictJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte, int) error) error {
541572 br := bufio.NewReader(r)
542573 lineNo := 0
543574 for {
575+ if err := contextErr(ctx); err != nil {
576+ return err
577+ }
544578 line, err := br.ReadBytes('\n')
545579 if len(line) > 0 {
546580 lineNo++
@@ -785,6 +819,8 @@ type SessionSummary struct {
785819 TotalCost float64
786820 Title string
787821 BranchDepth int
822+ // HideFromSessions marks internal branches omitted from the flat picker.
823+ HideFromSessions bool
788824 }
789825
790826 // RenameSession appends a sanitized rename line to the session file. This is
@@ -815,6 +851,22 @@ func DeleteSession(path string) error {
815851 return os.Remove(path)
816852 }
817853
854+// DescribeSession returns a lightweight summary for one on-disk session.
855+// It uses the effective snapshot so message counts, metadata, and usage agree
856+// with resume and branching behavior. The summary retains HideFromSessions so
857+// callers that load entries independently can apply the same picker filter as
858+// DescribeSessions.
859+func DescribeSession(path string) SessionSummary {
860+ return DescribeSessionContext(context.Background(), path)
861+}
862+
863+// DescribeSessionContext is the cancellation-aware form of DescribeSession.
864+// Cancellation is checked between JSONL records so a closed picker does not
865+// keep parsing a large transcript unnecessarily.
866+func DescribeSessionContext(ctx context.Context, path string) SessionSummary {
867+ return describeSessionContext(ctx, path)
868+}
869+
818870 // DescribeSessions returns lightweight summaries for every session in
819871 // cwd, newest first. It uses the effective snapshot so message counts,
820872 // metadata, and usage agree with resume and branching behavior.
@@ -860,14 +912,19 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[
860912 }
861913
862914 func describeSession(path string) SessionSummary {
915+ return describeSessionContext(context.Background(), path)
916+}
917+
918+func describeSessionContext(ctx context.Context, path string) SessionSummary {
863919 s := SessionSummary{Path: path}
864- snapshot, err := ReadSessionSnapshot(path)
920+ snapshot, err := readSessionSnapshot(ctx, path)
865921 if err != nil {
866922 return s
867923 }
868924 s.Started = snapshot.Meta.Started
869925 s.Model = snapshot.Meta.Model
870926 s.Provider = snapshot.Meta.Provider
927+ s.HideFromSessions = snapshot.Meta.HideFromSessions
871928 s.Title = NormalizeSessionTitle(snapshot.Title)
872929 s.MessageCount = len(snapshot.Messages)
873930 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
@@ -892,7 +949,7 @@ func describeSession(path string) SessionSummary {
892949 return err
893950 }
894951 defer f.Close()
895- return forEachJSONLLine(f, func(line []byte) error {
952+ return forEachJSONLLineContext(ctx, f, func(line []byte) error {
896953 var head sessionLineHead
897954 if err := json.Unmarshal(line, &head); err != nil {
898955 return nil
@@ -1021,26 +1078,52 @@ func sessionHasNoMessages(path string) bool {
10211078 // Files with identical ModTime fall back to filename desc so the
10221079 // order stays stable across calls.
10231080 func ListSessions(root, cwd string) []string {
1081+ return ListSessionsContext(context.Background(), root, cwd)
1082+}
1083+
1084+// ListSessionsContext is the cancellation-aware form of ListSessions. It
1085+// checks cancellation while walking directory entries so a closed picker does
1086+// not continue preparing a large result set.
1087+func ListSessionsContext(ctx context.Context, root, cwd string) []string {
1088+ if err := contextErr(ctx); err != nil {
1089+ return nil
1090+ }
10241091 dir := SessionsDir(root, cwd)
1025- entries, err := os.ReadDir(dir)
1092+ dirFile, err := os.Open(dir)
10261093 if err != nil {
10271094 return nil
10281095 }
1096+ defer dirFile.Close()
10291097 type rec struct {
10301098 path string
10311099 mod time.Time
10321100 }
10331101 var files []rec
1034- for _, e := range entries {
1035- if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
1036- continue
1102+ for {
1103+ if err := contextErr(ctx); err != nil {
1104+ return nil
10371105 }
1038- p := filepath.Join(dir, e.Name())
1039- info, err := e.Info()
1040- if err != nil {
1041- continue
1106+ entries, readErr := dirFile.ReadDir(128)
1107+ if readErr != nil && readErr != io.EOF {
1108+ return nil
1109+ }
1110+ for _, e := range entries {
1111+ if err := contextErr(ctx); err != nil {
1112+ return nil
1113+ }
1114+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
1115+ continue
1116+ }
1117+ p := filepath.Join(dir, e.Name())
1118+ info, err := e.Info()
1119+ if err != nil {
1120+ continue
1121+ }
1122+ files = append(files, rec{path: p, mod: info.ModTime()})
1123+ }
1124+ if readErr == io.EOF {
1125+ break
10421126 }
1043- files = append(files, rec{path: p, mod: info.ModTime()})
10441127 }
10451128 sort.Slice(files, func(i, j int) bool {
10461129 if !files[i].mod.Equal(files[j].mod) {
@@ -1050,6 +1133,9 @@ func ListSessions(root, cwd string) []string {
10501133 })
10511134 out := make([]string, 0, len(files))
10521135 for _, r := range files {
1136+ if err := contextErr(ctx); err != nil {
1137+ return nil
1138+ }
10531139 out = append(out, r.path)
10541140 }
10551141 return out
@@ -3,6 +3,7 @@ package core
3 import (3 import (
4 "bufio"4 "bufio"
5 "bytes"5 "bytes"
6+ "context"
6 "crypto/sha256"7 "crypto/sha256"
7 "encoding/hex"8 "encoding/hex"
8 "encoding/json"9 "encoding/json"
@@ -190,8 +191,15 @@ func newSessionAt(p, cwd, providerName, model, version string) (*Session, error)
190 }191 }
191 192
192 func forEachJSONLLine(r io.Reader, fn func([]byte) error) error {193 func forEachJSONLLine(r io.Reader, fn func([]byte) error) error {
194+ return forEachJSONLLineContext(context.Background(), r, fn)
195+}
196+
197+func forEachJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte) error) error {
193 br := bufio.NewReader(r)198 br := bufio.NewReader(r)
194 for {199 for {
200+ if err := contextErr(ctx); err != nil {
201+ return err
202+ }
195 line, err := br.ReadBytes('\n')203 line, err := br.ReadBytes('\n')
196 if len(line) > 0 {204 if len(line) > 0 {
197 line = bytes.TrimRight(line, "\r\n")205 line = bytes.TrimRight(line, "\r\n")
@@ -210,6 +218,18 @@ func forEachJSONLLine(r io.Reader, fn func([]byte) error) error {
210 }218 }
211 }219 }
212 220
221+func contextErr(ctx context.Context) error {
222+ if ctx == nil {
223+ return nil
224+ }
225+ select {
226+ case <-ctx.Done():
227+ return ctx.Err()
228+ default:
229+ return nil
230+ }
231+}
232+
213 // SessionUsage returns the most recent cumulative usage row stored in233 // SessionUsage returns the most recent cumulative usage row stored in
214 // a session file. Sessions append one usage row per completed turn; the234 // a session file. Sessions append one usage row per completed turn; the
215 // latest row's cumulative field is the session total. Missing usage rows235 // latest row's cumulative field is the session total. Missing usage rows
@@ -268,6 +288,13 @@ func nonNegDelta(cur, prev int) int {
268 // listing helpers: a caller that is about to resume, render, or branch a288 // listing helpers: a caller that is about to resume, render, or branch a
269 // session must not receive a plausible-looking partial transcript.289 // session must not receive a plausible-looking partial transcript.
270 func ReadSessionSnapshot(path string) (SessionSnapshot, error) {290 func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
291+ return readSessionSnapshot(context.Background(), path)
292+}
293+
294+func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, error) {
295+ if err := contextErr(ctx); err != nil {
296+ return SessionSnapshot{}, err
297+ }
271 f, err := os.Open(path)298 f, err := os.Open(path)
272 if err != nil {299 if err != nil {
273 return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)300 return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
@@ -286,7 +313,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
286 titleFromRename := false313 titleFromRename := false
287 extensionState := map[string]json.RawMessage{}314 extensionState := map[string]json.RawMessage{}
288 315
289- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {316+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
290 var head sessionLineHead317 var head sessionLineHead
291 if err := json.Unmarshal(line, &head); err != nil {318 if err := json.Unmarshal(line, &head); err != nil {
292 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)319 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
@@ -538,9 +565,16 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
538 // forEachJSONLLine. It rejects malformed and blank rows and reports the565 // forEachJSONLLine. It rejects malformed and blank rows and reports the
539 // line number so callers cannot accidentally proceed with a partial file.566 // line number so callers cannot accidentally proceed with a partial file.
540 func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {567 func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {
568+ return forEachStrictJSONLLineContext(context.Background(), r, fn)
569+}
570+
571+func forEachStrictJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte, int) error) error {
541 br := bufio.NewReader(r)572 br := bufio.NewReader(r)
542 lineNo := 0573 lineNo := 0
543 for {574 for {
575+ if err := contextErr(ctx); err != nil {
576+ return err
577+ }
544 line, err := br.ReadBytes('\n')578 line, err := br.ReadBytes('\n')
545 if len(line) > 0 {579 if len(line) > 0 {
546 lineNo++580 lineNo++
@@ -785,6 +819,8 @@ type SessionSummary struct {
785 TotalCost float64819 TotalCost float64
786 Title string820 Title string
787 BranchDepth int821 BranchDepth int
822+ // HideFromSessions marks internal branches omitted from the flat picker.
823+ HideFromSessions bool
788 }824 }
789 825
790 // RenameSession appends a sanitized rename line to the session file. This is826 // RenameSession appends a sanitized rename line to the session file. This is
@@ -815,6 +851,22 @@ func DeleteSession(path string) error {
815 return os.Remove(path)851 return os.Remove(path)
816 }852 }
817 853
854+// DescribeSession returns a lightweight summary for one on-disk session.
855+// It uses the effective snapshot so message counts, metadata, and usage agree
856+// with resume and branching behavior. The summary retains HideFromSessions so
857+// callers that load entries independently can apply the same picker filter as
858+// DescribeSessions.
859+func DescribeSession(path string) SessionSummary {
860+ return DescribeSessionContext(context.Background(), path)
861+}
862+
863+// DescribeSessionContext is the cancellation-aware form of DescribeSession.
864+// Cancellation is checked between JSONL records so a closed picker does not
865+// keep parsing a large transcript unnecessarily.
866+func DescribeSessionContext(ctx context.Context, path string) SessionSummary {
867+ return describeSessionContext(ctx, path)
868+}
869+
818 // DescribeSessions returns lightweight summaries for every session in870 // DescribeSessions returns lightweight summaries for every session in
819 // cwd, newest first. It uses the effective snapshot so message counts,871 // cwd, newest first. It uses the effective snapshot so message counts,
820 // metadata, and usage agree with resume and branching behavior.872 // metadata, and usage agree with resume and branching behavior.
@@ -860,14 +912,19 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[
860 }912 }
861 913
862 func describeSession(path string) SessionSummary {914 func describeSession(path string) SessionSummary {
915+ return describeSessionContext(context.Background(), path)
916+}
917+
918+func describeSessionContext(ctx context.Context, path string) SessionSummary {
863 s := SessionSummary{Path: path}919 s := SessionSummary{Path: path}
864- snapshot, err := ReadSessionSnapshot(path)920+ snapshot, err := readSessionSnapshot(ctx, path)
865 if err != nil {921 if err != nil {
866 return s922 return s
867 }923 }
868 s.Started = snapshot.Meta.Started924 s.Started = snapshot.Meta.Started
869 s.Model = snapshot.Meta.Model925 s.Model = snapshot.Meta.Model
870 s.Provider = snapshot.Meta.Provider926 s.Provider = snapshot.Meta.Provider
927+ s.HideFromSessions = snapshot.Meta.HideFromSessions
871 s.Title = NormalizeSessionTitle(snapshot.Title)928 s.Title = NormalizeSessionTitle(snapshot.Title)
872 s.MessageCount = len(snapshot.Messages)929 s.MessageCount = len(snapshot.Messages)
873 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)930 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
@@ -892,7 +949,7 @@ func describeSession(path string) SessionSummary {
892 return err949 return err
893 }950 }
894 defer f.Close()951 defer f.Close()
895- return forEachJSONLLine(f, func(line []byte) error {952+ return forEachJSONLLineContext(ctx, f, func(line []byte) error {
896 var head sessionLineHead953 var head sessionLineHead
897 if err := json.Unmarshal(line, &head); err != nil {954 if err := json.Unmarshal(line, &head); err != nil {
898 return nil955 return nil
@@ -1021,26 +1078,52 @@ func sessionHasNoMessages(path string) bool {
1021 // Files with identical ModTime fall back to filename desc so the1078 // Files with identical ModTime fall back to filename desc so the
1022 // order stays stable across calls.1079 // order stays stable across calls.
1023 func ListSessions(root, cwd string) []string {1080 func ListSessions(root, cwd string) []string {
1081+ return ListSessionsContext(context.Background(), root, cwd)
1082+}
1083+
1084+// ListSessionsContext is the cancellation-aware form of ListSessions. It
1085+// checks cancellation while walking directory entries so a closed picker does
1086+// not continue preparing a large result set.
1087+func ListSessionsContext(ctx context.Context, root, cwd string) []string {
1088+ if err := contextErr(ctx); err != nil {
1089+ return nil
1090+ }
1024 dir := SessionsDir(root, cwd)1091 dir := SessionsDir(root, cwd)
1025- entries, err := os.ReadDir(dir)1092+ dirFile, err := os.Open(dir)
1026 if err != nil {1093 if err != nil {
1027 return nil1094 return nil
1028 }1095 }
1096+ defer dirFile.Close()
1029 type rec struct {1097 type rec struct {
1030 path string1098 path string
1031 mod time.Time1099 mod time.Time
1032 }1100 }
1033 var files []rec1101 var files []rec
1034- for _, e := range entries {1102+ for {
1035- if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {1103+ if err := contextErr(ctx); err != nil {
1036- continue1104+ return nil
1037 }1105 }
1038- p := filepath.Join(dir, e.Name())1106+ entries, readErr := dirFile.ReadDir(128)
1039- info, err := e.Info()1107+ if readErr != nil && readErr != io.EOF {
1040- if err != nil {1108+ return nil
1041- continue1109+ }
1110+ for _, e := range entries {
1111+ if err := contextErr(ctx); err != nil {
1112+ return nil
1113+ }
1114+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
1115+ continue
1116+ }
1117+ p := filepath.Join(dir, e.Name())
1118+ info, err := e.Info()
1119+ if err != nil {
1120+ continue
1121+ }
1122+ files = append(files, rec{path: p, mod: info.ModTime()})
1123+ }
1124+ if readErr == io.EOF {
1125+ break
1042 }1126 }
1043- files = append(files, rec{path: p, mod: info.ModTime()})
1044 }1127 }
1045 sort.Slice(files, func(i, j int) bool {1128 sort.Slice(files, func(i, j int) bool {
1046 if !files[i].mod.Equal(files[j].mod) {1129 if !files[i].mod.Equal(files[j].mod) {
@@ -1050,6 +1133,9 @@ func ListSessions(root, cwd string) []string {
1050 })1133 })
1051 out := make([]string, 0, len(files))1134 out := make([]string, 0, len(files))
1052 for _, r := range files {1135 for _, r := range files {
1136+ if err := contextErr(ctx); err != nil {
1137+ return nil
1138+ }
1053 out = append(out, r.path)1139 out = append(out, r.path)
1054 }1140 }
1055 return out1141 return out
M packages/core/session_snapshot_test.go +43 -0
@@ -1,7 +1,9 @@
11 package core
22
33 import (
4+ "context"
45 "encoding/json"
6+ "errors"
57 "os"
68 "path/filepath"
79 "strings"
@@ -376,6 +378,9 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
376378 if err != nil {
377379 t.Fatal(err)
378380 }
381+ if summary := DescribeSession(childPath); !summary.HideFromSessions {
382+ t.Fatalf("DescribeSession hidden flag = %v, want true", summary.HideFromSessions)
383+ }
379384 if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {
380385 t.Fatalf("DescribeSessions = %+v, want only parent", got)
381386 }
@@ -388,6 +393,44 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
388393 }
389394 }
390395
396+func TestForEachStrictJSONLLineContextStopsAfterCancellation(t *testing.T) {
397+ ctx, cancel := context.WithCancel(context.Background())
398+ calls := 0
399+ err := forEachStrictJSONLLineContext(ctx, strings.NewReader("first\nsecond\n"), func(line []byte, _ int) error {
400+ calls++
401+ cancel()
402+ return nil
403+ })
404+ if !errors.Is(err, context.Canceled) {
405+ t.Fatalf("context-aware JSONL read error = %v, want context.Canceled", err)
406+ }
407+ if calls != 1 {
408+ t.Fatalf("context-aware JSONL callback count = %d, want 1", calls)
409+ }
410+}
411+
412+func TestDescribeSessionContextHonorsCancellation(t *testing.T) {
413+ root := t.TempDir()
414+ session, err := NewSession(root, "/workspace", "test", "model", "test")
415+ if err != nil {
416+ t.Fatal(err)
417+ }
418+ if err := session.AppendMessage(textMessage(provider.RoleUser, "cancel me")); err != nil {
419+ t.Fatal(err)
420+ }
421+ path := session.Path
422+ if err := session.Close(); err != nil {
423+ t.Fatal(err)
424+ }
425+
426+ ctx, cancel := context.WithCancel(context.Background())
427+ cancel()
428+ summary := DescribeSessionContext(ctx, path)
429+ if summary.Path != path || summary.MessageCount != 0 {
430+ t.Fatalf("canceled summary = %+v, want path only", summary)
431+ }
432+}
433+
391434 func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {
392435 root := t.TempDir()
393436 cwd := "/workspace/strict-tree"
@@ -1,7 +1,9 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
6+ "errors"
5 "os"7 "os"
6 "path/filepath"8 "path/filepath"
7 "strings"9 "strings"
@@ -376,6 +378,9 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
376 if err != nil {378 if err != nil {
377 t.Fatal(err)379 t.Fatal(err)
378 }380 }
381+ if summary := DescribeSession(childPath); !summary.HideFromSessions {
382+ t.Fatalf("DescribeSession hidden flag = %v, want true", summary.HideFromSessions)
383+ }
379 if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {384 if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {
380 t.Fatalf("DescribeSessions = %+v, want only parent", got)385 t.Fatalf("DescribeSessions = %+v, want only parent", got)
381 }386 }
@@ -388,6 +393,44 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
388 }393 }
389 }394 }
390 395
396+func TestForEachStrictJSONLLineContextStopsAfterCancellation(t *testing.T) {
397+ ctx, cancel := context.WithCancel(context.Background())
398+ calls := 0
399+ err := forEachStrictJSONLLineContext(ctx, strings.NewReader("first\nsecond\n"), func(line []byte, _ int) error {
400+ calls++
401+ cancel()
402+ return nil
403+ })
404+ if !errors.Is(err, context.Canceled) {
405+ t.Fatalf("context-aware JSONL read error = %v, want context.Canceled", err)
406+ }
407+ if calls != 1 {
408+ t.Fatalf("context-aware JSONL callback count = %d, want 1", calls)
409+ }
410+}
411+
412+func TestDescribeSessionContextHonorsCancellation(t *testing.T) {
413+ root := t.TempDir()
414+ session, err := NewSession(root, "/workspace", "test", "model", "test")
415+ if err != nil {
416+ t.Fatal(err)
417+ }
418+ if err := session.AppendMessage(textMessage(provider.RoleUser, "cancel me")); err != nil {
419+ t.Fatal(err)
420+ }
421+ path := session.Path
422+ if err := session.Close(); err != nil {
423+ t.Fatal(err)
424+ }
425+
426+ ctx, cancel := context.WithCancel(context.Background())
427+ cancel()
428+ summary := DescribeSessionContext(ctx, path)
429+ if summary.Path != path || summary.MessageCount != 0 {
430+ t.Fatalf("canceled summary = %+v, want path only", summary)
431+ }
432+}
433+
391 func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {434 func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {
392 root := t.TempDir()435 root := t.TempDir()
393 cwd := "/workspace/strict-tree"436 cwd := "/workspace/strict-tree"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close