fix(agent): load session picker asynchronously
- commit
- ceda2c89f6699592bdb8f178ac0c969f96c80664
- author
- brice · · 2026-08-05 08:43 UTC
- parent
- 1513f55
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 314 | 314 | ||
| 315 | 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 | 319 | ### `/session` | |
| 320 | 320 | ||
| @@ -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 { | |||
| 696 | 696 | // lets ctrl+c/exit remain responsive for very large JSONL sessions. | |
| 697 | 697 | 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 | 704 | // pendingRescuePrompt / pendingRescueImages stash the prompt and | |
| 700 | 705 | // images that should be re-run after the user picks a model in | |
| 701 | 706 | // the rescue dialog. Cleared once applyRescueSelection consumes | |
| @@ -848,6 +853,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 848 | 853 | defer restore() | |
| 849 | 854 | i.markInteractiveStarted() | |
| 850 | 855 | defer i.cancelSessionTitle() | |
| 856 | + | defer i.sessionDialog.Close() | |
| 851 | 857 | defer func() { | |
| 852 | 858 | if i.telegramBridge != nil { | |
| 853 | 859 | i.telegramBridge.Stop() | |
| @@ -1071,6 +1077,15 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1071 | 1077 | i.invalidate() | |
| 1072 | 1078 | } | |
| 1073 | 1079 | 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 | 1089 | case <-i.dirty: | |
| 1075 | 1090 | requestRedraw() | |
| 1076 | 1091 | case <-tick.C: | |
| @@ -1084,10 +1099,12 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1084 | 1099 | // Only force a periodic redraw when something is actually | |
| 1085 | 1100 | // animating (the main spinner during a busy turn, or the | |
| 1086 | 1101 | // 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 | |
| 1089 | 1104 | // blink inside dialogs that host their own editor (btw), | |
| 1090 | 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 | 1109 | // The subagent dashboard is also animated: its rows reflect | |
| 1093 | 1110 | // background subprocesses whose activity / age change | |
| @@ -1097,7 +1114,11 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1097 | 1114 | // of its inline editors (spawn task or prompt composer) | |
| 1098 | 1115 | // is active so the cursor blink in those editors works | |
| 1099 | 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 | 1122 | requestRedraw() | |
| 1102 | 1123 | } | |
| 1103 | 1124 | } | |
| @@ -2504,6 +2525,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2504 | 2525 | if i.sessionDialog.Active() { | |
| 2505 | 2526 | if k.Kind == tui.KeyCtrlC { | |
| 2506 | 2527 | i.sessionDialog.Close() | |
| 2528 | + | i.sessionLoads = nil | |
| 2529 | + | i.invalidate() | |
| 2507 | 2530 | return false | |
| 2508 | 2531 | } | |
| 2509 | 2532 | manualRenameCurrent := false | |
| @@ -2516,7 +2539,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2516 | 2539 | } | |
| 2517 | 2540 | act := i.sessionDialog.HandleKey(k) | |
| 2518 | 2541 | if act.Select { | |
| 2542 | + | i.sessionLoads = nil | |
| 2519 | 2543 | i.applySessionSelection(act.Path) | |
| 2544 | + | } else if act.Close { | |
| 2545 | + | i.sessionLoads = nil | |
| 2520 | 2546 | } | |
| 2521 | 2547 | if act.Err != nil { | |
| 2522 | 2548 | if manualRenameCurrent { | |
| @@ -5223,7 +5249,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5223 | 5249 | case "/settings": | |
| 5224 | 5250 | i.openSettingsDialog() | |
| 5225 | 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 | 5255 | case "/fork": | |
| 5228 | 5256 | i.doSessionFork() | |
| 5229 | 5257 | 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 bool | 697 | 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 and | 704 | // pendingRescuePrompt / pendingRescueImages stash the prompt and | ||
| 700 | // images that should be re-run after the user picks a model in | 705 | // images that should be re-run after the user picks a model in | ||
| 701 | // the rescue dialog. Cleared once applyRescueSelection consumes | 706 | // 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-shot | 1079 | 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 actually | 1099 | // Only force a periodic redraw when something is actually | ||
| 1085 | // animating (the main spinner during a busy turn, or the | 1100 | // 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 need | 1102 | + | // Static pickers (model, completed session, jump, etc.) don't |
| 1088 | - | // the tick and firing it cancels the terminal's cursor | 1103 | + | // 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 reflect | 1109 | // The subagent dashboard is also animated: its rows reflect | ||
| 1093 | // background subprocesses whose activity / age change | 1110 | // 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 works | 1115 | // 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 false | 2530 | return false | ||
| 2508 | } | 2531 | } | ||
| 2509 | manualRenameCurrent := false | 2532 | 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) { | |||
| 33 | 33 | CWD: cwd, | |
| 34 | 34 | }) | |
| 35 | 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 | 41 | if got := len(i.sessionDialog.sessions); got != 1 { | |
| 38 | 42 | 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "strings" | |
| 7 | + | "sync" | |
| 6 | 8 | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/patriceckhart/zot/packages/core" | |
| 9 | 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 | 42 | // sessionDialog is the inline picker shown when the user runs /sessions. | |
| 13 | 43 | type sessionDialog struct { | |
| 14 | 44 | active bool | |
| @@ -17,6 +47,14 @@ type sessionDialog struct { | |||
| 17 | 47 | renaming bool | |
| 18 | 48 | 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 | 58 | // MaxRows is the maximum number of session rows the dialog | |
| 21 | 59 | // will render in a single frame. Set by the host right before | |
| 22 | 60 | // Render based on the available chat space; if 0, the dialog | |
| @@ -44,24 +82,152 @@ type sessionDialogAction struct { | |||
| 44 | 82 | ||
| 45 | 83 | func newSessionDialog() *sessionDialog { return &sessionDialog{} } | |
| 46 | 84 | ||
| 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() | |
| 60 | 91 | } | |
| 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 | |
| 62 | 100 | d.cursor = 0 | |
| 63 | 101 | 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 | 109 | 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 | 233 | // CursorPos returns the row/col for the terminal cursor when in | |
| @@ -75,12 +241,23 @@ func (d *sessionDialog) CursorPos() (row, col int) { | |||
| 75 | 241 | return 3, 4 + len([]rune(d.rename)) | |
| 76 | 242 | } | |
| 77 | 243 | ||
| 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 | + | } | |
| 80 | 254 | ||
| 81 | 255 | // Active reports whether the dialog is visible and consumes input. | |
| 82 | 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 | 261 | // Render returns the dialog lines. | |
| 85 | 262 | func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |
| 86 | 263 | if !d.Active() { | |
| @@ -88,7 +265,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 88 | 265 | } | |
| 89 | 266 | var lines []string | |
| 90 | 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 | 276 | lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory")) | |
| 93 | 277 | lines = append(lines, th.FG256(th.Muted, "press esc to close")) | |
| 94 | 278 | lines = append(lines, frameRule(th, width)) | |
| @@ -106,7 +290,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 106 | 290 | lines = append(lines, frameRule(th, width)) | |
| 107 | 291 | 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 | 297 | // Viewport: windowed slice of d.sessions around d.cursor so a | |
| 112 | 298 | // list taller than the terminal still scrolls. Caller sets | |
| @@ -216,6 +402,30 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||
| 216 | 402 | 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 | 429 | func formatRelative(t time.Time) string { | |
| 220 | 430 | if t.IsZero() { | |
| 221 | 431 | return "unknown" | |
| @@ -279,6 +489,14 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 279 | 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 | 500 | page := d.MaxRows | |
| 283 | 501 | if page <= 0 { | |
| 284 | 502 | page = 10 | |
| @@ -1,14 +1,44 @@ | |||||
| 1 | package modes | 1 | 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 bool | 44 | active bool | ||
| @@ -17,6 +47,14 @@ type sessionDialog struct { | |||||
| 17 | renaming bool | 47 | renaming bool | ||
| 18 | rename string | 48 | 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 dialog | 58 | // MaxRows is the maximum number of session rows the dialog | ||
| 21 | // will render in a single frame. Set by the host right before | 59 | // will render in a single frame. Set by the host right before | ||
| 22 | // Render based on the available chat space; if 0, the dialog | 60 | // 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. Empty | 85 | + | // Open shows the dialog immediately and loads session entries on bounded |
| 48 | - | // sessions (zero messages) are filtered out so the currently-running | 86 | + | // 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 out | 88 | + | 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 = 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 | |||
| 62 | d.cursor = 0 | 100 | d.cursor = 0 | ||
| 63 | d.viewTop = 0 | 101 | 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 = true | 109 | 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 in | 233 | // 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 []string | 266 | 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 lines | 291 | 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 a | 297 | // Viewport: windowed slice of d.sessions around d.cursor so a | ||
| 112 | // list taller than the terminal still scrolls. Caller sets | 298 | // list taller than the terminal still scrolls. Caller sets | ||
| @@ -216,6 +402,30 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||||
| 216 | return row | 402 | 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.MaxRows | 500 | page := d.MaxRows | ||
| 283 | if page <= 0 { | 501 | if page <= 0 { | ||
| 284 | page = 10 | 502 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | 5 | "bytes" | |
| 6 | + | "context" | |
| 6 | 7 | "crypto/sha256" | |
| 7 | 8 | "encoding/hex" | |
| 8 | 9 | "encoding/json" | |
| @@ -190,8 +191,15 @@ func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) | |||
| 190 | 191 | } | |
| 191 | 192 | ||
| 192 | 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 | 198 | br := bufio.NewReader(r) | |
| 194 | 199 | for { | |
| 200 | + | if err := contextErr(ctx); err != nil { | |
| 201 | + | return err | |
| 202 | + | } | |
| 195 | 203 | line, err := br.ReadBytes('\n') | |
| 196 | 204 | if len(line) > 0 { | |
| 197 | 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 | 233 | // SessionUsage returns the most recent cumulative usage row stored in | |
| 214 | 234 | // a session file. Sessions append one usage row per completed turn; the | |
| 215 | 235 | // latest row's cumulative field is the session total. Missing usage rows | |
| @@ -268,6 +288,10 @@ func nonNegDelta(cur, prev int) int { | |||
| 268 | 288 | // listing helpers: a caller that is about to resume, render, or branch a | |
| 269 | 289 | // session must not receive a plausible-looking partial transcript. | |
| 270 | 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 | 295 | f, err := os.Open(path) | |
| 272 | 296 | if err != nil { | |
| 273 | 297 | return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err) | |
| @@ -286,7 +310,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 286 | 310 | titleFromRename := false | |
| 287 | 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 | 314 | var head sessionLineHead | |
| 291 | 315 | if err := json.Unmarshal(line, &head); err != nil { | |
| 292 | 316 | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | |
| @@ -538,9 +562,16 @@ func ReadSessionHistory(path string) (SessionHistory, error) { | |||
| 538 | 562 | // forEachJSONLLine. It rejects malformed and blank rows and reports the | |
| 539 | 563 | // line number so callers cannot accidentally proceed with a partial file. | |
| 540 | 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 | 569 | br := bufio.NewReader(r) | |
| 542 | 570 | lineNo := 0 | |
| 543 | 571 | for { | |
| 572 | + | if err := contextErr(ctx); err != nil { | |
| 573 | + | return err | |
| 574 | + | } | |
| 544 | 575 | line, err := br.ReadBytes('\n') | |
| 545 | 576 | if len(line) > 0 { | |
| 546 | 577 | lineNo++ | |
| @@ -785,6 +816,8 @@ type SessionSummary struct { | |||
| 785 | 816 | TotalCost float64 | |
| 786 | 817 | Title string | |
| 787 | 818 | BranchDepth int | |
| 819 | + | // HideFromSessions marks internal branches omitted from the flat picker. | |
| 820 | + | HideFromSessions bool | |
| 788 | 821 | } | |
| 789 | 822 | ||
| 790 | 823 | // RenameSession appends a sanitized rename line to the session file. This is | |
| @@ -815,6 +848,22 @@ func DeleteSession(path string) error { | |||
| 815 | 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 | 867 | // DescribeSessions returns lightweight summaries for every session in | |
| 819 | 868 | // cwd, newest first. It uses the effective snapshot so message counts, | |
| 820 | 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 | 911 | func describeSession(path string) SessionSummary { | |
| 912 | + | return describeSessionContext(context.Background(), path) | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | func describeSessionContext(ctx context.Context, path string) SessionSummary { | |
| 863 | 916 | s := SessionSummary{Path: path} | |
| 864 | - | snapshot, err := ReadSessionSnapshot(path) | |
| 917 | + | snapshot, err := readSessionSnapshot(ctx, path) | |
| 865 | 918 | if err != nil { | |
| 866 | 919 | return s | |
| 867 | 920 | } | |
| 868 | 921 | s.Started = snapshot.Meta.Started | |
| 869 | 922 | s.Model = snapshot.Meta.Model | |
| 870 | 923 | s.Provider = snapshot.Meta.Provider | |
| 924 | + | s.HideFromSessions = snapshot.Meta.HideFromSessions | |
| 871 | 925 | s.Title = NormalizeSessionTitle(snapshot.Title) | |
| 872 | 926 | s.MessageCount = len(snapshot.Messages) | |
| 873 | 927 | s.FirstUserText = firstUserTextFromMessages(snapshot.Messages) | |
| @@ -892,7 +946,7 @@ func describeSession(path string) SessionSummary { | |||
| 892 | 946 | return err | |
| 893 | 947 | } | |
| 894 | 948 | defer f.Close() | |
| 895 | - | return forEachJSONLLine(f, func(line []byte) error { | |
| 949 | + | return forEachJSONLLineContext(ctx, f, func(line []byte) error { | |
| 896 | 950 | var head sessionLineHead | |
| 897 | 951 | if err := json.Unmarshal(line, &head); err != nil { | |
| 898 | 952 | 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 in | 233 | // SessionUsage returns the most recent cumulative usage row stored in | ||
| 214 | // a session file. Sessions append one usage row per completed turn; the | 234 | // a session file. Sessions append one usage row per completed turn; the | ||
| 215 | // latest row's cumulative field is the session total. Missing usage rows | 235 | // 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 a | 288 | // 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 := false | 310 | 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 sessionLineHead | 314 | 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 the | 562 | // 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 := 0 | 570 | 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 float64 | 816 | TotalCost float64 | ||
| 786 | Title string | 817 | Title string | ||
| 787 | BranchDepth int | 818 | 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 is | 823 | // 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 in | 867 | // 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 s | 919 | return s | ||
| 867 | } | 920 | } | ||
| 868 | s.Started = snapshot.Meta.Started | 921 | s.Started = snapshot.Meta.Started | ||
| 869 | s.Model = snapshot.Meta.Model | 922 | s.Model = snapshot.Meta.Model | ||
| 870 | s.Provider = snapshot.Meta.Provider | 923 | 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 err | 946 | 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 sessionLineHead | 950 | var head sessionLineHead | ||
| 897 | if err := json.Unmarshal(line, &head); err != nil { | 951 | if err := json.Unmarshal(line, &head); err != nil { | ||
| 898 | return nil | 952 | return nil | ||
M packages/core/session_snapshot_test.go +26 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| @@ -376,6 +377,9 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) { | |||
| 376 | 377 | if err != nil { | |
| 377 | 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 | 383 | if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath { | |
| 380 | 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 | 417 | func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) { | |
| 392 | 418 | root := t.TempDir() | |
| 393 | 419 | cwd := "/workspace/strict-tree" | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | 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" | ||