detached ceda2c8

fix(agent): load session picker asynchronously

commit
ceda2c89f6699592bdb8f178ac0c969f96c80664
author
brice · · 2026-08-05 08:43 UTC
parent
1513f55
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +493 -26
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. 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. 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 +32 -4
@@ -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,15 @@ func (i *Interactive) Run(ctx context.Context) error {
10711077 i.invalidate()
10721078 }
10731079 changelog = nil // single-shot
1080+ case event, ok := <-i.sessionLoads:
1081+ if !ok {
1082+ i.sessionLoads = nil
1083+ break
1084+ }
1085+ i.mu.Lock()
1086+ i.sessionDialog.ApplyLoad(event)
1087+ i.mu.Unlock()
1088+ i.invalidate()
10741089 case <-i.dirty:
10751090 requestRedraw()
10761091 case <-tick.C:
@@ -1084,10 +1099,12 @@ func (i *Interactive) Run(ctx context.Context) error {
10841099 // Only force a periodic redraw when something is actually
10851100 // animating (the main spinner during a busy turn, or the
10861101 // 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
1102+ // Static pickers (model, completed session, jump, etc.) don't
1103+ // need the tick and firing it cancels the terminal's cursor
10891104 // blink inside dialogs that host their own editor (btw),
10901105 // because each frame re-emits hide-cursor + show-cursor.
1106+ // A session picker that is still loading is an exception so
1107+ // its dialog-local spinner can advance.
10911108 //
10921109 // The subagent dashboard is also animated: its rows reflect
10931110 // background subprocesses whose activity / age change
@@ -1097,7 +1114,11 @@ func (i *Interactive) Run(ctx context.Context) error {
10971114 // of its inline editors (spawn task or prompt composer)
10981115 // is active so the cursor blink in those editors works
10991116 // the same way it does inside btw.
1100- if i.busy || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1117+ i.mu.Lock()
1118+ busy := i.busy
1119+ sessionLoading := i.sessionDialog.Loading()
1120+ i.mu.Unlock()
1121+ if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
11011122 requestRedraw()
11021123 }
11031124 }
@@ -2504,6 +2525,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
25042525 if i.sessionDialog.Active() {
25052526 if k.Kind == tui.KeyCtrlC {
25062527 i.sessionDialog.Close()
2528+ i.sessionLoads = nil
2529+ i.invalidate()
25072530 return false
25082531 }
25092532 manualRenameCurrent := false
@@ -2516,7 +2539,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
25162539 }
25172540 act := i.sessionDialog.HandleKey(k)
25182541 if act.Select {
2542+ i.sessionLoads = nil
25192543 i.applySessionSelection(act.Path)
2544+ } else if act.Close {
2545+ i.sessionLoads = nil
25202546 }
25212547 if act.Err != nil {
25222548 if manualRenameCurrent {
@@ -5223,7 +5249,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
52235249 case "/settings":
52245250 i.openSettingsDialog()
52255251 case "/sessions":
5226- i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
5252+ i.mu.Lock()
5253+ i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
5254+ i.mu.Unlock()
52275255 case "/fork":
52285256 i.doSessionFork()
52295257 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,15 @@ 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+ if !ok {
1082+ i.sessionLoads = nil
1083+ break
1084+ }
1085+ i.mu.Lock()
1086+ i.sessionDialog.ApplyLoad(event)
1087+ i.mu.Unlock()
1088+ i.invalidate()
1074 case <-i.dirty:1089 case <-i.dirty:
1075 requestRedraw()1090 requestRedraw()
1076 case <-tick.C:1091 case <-tick.C:
@@ -1084,10 +1099,12 @@ func (i *Interactive) Run(ctx context.Context) error {
1084 // Only force a periodic redraw when something is actually1099 // Only force a periodic redraw when something is actually
1085 // animating (the main spinner during a busy turn, or the1100 // animating (the main spinner during a busy turn, or the
1086 // btw side-chat spinner while it's awaiting a response).1101 // btw side-chat spinner while it's awaiting a response).
1087- // Static pickers (model, session, jump, etc.) don't need1102+ // Static pickers (model, completed session, jump, etc.) don't
1088- // the tick and firing it cancels the terminal's cursor1103+ // need the tick and firing it cancels the terminal's cursor
1089 // blink inside dialogs that host their own editor (btw),1104 // blink inside dialogs that host their own editor (btw),
1090 // because each frame re-emits hide-cursor + show-cursor.1105 // because each frame re-emits hide-cursor + show-cursor.
1106+ // A session picker that is still loading is an exception so
1107+ // its dialog-local spinner can advance.
1091 //1108 //
1092 // The subagent dashboard is also animated: its rows reflect1109 // The subagent dashboard is also animated: its rows reflect
1093 // background subprocesses whose activity / age change1110 // background subprocesses whose activity / age change
@@ -1097,7 +1114,11 @@ func (i *Interactive) Run(ctx context.Context) error {
1097 // of its inline editors (spawn task or prompt composer)1114 // of its inline editors (spawn task or prompt composer)
1098 // is active so the cursor blink in those editors works1115 // is active so the cursor blink in those editors works
1099 // the same way it does inside btw.1116 // the same way it does inside btw.
1100- if i.busy || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {1117+ i.mu.Lock()
1118+ busy := i.busy
1119+ sessionLoading := i.sessionDialog.Loading()
1120+ i.mu.Unlock()
1121+ if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1101 requestRedraw()1122 requestRedraw()
1102 }1123 }
1103 }1124 }
@@ -2504,6 +2525,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2504 if i.sessionDialog.Active() {2525 if i.sessionDialog.Active() {
2505 if k.Kind == tui.KeyCtrlC {2526 if k.Kind == tui.KeyCtrlC {
2506 i.sessionDialog.Close()2527 i.sessionDialog.Close()
2528+ i.sessionLoads = nil
2529+ i.invalidate()
2507 return false2530 return false
2508 }2531 }
2509 manualRenameCurrent := false2532 manualRenameCurrent := false
@@ -2516,7 +2539,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2516 }2539 }
2517 act := i.sessionDialog.HandleKey(k)2540 act := i.sessionDialog.HandleKey(k)
2518 if act.Select {2541 if act.Select {
2542+ i.sessionLoads = nil
2519 i.applySessionSelection(act.Path)2543 i.applySessionSelection(act.Path)
2544+ } else if act.Close {
2545+ i.sessionLoads = nil
2520 }2546 }
2521 if act.Err != nil {2547 if act.Err != nil {
2522 if manualRenameCurrent {2548 if manualRenameCurrent {
@@ -5223,7 +5249,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5223 case "/settings":5249 case "/settings":
5224 i.openSettingsDialog()5250 i.openSettingsDialog()
5225 case "/sessions":5251 case "/sessions":
5226- i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)5252+ i.mu.Lock()
5253+ i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
5254+ i.mu.Unlock()
5227 case "/fork":5255 case "/fork":
5228 i.doSessionFork()5256 i.doSessionFork()
5229 case "/jump":5257 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 +236 -18
@@ -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,14 @@ 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+ loadSlots []sessionLoadSlot
57+
2058 // MaxRows is the maximum number of session rows the dialog
2159 // will render in a single frame. Set by the host right before
2260 // Render based on the available chat space; if 0, the dialog
@@ -44,24 +82,152 @@ type sessionDialogAction struct {
4482
4583 func newSessionDialog() *sessionDialog { return &sessionDialog{} }
4684
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)
85+// Open shows the dialog immediately and loads session entries on bounded
86+// background workers. Results are delivered to the caller so the UI goroutine
87+// remains responsible for mutating dialog state and rendering it.
88+func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent {
89+ if d.loadCancel != nil {
90+ d.loadCancel()
6091 }
61- d.sessions = filtered
92+ if parent == nil {
93+ parent = context.Background()
94+ }
95+ ctx, cancel := context.WithCancel(parent)
96+ d.loadCancel = cancel
97+ d.loadGeneration++
98+ generation := d.loadGeneration
99+ d.sessions = nil
62100 d.cursor = 0
63101 d.viewTop = 0
102+ d.renaming = false
103+ d.rename = ""
104+ d.loading = true
105+ d.loadingDone = 0
106+ d.loadingTotal = 0
107+ d.loadingStartedAt = time.Now()
108+ d.loadSlots = nil
64109 d.active = true
110+
111+ events := make(chan sessionLoadEvent, 1)
112+ send := func(event sessionLoadEvent) bool {
113+ select {
114+ case events <- event:
115+ return true
116+ case <-ctx.Done():
117+ return false
118+ }
119+ }
120+ go func() {
121+ defer close(events)
122+ select {
123+ case <-ctx.Done():
124+ return
125+ default:
126+ }
127+ paths := core.ListSessions(root, cwd)
128+ if !send(sessionLoadEvent{
129+ kind: sessionLoadStarted,
130+ generation: generation,
131+ total: len(paths),
132+ }) {
133+ return
134+ }
135+
136+ workerCount := len(paths)
137+ if workerCount > maxSessionLoadWorkers {
138+ workerCount = maxSessionLoadWorkers
139+ }
140+ jobs := make(chan sessionLoadJob)
141+ var wg sync.WaitGroup
142+ wg.Add(workerCount)
143+ for worker := 0; worker < workerCount; worker++ {
144+ go func() {
145+ defer wg.Done()
146+ for {
147+ select {
148+ case <-ctx.Done():
149+ return
150+ case job, ok := <-jobs:
151+ if !ok {
152+ return
153+ }
154+ summary := core.DescribeSessionContext(ctx, job.path)
155+ select {
156+ case <-ctx.Done():
157+ return
158+ default:
159+ }
160+ if !send(sessionLoadEvent{
161+ kind: sessionLoadEntry,
162+ generation: generation,
163+ index: job.index,
164+ summary: summary,
165+ }) {
166+ return
167+ }
168+ }
169+ }
170+ }()
171+ }
172+ dispatch:
173+ for index, path := range paths {
174+ select {
175+ case jobs <- sessionLoadJob{index: index, path: path}:
176+ case <-ctx.Done():
177+ break dispatch
178+ }
179+ }
180+ close(jobs)
181+ wg.Wait()
182+ send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation})
183+ }()
184+ return events
185+}
186+
187+// ApplyLoad incorporates one result from Open. It must be called by the UI
188+// goroutine so Render and HandleKey never race with background file reads.
189+func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
190+ if !d.active || event.generation != d.loadGeneration {
191+ return
192+ }
193+ switch event.kind {
194+ case sessionLoadStarted:
195+ d.loadingTotal = event.total
196+ d.loadingDone = 0
197+ d.loadSlots = make([]sessionLoadSlot, event.total)
198+ d.sessions = nil
199+ case sessionLoadEntry:
200+ if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
201+ return
202+ }
203+ d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}
204+ d.loadingDone++
205+ case sessionLoadFinished:
206+ d.rebuildLoadedSessions()
207+ d.loading = false
208+ d.loadSlots = nil
209+ if d.loadCancel != nil {
210+ d.loadCancel()
211+ d.loadCancel = nil
212+ }
213+ }
214+}
215+
216+func (d *sessionDialog) rebuildLoadedSessions() {
217+ filtered := make([]core.SessionSummary, 0, len(d.loadSlots))
218+ for _, slot := range d.loadSlots {
219+ if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
220+ continue
221+ }
222+ filtered = append(filtered, slot.summary)
223+ }
224+ d.sessions = filtered
225+ if d.cursor >= len(d.sessions) {
226+ d.cursor = len(d.sessions) - 1
227+ }
228+ if d.cursor < 0 {
229+ d.cursor = 0
230+ }
65231 }
66232
67233 // CursorPos returns the row/col for the terminal cursor when in
@@ -75,12 +241,23 @@ func (d *sessionDialog) CursorPos() (row, col int) {
75241 return 3, 4 + len([]rune(d.rename))
76242 }
77243
78-// Close hides the dialog.
79-func (d *sessionDialog) Close() { d.active = false }
244+// Close hides the dialog and cancels any in-flight session reads.
245+func (d *sessionDialog) Close() {
246+ if d.loadCancel != nil {
247+ d.loadCancel()
248+ d.loadCancel = nil
249+ }
250+ d.loading = false
251+ d.loadSlots = nil
252+ d.active = false
253+}
80254
81255 // Active reports whether the dialog is visible and consumes input.
82256 func (d *sessionDialog) Active() bool { return d != nil && d.active }
83257
258+// Loading reports whether the dialog still has session entries in flight.
259+func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading }
260+
84261 // Render returns the dialog lines.
85262 func (d *sessionDialog) Render(th tui.Theme, width int) []string {
86263 if !d.Active() {
@@ -88,7 +265,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
88265 }
89266 var lines []string
90267 lines = append(lines, frameHeader(th, "sessions", width))
91- if len(d.sessions) == 0 {
268+ if d.loading {
269+ lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th)))
270+ if len(d.sessions) == 0 {
271+ lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes"))
272+ lines = append(lines, frameRule(th, width))
273+ return lines
274+ }
275+ } else if len(d.sessions) == 0 {
92276 lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))
93277 lines = append(lines, th.FG256(th.Muted, "press esc to close"))
94278 lines = append(lines, frameRule(th, width))
@@ -106,7 +290,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
106290 lines = append(lines, frameRule(th, width))
107291 return lines
108292 }
109- lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))
293+ if !d.loading {
294+ lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))
295+ }
110296
111297 // Viewport: windowed slice of d.sessions around d.cursor so a
112298 // list taller than the terminal still scrolls. Caller sets
@@ -216,6 +402,30 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
216402 return row
217403 }
218404
405+func (d *sessionDialog) loadingMessage(th tui.Theme) string {
406+ frames := th.SpinnerFrames
407+ if len(frames) == 0 {
408+ frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
409+ }
410+ interval := th.SpinnerIntervalMS
411+ if interval <= 0 {
412+ interval = 80
413+ }
414+ idx := 0
415+ if !d.loadingStartedAt.IsZero() {
416+ elapsed := time.Since(d.loadingStartedAt)
417+ if elapsed < 0 {
418+ elapsed = 0
419+ }
420+ idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)
421+ }
422+ progress := "loading sessions"
423+ if d.loadingTotal > 0 {
424+ progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
425+ }
426+ return th.FG256(th.Spinner, frames[idx]) + " " + progress + " (esc cancel)"
427+}
428+
219429 func formatRelative(t time.Time) string {
220430 if t.IsZero() {
221431 return "unknown"
@@ -279,6 +489,14 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
279489 return sessionDialogAction{}
280490 }
281491
492+ if d.loading {
493+ if k.Kind == tui.KeyEsc {
494+ d.Close()
495+ return sessionDialogAction{Close: true}
496+ }
497+ return sessionDialogAction{}
498+ }
499+
282500 page := d.MaxRows
283501 if page <= 0 {
284502 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,14 @@ 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+ loadSlots []sessionLoadSlot
57+
20 // MaxRows is the maximum number of session rows the dialog58 // MaxRows is the maximum number of session rows the dialog
21 // will render in a single frame. Set by the host right before59 // will render in a single frame. Set by the host right before
22 // Render based on the available chat space; if 0, the dialog60 // Render based on the available chat space; if 0, the dialog
@@ -44,24 +82,152 @@ type sessionDialogAction struct {
44 82
45 func newSessionDialog() *sessionDialog { return &sessionDialog{} }83 func newSessionDialog() *sessionDialog { return &sessionDialog{} }
46 84
47-// Open populates the dialog from root + cwd and shows it. Empty85+// Open shows the dialog immediately and loads session entries on bounded
48-// sessions (zero messages) are filtered out so the currently-running86+// 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,87+// remains responsible for mutating dialog state and rendering it.
50-// and any stale empties that haven't been pruned yet all stay out88+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.89+ if d.loadCancel != nil {
52-func (d *sessionDialog) Open(root, cwd string) {90+ 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 }91 }
61- d.sessions = filtered92+ if parent == nil {
93+ parent = context.Background()
94+ }
95+ ctx, cancel := context.WithCancel(parent)
96+ d.loadCancel = cancel
97+ d.loadGeneration++
98+ generation := d.loadGeneration
99+ d.sessions = nil
62 d.cursor = 0100 d.cursor = 0
63 d.viewTop = 0101 d.viewTop = 0
102+ d.renaming = false
103+ d.rename = ""
104+ d.loading = true
105+ d.loadingDone = 0
106+ d.loadingTotal = 0
107+ d.loadingStartedAt = time.Now()
108+ d.loadSlots = nil
64 d.active = true109 d.active = true
110+
111+ events := make(chan sessionLoadEvent, 1)
112+ send := func(event sessionLoadEvent) bool {
113+ select {
114+ case events <- event:
115+ return true
116+ case <-ctx.Done():
117+ return false
118+ }
119+ }
120+ go func() {
121+ defer close(events)
122+ select {
123+ case <-ctx.Done():
124+ return
125+ default:
126+ }
127+ paths := core.ListSessions(root, cwd)
128+ if !send(sessionLoadEvent{
129+ kind: sessionLoadStarted,
130+ generation: generation,
131+ total: len(paths),
132+ }) {
133+ return
134+ }
135+
136+ workerCount := len(paths)
137+ if workerCount > maxSessionLoadWorkers {
138+ workerCount = maxSessionLoadWorkers
139+ }
140+ jobs := make(chan sessionLoadJob)
141+ var wg sync.WaitGroup
142+ wg.Add(workerCount)
143+ for worker := 0; worker < workerCount; worker++ {
144+ go func() {
145+ defer wg.Done()
146+ for {
147+ select {
148+ case <-ctx.Done():
149+ return
150+ case job, ok := <-jobs:
151+ if !ok {
152+ return
153+ }
154+ summary := core.DescribeSessionContext(ctx, job.path)
155+ select {
156+ case <-ctx.Done():
157+ return
158+ default:
159+ }
160+ if !send(sessionLoadEvent{
161+ kind: sessionLoadEntry,
162+ generation: generation,
163+ index: job.index,
164+ summary: summary,
165+ }) {
166+ return
167+ }
168+ }
169+ }
170+ }()
171+ }
172+ dispatch:
173+ for index, path := range paths {
174+ select {
175+ case jobs <- sessionLoadJob{index: index, path: path}:
176+ case <-ctx.Done():
177+ break dispatch
178+ }
179+ }
180+ close(jobs)
181+ wg.Wait()
182+ send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation})
183+ }()
184+ return events
185+}
186+
187+// ApplyLoad incorporates one result from Open. It must be called by the UI
188+// goroutine so Render and HandleKey never race with background file reads.
189+func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
190+ if !d.active || event.generation != d.loadGeneration {
191+ return
192+ }
193+ switch event.kind {
194+ case sessionLoadStarted:
195+ d.loadingTotal = event.total
196+ d.loadingDone = 0
197+ d.loadSlots = make([]sessionLoadSlot, event.total)
198+ d.sessions = nil
199+ case sessionLoadEntry:
200+ if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
201+ return
202+ }
203+ d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}
204+ d.loadingDone++
205+ case sessionLoadFinished:
206+ d.rebuildLoadedSessions()
207+ d.loading = false
208+ d.loadSlots = nil
209+ if d.loadCancel != nil {
210+ d.loadCancel()
211+ d.loadCancel = nil
212+ }
213+ }
214+}
215+
216+func (d *sessionDialog) rebuildLoadedSessions() {
217+ filtered := make([]core.SessionSummary, 0, len(d.loadSlots))
218+ for _, slot := range d.loadSlots {
219+ if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
220+ continue
221+ }
222+ filtered = append(filtered, slot.summary)
223+ }
224+ d.sessions = filtered
225+ if d.cursor >= len(d.sessions) {
226+ d.cursor = len(d.sessions) - 1
227+ }
228+ if d.cursor < 0 {
229+ d.cursor = 0
230+ }
65 }231 }
66 232
67 // CursorPos returns the row/col for the terminal cursor when in233 // CursorPos returns the row/col for the terminal cursor when in
@@ -75,12 +241,23 @@ func (d *sessionDialog) CursorPos() (row, col int) {
75 return 3, 4 + len([]rune(d.rename))241 return 3, 4 + len([]rune(d.rename))
76 }242 }
77 243
78-// Close hides the dialog.244+// Close hides the dialog and cancels any in-flight session reads.
79-func (d *sessionDialog) Close() { d.active = false }245+func (d *sessionDialog) Close() {
246+ if d.loadCancel != nil {
247+ d.loadCancel()
248+ d.loadCancel = nil
249+ }
250+ d.loading = false
251+ d.loadSlots = nil
252+ d.active = false
253+}
80 254
81 // Active reports whether the dialog is visible and consumes input.255 // Active reports whether the dialog is visible and consumes input.
82 func (d *sessionDialog) Active() bool { return d != nil && d.active }256 func (d *sessionDialog) Active() bool { return d != nil && d.active }
83 257
258+// Loading reports whether the dialog still has session entries in flight.
259+func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading }
260+
84 // Render returns the dialog lines.261 // Render returns the dialog lines.
85 func (d *sessionDialog) Render(th tui.Theme, width int) []string {262 func (d *sessionDialog) Render(th tui.Theme, width int) []string {
86 if !d.Active() {263 if !d.Active() {
@@ -88,7 +265,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
88 }265 }
89 var lines []string266 var lines []string
90 lines = append(lines, frameHeader(th, "sessions", width))267 lines = append(lines, frameHeader(th, "sessions", width))
91- if len(d.sessions) == 0 {268+ if d.loading {
269+ lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th)))
270+ if len(d.sessions) == 0 {
271+ lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes"))
272+ lines = append(lines, frameRule(th, width))
273+ return lines
274+ }
275+ } else if len(d.sessions) == 0 {
92 lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))276 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"))277 lines = append(lines, th.FG256(th.Muted, "press esc to close"))
94 lines = append(lines, frameRule(th, width))278 lines = append(lines, frameRule(th, width))
@@ -106,7 +290,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
106 lines = append(lines, frameRule(th, width))290 lines = append(lines, frameRule(th, width))
107 return lines291 return lines
108 }292 }
109- lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))293+ if !d.loading {
294+ lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))
295+ }
110 296
111 // Viewport: windowed slice of d.sessions around d.cursor so a297 // Viewport: windowed slice of d.sessions around d.cursor so a
112 // list taller than the terminal still scrolls. Caller sets298 // list taller than the terminal still scrolls. Caller sets
@@ -216,6 +402,30 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
216 return row402 return row
217 }403 }
218 404
405+func (d *sessionDialog) loadingMessage(th tui.Theme) string {
406+ frames := th.SpinnerFrames
407+ if len(frames) == 0 {
408+ frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
409+ }
410+ interval := th.SpinnerIntervalMS
411+ if interval <= 0 {
412+ interval = 80
413+ }
414+ idx := 0
415+ if !d.loadingStartedAt.IsZero() {
416+ elapsed := time.Since(d.loadingStartedAt)
417+ if elapsed < 0 {
418+ elapsed = 0
419+ }
420+ idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)
421+ }
422+ progress := "loading sessions"
423+ if d.loadingTotal > 0 {
424+ progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
425+ }
426+ return th.FG256(th.Spinner, frames[idx]) + " " + progress + " (esc cancel)"
427+}
428+
219 func formatRelative(t time.Time) string {429 func formatRelative(t time.Time) string {
220 if t.IsZero() {430 if t.IsZero() {
221 return "unknown"431 return "unknown"
@@ -279,6 +489,14 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
279 return sessionDialogAction{}489 return sessionDialogAction{}
280 }490 }
281 491
492+ if d.loading {
493+ if k.Kind == tui.KeyEsc {
494+ d.Close()
495+ return sessionDialogAction{Close: true}
496+ }
497+ return sessionDialogAction{}
498+ }
499+
282 page := d.MaxRows500 page := d.MaxRows
283 if page <= 0 {501 if page <= 0 {
284 page = 10502 page = 10
A packages/agent/modes/session_dialog_test.go +137 -0
@@ -0,0 +1,137 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
14+ root := t.TempDir()
15+ cwd := t.TempDir()
16+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := session.AppendMessage(provider.Message{
21+ Role: provider.RoleUser,
22+ Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
23+ }); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := session.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ d := newSessionDialog()
31+ events := d.Open(context.Background(), root, cwd)
32+ if !d.Active() || !d.Loading() {
33+ t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
34+ }
35+ loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
36+ if !strings.Contains(loadingText, "loading sessions") {
37+ t.Fatalf("loading render = %q, want spinner status", loadingText)
38+ }
39+ act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
40+ if act.Select || !d.Active() {
41+ t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
42+ }
43+
44+ for event := range events {
45+ d.ApplyLoad(event)
46+ }
47+ defer d.Close()
48+
49+ if d.Loading() {
50+ t.Fatal("dialog still loading after final result")
51+ }
52+ if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
53+ t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
54+ }
55+ loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
56+ if strings.Contains(loadedText, "loading sessions") {
57+ t.Fatalf("completed render still shows loading spinner: %q", loadedText)
58+ }
59+ if !strings.Contains(loadedText, "load this session") {
60+ t.Fatalf("completed render missing session text: %q", loadedText)
61+ }
62+}
63+
64+func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
65+ d := newSessionDialog()
66+ d.active = true
67+ d.loading = true
68+ d.loadGeneration = 7
69+
70+ d.ApplyLoad(sessionLoadEvent{
71+ kind: sessionLoadStarted,
72+ generation: 7,
73+ total: 4,
74+ })
75+ d.ApplyLoad(sessionLoadEvent{
76+ kind: sessionLoadEntry,
77+ generation: 7,
78+ index: 2,
79+ summary: core.SessionSummary{Path: "third", MessageCount: 1},
80+ })
81+ d.ApplyLoad(sessionLoadEvent{
82+ kind: sessionLoadEntry,
83+ generation: 7,
84+ index: 1,
85+ summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
86+ })
87+ d.ApplyLoad(sessionLoadEvent{
88+ kind: sessionLoadEntry,
89+ generation: 7,
90+ index: 3,
91+ summary: core.SessionSummary{Path: "empty"},
92+ })
93+ d.ApplyLoad(sessionLoadEvent{
94+ kind: sessionLoadEntry,
95+ generation: 7,
96+ index: 0,
97+ summary: core.SessionSummary{Path: "first", MessageCount: 1},
98+ })
99+ d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
100+
101+ if d.Loading() {
102+ t.Fatal("dialog still loading after final result")
103+ }
104+ if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
105+ t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
106+ }
107+}
108+
109+func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
110+ first := newSessionDialog()
111+ firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
112+ firstGeneration := first.loadGeneration
113+
114+ secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
115+ if first.loadGeneration == firstGeneration {
116+ t.Fatal("reopening picker did not advance load generation")
117+ }
118+
119+ first.ApplyLoad(sessionLoadEvent{
120+ kind: sessionLoadEntry,
121+ generation: firstGeneration,
122+ index: 0,
123+ summary: core.SessionSummary{
124+ Path: "stale",
125+ MessageCount: 1,
126+ },
127+ })
128+ if len(first.sessions) != 0 {
129+ t.Fatalf("stale results repopulated picker: %+v", first.sessions)
130+ }
131+
132+ first.Close()
133+ for range firstEvents {
134+ }
135+ for range secondEvents {
136+ }
137+}
@@ -0,0 +1,137 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
14+ root := t.TempDir()
15+ cwd := t.TempDir()
16+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := session.AppendMessage(provider.Message{
21+ Role: provider.RoleUser,
22+ Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
23+ }); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := session.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ d := newSessionDialog()
31+ events := d.Open(context.Background(), root, cwd)
32+ if !d.Active() || !d.Loading() {
33+ t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
34+ }
35+ loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
36+ if !strings.Contains(loadingText, "loading sessions") {
37+ t.Fatalf("loading render = %q, want spinner status", loadingText)
38+ }
39+ act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
40+ if act.Select || !d.Active() {
41+ t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
42+ }
43+
44+ for event := range events {
45+ d.ApplyLoad(event)
46+ }
47+ defer d.Close()
48+
49+ if d.Loading() {
50+ t.Fatal("dialog still loading after final result")
51+ }
52+ if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
53+ t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
54+ }
55+ loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
56+ if strings.Contains(loadedText, "loading sessions") {
57+ t.Fatalf("completed render still shows loading spinner: %q", loadedText)
58+ }
59+ if !strings.Contains(loadedText, "load this session") {
60+ t.Fatalf("completed render missing session text: %q", loadedText)
61+ }
62+}
63+
64+func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
65+ d := newSessionDialog()
66+ d.active = true
67+ d.loading = true
68+ d.loadGeneration = 7
69+
70+ d.ApplyLoad(sessionLoadEvent{
71+ kind: sessionLoadStarted,
72+ generation: 7,
73+ total: 4,
74+ })
75+ d.ApplyLoad(sessionLoadEvent{
76+ kind: sessionLoadEntry,
77+ generation: 7,
78+ index: 2,
79+ summary: core.SessionSummary{Path: "third", MessageCount: 1},
80+ })
81+ d.ApplyLoad(sessionLoadEvent{
82+ kind: sessionLoadEntry,
83+ generation: 7,
84+ index: 1,
85+ summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
86+ })
87+ d.ApplyLoad(sessionLoadEvent{
88+ kind: sessionLoadEntry,
89+ generation: 7,
90+ index: 3,
91+ summary: core.SessionSummary{Path: "empty"},
92+ })
93+ d.ApplyLoad(sessionLoadEvent{
94+ kind: sessionLoadEntry,
95+ generation: 7,
96+ index: 0,
97+ summary: core.SessionSummary{Path: "first", MessageCount: 1},
98+ })
99+ d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
100+
101+ if d.Loading() {
102+ t.Fatal("dialog still loading after final result")
103+ }
104+ if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
105+ t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
106+ }
107+}
108+
109+func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
110+ first := newSessionDialog()
111+ firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
112+ firstGeneration := first.loadGeneration
113+
114+ secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
115+ if first.loadGeneration == firstGeneration {
116+ t.Fatal("reopening picker did not advance load generation")
117+ }
118+
119+ first.ApplyLoad(sessionLoadEvent{
120+ kind: sessionLoadEntry,
121+ generation: firstGeneration,
122+ index: 0,
123+ summary: core.SessionSummary{
124+ Path: "stale",
125+ MessageCount: 1,
126+ },
127+ })
128+ if len(first.sessions) != 0 {
129+ t.Fatalf("stale results repopulated picker: %+v", first.sessions)
130+ }
131+
132+ first.Close()
133+ for range firstEvents {
134+ }
135+ for range secondEvents {
136+ }
137+}
M packages/core/session.go +57 -3
@@ -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,10 @@ 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) {
271295 f, err := os.Open(path)
272296 if err != nil {
273297 return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
@@ -286,7 +310,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
286310 titleFromRename := false
287311 extensionState := map[string]json.RawMessage{}
288312
289- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
313+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
290314 var head sessionLineHead
291315 if err := json.Unmarshal(line, &head); err != nil {
292316 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
@@ -538,9 +562,16 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
538562 // forEachJSONLLine. It rejects malformed and blank rows and reports the
539563 // line number so callers cannot accidentally proceed with a partial file.
540564 func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {
565+ return forEachStrictJSONLLineContext(context.Background(), r, fn)
566+}
567+
568+func forEachStrictJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte, int) error) error {
541569 br := bufio.NewReader(r)
542570 lineNo := 0
543571 for {
572+ if err := contextErr(ctx); err != nil {
573+ return err
574+ }
544575 line, err := br.ReadBytes('\n')
545576 if len(line) > 0 {
546577 lineNo++
@@ -785,6 +816,8 @@ type SessionSummary struct {
785816 TotalCost float64
786817 Title string
787818 BranchDepth int
819+ // HideFromSessions marks internal branches omitted from the flat picker.
820+ HideFromSessions bool
788821 }
789822
790823 // RenameSession appends a sanitized rename line to the session file. This is
@@ -815,6 +848,22 @@ func DeleteSession(path string) error {
815848 return os.Remove(path)
816849 }
817850
851+// DescribeSession returns a lightweight summary for one on-disk session.
852+// It uses the effective snapshot so message counts, metadata, and usage agree
853+// with resume and branching behavior. The summary retains HideFromSessions so
854+// callers that load entries independently can apply the same picker filter as
855+// DescribeSessions.
856+func DescribeSession(path string) SessionSummary {
857+ return DescribeSessionContext(context.Background(), path)
858+}
859+
860+// DescribeSessionContext is the cancellation-aware form of DescribeSession.
861+// Cancellation is checked between JSONL records so a closed picker does not
862+// keep parsing a large transcript unnecessarily.
863+func DescribeSessionContext(ctx context.Context, path string) SessionSummary {
864+ return describeSessionContext(ctx, path)
865+}
866+
818867 // DescribeSessions returns lightweight summaries for every session in
819868 // cwd, newest first. It uses the effective snapshot so message counts,
820869 // metadata, and usage agree with resume and branching behavior.
@@ -860,14 +909,19 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[
860909 }
861910
862911 func describeSession(path string) SessionSummary {
912+ return describeSessionContext(context.Background(), path)
913+}
914+
915+func describeSessionContext(ctx context.Context, path string) SessionSummary {
863916 s := SessionSummary{Path: path}
864- snapshot, err := ReadSessionSnapshot(path)
917+ snapshot, err := readSessionSnapshot(ctx, path)
865918 if err != nil {
866919 return s
867920 }
868921 s.Started = snapshot.Meta.Started
869922 s.Model = snapshot.Meta.Model
870923 s.Provider = snapshot.Meta.Provider
924+ s.HideFromSessions = snapshot.Meta.HideFromSessions
871925 s.Title = NormalizeSessionTitle(snapshot.Title)
872926 s.MessageCount = len(snapshot.Messages)
873927 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
@@ -892,7 +946,7 @@ func describeSession(path string) SessionSummary {
892946 return err
893947 }
894948 defer f.Close()
895- return forEachJSONLLine(f, func(line []byte) error {
949+ return forEachJSONLLineContext(ctx, f, func(line []byte) error {
896950 var head sessionLineHead
897951 if err := json.Unmarshal(line, &head); err != nil {
898952 return nil
@@ -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,10 @@ 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) {
271 f, err := os.Open(path)295 f, err := os.Open(path)
272 if err != nil {296 if err != nil {
273 return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)297 return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
@@ -286,7 +310,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
286 titleFromRename := false310 titleFromRename := false
287 extensionState := map[string]json.RawMessage{}311 extensionState := map[string]json.RawMessage{}
288 312
289- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {313+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
290 var head sessionLineHead314 var head sessionLineHead
291 if err := json.Unmarshal(line, &head); err != nil {315 if err := json.Unmarshal(line, &head); err != nil {
292 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)316 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
@@ -538,9 +562,16 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
538 // forEachJSONLLine. It rejects malformed and blank rows and reports the562 // forEachJSONLLine. It rejects malformed and blank rows and reports the
539 // line number so callers cannot accidentally proceed with a partial file.563 // line number so callers cannot accidentally proceed with a partial file.
540 func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {564 func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {
565+ return forEachStrictJSONLLineContext(context.Background(), r, fn)
566+}
567+
568+func forEachStrictJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte, int) error) error {
541 br := bufio.NewReader(r)569 br := bufio.NewReader(r)
542 lineNo := 0570 lineNo := 0
543 for {571 for {
572+ if err := contextErr(ctx); err != nil {
573+ return err
574+ }
544 line, err := br.ReadBytes('\n')575 line, err := br.ReadBytes('\n')
545 if len(line) > 0 {576 if len(line) > 0 {
546 lineNo++577 lineNo++
@@ -785,6 +816,8 @@ type SessionSummary struct {
785 TotalCost float64816 TotalCost float64
786 Title string817 Title string
787 BranchDepth int818 BranchDepth int
819+ // HideFromSessions marks internal branches omitted from the flat picker.
820+ HideFromSessions bool
788 }821 }
789 822
790 // RenameSession appends a sanitized rename line to the session file. This is823 // RenameSession appends a sanitized rename line to the session file. This is
@@ -815,6 +848,22 @@ func DeleteSession(path string) error {
815 return os.Remove(path)848 return os.Remove(path)
816 }849 }
817 850
851+// DescribeSession returns a lightweight summary for one on-disk session.
852+// It uses the effective snapshot so message counts, metadata, and usage agree
853+// with resume and branching behavior. The summary retains HideFromSessions so
854+// callers that load entries independently can apply the same picker filter as
855+// DescribeSessions.
856+func DescribeSession(path string) SessionSummary {
857+ return DescribeSessionContext(context.Background(), path)
858+}
859+
860+// DescribeSessionContext is the cancellation-aware form of DescribeSession.
861+// Cancellation is checked between JSONL records so a closed picker does not
862+// keep parsing a large transcript unnecessarily.
863+func DescribeSessionContext(ctx context.Context, path string) SessionSummary {
864+ return describeSessionContext(ctx, path)
865+}
866+
818 // DescribeSessions returns lightweight summaries for every session in867 // DescribeSessions returns lightweight summaries for every session in
819 // cwd, newest first. It uses the effective snapshot so message counts,868 // cwd, newest first. It uses the effective snapshot so message counts,
820 // metadata, and usage agree with resume and branching behavior.869 // metadata, and usage agree with resume and branching behavior.
@@ -860,14 +909,19 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[
860 }909 }
861 910
862 func describeSession(path string) SessionSummary {911 func describeSession(path string) SessionSummary {
912+ return describeSessionContext(context.Background(), path)
913+}
914+
915+func describeSessionContext(ctx context.Context, path string) SessionSummary {
863 s := SessionSummary{Path: path}916 s := SessionSummary{Path: path}
864- snapshot, err := ReadSessionSnapshot(path)917+ snapshot, err := readSessionSnapshot(ctx, path)
865 if err != nil {918 if err != nil {
866 return s919 return s
867 }920 }
868 s.Started = snapshot.Meta.Started921 s.Started = snapshot.Meta.Started
869 s.Model = snapshot.Meta.Model922 s.Model = snapshot.Meta.Model
870 s.Provider = snapshot.Meta.Provider923 s.Provider = snapshot.Meta.Provider
924+ s.HideFromSessions = snapshot.Meta.HideFromSessions
871 s.Title = NormalizeSessionTitle(snapshot.Title)925 s.Title = NormalizeSessionTitle(snapshot.Title)
872 s.MessageCount = len(snapshot.Messages)926 s.MessageCount = len(snapshot.Messages)
873 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)927 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
@@ -892,7 +946,7 @@ func describeSession(path string) SessionSummary {
892 return err946 return err
893 }947 }
894 defer f.Close()948 defer f.Close()
895- return forEachJSONLLine(f, func(line []byte) error {949+ return forEachJSONLLineContext(ctx, f, func(line []byte) error {
896 var head sessionLineHead950 var head sessionLineHead
897 if err := json.Unmarshal(line, &head); err != nil {951 if err := json.Unmarshal(line, &head); err != nil {
898 return nil952 return nil
M packages/core/session_snapshot_test.go +26 -0
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "context"
45 "encoding/json"
56 "os"
67 "path/filepath"
@@ -376,6 +377,9 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
376377 if err != nil {
377378 t.Fatal(err)
378379 }
380+ if summary := DescribeSession(childPath); !summary.HideFromSessions {
381+ t.Fatalf("DescribeSession hidden flag = %v, want true", summary.HideFromSessions)
382+ }
379383 if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {
380384 t.Fatalf("DescribeSessions = %+v, want only parent", got)
381385 }
@@ -388,6 +392,28 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
388392 }
389393 }
390394
395+func TestDescribeSessionContextHonorsCancellation(t *testing.T) {
396+ root := t.TempDir()
397+ session, err := NewSession(root, "/workspace", "test", "model", "test")
398+ if err != nil {
399+ t.Fatal(err)
400+ }
401+ if err := session.AppendMessage(textMessage(provider.RoleUser, "cancel me")); err != nil {
402+ t.Fatal(err)
403+ }
404+ path := session.Path
405+ if err := session.Close(); err != nil {
406+ t.Fatal(err)
407+ }
408+
409+ ctx, cancel := context.WithCancel(context.Background())
410+ cancel()
411+ summary := DescribeSessionContext(ctx, path)
412+ if summary.Path != path || summary.MessageCount != 0 {
413+ t.Fatalf("canceled summary = %+v, want path only", summary)
414+ }
415+}
416+
391417 func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {
392418 root := t.TempDir()
393419 cwd := "/workspace/strict-tree"
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
@@ -376,6 +377,9 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
376 if err != nil {377 if err != nil {
377 t.Fatal(err)378 t.Fatal(err)
378 }379 }
380+ if summary := DescribeSession(childPath); !summary.HideFromSessions {
381+ t.Fatalf("DescribeSession hidden flag = %v, want true", summary.HideFromSessions)
382+ }
379 if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {383 if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {
380 t.Fatalf("DescribeSessions = %+v, want only parent", got)384 t.Fatalf("DescribeSessions = %+v, want only parent", got)
381 }385 }
@@ -388,6 +392,28 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
388 }392 }
389 }393 }
390 394
395+func TestDescribeSessionContextHonorsCancellation(t *testing.T) {
396+ root := t.TempDir()
397+ session, err := NewSession(root, "/workspace", "test", "model", "test")
398+ if err != nil {
399+ t.Fatal(err)
400+ }
401+ if err := session.AppendMessage(textMessage(provider.RoleUser, "cancel me")); err != nil {
402+ t.Fatal(err)
403+ }
404+ path := session.Path
405+ if err := session.Close(); err != nil {
406+ t.Fatal(err)
407+ }
408+
409+ ctx, cancel := context.WithCancel(context.Background())
410+ cancel()
411+ summary := DescribeSessionContext(ctx, path)
412+ if summary.Path != path || summary.MessageCount != 0 {
413+ t.Fatalf("canceled summary = %+v, want path only", summary)
414+ }
415+}
416+
391 func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {417 func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {
392 root := t.TempDir()418 root := t.TempDir()
393 cwd := "/workspace/strict-tree"419 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