detached a2c78af

Merge pull request #31 from bnema/fix/session-picker-streaming

fix(agent): stream session picker entries
commit
a2c78af91d6e29e04a87981cf10c7e3c176f954d
author
bnema · · 2026-08-05 14:30 UTC
committer
GitHub · · 2026-08-05 14:30 UTC
merge of
7345a0b + 7cf266d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +277 -13
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. 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.
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 and are revealed incrementally in newest-first order; an older result may wait briefly for entries ahead of it so the list never reorders, while the picker keeps showing progress for the remaining entries. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` cancels and closes the picker. 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. 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.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 and are revealed incrementally in newest-first order; an older result may wait briefly for entries ahead of it so the list never reorders, while the picker keeps showing progress for the remaining entries. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` cancels and closes the picker. 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/session_dialog.go +68 -10
@@ -54,7 +54,15 @@ type sessionDialog struct {
5454 loadGeneration uint64
5555 loadCancel context.CancelFunc
5656 loadDone chan struct{}
57+ // loadReadyThrough is the contiguous path prefix whose summaries have
58+ // arrived. It prevents an older worker result from appearing before a
59+ // newer session that is still being read.
5760 loadSlots []sessionLoadSlot
61+ loadReadyThrough int
62+
63+ // describeSession is replaceable in tests so cancellation can be
64+ // exercised without relying on file-size-dependent timing.
65+ describeSession func(context.Context, string) core.SessionSummary
5866
5967 // MaxRows is the maximum number of session rows the dialog
6068 // will render in a single frame. Set by the host right before
@@ -110,12 +118,15 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
110118 d.loadingTotal = 0
111119 d.loadingStartedAt = time.Now()
112120 d.loadSlots = nil
121+ d.loadReadyThrough = 0
113122 d.active = true
114123
115124 if ctxErr := ctx.Err(); ctxErr != nil {
116125 cancel()
117126 d.loadCancel = nil
118127 d.loading = false
128+ d.active = false
129+ d.loadDone = previousDone
119130 events := make(chan sessionLoadEvent)
120131 close(events)
121132 close(done)
@@ -131,6 +142,10 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
131142 return false
132143 }
133144 }
145+ describeSession := d.describeSession
146+ if describeSession == nil {
147+ describeSession = core.DescribeSessionContext
148+ }
134149 go func() {
135150 defer close(done)
136151 defer close(events)
@@ -169,7 +184,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
169184 if !ok {
170185 return
171186 }
172- summary := core.DescribeSessionContext(ctx, job.path)
187+ summary := describeSession(ctx, job.path)
173188 select {
174189 case <-ctx.Done():
175190 return
@@ -197,6 +212,9 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
197212 }
198213 close(jobs)
199214 wg.Wait()
215+ if ctx.Err() != nil {
216+ return
217+ }
200218 send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation})
201219 }()
202220 return events
@@ -213,6 +231,7 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
213231 d.loadingTotal = event.total
214232 d.loadingDone = 0
215233 d.loadSlots = make([]sessionLoadSlot, event.total)
234+ d.loadReadyThrough = 0
216235 d.sessions = nil
217236 case sessionLoadEntry:
218237 if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
@@ -220,6 +239,11 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
220239 }
221240 d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}
222241 d.loadingDone++
242+ previousReady := d.loadReadyThrough
243+ for d.loadReadyThrough < len(d.loadSlots) && d.loadSlots[d.loadReadyThrough].loaded {
244+ d.loadReadyThrough++
245+ }
246+ d.appendLoadedSessions(previousReady, d.loadReadyThrough)
223247 case sessionLoadFinished:
224248 d.finishLoad(true)
225249 }
@@ -232,25 +256,56 @@ func (d *sessionDialog) ApplyLoadClosed() {
232256 return
233257 }
234258 d.finishLoad(false)
259+ d.active = false
235260 }
236261
237262 func (d *sessionDialog) finishLoad(complete bool) {
238263 if complete {
239- d.rebuildLoadedSessions()
264+ d.rebuildLoadedSessions(len(d.loadSlots))
240265 } else {
241266 d.sessions = nil
242267 }
243268 d.loading = false
244269 d.loadSlots = nil
270+ d.loadReadyThrough = 0
245271 if d.loadCancel != nil {
246272 d.loadCancel()
247273 d.loadCancel = nil
248274 }
249275 }
250276
251-func (d *sessionDialog) rebuildLoadedSessions() {
252- filtered := make([]core.SessionSummary, 0, len(d.loadSlots))
253- for _, slot := range d.loadSlots {
277+// appendLoadedSessions exposes the newly contiguous portion of the path
278+// list. Paths are already newest-first, so this keeps the first visible entry
279+// stable while workers finish out of order without rescanning old slots.
280+func (d *sessionDialog) appendLoadedSessions(start, end int) {
281+ limit := len(d.loadSlots)
282+ if start < 0 {
283+ start = 0
284+ } else if start > limit {
285+ start = limit
286+ }
287+ if end < 0 {
288+ end = 0
289+ } else if end > limit {
290+ end = limit
291+ }
292+ if start >= end {
293+ return
294+ }
295+ for _, slot := range d.loadSlots[start:end] {
296+ if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
297+ continue
298+ }
299+ d.sessions = append(d.sessions, slot.summary)
300+ }
301+}
302+
303+func (d *sessionDialog) rebuildLoadedSessions(limit int) {
304+ if limit > len(d.loadSlots) {
305+ limit = len(d.loadSlots)
306+ }
307+ filtered := make([]core.SessionSummary, 0, limit)
308+ for _, slot := range d.loadSlots[:limit] {
254309 if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
255310 continue
256311 }
@@ -284,6 +339,7 @@ func (d *sessionDialog) Close() {
284339 }
285340 d.loading = false
286341 d.loadSlots = nil
342+ d.loadReadyThrough = 0
287343 d.active = false
288344 }
289345
@@ -303,7 +359,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
303359 if d.loading {
304360 lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th)))
305361 if len(d.sessions) == 0 {
306- lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes"))
362+ lines = append(lines, th.FG256(th.Muted, "session entries appear as they load"))
307363 lines = append(lines, frameRule(th, width))
308364 return lines
309365 }
@@ -325,9 +381,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
325381 lines = append(lines, frameRule(th, width))
326382 return lines
327383 }
328- if !d.loading {
329- lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))
384+ hint := "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"
385+ if d.loading {
386+ hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)"
330387 }
388+ lines = append(lines, th.FG256(th.Muted, hint))
331389
332390 // Viewport: windowed slice of d.sessions around d.cursor so a
333391 // list taller than the terminal still scrolls. Caller sets
@@ -526,7 +584,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
526584 return sessionDialogAction{}
527585 }
528586
529- if d.loading {
587+ if d.loading && len(d.sessions) == 0 {
530588 if k.Kind == tui.KeyEsc {
531589 d.Close()
532590 return sessionDialogAction{Close: true}
@@ -581,7 +639,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
581639 d.Close()
582640 return sessionDialogAction{Select: true, Path: s.Path}
583641 case tui.KeyRune:
584- if k.Rune == 'r' && len(d.sessions) > 0 {
642+ if k.Rune == 'r' && !d.loading && len(d.sessions) > 0 {
585643 s := d.sessions[d.cursor]
586644 d.renaming = true
587645 if s.Title != "" {
@@ -54,7 +54,15 @@ type sessionDialog struct {
54 loadGeneration uint6454 loadGeneration uint64
55 loadCancel context.CancelFunc55 loadCancel context.CancelFunc
56 loadDone chan struct{}56 loadDone chan struct{}
57+ // loadReadyThrough is the contiguous path prefix whose summaries have
58+ // arrived. It prevents an older worker result from appearing before a
59+ // newer session that is still being read.
57 loadSlots []sessionLoadSlot60 loadSlots []sessionLoadSlot
61+ loadReadyThrough int
62+
63+ // describeSession is replaceable in tests so cancellation can be
64+ // exercised without relying on file-size-dependent timing.
65+ describeSession func(context.Context, string) core.SessionSummary
58 66
59 // MaxRows is the maximum number of session rows the dialog67 // MaxRows is the maximum number of session rows the dialog
60 // will render in a single frame. Set by the host right before68 // will render in a single frame. Set by the host right before
@@ -110,12 +118,15 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
110 d.loadingTotal = 0118 d.loadingTotal = 0
111 d.loadingStartedAt = time.Now()119 d.loadingStartedAt = time.Now()
112 d.loadSlots = nil120 d.loadSlots = nil
121+ d.loadReadyThrough = 0
113 d.active = true122 d.active = true
114 123
115 if ctxErr := ctx.Err(); ctxErr != nil {124 if ctxErr := ctx.Err(); ctxErr != nil {
116 cancel()125 cancel()
117 d.loadCancel = nil126 d.loadCancel = nil
118 d.loading = false127 d.loading = false
128+ d.active = false
129+ d.loadDone = previousDone
119 events := make(chan sessionLoadEvent)130 events := make(chan sessionLoadEvent)
120 close(events)131 close(events)
121 close(done)132 close(done)
@@ -131,6 +142,10 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
131 return false142 return false
132 }143 }
133 }144 }
145+ describeSession := d.describeSession
146+ if describeSession == nil {
147+ describeSession = core.DescribeSessionContext
148+ }
134 go func() {149 go func() {
135 defer close(done)150 defer close(done)
136 defer close(events)151 defer close(events)
@@ -169,7 +184,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
169 if !ok {184 if !ok {
170 return185 return
171 }186 }
172- summary := core.DescribeSessionContext(ctx, job.path)187+ summary := describeSession(ctx, job.path)
173 select {188 select {
174 case <-ctx.Done():189 case <-ctx.Done():
175 return190 return
@@ -197,6 +212,9 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
197 }212 }
198 close(jobs)213 close(jobs)
199 wg.Wait()214 wg.Wait()
215+ if ctx.Err() != nil {
216+ return
217+ }
200 send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation})218 send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation})
201 }()219 }()
202 return events220 return events
@@ -213,6 +231,7 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
213 d.loadingTotal = event.total231 d.loadingTotal = event.total
214 d.loadingDone = 0232 d.loadingDone = 0
215 d.loadSlots = make([]sessionLoadSlot, event.total)233 d.loadSlots = make([]sessionLoadSlot, event.total)
234+ d.loadReadyThrough = 0
216 d.sessions = nil235 d.sessions = nil
217 case sessionLoadEntry:236 case sessionLoadEntry:
218 if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {237 if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
@@ -220,6 +239,11 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
220 }239 }
221 d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}240 d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}
222 d.loadingDone++241 d.loadingDone++
242+ previousReady := d.loadReadyThrough
243+ for d.loadReadyThrough < len(d.loadSlots) && d.loadSlots[d.loadReadyThrough].loaded {
244+ d.loadReadyThrough++
245+ }
246+ d.appendLoadedSessions(previousReady, d.loadReadyThrough)
223 case sessionLoadFinished:247 case sessionLoadFinished:
224 d.finishLoad(true)248 d.finishLoad(true)
225 }249 }
@@ -232,25 +256,56 @@ func (d *sessionDialog) ApplyLoadClosed() {
232 return256 return
233 }257 }
234 d.finishLoad(false)258 d.finishLoad(false)
259+ d.active = false
235 }260 }
236 261
237 func (d *sessionDialog) finishLoad(complete bool) {262 func (d *sessionDialog) finishLoad(complete bool) {
238 if complete {263 if complete {
239- d.rebuildLoadedSessions()264+ d.rebuildLoadedSessions(len(d.loadSlots))
240 } else {265 } else {
241 d.sessions = nil266 d.sessions = nil
242 }267 }
243 d.loading = false268 d.loading = false
244 d.loadSlots = nil269 d.loadSlots = nil
270+ d.loadReadyThrough = 0
245 if d.loadCancel != nil {271 if d.loadCancel != nil {
246 d.loadCancel()272 d.loadCancel()
247 d.loadCancel = nil273 d.loadCancel = nil
248 }274 }
249 }275 }
250 276
251-func (d *sessionDialog) rebuildLoadedSessions() {277+// appendLoadedSessions exposes the newly contiguous portion of the path
252- filtered := make([]core.SessionSummary, 0, len(d.loadSlots))278+// list. Paths are already newest-first, so this keeps the first visible entry
253- for _, slot := range d.loadSlots {279+// stable while workers finish out of order without rescanning old slots.
280+func (d *sessionDialog) appendLoadedSessions(start, end int) {
281+ limit := len(d.loadSlots)
282+ if start < 0 {
283+ start = 0
284+ } else if start > limit {
285+ start = limit
286+ }
287+ if end < 0 {
288+ end = 0
289+ } else if end > limit {
290+ end = limit
291+ }
292+ if start >= end {
293+ return
294+ }
295+ for _, slot := range d.loadSlots[start:end] {
296+ if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
297+ continue
298+ }
299+ d.sessions = append(d.sessions, slot.summary)
300+ }
301+}
302+
303+func (d *sessionDialog) rebuildLoadedSessions(limit int) {
304+ if limit > len(d.loadSlots) {
305+ limit = len(d.loadSlots)
306+ }
307+ filtered := make([]core.SessionSummary, 0, limit)
308+ for _, slot := range d.loadSlots[:limit] {
254 if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {309 if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
255 continue310 continue
256 }311 }
@@ -284,6 +339,7 @@ func (d *sessionDialog) Close() {
284 }339 }
285 d.loading = false340 d.loading = false
286 d.loadSlots = nil341 d.loadSlots = nil
342+ d.loadReadyThrough = 0
287 d.active = false343 d.active = false
288 }344 }
289 345
@@ -303,7 +359,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
303 if d.loading {359 if d.loading {
304 lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th)))360 lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th)))
305 if len(d.sessions) == 0 {361 if len(d.sessions) == 0 {
306- lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes"))362+ lines = append(lines, th.FG256(th.Muted, "session entries appear as they load"))
307 lines = append(lines, frameRule(th, width))363 lines = append(lines, frameRule(th, width))
308 return lines364 return lines
309 }365 }
@@ -325,9 +381,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
325 lines = append(lines, frameRule(th, width))381 lines = append(lines, frameRule(th, width))
326 return lines382 return lines
327 }383 }
328- if !d.loading {384+ hint := "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"
329- lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))385+ if d.loading {
386+ hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)"
330 }387 }
388+ lines = append(lines, th.FG256(th.Muted, hint))
331 389
332 // Viewport: windowed slice of d.sessions around d.cursor so a390 // Viewport: windowed slice of d.sessions around d.cursor so a
333 // list taller than the terminal still scrolls. Caller sets391 // list taller than the terminal still scrolls. Caller sets
@@ -526,7 +584,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
526 return sessionDialogAction{}584 return sessionDialogAction{}
527 }585 }
528 586
529- if d.loading {587+ if d.loading && len(d.sessions) == 0 {
530 if k.Kind == tui.KeyEsc {588 if k.Kind == tui.KeyEsc {
531 d.Close()589 d.Close()
532 return sessionDialogAction{Close: true}590 return sessionDialogAction{Close: true}
@@ -581,7 +639,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
581 d.Close()639 d.Close()
582 return sessionDialogAction{Select: true, Path: s.Path}640 return sessionDialogAction{Select: true, Path: s.Path}
583 case tui.KeyRune:641 case tui.KeyRune:
584- if k.Rune == 'r' && len(d.sessions) > 0 {642+ if k.Rune == 'r' && !d.loading && len(d.sessions) > 0 {
585 s := d.sessions[d.cursor]643 s := d.sessions[d.cursor]
586 d.renaming = true644 d.renaming = true
587 if s.Title != "" {645 if s.Title != "" {
M packages/agent/modes/session_dialog_test.go +208 -2
@@ -3,7 +3,9 @@ package modes
33 import (
44 "context"
55 "strings"
6+ "sync"
67 "testing"
8+ "time"
79 "unicode"
810
911 "github.com/patriceckhart/zot/packages/core"
@@ -86,6 +88,9 @@ func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
8688 if d.Loading() {
8789 t.Fatal("dialog remained loading with an already-canceled parent")
8890 }
91+ if d.Active() {
92+ t.Fatal("dialog remained active with an already-canceled parent")
93+ }
8994 if _, ok := <-events; ok {
9095 t.Fatal("canceled dialog emitted a load event")
9196 }
@@ -102,6 +107,159 @@ func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
102107 if d.Loading() {
103108 t.Fatal("dialog remained loading after parent cancellation")
104109 }
110+ if d.Active() {
111+ t.Fatal("dialog remained active after parent cancellation")
112+ }
113+}
114+
115+func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
116+ root := t.TempDir()
117+ cwd := t.TempDir()
118+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ if err := session.AppendMessage(provider.Message{
123+ Role: provider.RoleUser,
124+ Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}},
125+ }); err != nil {
126+ t.Fatal(err)
127+ }
128+ if err := session.Close(); err != nil {
129+ t.Fatal(err)
130+ }
131+
132+ entered := make(chan struct{})
133+ d := newSessionDialog()
134+ d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
135+ close(entered)
136+ <-ctx.Done()
137+ return core.SessionSummary{Path: path}
138+ }
139+ ctx, cancel := context.WithCancel(context.Background())
140+ events := d.Open(ctx, root, cwd)
141+ t.Cleanup(func() {
142+ cancel()
143+ d.Close()
144+ })
145+
146+ select {
147+ case event, ok := <-events:
148+ if !ok || event.kind != sessionLoadStarted {
149+ t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
150+ }
151+ d.ApplyLoad(event)
152+ case <-time.After(time.Second):
153+ t.Fatal("timed out waiting for sessionLoadStarted")
154+ }
155+ select {
156+ case <-entered:
157+ case <-time.After(time.Second):
158+ t.Fatal("timed out waiting for the session worker")
159+ }
160+
161+ cancel()
162+ for {
163+ select {
164+ case event, ok := <-events:
165+ if !ok {
166+ d.ApplyLoadClosed()
167+ if d.Loading() || d.Active() {
168+ t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active())
169+ }
170+ return
171+ }
172+ if event.kind == sessionLoadFinished {
173+ t.Fatal("canceled load emitted sessionLoadFinished")
174+ }
175+ d.ApplyLoad(event)
176+ case <-time.After(time.Second):
177+ t.Fatal("timed out waiting for canceled session load to close")
178+ }
179+ }
180+}
181+
182+func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) {
183+ root := t.TempDir()
184+ cwd := t.TempDir()
185+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
186+ if err != nil {
187+ t.Fatal(err)
188+ }
189+ if err := session.AppendMessage(provider.Message{
190+ Role: provider.RoleUser,
191+ Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}},
192+ }); err != nil {
193+ t.Fatal(err)
194+ }
195+ if err := session.Close(); err != nil {
196+ t.Fatal(err)
197+ }
198+
199+ entered := make(chan struct{})
200+ release := make(chan struct{})
201+ var enteredOnce sync.Once
202+ var releaseOnce sync.Once
203+ releaseLoad := func() { releaseOnce.Do(func() { close(release) }) }
204+ d := newSessionDialog()
205+ d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
206+ enteredOnce.Do(func() { close(entered) })
207+ <-release
208+ return core.SessionSummary{Path: path, MessageCount: 1}
209+ }
210+ firstCtx, firstCancel := context.WithCancel(context.Background())
211+ firstEvents := d.Open(firstCtx, root, cwd)
212+ t.Cleanup(func() {
213+ firstCancel()
214+ releaseLoad()
215+ d.Close()
216+ })
217+
218+ select {
219+ case event, ok := <-firstEvents:
220+ if !ok || event.kind != sessionLoadStarted {
221+ t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
222+ }
223+ d.ApplyLoad(event)
224+ case <-time.After(time.Second):
225+ t.Fatal("timed out waiting for the first load to start")
226+ }
227+ select {
228+ case <-entered:
229+ case <-time.After(time.Second):
230+ t.Fatal("timed out waiting for the first worker")
231+ }
232+
233+ preCanceled, cancelPreCanceled := context.WithCancel(context.Background())
234+ cancelPreCanceled()
235+ preCanceledEvents := d.Open(preCanceled, root, cwd)
236+ if _, ok := <-preCanceledEvents; ok {
237+ t.Fatal("pre-canceled open emitted a load event")
238+ }
239+
240+ thirdCtx, cancelThird := context.WithCancel(context.Background())
241+ thirdEvents := d.Open(thirdCtx, root, cwd)
242+ select {
243+ case event, ok := <-thirdEvents:
244+ t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok)
245+ case <-time.After(100 * time.Millisecond):
246+ }
247+
248+ releaseLoad()
249+ firstCancel()
250+ select {
251+ case event, ok := <-thirdEvents:
252+ if !ok || event.kind != sessionLoadStarted {
253+ t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
254+ }
255+ d.ApplyLoad(event)
256+ case <-time.After(time.Second):
257+ t.Fatal("timed out waiting for third load after releasing the first")
258+ }
259+ cancelThird()
260+ for event := range thirdEvents {
261+ d.ApplyLoad(event)
262+ }
105263 }
106264
107265 func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
@@ -115,7 +273,7 @@ func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
115273 }
116274 }
117275
118-func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
276+func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) {
119277 d := newSessionDialog()
120278 d.active = true
121279 d.loading = true
@@ -132,6 +290,9 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
132290 index: 2,
133291 summary: core.SessionSummary{Path: "third", MessageCount: 1},
134292 })
293+ if len(d.sessions) != 0 {
294+ t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions)
295+ }
135296 d.ApplyLoad(sessionLoadEvent{
136297 kind: sessionLoadEntry,
137298 generation: 7,
@@ -150,6 +311,13 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
150311 index: 0,
151312 summary: core.SessionSummary{Path: "first", MessageCount: 1},
152313 })
314+ if !d.Loading() {
315+ t.Fatal("dialog stopped loading before the terminal event")
316+ }
317+ if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
318+ t.Fatalf("incremental sessions = %+v, want first then third", d.sessions)
319+ }
320+
153321 d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
154322
155323 if d.Loading() {
@@ -160,15 +328,53 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
160328 }
161329 }
162330
331+func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) {
332+ d := newSessionDialog()
333+ d.active = true
334+ d.loading = true
335+ d.loadGeneration = 9
336+
337+ d.ApplyLoad(sessionLoadEvent{
338+ kind: sessionLoadStarted,
339+ generation: 9,
340+ total: 2,
341+ })
342+ d.ApplyLoad(sessionLoadEvent{
343+ kind: sessionLoadEntry,
344+ generation: 9,
345+ index: 0,
346+ summary: core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"},
347+ })
348+
349+ if !d.Loading() {
350+ t.Fatal("dialog stopped loading after first entry")
351+ }
352+ rendered := strings.Join(d.Render(tui.Dark, 100), "\n")
353+ if !strings.Contains(rendered, "recent work") {
354+ t.Fatalf("incremental render missing newest entry: %q", rendered)
355+ }
356+
357+ act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
358+ if !act.Select || act.Path != "newest" || d.Active() {
359+ t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active())
360+ }
361+}
362+
163363 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
164364 first := newSessionDialog()
165365 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
166366 firstGeneration := first.loadGeneration
167367
168368 secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
169- if first.loadGeneration == firstGeneration {
369+ secondGeneration := first.loadGeneration
370+ if secondGeneration == firstGeneration {
170371 t.Fatal("reopening picker did not advance load generation")
171372 }
373+ first.ApplyLoad(sessionLoadEvent{
374+ kind: sessionLoadStarted,
375+ generation: secondGeneration,
376+ total: 1,
377+ })
172378
173379 first.ApplyLoad(sessionLoadEvent{
174380 kind: sessionLoadEntry,
@@ -3,7 +3,9 @@ package modes
3 import (3 import (
4 "context"4 "context"
5 "strings"5 "strings"
6+ "sync"
6 "testing"7 "testing"
8+ "time"
7 "unicode"9 "unicode"
8 10
9 "github.com/patriceckhart/zot/packages/core"11 "github.com/patriceckhart/zot/packages/core"
@@ -86,6 +88,9 @@ func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
86 if d.Loading() {88 if d.Loading() {
87 t.Fatal("dialog remained loading with an already-canceled parent")89 t.Fatal("dialog remained loading with an already-canceled parent")
88 }90 }
91+ if d.Active() {
92+ t.Fatal("dialog remained active with an already-canceled parent")
93+ }
89 if _, ok := <-events; ok {94 if _, ok := <-events; ok {
90 t.Fatal("canceled dialog emitted a load event")95 t.Fatal("canceled dialog emitted a load event")
91 }96 }
@@ -102,6 +107,159 @@ func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
102 if d.Loading() {107 if d.Loading() {
103 t.Fatal("dialog remained loading after parent cancellation")108 t.Fatal("dialog remained loading after parent cancellation")
104 }109 }
110+ if d.Active() {
111+ t.Fatal("dialog remained active after parent cancellation")
112+ }
113+}
114+
115+func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
116+ root := t.TempDir()
117+ cwd := t.TempDir()
118+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ if err := session.AppendMessage(provider.Message{
123+ Role: provider.RoleUser,
124+ Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}},
125+ }); err != nil {
126+ t.Fatal(err)
127+ }
128+ if err := session.Close(); err != nil {
129+ t.Fatal(err)
130+ }
131+
132+ entered := make(chan struct{})
133+ d := newSessionDialog()
134+ d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
135+ close(entered)
136+ <-ctx.Done()
137+ return core.SessionSummary{Path: path}
138+ }
139+ ctx, cancel := context.WithCancel(context.Background())
140+ events := d.Open(ctx, root, cwd)
141+ t.Cleanup(func() {
142+ cancel()
143+ d.Close()
144+ })
145+
146+ select {
147+ case event, ok := <-events:
148+ if !ok || event.kind != sessionLoadStarted {
149+ t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
150+ }
151+ d.ApplyLoad(event)
152+ case <-time.After(time.Second):
153+ t.Fatal("timed out waiting for sessionLoadStarted")
154+ }
155+ select {
156+ case <-entered:
157+ case <-time.After(time.Second):
158+ t.Fatal("timed out waiting for the session worker")
159+ }
160+
161+ cancel()
162+ for {
163+ select {
164+ case event, ok := <-events:
165+ if !ok {
166+ d.ApplyLoadClosed()
167+ if d.Loading() || d.Active() {
168+ t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active())
169+ }
170+ return
171+ }
172+ if event.kind == sessionLoadFinished {
173+ t.Fatal("canceled load emitted sessionLoadFinished")
174+ }
175+ d.ApplyLoad(event)
176+ case <-time.After(time.Second):
177+ t.Fatal("timed out waiting for canceled session load to close")
178+ }
179+ }
180+}
181+
182+func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) {
183+ root := t.TempDir()
184+ cwd := t.TempDir()
185+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
186+ if err != nil {
187+ t.Fatal(err)
188+ }
189+ if err := session.AppendMessage(provider.Message{
190+ Role: provider.RoleUser,
191+ Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}},
192+ }); err != nil {
193+ t.Fatal(err)
194+ }
195+ if err := session.Close(); err != nil {
196+ t.Fatal(err)
197+ }
198+
199+ entered := make(chan struct{})
200+ release := make(chan struct{})
201+ var enteredOnce sync.Once
202+ var releaseOnce sync.Once
203+ releaseLoad := func() { releaseOnce.Do(func() { close(release) }) }
204+ d := newSessionDialog()
205+ d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
206+ enteredOnce.Do(func() { close(entered) })
207+ <-release
208+ return core.SessionSummary{Path: path, MessageCount: 1}
209+ }
210+ firstCtx, firstCancel := context.WithCancel(context.Background())
211+ firstEvents := d.Open(firstCtx, root, cwd)
212+ t.Cleanup(func() {
213+ firstCancel()
214+ releaseLoad()
215+ d.Close()
216+ })
217+
218+ select {
219+ case event, ok := <-firstEvents:
220+ if !ok || event.kind != sessionLoadStarted {
221+ t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
222+ }
223+ d.ApplyLoad(event)
224+ case <-time.After(time.Second):
225+ t.Fatal("timed out waiting for the first load to start")
226+ }
227+ select {
228+ case <-entered:
229+ case <-time.After(time.Second):
230+ t.Fatal("timed out waiting for the first worker")
231+ }
232+
233+ preCanceled, cancelPreCanceled := context.WithCancel(context.Background())
234+ cancelPreCanceled()
235+ preCanceledEvents := d.Open(preCanceled, root, cwd)
236+ if _, ok := <-preCanceledEvents; ok {
237+ t.Fatal("pre-canceled open emitted a load event")
238+ }
239+
240+ thirdCtx, cancelThird := context.WithCancel(context.Background())
241+ thirdEvents := d.Open(thirdCtx, root, cwd)
242+ select {
243+ case event, ok := <-thirdEvents:
244+ t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok)
245+ case <-time.After(100 * time.Millisecond):
246+ }
247+
248+ releaseLoad()
249+ firstCancel()
250+ select {
251+ case event, ok := <-thirdEvents:
252+ if !ok || event.kind != sessionLoadStarted {
253+ t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
254+ }
255+ d.ApplyLoad(event)
256+ case <-time.After(time.Second):
257+ t.Fatal("timed out waiting for third load after releasing the first")
258+ }
259+ cancelThird()
260+ for event := range thirdEvents {
261+ d.ApplyLoad(event)
262+ }
105 }263 }
106 264
107 func TestSessionDialogEscapeCancelsLoading(t *testing.T) {265 func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
@@ -115,7 +273,7 @@ func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
115 }273 }
116 }274 }
117 275
118-func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {276+func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) {
119 d := newSessionDialog()277 d := newSessionDialog()
120 d.active = true278 d.active = true
121 d.loading = true279 d.loading = true
@@ -132,6 +290,9 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
132 index: 2,290 index: 2,
133 summary: core.SessionSummary{Path: "third", MessageCount: 1},291 summary: core.SessionSummary{Path: "third", MessageCount: 1},
134 })292 })
293+ if len(d.sessions) != 0 {
294+ t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions)
295+ }
135 d.ApplyLoad(sessionLoadEvent{296 d.ApplyLoad(sessionLoadEvent{
136 kind: sessionLoadEntry,297 kind: sessionLoadEntry,
137 generation: 7,298 generation: 7,
@@ -150,6 +311,13 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
150 index: 0,311 index: 0,
151 summary: core.SessionSummary{Path: "first", MessageCount: 1},312 summary: core.SessionSummary{Path: "first", MessageCount: 1},
152 })313 })
314+ if !d.Loading() {
315+ t.Fatal("dialog stopped loading before the terminal event")
316+ }
317+ if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
318+ t.Fatalf("incremental sessions = %+v, want first then third", d.sessions)
319+ }
320+
153 d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})321 d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
154 322
155 if d.Loading() {323 if d.Loading() {
@@ -160,15 +328,53 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
160 }328 }
161 }329 }
162 330
331+func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) {
332+ d := newSessionDialog()
333+ d.active = true
334+ d.loading = true
335+ d.loadGeneration = 9
336+
337+ d.ApplyLoad(sessionLoadEvent{
338+ kind: sessionLoadStarted,
339+ generation: 9,
340+ total: 2,
341+ })
342+ d.ApplyLoad(sessionLoadEvent{
343+ kind: sessionLoadEntry,
344+ generation: 9,
345+ index: 0,
346+ summary: core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"},
347+ })
348+
349+ if !d.Loading() {
350+ t.Fatal("dialog stopped loading after first entry")
351+ }
352+ rendered := strings.Join(d.Render(tui.Dark, 100), "\n")
353+ if !strings.Contains(rendered, "recent work") {
354+ t.Fatalf("incremental render missing newest entry: %q", rendered)
355+ }
356+
357+ act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
358+ if !act.Select || act.Path != "newest" || d.Active() {
359+ t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active())
360+ }
361+}
362+
163 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {363 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
164 first := newSessionDialog()364 first := newSessionDialog()
165 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())365 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
166 firstGeneration := first.loadGeneration366 firstGeneration := first.loadGeneration
167 367
168 secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())368 secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
169- if first.loadGeneration == firstGeneration {369+ secondGeneration := first.loadGeneration
370+ if secondGeneration == firstGeneration {
170 t.Fatal("reopening picker did not advance load generation")371 t.Fatal("reopening picker did not advance load generation")
171 }372 }
373+ first.ApplyLoad(sessionLoadEvent{
374+ kind: sessionLoadStarted,
375+ generation: secondGeneration,
376+ total: 1,
377+ })
172 378
173 first.ApplyLoad(sessionLoadEvent{379 first.ApplyLoad(sessionLoadEvent{
174 kind: sessionLoadEntry,380 kind: sessionLoadEntry,

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