Merge pull request #24 from bnema/fix/async-session-picker
fix(agent): load session picker asynchronously
7 files · +670 -45
M README.md +1 -1
| @@ -314,7 +314,7 @@ Type `!` followed by a command to run it directly without going through the mode | |||
| 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. While loading, `esc` cancels and closes the picker; other keys, including `up`, `down`, and `enter`, are ignored until loading finishes. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. | |
| 318 | 318 | ||
| 319 | 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. While loading, `esc` cancels and closes the picker; other keys, including `up`, `down`, and `enter`, are ignored until loading finishes. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. |
| 318 | 318 | ||||
| 319 | ### `/session` | 319 | ### `/session` | ||
| 320 | 320 | ||||
M packages/agent/modes/interactive.go +58 -12
| @@ -696,6 +696,11 @@ type Interactive struct { | |||
| 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,18 @@ 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 | + | i.mu.Lock() | |
| 1082 | + | if !ok { | |
| 1083 | + | i.sessionLoads = nil | |
| 1084 | + | i.sessionDialog.ApplyLoadClosed() | |
| 1085 | + | i.mu.Unlock() | |
| 1086 | + | i.invalidate() | |
| 1087 | + | break | |
| 1088 | + | } | |
| 1089 | + | i.sessionDialog.ApplyLoad(event) | |
| 1090 | + | i.mu.Unlock() | |
| 1091 | + | i.invalidate() | |
| 1074 | 1092 | case <-i.dirty: | |
| 1075 | 1093 | requestRedraw() | |
| 1076 | 1094 | case <-tick.C: | |
| @@ -1084,10 +1102,12 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1084 | 1102 | // Only force a periodic redraw when something is actually | |
| 1085 | 1103 | // animating (the main spinner during a busy turn, or the | |
| 1086 | 1104 | // btw side-chat spinner while it's awaiting a response). | |
| 1087 | - | // Static pickers (model, session, jump, etc.) don't need | |
| 1088 | - | // the tick and firing it cancels the terminal's cursor | |
| 1105 | + | // Static pickers (model, completed session, jump, etc.) don't | |
| 1106 | + | // need the tick and firing it cancels the terminal's cursor | |
| 1089 | 1107 | // blink inside dialogs that host their own editor (btw), | |
| 1090 | 1108 | // because each frame re-emits hide-cursor + show-cursor. | |
| 1109 | + | // A session picker that is still loading is an exception so | |
| 1110 | + | // its dialog-local spinner can advance. | |
| 1091 | 1111 | // | |
| 1092 | 1112 | // The subagent dashboard is also animated: its rows reflect | |
| 1093 | 1113 | // background subprocesses whose activity / age change | |
| @@ -1097,7 +1117,11 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1097 | 1117 | // of its inline editors (spawn task or prompt composer) | |
| 1098 | 1118 | // is active so the cursor blink in those editors works | |
| 1099 | 1119 | // the same way it does inside btw. | |
| 1100 | - | if i.busy || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() { | |
| 1120 | + | i.mu.Lock() | |
| 1121 | + | busy := i.busy | |
| 1122 | + | sessionLoading := i.sessionDialog.Loading() | |
| 1123 | + | i.mu.Unlock() | |
| 1124 | + | if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() { | |
| 1101 | 1125 | requestRedraw() | |
| 1102 | 1126 | } | |
| 1103 | 1127 | } | |
| @@ -1647,7 +1671,7 @@ func (i *Interactive) redraw() { | |||
| 1647 | 1671 | // in runSlash already handles the busy case per-command: safe | |
| 1648 | 1672 | // ones run immediately, destructive ones cancel the turn first. | |
| 1649 | 1673 | i.fileSuggest.SetCWD(i.cfg.CWD) | |
| 1650 | - | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive()) | |
| 1674 | + | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked()) | |
| 1651 | 1675 | if mainInputFocused && i.suggest.Active(currentInput) { | |
| 1652 | 1676 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | |
| 1653 | 1677 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | |
| @@ -2322,6 +2346,12 @@ func (i *Interactive) toggleBtwToolExpansion() { | |||
| 2322 | 2346 | // confirmChildActive reports whether an interaction opened from slash input | |
| 2323 | 2347 | // currently owns the keyboard while a tool confirmation remains pending. | |
| 2324 | 2348 | func (i *Interactive) confirmChildActive() bool { | |
| 2349 | + | i.mu.Lock() | |
| 2350 | + | defer i.mu.Unlock() | |
| 2351 | + | return i.confirmChildActiveLocked() | |
| 2352 | + | } | |
| 2353 | + | ||
| 2354 | + | func (i *Interactive) confirmChildActiveLocked() bool { | |
| 2325 | 2355 | return len(i.helpBlock) > 0 || | |
| 2326 | 2356 | len(i.extNotes) > 0 || | |
| 2327 | 2357 | i.dialog.Active() || | |
| @@ -2501,20 +2531,34 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2501 | 2531 | i.invalidate() | |
| 2502 | 2532 | return false | |
| 2503 | 2533 | } | |
| 2504 | - | if i.sessionDialog.Active() { | |
| 2534 | + | i.mu.Lock() | |
| 2535 | + | sessionDialogActive := i.sessionDialog.Active() | |
| 2536 | + | i.mu.Unlock() | |
| 2537 | + | if sessionDialogActive { | |
| 2505 | 2538 | if k.Kind == tui.KeyCtrlC { | |
| 2539 | + | i.mu.Lock() | |
| 2506 | 2540 | i.sessionDialog.Close() | |
| 2541 | + | i.sessionLoads = nil | |
| 2542 | + | i.mu.Unlock() | |
| 2543 | + | i.invalidate() | |
| 2507 | 2544 | return false | |
| 2508 | 2545 | } | |
| 2509 | - | manualRenameCurrent := false | |
| 2546 | + | manualRenamePath := "" | |
| 2547 | + | i.mu.Lock() | |
| 2510 | 2548 | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | |
| 2511 | - | path := i.sessionDialog.sessions[i.sessionDialog.cursor].Path | |
| 2512 | - | manualRenameCurrent = i.cfg.CurrentSessionPath() == path | |
| 2513 | - | if manualRenameCurrent { | |
| 2514 | - | i.markSessionTitleSwitching() | |
| 2515 | - | } | |
| 2549 | + | manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path | |
| 2516 | 2550 | } | |
| 2551 | + | i.mu.Unlock() | |
| 2552 | + | manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath | |
| 2553 | + | if manualRenameCurrent { | |
| 2554 | + | i.markSessionTitleSwitching() | |
| 2555 | + | } | |
| 2556 | + | i.mu.Lock() | |
| 2517 | 2557 | act := i.sessionDialog.HandleKey(k) | |
| 2558 | + | if act.Select || act.Close { | |
| 2559 | + | i.sessionLoads = nil | |
| 2560 | + | } | |
| 2561 | + | i.mu.Unlock() | |
| 2518 | 2562 | if act.Select { | |
| 2519 | 2563 | i.applySessionSelection(act.Path) | |
| 2520 | 2564 | } | |
| @@ -5223,7 +5267,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5223 | 5267 | case "/settings": | |
| 5224 | 5268 | i.openSettingsDialog() | |
| 5225 | 5269 | case "/sessions": | |
| 5226 | - | i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD) | |
| 5270 | + | i.mu.Lock() | |
| 5271 | + | i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD) | |
| 5272 | + | i.mu.Unlock() | |
| 5227 | 5273 | case "/fork": | |
| 5228 | 5274 | i.doSessionFork() | |
| 5229 | 5275 | 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,18 @@ 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 | + | i.mu.Lock() | |||
| 1082 | + | if !ok { | |||
| 1083 | + | i.sessionLoads = nil | |||
| 1084 | + | i.sessionDialog.ApplyLoadClosed() | |||
| 1085 | + | i.mu.Unlock() | |||
| 1086 | + | i.invalidate() | |||
| 1087 | + | break | |||
| 1088 | + | } | |||
| 1089 | + | i.sessionDialog.ApplyLoad(event) | |||
| 1090 | + | i.mu.Unlock() | |||
| 1091 | + | i.invalidate() | |||
| 1074 | case <-i.dirty: | 1092 | case <-i.dirty: | ||
| 1075 | requestRedraw() | 1093 | requestRedraw() | ||
| 1076 | case <-tick.C: | 1094 | case <-tick.C: | ||
| @@ -1084,10 +1102,12 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1084 | // Only force a periodic redraw when something is actually | 1102 | // Only force a periodic redraw when something is actually | ||
| 1085 | // animating (the main spinner during a busy turn, or the | 1103 | // animating (the main spinner during a busy turn, or the | ||
| 1086 | // btw side-chat spinner while it's awaiting a response). | 1104 | // btw side-chat spinner while it's awaiting a response). | ||
| 1087 | - | // Static pickers (model, session, jump, etc.) don't need | 1105 | + | // Static pickers (model, completed session, jump, etc.) don't |
| 1088 | - | // the tick and firing it cancels the terminal's cursor | 1106 | + | // need the tick and firing it cancels the terminal's cursor |
| 1089 | // blink inside dialogs that host their own editor (btw), | 1107 | // blink inside dialogs that host their own editor (btw), | ||
| 1090 | // because each frame re-emits hide-cursor + show-cursor. | 1108 | // because each frame re-emits hide-cursor + show-cursor. | ||
| 1109 | + | // A session picker that is still loading is an exception so | |||
| 1110 | + | // its dialog-local spinner can advance. | |||
| 1091 | // | 1111 | // | ||
| 1092 | // The subagent dashboard is also animated: its rows reflect | 1112 | // The subagent dashboard is also animated: its rows reflect | ||
| 1093 | // background subprocesses whose activity / age change | 1113 | // background subprocesses whose activity / age change | ||
| @@ -1097,7 +1117,11 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1097 | // of its inline editors (spawn task or prompt composer) | 1117 | // of its inline editors (spawn task or prompt composer) | ||
| 1098 | // is active so the cursor blink in those editors works | 1118 | // is active so the cursor blink in those editors works | ||
| 1099 | // the same way it does inside btw. | 1119 | // the same way it does inside btw. | ||
| 1100 | - | if i.busy || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() { | 1120 | + | i.mu.Lock() |
| 1121 | + | busy := i.busy | |||
| 1122 | + | sessionLoading := i.sessionDialog.Loading() | |||
| 1123 | + | i.mu.Unlock() | |||
| 1124 | + | if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() { | |||
| 1101 | requestRedraw() | 1125 | requestRedraw() | ||
| 1102 | } | 1126 | } | ||
| 1103 | } | 1127 | } | ||
| @@ -1647,7 +1671,7 @@ func (i *Interactive) redraw() { | |||||
| 1647 | // in runSlash already handles the busy case per-command: safe | 1671 | // in runSlash already handles the busy case per-command: safe | ||
| 1648 | // ones run immediately, destructive ones cancel the turn first. | 1672 | // ones run immediately, destructive ones cancel the turn first. | ||
| 1649 | i.fileSuggest.SetCWD(i.cfg.CWD) | 1673 | i.fileSuggest.SetCWD(i.cfg.CWD) | ||
| 1650 | - | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive()) | 1674 | + | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked()) |
| 1651 | if mainInputFocused && i.suggest.Active(currentInput) { | 1675 | if mainInputFocused && i.suggest.Active(currentInput) { | ||
| 1652 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | 1676 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | ||
| 1653 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | 1677 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | ||
| @@ -2322,6 +2346,12 @@ func (i *Interactive) toggleBtwToolExpansion() { | |||||
| 2322 | // confirmChildActive reports whether an interaction opened from slash input | 2346 | // confirmChildActive reports whether an interaction opened from slash input | ||
| 2323 | // currently owns the keyboard while a tool confirmation remains pending. | 2347 | // currently owns the keyboard while a tool confirmation remains pending. | ||
| 2324 | func (i *Interactive) confirmChildActive() bool { | 2348 | func (i *Interactive) confirmChildActive() bool { | ||
| 2349 | + | i.mu.Lock() | |||
| 2350 | + | defer i.mu.Unlock() | |||
| 2351 | + | return i.confirmChildActiveLocked() | |||
| 2352 | + | } | |||
| 2353 | + | ||||
| 2354 | + | func (i *Interactive) confirmChildActiveLocked() bool { | |||
| 2325 | return len(i.helpBlock) > 0 || | 2355 | return len(i.helpBlock) > 0 || | ||
| 2326 | len(i.extNotes) > 0 || | 2356 | len(i.extNotes) > 0 || | ||
| 2327 | i.dialog.Active() || | 2357 | i.dialog.Active() || | ||
| @@ -2501,20 +2531,34 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2501 | i.invalidate() | 2531 | i.invalidate() | ||
| 2502 | return false | 2532 | return false | ||
| 2503 | } | 2533 | } | ||
| 2504 | - | if i.sessionDialog.Active() { | 2534 | + | i.mu.Lock() |
| 2535 | + | sessionDialogActive := i.sessionDialog.Active() | |||
| 2536 | + | i.mu.Unlock() | |||
| 2537 | + | if sessionDialogActive { | |||
| 2505 | if k.Kind == tui.KeyCtrlC { | 2538 | if k.Kind == tui.KeyCtrlC { | ||
| 2539 | + | i.mu.Lock() | |||
| 2506 | i.sessionDialog.Close() | 2540 | i.sessionDialog.Close() | ||
| 2541 | + | i.sessionLoads = nil | |||
| 2542 | + | i.mu.Unlock() | |||
| 2543 | + | i.invalidate() | |||
| 2507 | return false | 2544 | return false | ||
| 2508 | } | 2545 | } | ||
| 2509 | - | manualRenameCurrent := false | 2546 | + | manualRenamePath := "" |
| 2547 | + | i.mu.Lock() | |||
| 2510 | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | 2548 | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | ||
| 2511 | - | path := i.sessionDialog.sessions[i.sessionDialog.cursor].Path | 2549 | + | manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path |
| 2512 | - | manualRenameCurrent = i.cfg.CurrentSessionPath() == path | |||
| 2513 | - | if manualRenameCurrent { | |||
| 2514 | - | i.markSessionTitleSwitching() | |||
| 2515 | - | } | |||
| 2516 | } | 2550 | } | ||
| 2551 | + | i.mu.Unlock() | |||
| 2552 | + | manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath | |||
| 2553 | + | if manualRenameCurrent { | |||
| 2554 | + | i.markSessionTitleSwitching() | |||
| 2555 | + | } | |||
| 2556 | + | i.mu.Lock() | |||
| 2517 | act := i.sessionDialog.HandleKey(k) | 2557 | act := i.sessionDialog.HandleKey(k) | ||
| 2558 | + | if act.Select || act.Close { | |||
| 2559 | + | i.sessionLoads = nil | |||
| 2560 | + | } | |||
| 2561 | + | i.mu.Unlock() | |||
| 2518 | if act.Select { | 2562 | if act.Select { | ||
| 2519 | i.applySessionSelection(act.Path) | 2563 | i.applySessionSelection(act.Path) | ||
| 2520 | } | 2564 | } | ||
| @@ -5223,7 +5267,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 5223 | case "/settings": | 5267 | case "/settings": | ||
| 5224 | i.openSettingsDialog() | 5268 | i.openSettingsDialog() | ||
| 5225 | case "/sessions": | 5269 | case "/sessions": | ||
| 5226 | - | i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD) | 5270 | + | i.mu.Lock() |
| 5271 | + | i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD) | |||
| 5272 | + | i.mu.Unlock() | |||
| 5227 | case "/fork": | 5273 | case "/fork": | ||
| 5228 | i.doSessionFork() | 5274 | i.doSessionFork() | ||
| 5229 | case "/jump": | 5275 | case "/jump": | ||
M packages/agent/modes/session_agent_root_test.go +4 -0
| @@ -33,6 +33,10 @@ func TestSessionsSlashUsesAgentSessionRoot(t *testing.T) { | |||
| 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 +275 -20
| @@ -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,15 @@ 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 | + | loadDone chan struct{} | |
| 57 | + | loadSlots []sessionLoadSlot | |
| 58 | + | ||
| 20 | 59 | // MaxRows is the maximum number of session rows the dialog | |
| 21 | 60 | // will render in a single frame. Set by the host right before | |
| 22 | 61 | // Render based on the available chat space; if 0, the dialog | |
| @@ -44,24 +83,186 @@ type sessionDialogAction struct { | |||
| 44 | 83 | ||
| 45 | 84 | func newSessionDialog() *sessionDialog { return &sessionDialog{} } | |
| 46 | 85 | ||
| 47 | - | // Open populates the dialog from root + cwd and shows it. Empty | |
| 48 | - | // sessions (zero messages) are filtered out so the currently-running | |
| 49 | - | // session, a freshly-opened one that hasn't received a prompt yet, | |
| 50 | - | // and any stale empties that haven't been pruned yet all stay out | |
| 51 | - | // of the picker. Resuming an empty session is a no-op anyway. | |
| 52 | - | func (d *sessionDialog) Open(root, cwd string) { | |
| 53 | - | all := core.DescribeSessions(root, cwd) | |
| 54 | - | filtered := make([]core.SessionSummary, 0, len(all)) | |
| 55 | - | for _, s := range all { | |
| 56 | - | if s.MessageCount == 0 { | |
| 57 | - | continue | |
| 58 | - | } | |
| 59 | - | filtered = append(filtered, s) | |
| 86 | + | // Open shows the dialog immediately and loads session entries on bounded | |
| 87 | + | // background workers. Results are delivered to the caller so the UI goroutine | |
| 88 | + | // remains responsible for mutating dialog state and rendering it. | |
| 89 | + | func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent { | |
| 90 | + | if d.loadCancel != nil { | |
| 91 | + | d.loadCancel() | |
| 60 | 92 | } | |
| 61 | - | d.sessions = filtered | |
| 93 | + | previousDone := d.loadDone | |
| 94 | + | if parent == nil { | |
| 95 | + | parent = context.Background() | |
| 96 | + | } | |
| 97 | + | ctx, cancel := context.WithCancel(parent) | |
| 98 | + | d.loadCancel = cancel | |
| 99 | + | d.loadGeneration++ | |
| 100 | + | generation := d.loadGeneration | |
| 101 | + | done := make(chan struct{}) | |
| 102 | + | d.loadDone = done | |
| 103 | + | d.sessions = nil | |
| 62 | 104 | d.cursor = 0 | |
| 63 | 105 | d.viewTop = 0 | |
| 106 | + | d.renaming = false | |
| 107 | + | d.rename = "" | |
| 108 | + | d.loading = true | |
| 109 | + | d.loadingDone = 0 | |
| 110 | + | d.loadingTotal = 0 | |
| 111 | + | d.loadingStartedAt = time.Now() | |
| 112 | + | d.loadSlots = nil | |
| 64 | 113 | d.active = true | |
| 114 | + | ||
| 115 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 116 | + | cancel() | |
| 117 | + | d.loadCancel = nil | |
| 118 | + | d.loading = false | |
| 119 | + | events := make(chan sessionLoadEvent) | |
| 120 | + | close(events) | |
| 121 | + | close(done) | |
| 122 | + | return events | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | events := make(chan sessionLoadEvent, 1) | |
| 126 | + | send := func(event sessionLoadEvent) bool { | |
| 127 | + | select { | |
| 128 | + | case events <- event: | |
| 129 | + | return true | |
| 130 | + | case <-ctx.Done(): | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | go func() { | |
| 135 | + | defer close(done) | |
| 136 | + | defer close(events) | |
| 137 | + | if previousDone != nil { | |
| 138 | + | <-previousDone | |
| 139 | + | } | |
| 140 | + | select { | |
| 141 | + | case <-ctx.Done(): | |
| 142 | + | return | |
| 143 | + | default: | |
| 144 | + | } | |
| 145 | + | paths := core.ListSessionsContext(ctx, root, cwd) | |
| 146 | + | if !send(sessionLoadEvent{ | |
| 147 | + | kind: sessionLoadStarted, | |
| 148 | + | generation: generation, | |
| 149 | + | total: len(paths), | |
| 150 | + | }) { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | workerCount := len(paths) | |
| 155 | + | if workerCount > maxSessionLoadWorkers { | |
| 156 | + | workerCount = maxSessionLoadWorkers | |
| 157 | + | } | |
| 158 | + | jobs := make(chan sessionLoadJob) | |
| 159 | + | var wg sync.WaitGroup | |
| 160 | + | wg.Add(workerCount) | |
| 161 | + | for worker := 0; worker < workerCount; worker++ { | |
| 162 | + | go func() { | |
| 163 | + | defer wg.Done() | |
| 164 | + | for { | |
| 165 | + | select { | |
| 166 | + | case <-ctx.Done(): | |
| 167 | + | return | |
| 168 | + | case job, ok := <-jobs: | |
| 169 | + | if !ok { | |
| 170 | + | return | |
| 171 | + | } | |
| 172 | + | summary := core.DescribeSessionContext(ctx, job.path) | |
| 173 | + | select { | |
| 174 | + | case <-ctx.Done(): | |
| 175 | + | return | |
| 176 | + | default: | |
| 177 | + | } | |
| 178 | + | if !send(sessionLoadEvent{ | |
| 179 | + | kind: sessionLoadEntry, | |
| 180 | + | generation: generation, | |
| 181 | + | index: job.index, | |
| 182 | + | summary: summary, | |
| 183 | + | }) { | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | }() | |
| 189 | + | } | |
| 190 | + | dispatch: | |
| 191 | + | for index, path := range paths { | |
| 192 | + | select { | |
| 193 | + | case jobs <- sessionLoadJob{index: index, path: path}: | |
| 194 | + | case <-ctx.Done(): | |
| 195 | + | break dispatch | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | close(jobs) | |
| 199 | + | wg.Wait() | |
| 200 | + | send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation}) | |
| 201 | + | }() | |
| 202 | + | return events | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // ApplyLoad incorporates one result from Open. It must be called by the UI | |
| 206 | + | // goroutine so Render and HandleKey never race with background file reads. | |
| 207 | + | func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |
| 208 | + | if !d.active || event.generation != d.loadGeneration { | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | switch event.kind { | |
| 212 | + | case sessionLoadStarted: | |
| 213 | + | d.loadingTotal = event.total | |
| 214 | + | d.loadingDone = 0 | |
| 215 | + | d.loadSlots = make([]sessionLoadSlot, event.total) | |
| 216 | + | d.sessions = nil | |
| 217 | + | case sessionLoadEntry: | |
| 218 | + | if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded { | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary} | |
| 222 | + | d.loadingDone++ | |
| 223 | + | case sessionLoadFinished: | |
| 224 | + | d.finishLoad(true) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // ApplyLoadClosed finalizes a load whose producer stopped without delivering a | |
| 229 | + | // terminal event, such as when its parent context is canceled. | |
| 230 | + | func (d *sessionDialog) ApplyLoadClosed() { | |
| 231 | + | if !d.active || !d.loading { | |
| 232 | + | return | |
| 233 | + | } | |
| 234 | + | d.finishLoad(false) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func (d *sessionDialog) finishLoad(complete bool) { | |
| 238 | + | if complete { | |
| 239 | + | d.rebuildLoadedSessions() | |
| 240 | + | } else { | |
| 241 | + | d.sessions = nil | |
| 242 | + | } | |
| 243 | + | d.loading = false | |
| 244 | + | d.loadSlots = nil | |
| 245 | + | if d.loadCancel != nil { | |
| 246 | + | d.loadCancel() | |
| 247 | + | d.loadCancel = nil | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (d *sessionDialog) rebuildLoadedSessions() { | |
| 252 | + | filtered := make([]core.SessionSummary, 0, len(d.loadSlots)) | |
| 253 | + | for _, slot := range d.loadSlots { | |
| 254 | + | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | |
| 255 | + | continue | |
| 256 | + | } | |
| 257 | + | filtered = append(filtered, slot.summary) | |
| 258 | + | } | |
| 259 | + | d.sessions = filtered | |
| 260 | + | if d.cursor >= len(d.sessions) { | |
| 261 | + | d.cursor = len(d.sessions) - 1 | |
| 262 | + | } | |
| 263 | + | if d.cursor < 0 { | |
| 264 | + | d.cursor = 0 | |
| 265 | + | } | |
| 65 | 266 | } | |
| 66 | 267 | ||
| 67 | 268 | // CursorPos returns the row/col for the terminal cursor when in | |
| @@ -75,12 +276,23 @@ func (d *sessionDialog) CursorPos() (row, col int) { | |||
| 75 | 276 | return 3, 4 + len([]rune(d.rename)) | |
| 76 | 277 | } | |
| 77 | 278 | ||
| 78 | - | // Close hides the dialog. | |
| 79 | - | func (d *sessionDialog) Close() { d.active = false } | |
| 279 | + | // Close hides the dialog and cancels any in-flight session reads. | |
| 280 | + | func (d *sessionDialog) Close() { | |
| 281 | + | if d.loadCancel != nil { | |
| 282 | + | d.loadCancel() | |
| 283 | + | d.loadCancel = nil | |
| 284 | + | } | |
| 285 | + | d.loading = false | |
| 286 | + | d.loadSlots = nil | |
| 287 | + | d.active = false | |
| 288 | + | } | |
| 80 | 289 | ||
| 81 | 290 | // Active reports whether the dialog is visible and consumes input. | |
| 82 | 291 | func (d *sessionDialog) Active() bool { return d != nil && d.active } | |
| 83 | 292 | ||
| 293 | + | // Loading reports whether the dialog still has session entries in flight. | |
| 294 | + | func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading } | |
| 295 | + | ||
| 84 | 296 | // Render returns the dialog lines. | |
| 85 | 297 | func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |
| 86 | 298 | if !d.Active() { | |
| @@ -88,7 +300,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 88 | 300 | } | |
| 89 | 301 | var lines []string | |
| 90 | 302 | lines = append(lines, frameHeader(th, "sessions", width)) | |
| 91 | - | if len(d.sessions) == 0 { | |
| 303 | + | if d.loading { | |
| 304 | + | lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th))) | |
| 305 | + | if len(d.sessions) == 0 { | |
| 306 | + | lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes")) | |
| 307 | + | lines = append(lines, frameRule(th, width)) | |
| 308 | + | return lines | |
| 309 | + | } | |
| 310 | + | } else if len(d.sessions) == 0 { | |
| 92 | 311 | lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory")) | |
| 93 | 312 | lines = append(lines, th.FG256(th.Muted, "press esc to close")) | |
| 94 | 313 | lines = append(lines, frameRule(th, width)) | |
| @@ -106,7 +325,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 106 | 325 | lines = append(lines, frameRule(th, width)) | |
| 107 | 326 | return lines | |
| 108 | 327 | } | |
| 109 | - | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | |
| 328 | + | if !d.loading { | |
| 329 | + | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | |
| 330 | + | } | |
| 110 | 331 | ||
| 111 | 332 | // Viewport: windowed slice of d.sessions around d.cursor so a | |
| 112 | 333 | // list taller than the terminal still scrolls. Caller sets | |
| @@ -192,9 +413,11 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||
| 192 | 413 | if summary == "" { | |
| 193 | 414 | summary = "(empty)" | |
| 194 | 415 | } | |
| 195 | - | summary = strings.ReplaceAll(summary, "\n", " ") | |
| 416 | + | provider := sanitizeSessionTreeText(s.Provider) | |
| 417 | + | model := sanitizeSessionTreeText(s.Model) | |
| 418 | + | summary = sanitizeSessionTreeText(summary) | |
| 196 | 419 | left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ", | |
| 197 | - | when, s.Provider, s.Model, s.MessageCount, s.TotalCost) | |
| 420 | + | when, provider, model, s.MessageCount, s.TotalCost) | |
| 198 | 421 | room := maxWidth - len([]rune(left)) | |
| 199 | 422 | if room < 4 { | |
| 200 | 423 | room = 4 | |
| @@ -216,6 +439,30 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||
| 216 | 439 | return row | |
| 217 | 440 | } | |
| 218 | 441 | ||
| 442 | + | func (d *sessionDialog) loadingMessage(th tui.Theme) string { | |
| 443 | + | frames := th.SpinnerFrames | |
| 444 | + | if len(frames) == 0 { | |
| 445 | + | frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"} | |
| 446 | + | } | |
| 447 | + | interval := th.SpinnerIntervalMS | |
| 448 | + | if interval <= 0 { | |
| 449 | + | interval = 80 | |
| 450 | + | } | |
| 451 | + | idx := 0 | |
| 452 | + | if !d.loadingStartedAt.IsZero() { | |
| 453 | + | elapsed := time.Since(d.loadingStartedAt) | |
| 454 | + | if elapsed < 0 { | |
| 455 | + | elapsed = 0 | |
| 456 | + | } | |
| 457 | + | idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames) | |
| 458 | + | } | |
| 459 | + | progress := "loading sessions" | |
| 460 | + | if d.loadingTotal > 0 { | |
| 461 | + | progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal) | |
| 462 | + | } | |
| 463 | + | return th.FG256(th.Spinner, frames[idx]) + " " + th.FG256(th.Muted, progress+" (esc cancel)") | |
| 464 | + | } | |
| 465 | + | ||
| 219 | 466 | func formatRelative(t time.Time) string { | |
| 220 | 467 | if t.IsZero() { | |
| 221 | 468 | return "unknown" | |
| @@ -279,6 +526,14 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 279 | 526 | return sessionDialogAction{} | |
| 280 | 527 | } | |
| 281 | 528 | ||
| 529 | + | if d.loading { | |
| 530 | + | if k.Kind == tui.KeyEsc { | |
| 531 | + | d.Close() | |
| 532 | + | return sessionDialogAction{Close: true} | |
| 533 | + | } | |
| 534 | + | return sessionDialogAction{} | |
| 535 | + | } | |
| 536 | + | ||
| 282 | 537 | page := d.MaxRows | |
| 283 | 538 | if page <= 0 { | |
| 284 | 539 | 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,15 @@ 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 | + | loadDone chan struct{} | |||
| 57 | + | loadSlots []sessionLoadSlot | |||
| 58 | + | ||||
| 20 | // MaxRows is the maximum number of session rows the dialog | 59 | // MaxRows is the maximum number of session rows the dialog | ||
| 21 | // will render in a single frame. Set by the host right before | 60 | // will render in a single frame. Set by the host right before | ||
| 22 | // Render based on the available chat space; if 0, the dialog | 61 | // Render based on the available chat space; if 0, the dialog | ||
| @@ -44,24 +83,186 @@ type sessionDialogAction struct { | |||||
| 44 | 83 | ||||
| 45 | func newSessionDialog() *sessionDialog { return &sessionDialog{} } | 84 | func newSessionDialog() *sessionDialog { return &sessionDialog{} } | ||
| 46 | 85 | ||||
| 47 | - | // Open populates the dialog from root + cwd and shows it. Empty | 86 | + | // Open shows the dialog immediately and loads session entries on bounded |
| 48 | - | // sessions (zero messages) are filtered out so the currently-running | 87 | + | // background workers. Results are delivered to the caller so the UI goroutine |
| 49 | - | // session, a freshly-opened one that hasn't received a prompt yet, | 88 | + | // remains responsible for mutating dialog state and rendering it. |
| 50 | - | // and any stale empties that haven't been pruned yet all stay out | 89 | + | func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent { |
| 51 | - | // of the picker. Resuming an empty session is a no-op anyway. | 90 | + | if d.loadCancel != nil { |
| 52 | - | func (d *sessionDialog) Open(root, cwd string) { | 91 | + | d.loadCancel() |
| 53 | - | all := core.DescribeSessions(root, cwd) | |||
| 54 | - | filtered := make([]core.SessionSummary, 0, len(all)) | |||
| 55 | - | for _, s := range all { | |||
| 56 | - | if s.MessageCount == 0 { | |||
| 57 | - | continue | |||
| 58 | - | } | |||
| 59 | - | filtered = append(filtered, s) | |||
| 60 | } | 92 | } | ||
| 61 | - | d.sessions = filtered | 93 | + | previousDone := d.loadDone |
| 94 | + | if parent == nil { | |||
| 95 | + | parent = context.Background() | |||
| 96 | + | } | |||
| 97 | + | ctx, cancel := context.WithCancel(parent) | |||
| 98 | + | d.loadCancel = cancel | |||
| 99 | + | d.loadGeneration++ | |||
| 100 | + | generation := d.loadGeneration | |||
| 101 | + | done := make(chan struct{}) | |||
| 102 | + | d.loadDone = done | |||
| 103 | + | d.sessions = nil | |||
| 62 | d.cursor = 0 | 104 | d.cursor = 0 | ||
| 63 | d.viewTop = 0 | 105 | d.viewTop = 0 | ||
| 106 | + | d.renaming = false | |||
| 107 | + | d.rename = "" | |||
| 108 | + | d.loading = true | |||
| 109 | + | d.loadingDone = 0 | |||
| 110 | + | d.loadingTotal = 0 | |||
| 111 | + | d.loadingStartedAt = time.Now() | |||
| 112 | + | d.loadSlots = nil | |||
| 64 | d.active = true | 113 | d.active = true | ||
| 114 | + | ||||
| 115 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 116 | + | cancel() | |||
| 117 | + | d.loadCancel = nil | |||
| 118 | + | d.loading = false | |||
| 119 | + | events := make(chan sessionLoadEvent) | |||
| 120 | + | close(events) | |||
| 121 | + | close(done) | |||
| 122 | + | return events | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | events := make(chan sessionLoadEvent, 1) | |||
| 126 | + | send := func(event sessionLoadEvent) bool { | |||
| 127 | + | select { | |||
| 128 | + | case events <- event: | |||
| 129 | + | return true | |||
| 130 | + | case <-ctx.Done(): | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | go func() { | |||
| 135 | + | defer close(done) | |||
| 136 | + | defer close(events) | |||
| 137 | + | if previousDone != nil { | |||
| 138 | + | <-previousDone | |||
| 139 | + | } | |||
| 140 | + | select { | |||
| 141 | + | case <-ctx.Done(): | |||
| 142 | + | return | |||
| 143 | + | default: | |||
| 144 | + | } | |||
| 145 | + | paths := core.ListSessionsContext(ctx, root, cwd) | |||
| 146 | + | if !send(sessionLoadEvent{ | |||
| 147 | + | kind: sessionLoadStarted, | |||
| 148 | + | generation: generation, | |||
| 149 | + | total: len(paths), | |||
| 150 | + | }) { | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | workerCount := len(paths) | |||
| 155 | + | if workerCount > maxSessionLoadWorkers { | |||
| 156 | + | workerCount = maxSessionLoadWorkers | |||
| 157 | + | } | |||
| 158 | + | jobs := make(chan sessionLoadJob) | |||
| 159 | + | var wg sync.WaitGroup | |||
| 160 | + | wg.Add(workerCount) | |||
| 161 | + | for worker := 0; worker < workerCount; worker++ { | |||
| 162 | + | go func() { | |||
| 163 | + | defer wg.Done() | |||
| 164 | + | for { | |||
| 165 | + | select { | |||
| 166 | + | case <-ctx.Done(): | |||
| 167 | + | return | |||
| 168 | + | case job, ok := <-jobs: | |||
| 169 | + | if !ok { | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | summary := core.DescribeSessionContext(ctx, job.path) | |||
| 173 | + | select { | |||
| 174 | + | case <-ctx.Done(): | |||
| 175 | + | return | |||
| 176 | + | default: | |||
| 177 | + | } | |||
| 178 | + | if !send(sessionLoadEvent{ | |||
| 179 | + | kind: sessionLoadEntry, | |||
| 180 | + | generation: generation, | |||
| 181 | + | index: job.index, | |||
| 182 | + | summary: summary, | |||
| 183 | + | }) { | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | }() | |||
| 189 | + | } | |||
| 190 | + | dispatch: | |||
| 191 | + | for index, path := range paths { | |||
| 192 | + | select { | |||
| 193 | + | case jobs <- sessionLoadJob{index: index, path: path}: | |||
| 194 | + | case <-ctx.Done(): | |||
| 195 | + | break dispatch | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | close(jobs) | |||
| 199 | + | wg.Wait() | |||
| 200 | + | send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation}) | |||
| 201 | + | }() | |||
| 202 | + | return events | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // ApplyLoad incorporates one result from Open. It must be called by the UI | |||
| 206 | + | // goroutine so Render and HandleKey never race with background file reads. | |||
| 207 | + | func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||
| 208 | + | if !d.active || event.generation != d.loadGeneration { | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | switch event.kind { | |||
| 212 | + | case sessionLoadStarted: | |||
| 213 | + | d.loadingTotal = event.total | |||
| 214 | + | d.loadingDone = 0 | |||
| 215 | + | d.loadSlots = make([]sessionLoadSlot, event.total) | |||
| 216 | + | d.sessions = nil | |||
| 217 | + | case sessionLoadEntry: | |||
| 218 | + | if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded { | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary} | |||
| 222 | + | d.loadingDone++ | |||
| 223 | + | case sessionLoadFinished: | |||
| 224 | + | d.finishLoad(true) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // ApplyLoadClosed finalizes a load whose producer stopped without delivering a | |||
| 229 | + | // terminal event, such as when its parent context is canceled. | |||
| 230 | + | func (d *sessionDialog) ApplyLoadClosed() { | |||
| 231 | + | if !d.active || !d.loading { | |||
| 232 | + | return | |||
| 233 | + | } | |||
| 234 | + | d.finishLoad(false) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func (d *sessionDialog) finishLoad(complete bool) { | |||
| 238 | + | if complete { | |||
| 239 | + | d.rebuildLoadedSessions() | |||
| 240 | + | } else { | |||
| 241 | + | d.sessions = nil | |||
| 242 | + | } | |||
| 243 | + | d.loading = false | |||
| 244 | + | d.loadSlots = nil | |||
| 245 | + | if d.loadCancel != nil { | |||
| 246 | + | d.loadCancel() | |||
| 247 | + | d.loadCancel = nil | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func (d *sessionDialog) rebuildLoadedSessions() { | |||
| 252 | + | filtered := make([]core.SessionSummary, 0, len(d.loadSlots)) | |||
| 253 | + | for _, slot := range d.loadSlots { | |||
| 254 | + | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | |||
| 255 | + | continue | |||
| 256 | + | } | |||
| 257 | + | filtered = append(filtered, slot.summary) | |||
| 258 | + | } | |||
| 259 | + | d.sessions = filtered | |||
| 260 | + | if d.cursor >= len(d.sessions) { | |||
| 261 | + | d.cursor = len(d.sessions) - 1 | |||
| 262 | + | } | |||
| 263 | + | if d.cursor < 0 { | |||
| 264 | + | d.cursor = 0 | |||
| 265 | + | } | |||
| 65 | } | 266 | } | ||
| 66 | 267 | ||||
| 67 | // CursorPos returns the row/col for the terminal cursor when in | 268 | // CursorPos returns the row/col for the terminal cursor when in | ||
| @@ -75,12 +276,23 @@ func (d *sessionDialog) CursorPos() (row, col int) { | |||||
| 75 | return 3, 4 + len([]rune(d.rename)) | 276 | return 3, 4 + len([]rune(d.rename)) | ||
| 76 | } | 277 | } | ||
| 77 | 278 | ||||
| 78 | - | // Close hides the dialog. | 279 | + | // Close hides the dialog and cancels any in-flight session reads. |
| 79 | - | func (d *sessionDialog) Close() { d.active = false } | 280 | + | func (d *sessionDialog) Close() { |
| 281 | + | if d.loadCancel != nil { | |||
| 282 | + | d.loadCancel() | |||
| 283 | + | d.loadCancel = nil | |||
| 284 | + | } | |||
| 285 | + | d.loading = false | |||
| 286 | + | d.loadSlots = nil | |||
| 287 | + | d.active = false | |||
| 288 | + | } | |||
| 80 | 289 | ||||
| 81 | // Active reports whether the dialog is visible and consumes input. | 290 | // Active reports whether the dialog is visible and consumes input. | ||
| 82 | func (d *sessionDialog) Active() bool { return d != nil && d.active } | 291 | func (d *sessionDialog) Active() bool { return d != nil && d.active } | ||
| 83 | 292 | ||||
| 293 | + | // Loading reports whether the dialog still has session entries in flight. | |||
| 294 | + | func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading } | |||
| 295 | + | ||||
| 84 | // Render returns the dialog lines. | 296 | // Render returns the dialog lines. | ||
| 85 | func (d *sessionDialog) Render(th tui.Theme, width int) []string { | 297 | func (d *sessionDialog) Render(th tui.Theme, width int) []string { | ||
| 86 | if !d.Active() { | 298 | if !d.Active() { | ||
| @@ -88,7 +300,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 88 | } | 300 | } | ||
| 89 | var lines []string | 301 | var lines []string | ||
| 90 | lines = append(lines, frameHeader(th, "sessions", width)) | 302 | lines = append(lines, frameHeader(th, "sessions", width)) | ||
| 91 | - | if len(d.sessions) == 0 { | 303 | + | if d.loading { |
| 304 | + | lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th))) | |||
| 305 | + | if len(d.sessions) == 0 { | |||
| 306 | + | lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes")) | |||
| 307 | + | lines = append(lines, frameRule(th, width)) | |||
| 308 | + | return lines | |||
| 309 | + | } | |||
| 310 | + | } else if len(d.sessions) == 0 { | |||
| 92 | lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory")) | 311 | lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory")) | ||
| 93 | lines = append(lines, th.FG256(th.Muted, "press esc to close")) | 312 | lines = append(lines, th.FG256(th.Muted, "press esc to close")) | ||
| 94 | lines = append(lines, frameRule(th, width)) | 313 | lines = append(lines, frameRule(th, width)) | ||
| @@ -106,7 +325,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 106 | lines = append(lines, frameRule(th, width)) | 325 | lines = append(lines, frameRule(th, width)) | ||
| 107 | return lines | 326 | return lines | ||
| 108 | } | 327 | } | ||
| 109 | - | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | 328 | + | if !d.loading { |
| 329 | + | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | |||
| 330 | + | } | |||
| 110 | 331 | ||||
| 111 | // Viewport: windowed slice of d.sessions around d.cursor so a | 332 | // Viewport: windowed slice of d.sessions around d.cursor so a | ||
| 112 | // list taller than the terminal still scrolls. Caller sets | 333 | // list taller than the terminal still scrolls. Caller sets | ||
| @@ -192,9 +413,11 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||||
| 192 | if summary == "" { | 413 | if summary == "" { | ||
| 193 | summary = "(empty)" | 414 | summary = "(empty)" | ||
| 194 | } | 415 | } | ||
| 195 | - | summary = strings.ReplaceAll(summary, "\n", " ") | 416 | + | provider := sanitizeSessionTreeText(s.Provider) |
| 417 | + | model := sanitizeSessionTreeText(s.Model) | |||
| 418 | + | summary = sanitizeSessionTreeText(summary) | |||
| 196 | left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ", | 419 | left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ", | ||
| 197 | - | when, s.Provider, s.Model, s.MessageCount, s.TotalCost) | 420 | + | when, provider, model, s.MessageCount, s.TotalCost) |
| 198 | room := maxWidth - len([]rune(left)) | 421 | room := maxWidth - len([]rune(left)) | ||
| 199 | if room < 4 { | 422 | if room < 4 { | ||
| 200 | room = 4 | 423 | room = 4 | ||
| @@ -216,6 +439,30 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||||
| 216 | return row | 439 | return row | ||
| 217 | } | 440 | } | ||
| 218 | 441 | ||||
| 442 | + | func (d *sessionDialog) loadingMessage(th tui.Theme) string { | |||
| 443 | + | frames := th.SpinnerFrames | |||
| 444 | + | if len(frames) == 0 { | |||
| 445 | + | frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"} | |||
| 446 | + | } | |||
| 447 | + | interval := th.SpinnerIntervalMS | |||
| 448 | + | if interval <= 0 { | |||
| 449 | + | interval = 80 | |||
| 450 | + | } | |||
| 451 | + | idx := 0 | |||
| 452 | + | if !d.loadingStartedAt.IsZero() { | |||
| 453 | + | elapsed := time.Since(d.loadingStartedAt) | |||
| 454 | + | if elapsed < 0 { | |||
| 455 | + | elapsed = 0 | |||
| 456 | + | } | |||
| 457 | + | idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames) | |||
| 458 | + | } | |||
| 459 | + | progress := "loading sessions" | |||
| 460 | + | if d.loadingTotal > 0 { | |||
| 461 | + | progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal) | |||
| 462 | + | } | |||
| 463 | + | return th.FG256(th.Spinner, frames[idx]) + " " + th.FG256(th.Muted, progress+" (esc cancel)") | |||
| 464 | + | } | |||
| 465 | + | ||||
| 219 | func formatRelative(t time.Time) string { | 466 | func formatRelative(t time.Time) string { | ||
| 220 | if t.IsZero() { | 467 | if t.IsZero() { | ||
| 221 | return "unknown" | 468 | return "unknown" | ||
| @@ -279,6 +526,14 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 279 | return sessionDialogAction{} | 526 | return sessionDialogAction{} | ||
| 280 | } | 527 | } | ||
| 281 | 528 | ||||
| 529 | + | if d.loading { | |||
| 530 | + | if k.Kind == tui.KeyEsc { | |||
| 531 | + | d.Close() | |||
| 532 | + | return sessionDialogAction{Close: true} | |||
| 533 | + | } | |||
| 534 | + | return sessionDialogAction{} | |||
| 535 | + | } | |||
| 536 | + | ||||
| 282 | page := d.MaxRows | 537 | page := d.MaxRows | ||
| 283 | if page <= 0 { | 538 | if page <= 0 { | ||
| 284 | page = 10 | 539 | page = 10 | ||
A packages/agent/modes/session_dialog_test.go +191 -0
| @@ -0,0 +1,191 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "unicode" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 10 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 11 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) { | |
| 15 | + | row := formatSessionRowPlain(core.SessionSummary{ | |
| 16 | + | Provider: "test\x1b]0;bad\a", | |
| 17 | + | Model: "model\x1b[31m", | |
| 18 | + | MessageCount: 1, | |
| 19 | + | FirstUserText: "hello\x1b[2J\nworld", | |
| 20 | + | }, 120) | |
| 21 | + | if strings.IndexFunc(row, unicode.IsControl) >= 0 { | |
| 22 | + | t.Fatalf("session row contains control characters: %q", row) | |
| 23 | + | } | |
| 24 | + | if !strings.Contains(row, "hello world") { | |
| 25 | + | t.Fatalf("sanitized session text missing: %q", row) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |
| 30 | + | root := t.TempDir() | |
| 31 | + | cwd := t.TempDir() | |
| 32 | + | session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if err := session.AppendMessage(provider.Message{ | |
| 37 | + | Role: provider.RoleUser, | |
| 38 | + | Content: []provider.Content{provider.TextBlock{Text: "load this session"}}, | |
| 39 | + | }); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | if err := session.Close(); err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | d := newSessionDialog() | |
| 47 | + | events := d.Open(context.Background(), root, cwd) | |
| 48 | + | t.Cleanup(d.Close) | |
| 49 | + | if !d.Active() || !d.Loading() { | |
| 50 | + | t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading()) | |
| 51 | + | } | |
| 52 | + | loadingText := strings.Join(d.Render(tui.Dark, 100), "\n") | |
| 53 | + | if !strings.Contains(loadingText, "loading sessions") { | |
| 54 | + | t.Fatalf("loading render = %q, want spinner status", loadingText) | |
| 55 | + | } | |
| 56 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 57 | + | if act.Select || !d.Active() { | |
| 58 | + | t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active()) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | for event := range events { | |
| 62 | + | d.ApplyLoad(event) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | if d.Loading() { | |
| 66 | + | t.Fatal("dialog still loading after final result") | |
| 67 | + | } | |
| 68 | + | if len(d.sessions) != 1 || d.sessions[0].Path != session.Path { | |
| 69 | + | t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path) | |
| 70 | + | } | |
| 71 | + | loadedText := strings.Join(d.Render(tui.Dark, 100), "\n") | |
| 72 | + | if strings.Contains(loadedText, "loading sessions") { | |
| 73 | + | t.Fatalf("completed render still shows loading spinner: %q", loadedText) | |
| 74 | + | } | |
| 75 | + | if !strings.Contains(loadedText, "load this session") { | |
| 76 | + | t.Fatalf("completed render missing session text: %q", loadedText) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) { | |
| 81 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 82 | + | cancel() | |
| 83 | + | ||
| 84 | + | d := newSessionDialog() | |
| 85 | + | events := d.Open(ctx, t.TempDir(), t.TempDir()) | |
| 86 | + | if d.Loading() { | |
| 87 | + | t.Fatal("dialog remained loading with an already-canceled parent") | |
| 88 | + | } | |
| 89 | + | if _, ok := <-events; ok { | |
| 90 | + | t.Fatal("canceled dialog emitted a load event") | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestSessionDialogCancellationFinishesLoading(t *testing.T) { | |
| 95 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 96 | + | d := newSessionDialog() | |
| 97 | + | events := d.Open(ctx, t.TempDir(), t.TempDir()) | |
| 98 | + | cancel() | |
| 99 | + | for range events { | |
| 100 | + | } | |
| 101 | + | d.ApplyLoadClosed() | |
| 102 | + | if d.Loading() { | |
| 103 | + | t.Fatal("dialog remained loading after parent cancellation") | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestSessionDialogEscapeCancelsLoading(t *testing.T) { | |
| 108 | + | d := newSessionDialog() | |
| 109 | + | events := d.Open(context.Background(), t.TempDir(), t.TempDir()) | |
| 110 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 111 | + | if !act.Close || d.Active() || d.Loading() { | |
| 112 | + | t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading()) | |
| 113 | + | } | |
| 114 | + | for range events { | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |
| 119 | + | d := newSessionDialog() | |
| 120 | + | d.active = true | |
| 121 | + | d.loading = true | |
| 122 | + | d.loadGeneration = 7 | |
| 123 | + | ||
| 124 | + | d.ApplyLoad(sessionLoadEvent{ | |
| 125 | + | kind: sessionLoadStarted, | |
| 126 | + | generation: 7, | |
| 127 | + | total: 4, | |
| 128 | + | }) | |
| 129 | + | d.ApplyLoad(sessionLoadEvent{ | |
| 130 | + | kind: sessionLoadEntry, | |
| 131 | + | generation: 7, | |
| 132 | + | index: 2, | |
| 133 | + | summary: core.SessionSummary{Path: "third", MessageCount: 1}, | |
| 134 | + | }) | |
| 135 | + | d.ApplyLoad(sessionLoadEvent{ | |
| 136 | + | kind: sessionLoadEntry, | |
| 137 | + | generation: 7, | |
| 138 | + | index: 1, | |
| 139 | + | summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true}, | |
| 140 | + | }) | |
| 141 | + | d.ApplyLoad(sessionLoadEvent{ | |
| 142 | + | kind: sessionLoadEntry, | |
| 143 | + | generation: 7, | |
| 144 | + | index: 3, | |
| 145 | + | summary: core.SessionSummary{Path: "empty"}, | |
| 146 | + | }) | |
| 147 | + | d.ApplyLoad(sessionLoadEvent{ | |
| 148 | + | kind: sessionLoadEntry, | |
| 149 | + | generation: 7, | |
| 150 | + | index: 0, | |
| 151 | + | summary: core.SessionSummary{Path: "first", MessageCount: 1}, | |
| 152 | + | }) | |
| 153 | + | d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7}) | |
| 154 | + | ||
| 155 | + | if d.Loading() { | |
| 156 | + | t.Fatal("dialog still loading after final result") | |
| 157 | + | } | |
| 158 | + | if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" { | |
| 159 | + | t.Fatalf("loaded sessions = %+v, want first then third", d.sessions) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { | |
| 164 | + | first := newSessionDialog() | |
| 165 | + | firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | |
| 166 | + | firstGeneration := first.loadGeneration | |
| 167 | + | ||
| 168 | + | secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | |
| 169 | + | if first.loadGeneration == firstGeneration { | |
| 170 | + | t.Fatal("reopening picker did not advance load generation") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | first.ApplyLoad(sessionLoadEvent{ | |
| 174 | + | kind: sessionLoadEntry, | |
| 175 | + | generation: firstGeneration, | |
| 176 | + | index: 0, | |
| 177 | + | summary: core.SessionSummary{ | |
| 178 | + | Path: "stale", | |
| 179 | + | MessageCount: 1, | |
| 180 | + | }, | |
| 181 | + | }) | |
| 182 | + | if len(first.sessions) != 0 { | |
| 183 | + | t.Fatalf("stale results repopulated picker: %+v", first.sessions) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | first.Close() | |
| 187 | + | for range firstEvents { | |
| 188 | + | } | |
| 189 | + | for range secondEvents { | |
| 190 | + | } | |
| 191 | + | } | |
| @@ -0,0 +1,191 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "unicode" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) { | |||
| 15 | + | row := formatSessionRowPlain(core.SessionSummary{ | |||
| 16 | + | Provider: "test\x1b]0;bad\a", | |||
| 17 | + | Model: "model\x1b[31m", | |||
| 18 | + | MessageCount: 1, | |||
| 19 | + | FirstUserText: "hello\x1b[2J\nworld", | |||
| 20 | + | }, 120) | |||
| 21 | + | if strings.IndexFunc(row, unicode.IsControl) >= 0 { | |||
| 22 | + | t.Fatalf("session row contains control characters: %q", row) | |||
| 23 | + | } | |||
| 24 | + | if !strings.Contains(row, "hello world") { | |||
| 25 | + | t.Fatalf("sanitized session text missing: %q", row) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |||
| 30 | + | root := t.TempDir() | |||
| 31 | + | cwd := t.TempDir() | |||
| 32 | + | session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | if err := session.AppendMessage(provider.Message{ | |||
| 37 | + | Role: provider.RoleUser, | |||
| 38 | + | Content: []provider.Content{provider.TextBlock{Text: "load this session"}}, | |||
| 39 | + | }); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | if err := session.Close(); err != nil { | |||
| 43 | + | t.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | d := newSessionDialog() | |||
| 47 | + | events := d.Open(context.Background(), root, cwd) | |||
| 48 | + | t.Cleanup(d.Close) | |||
| 49 | + | if !d.Active() || !d.Loading() { | |||
| 50 | + | t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading()) | |||
| 51 | + | } | |||
| 52 | + | loadingText := strings.Join(d.Render(tui.Dark, 100), "\n") | |||
| 53 | + | if !strings.Contains(loadingText, "loading sessions") { | |||
| 54 | + | t.Fatalf("loading render = %q, want spinner status", loadingText) | |||
| 55 | + | } | |||
| 56 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 57 | + | if act.Select || !d.Active() { | |||
| 58 | + | t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active()) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | for event := range events { | |||
| 62 | + | d.ApplyLoad(event) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | if d.Loading() { | |||
| 66 | + | t.Fatal("dialog still loading after final result") | |||
| 67 | + | } | |||
| 68 | + | if len(d.sessions) != 1 || d.sessions[0].Path != session.Path { | |||
| 69 | + | t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path) | |||
| 70 | + | } | |||
| 71 | + | loadedText := strings.Join(d.Render(tui.Dark, 100), "\n") | |||
| 72 | + | if strings.Contains(loadedText, "loading sessions") { | |||
| 73 | + | t.Fatalf("completed render still shows loading spinner: %q", loadedText) | |||
| 74 | + | } | |||
| 75 | + | if !strings.Contains(loadedText, "load this session") { | |||
| 76 | + | t.Fatalf("completed render missing session text: %q", loadedText) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) { | |||
| 81 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 82 | + | cancel() | |||
| 83 | + | ||||
| 84 | + | d := newSessionDialog() | |||
| 85 | + | events := d.Open(ctx, t.TempDir(), t.TempDir()) | |||
| 86 | + | if d.Loading() { | |||
| 87 | + | t.Fatal("dialog remained loading with an already-canceled parent") | |||
| 88 | + | } | |||
| 89 | + | if _, ok := <-events; ok { | |||
| 90 | + | t.Fatal("canceled dialog emitted a load event") | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestSessionDialogCancellationFinishesLoading(t *testing.T) { | |||
| 95 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 96 | + | d := newSessionDialog() | |||
| 97 | + | events := d.Open(ctx, t.TempDir(), t.TempDir()) | |||
| 98 | + | cancel() | |||
| 99 | + | for range events { | |||
| 100 | + | } | |||
| 101 | + | d.ApplyLoadClosed() | |||
| 102 | + | if d.Loading() { | |||
| 103 | + | t.Fatal("dialog remained loading after parent cancellation") | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestSessionDialogEscapeCancelsLoading(t *testing.T) { | |||
| 108 | + | d := newSessionDialog() | |||
| 109 | + | events := d.Open(context.Background(), t.TempDir(), t.TempDir()) | |||
| 110 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 111 | + | if !act.Close || d.Active() || d.Loading() { | |||
| 112 | + | t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading()) | |||
| 113 | + | } | |||
| 114 | + | for range events { | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |||
| 119 | + | d := newSessionDialog() | |||
| 120 | + | d.active = true | |||
| 121 | + | d.loading = true | |||
| 122 | + | d.loadGeneration = 7 | |||
| 123 | + | ||||
| 124 | + | d.ApplyLoad(sessionLoadEvent{ | |||
| 125 | + | kind: sessionLoadStarted, | |||
| 126 | + | generation: 7, | |||
| 127 | + | total: 4, | |||
| 128 | + | }) | |||
| 129 | + | d.ApplyLoad(sessionLoadEvent{ | |||
| 130 | + | kind: sessionLoadEntry, | |||
| 131 | + | generation: 7, | |||
| 132 | + | index: 2, | |||
| 133 | + | summary: core.SessionSummary{Path: "third", MessageCount: 1}, | |||
| 134 | + | }) | |||
| 135 | + | d.ApplyLoad(sessionLoadEvent{ | |||
| 136 | + | kind: sessionLoadEntry, | |||
| 137 | + | generation: 7, | |||
| 138 | + | index: 1, | |||
| 139 | + | summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true}, | |||
| 140 | + | }) | |||
| 141 | + | d.ApplyLoad(sessionLoadEvent{ | |||
| 142 | + | kind: sessionLoadEntry, | |||
| 143 | + | generation: 7, | |||
| 144 | + | index: 3, | |||
| 145 | + | summary: core.SessionSummary{Path: "empty"}, | |||
| 146 | + | }) | |||
| 147 | + | d.ApplyLoad(sessionLoadEvent{ | |||
| 148 | + | kind: sessionLoadEntry, | |||
| 149 | + | generation: 7, | |||
| 150 | + | index: 0, | |||
| 151 | + | summary: core.SessionSummary{Path: "first", MessageCount: 1}, | |||
| 152 | + | }) | |||
| 153 | + | d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7}) | |||
| 154 | + | ||||
| 155 | + | if d.Loading() { | |||
| 156 | + | t.Fatal("dialog still loading after final result") | |||
| 157 | + | } | |||
| 158 | + | if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" { | |||
| 159 | + | t.Fatalf("loaded sessions = %+v, want first then third", d.sessions) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { | |||
| 164 | + | first := newSessionDialog() | |||
| 165 | + | firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | |||
| 166 | + | firstGeneration := first.loadGeneration | |||
| 167 | + | ||||
| 168 | + | secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | |||
| 169 | + | if first.loadGeneration == firstGeneration { | |||
| 170 | + | t.Fatal("reopening picker did not advance load generation") | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | first.ApplyLoad(sessionLoadEvent{ | |||
| 174 | + | kind: sessionLoadEntry, | |||
| 175 | + | generation: firstGeneration, | |||
| 176 | + | index: 0, | |||
| 177 | + | summary: core.SessionSummary{ | |||
| 178 | + | Path: "stale", | |||
| 179 | + | MessageCount: 1, | |||
| 180 | + | }, | |||
| 181 | + | }) | |||
| 182 | + | if len(first.sessions) != 0 { | |||
| 183 | + | t.Fatalf("stale results repopulated picker: %+v", first.sessions) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | first.Close() | |||
| 187 | + | for range firstEvents { | |||
| 188 | + | } | |||
| 189 | + | for range secondEvents { | |||
| 190 | + | } | |||
| 191 | + | } | |||
M packages/core/session.go +98 -12
| @@ -3,6 +3,7 @@ package core | |||
| 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,13 @@ 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) { | |
| 295 | + | if err := contextErr(ctx); err != nil { | |
| 296 | + | return SessionSnapshot{}, err | |
| 297 | + | } | |
| 271 | 298 | f, err := os.Open(path) | |
| 272 | 299 | if err != nil { | |
| 273 | 300 | return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err) | |
| @@ -286,7 +313,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 286 | 313 | titleFromRename := false | |
| 287 | 314 | extensionState := map[string]json.RawMessage{} | |
| 288 | 315 | ||
| 289 | - | err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error { | |
| 316 | + | err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error { | |
| 290 | 317 | var head sessionLineHead | |
| 291 | 318 | if err := json.Unmarshal(line, &head); err != nil { | |
| 292 | 319 | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | |
| @@ -538,9 +565,16 @@ func ReadSessionHistory(path string) (SessionHistory, error) { | |||
| 538 | 565 | // forEachJSONLLine. It rejects malformed and blank rows and reports the | |
| 539 | 566 | // line number so callers cannot accidentally proceed with a partial file. | |
| 540 | 567 | func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error { | |
| 568 | + | return forEachStrictJSONLLineContext(context.Background(), r, fn) | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | func forEachStrictJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte, int) error) error { | |
| 541 | 572 | br := bufio.NewReader(r) | |
| 542 | 573 | lineNo := 0 | |
| 543 | 574 | for { | |
| 575 | + | if err := contextErr(ctx); err != nil { | |
| 576 | + | return err | |
| 577 | + | } | |
| 544 | 578 | line, err := br.ReadBytes('\n') | |
| 545 | 579 | if len(line) > 0 { | |
| 546 | 580 | lineNo++ | |
| @@ -785,6 +819,8 @@ type SessionSummary struct { | |||
| 785 | 819 | TotalCost float64 | |
| 786 | 820 | Title string | |
| 787 | 821 | BranchDepth int | |
| 822 | + | // HideFromSessions marks internal branches omitted from the flat picker. | |
| 823 | + | HideFromSessions bool | |
| 788 | 824 | } | |
| 789 | 825 | ||
| 790 | 826 | // RenameSession appends a sanitized rename line to the session file. This is | |
| @@ -815,6 +851,22 @@ func DeleteSession(path string) error { | |||
| 815 | 851 | return os.Remove(path) | |
| 816 | 852 | } | |
| 817 | 853 | ||
| 854 | + | // DescribeSession returns a lightweight summary for one on-disk session. | |
| 855 | + | // It uses the effective snapshot so message counts, metadata, and usage agree | |
| 856 | + | // with resume and branching behavior. The summary retains HideFromSessions so | |
| 857 | + | // callers that load entries independently can apply the same picker filter as | |
| 858 | + | // DescribeSessions. | |
| 859 | + | func DescribeSession(path string) SessionSummary { | |
| 860 | + | return DescribeSessionContext(context.Background(), path) | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | // DescribeSessionContext is the cancellation-aware form of DescribeSession. | |
| 864 | + | // Cancellation is checked between JSONL records so a closed picker does not | |
| 865 | + | // keep parsing a large transcript unnecessarily. | |
| 866 | + | func DescribeSessionContext(ctx context.Context, path string) SessionSummary { | |
| 867 | + | return describeSessionContext(ctx, path) | |
| 868 | + | } | |
| 869 | + | ||
| 818 | 870 | // DescribeSessions returns lightweight summaries for every session in | |
| 819 | 871 | // cwd, newest first. It uses the effective snapshot so message counts, | |
| 820 | 872 | // metadata, and usage agree with resume and branching behavior. | |
| @@ -860,14 +912,19 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[ | |||
| 860 | 912 | } | |
| 861 | 913 | ||
| 862 | 914 | func describeSession(path string) SessionSummary { | |
| 915 | + | return describeSessionContext(context.Background(), path) | |
| 916 | + | } | |
| 917 | + | ||
| 918 | + | func describeSessionContext(ctx context.Context, path string) SessionSummary { | |
| 863 | 919 | s := SessionSummary{Path: path} | |
| 864 | - | snapshot, err := ReadSessionSnapshot(path) | |
| 920 | + | snapshot, err := readSessionSnapshot(ctx, path) | |
| 865 | 921 | if err != nil { | |
| 866 | 922 | return s | |
| 867 | 923 | } | |
| 868 | 924 | s.Started = snapshot.Meta.Started | |
| 869 | 925 | s.Model = snapshot.Meta.Model | |
| 870 | 926 | s.Provider = snapshot.Meta.Provider | |
| 927 | + | s.HideFromSessions = snapshot.Meta.HideFromSessions | |
| 871 | 928 | s.Title = NormalizeSessionTitle(snapshot.Title) | |
| 872 | 929 | s.MessageCount = len(snapshot.Messages) | |
| 873 | 930 | s.FirstUserText = firstUserTextFromMessages(snapshot.Messages) | |
| @@ -892,7 +949,7 @@ func describeSession(path string) SessionSummary { | |||
| 892 | 949 | return err | |
| 893 | 950 | } | |
| 894 | 951 | defer f.Close() | |
| 895 | - | return forEachJSONLLine(f, func(line []byte) error { | |
| 952 | + | return forEachJSONLLineContext(ctx, f, func(line []byte) error { | |
| 896 | 953 | var head sessionLineHead | |
| 897 | 954 | if err := json.Unmarshal(line, &head); err != nil { | |
| 898 | 955 | return nil | |
| @@ -1021,26 +1078,52 @@ func sessionHasNoMessages(path string) bool { | |||
| 1021 | 1078 | // Files with identical ModTime fall back to filename desc so the | |
| 1022 | 1079 | // order stays stable across calls. | |
| 1023 | 1080 | func ListSessions(root, cwd string) []string { | |
| 1081 | + | return ListSessionsContext(context.Background(), root, cwd) | |
| 1082 | + | } | |
| 1083 | + | ||
| 1084 | + | // ListSessionsContext is the cancellation-aware form of ListSessions. It | |
| 1085 | + | // checks cancellation while walking directory entries so a closed picker does | |
| 1086 | + | // not continue preparing a large result set. | |
| 1087 | + | func ListSessionsContext(ctx context.Context, root, cwd string) []string { | |
| 1088 | + | if err := contextErr(ctx); err != nil { | |
| 1089 | + | return nil | |
| 1090 | + | } | |
| 1024 | 1091 | dir := SessionsDir(root, cwd) | |
| 1025 | - | entries, err := os.ReadDir(dir) | |
| 1092 | + | dirFile, err := os.Open(dir) | |
| 1026 | 1093 | if err != nil { | |
| 1027 | 1094 | return nil | |
| 1028 | 1095 | } | |
| 1096 | + | defer dirFile.Close() | |
| 1029 | 1097 | type rec struct { | |
| 1030 | 1098 | path string | |
| 1031 | 1099 | mod time.Time | |
| 1032 | 1100 | } | |
| 1033 | 1101 | var files []rec | |
| 1034 | - | for _, e := range entries { | |
| 1035 | - | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | |
| 1036 | - | continue | |
| 1102 | + | for { | |
| 1103 | + | if err := contextErr(ctx); err != nil { | |
| 1104 | + | return nil | |
| 1037 | 1105 | } | |
| 1038 | - | p := filepath.Join(dir, e.Name()) | |
| 1039 | - | info, err := e.Info() | |
| 1040 | - | if err != nil { | |
| 1041 | - | continue | |
| 1106 | + | entries, readErr := dirFile.ReadDir(128) | |
| 1107 | + | if readErr != nil && readErr != io.EOF { | |
| 1108 | + | return nil | |
| 1109 | + | } | |
| 1110 | + | for _, e := range entries { | |
| 1111 | + | if err := contextErr(ctx); err != nil { | |
| 1112 | + | return nil | |
| 1113 | + | } | |
| 1114 | + | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | |
| 1115 | + | continue | |
| 1116 | + | } | |
| 1117 | + | p := filepath.Join(dir, e.Name()) | |
| 1118 | + | info, err := e.Info() | |
| 1119 | + | if err != nil { | |
| 1120 | + | continue | |
| 1121 | + | } | |
| 1122 | + | files = append(files, rec{path: p, mod: info.ModTime()}) | |
| 1123 | + | } | |
| 1124 | + | if readErr == io.EOF { | |
| 1125 | + | break | |
| 1042 | 1126 | } | |
| 1043 | - | files = append(files, rec{path: p, mod: info.ModTime()}) | |
| 1044 | 1127 | } | |
| 1045 | 1128 | sort.Slice(files, func(i, j int) bool { | |
| 1046 | 1129 | if !files[i].mod.Equal(files[j].mod) { | |
| @@ -1050,6 +1133,9 @@ func ListSessions(root, cwd string) []string { | |||
| 1050 | 1133 | }) | |
| 1051 | 1134 | out := make([]string, 0, len(files)) | |
| 1052 | 1135 | for _, r := range files { | |
| 1136 | + | if err := contextErr(ctx); err != nil { | |
| 1137 | + | return nil | |
| 1138 | + | } | |
| 1053 | 1139 | out = append(out, r.path) | |
| 1054 | 1140 | } | |
| 1055 | 1141 | return out | |
| @@ -3,6 +3,7 @@ package core | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | + | "context" | |||
| 6 | "crypto/sha256" | 7 | "crypto/sha256" | ||
| 7 | "encoding/hex" | 8 | "encoding/hex" | ||
| 8 | "encoding/json" | 9 | "encoding/json" | ||
| @@ -190,8 +191,15 @@ func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) | |||||
| 190 | } | 191 | } | ||
| 191 | 192 | ||||
| 192 | func forEachJSONLLine(r io.Reader, fn func([]byte) error) error { | 193 | func forEachJSONLLine(r io.Reader, fn func([]byte) error) error { | ||
| 194 | + | return forEachJSONLLineContext(context.Background(), r, fn) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func forEachJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte) error) error { | |||
| 193 | br := bufio.NewReader(r) | 198 | br := bufio.NewReader(r) | ||
| 194 | for { | 199 | for { | ||
| 200 | + | if err := contextErr(ctx); err != nil { | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 195 | line, err := br.ReadBytes('\n') | 203 | line, err := br.ReadBytes('\n') | ||
| 196 | if len(line) > 0 { | 204 | if len(line) > 0 { | ||
| 197 | line = bytes.TrimRight(line, "\r\n") | 205 | line = bytes.TrimRight(line, "\r\n") | ||
| @@ -210,6 +218,18 @@ func forEachJSONLLine(r io.Reader, fn func([]byte) error) error { | |||||
| 210 | } | 218 | } | ||
| 211 | } | 219 | } | ||
| 212 | 220 | ||||
| 221 | + | func contextErr(ctx context.Context) error { | |||
| 222 | + | if ctx == nil { | |||
| 223 | + | return nil | |||
| 224 | + | } | |||
| 225 | + | select { | |||
| 226 | + | case <-ctx.Done(): | |||
| 227 | + | return ctx.Err() | |||
| 228 | + | default: | |||
| 229 | + | return nil | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 213 | // SessionUsage returns the most recent cumulative usage row stored 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,13 @@ 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) { | |||
| 295 | + | if err := contextErr(ctx); err != nil { | |||
| 296 | + | return SessionSnapshot{}, err | |||
| 297 | + | } | |||
| 271 | f, err := os.Open(path) | 298 | f, err := os.Open(path) | ||
| 272 | if err != nil { | 299 | if err != nil { | ||
| 273 | return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err) | 300 | return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err) | ||
| @@ -286,7 +313,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||||
| 286 | titleFromRename := false | 313 | titleFromRename := false | ||
| 287 | extensionState := map[string]json.RawMessage{} | 314 | extensionState := map[string]json.RawMessage{} | ||
| 288 | 315 | ||||
| 289 | - | err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error { | 316 | + | err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error { |
| 290 | var head sessionLineHead | 317 | var head sessionLineHead | ||
| 291 | if err := json.Unmarshal(line, &head); err != nil { | 318 | if err := json.Unmarshal(line, &head); err != nil { | ||
| 292 | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | 319 | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | ||
| @@ -538,9 +565,16 @@ func ReadSessionHistory(path string) (SessionHistory, error) { | |||||
| 538 | // forEachJSONLLine. It rejects malformed and blank rows and reports the | 565 | // forEachJSONLLine. It rejects malformed and blank rows and reports the | ||
| 539 | // line number so callers cannot accidentally proceed with a partial file. | 566 | // line number so callers cannot accidentally proceed with a partial file. | ||
| 540 | func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error { | 567 | func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error { | ||
| 568 | + | return forEachStrictJSONLLineContext(context.Background(), r, fn) | |||
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | func forEachStrictJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte, int) error) error { | |||
| 541 | br := bufio.NewReader(r) | 572 | br := bufio.NewReader(r) | ||
| 542 | lineNo := 0 | 573 | lineNo := 0 | ||
| 543 | for { | 574 | for { | ||
| 575 | + | if err := contextErr(ctx); err != nil { | |||
| 576 | + | return err | |||
| 577 | + | } | |||
| 544 | line, err := br.ReadBytes('\n') | 578 | line, err := br.ReadBytes('\n') | ||
| 545 | if len(line) > 0 { | 579 | if len(line) > 0 { | ||
| 546 | lineNo++ | 580 | lineNo++ | ||
| @@ -785,6 +819,8 @@ type SessionSummary struct { | |||||
| 785 | TotalCost float64 | 819 | TotalCost float64 | ||
| 786 | Title string | 820 | Title string | ||
| 787 | BranchDepth int | 821 | BranchDepth int | ||
| 822 | + | // HideFromSessions marks internal branches omitted from the flat picker. | |||
| 823 | + | HideFromSessions bool | |||
| 788 | } | 824 | } | ||
| 789 | 825 | ||||
| 790 | // RenameSession appends a sanitized rename line to the session file. This is | 826 | // RenameSession appends a sanitized rename line to the session file. This is | ||
| @@ -815,6 +851,22 @@ func DeleteSession(path string) error { | |||||
| 815 | return os.Remove(path) | 851 | return os.Remove(path) | ||
| 816 | } | 852 | } | ||
| 817 | 853 | ||||
| 854 | + | // DescribeSession returns a lightweight summary for one on-disk session. | |||
| 855 | + | // It uses the effective snapshot so message counts, metadata, and usage agree | |||
| 856 | + | // with resume and branching behavior. The summary retains HideFromSessions so | |||
| 857 | + | // callers that load entries independently can apply the same picker filter as | |||
| 858 | + | // DescribeSessions. | |||
| 859 | + | func DescribeSession(path string) SessionSummary { | |||
| 860 | + | return DescribeSessionContext(context.Background(), path) | |||
| 861 | + | } | |||
| 862 | + | ||||
| 863 | + | // DescribeSessionContext is the cancellation-aware form of DescribeSession. | |||
| 864 | + | // Cancellation is checked between JSONL records so a closed picker does not | |||
| 865 | + | // keep parsing a large transcript unnecessarily. | |||
| 866 | + | func DescribeSessionContext(ctx context.Context, path string) SessionSummary { | |||
| 867 | + | return describeSessionContext(ctx, path) | |||
| 868 | + | } | |||
| 869 | + | ||||
| 818 | // DescribeSessions returns lightweight summaries for every session in | 870 | // DescribeSessions returns lightweight summaries for every session in | ||
| 819 | // cwd, newest first. It uses the effective snapshot so message counts, | 871 | // cwd, newest first. It uses the effective snapshot so message counts, | ||
| 820 | // metadata, and usage agree with resume and branching behavior. | 872 | // metadata, and usage agree with resume and branching behavior. | ||
| @@ -860,14 +912,19 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[ | |||||
| 860 | } | 912 | } | ||
| 861 | 913 | ||||
| 862 | func describeSession(path string) SessionSummary { | 914 | func describeSession(path string) SessionSummary { | ||
| 915 | + | return describeSessionContext(context.Background(), path) | |||
| 916 | + | } | |||
| 917 | + | ||||
| 918 | + | func describeSessionContext(ctx context.Context, path string) SessionSummary { | |||
| 863 | s := SessionSummary{Path: path} | 919 | s := SessionSummary{Path: path} | ||
| 864 | - | snapshot, err := ReadSessionSnapshot(path) | 920 | + | snapshot, err := readSessionSnapshot(ctx, path) |
| 865 | if err != nil { | 921 | if err != nil { | ||
| 866 | return s | 922 | return s | ||
| 867 | } | 923 | } | ||
| 868 | s.Started = snapshot.Meta.Started | 924 | s.Started = snapshot.Meta.Started | ||
| 869 | s.Model = snapshot.Meta.Model | 925 | s.Model = snapshot.Meta.Model | ||
| 870 | s.Provider = snapshot.Meta.Provider | 926 | s.Provider = snapshot.Meta.Provider | ||
| 927 | + | s.HideFromSessions = snapshot.Meta.HideFromSessions | |||
| 871 | s.Title = NormalizeSessionTitle(snapshot.Title) | 928 | s.Title = NormalizeSessionTitle(snapshot.Title) | ||
| 872 | s.MessageCount = len(snapshot.Messages) | 929 | s.MessageCount = len(snapshot.Messages) | ||
| 873 | s.FirstUserText = firstUserTextFromMessages(snapshot.Messages) | 930 | s.FirstUserText = firstUserTextFromMessages(snapshot.Messages) | ||
| @@ -892,7 +949,7 @@ func describeSession(path string) SessionSummary { | |||||
| 892 | return err | 949 | return err | ||
| 893 | } | 950 | } | ||
| 894 | defer f.Close() | 951 | defer f.Close() | ||
| 895 | - | return forEachJSONLLine(f, func(line []byte) error { | 952 | + | return forEachJSONLLineContext(ctx, f, func(line []byte) error { |
| 896 | var head sessionLineHead | 953 | var head sessionLineHead | ||
| 897 | if err := json.Unmarshal(line, &head); err != nil { | 954 | if err := json.Unmarshal(line, &head); err != nil { | ||
| 898 | return nil | 955 | return nil | ||
| @@ -1021,26 +1078,52 @@ func sessionHasNoMessages(path string) bool { | |||||
| 1021 | // Files with identical ModTime fall back to filename desc so the | 1078 | // Files with identical ModTime fall back to filename desc so the | ||
| 1022 | // order stays stable across calls. | 1079 | // order stays stable across calls. | ||
| 1023 | func ListSessions(root, cwd string) []string { | 1080 | func ListSessions(root, cwd string) []string { | ||
| 1081 | + | return ListSessionsContext(context.Background(), root, cwd) | |||
| 1082 | + | } | |||
| 1083 | + | ||||
| 1084 | + | // ListSessionsContext is the cancellation-aware form of ListSessions. It | |||
| 1085 | + | // checks cancellation while walking directory entries so a closed picker does | |||
| 1086 | + | // not continue preparing a large result set. | |||
| 1087 | + | func ListSessionsContext(ctx context.Context, root, cwd string) []string { | |||
| 1088 | + | if err := contextErr(ctx); err != nil { | |||
| 1089 | + | return nil | |||
| 1090 | + | } | |||
| 1024 | dir := SessionsDir(root, cwd) | 1091 | dir := SessionsDir(root, cwd) | ||
| 1025 | - | entries, err := os.ReadDir(dir) | 1092 | + | dirFile, err := os.Open(dir) |
| 1026 | if err != nil { | 1093 | if err != nil { | ||
| 1027 | return nil | 1094 | return nil | ||
| 1028 | } | 1095 | } | ||
| 1096 | + | defer dirFile.Close() | |||
| 1029 | type rec struct { | 1097 | type rec struct { | ||
| 1030 | path string | 1098 | path string | ||
| 1031 | mod time.Time | 1099 | mod time.Time | ||
| 1032 | } | 1100 | } | ||
| 1033 | var files []rec | 1101 | var files []rec | ||
| 1034 | - | for _, e := range entries { | 1102 | + | for { |
| 1035 | - | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | 1103 | + | if err := contextErr(ctx); err != nil { |
| 1036 | - | continue | 1104 | + | return nil |
| 1037 | } | 1105 | } | ||
| 1038 | - | p := filepath.Join(dir, e.Name()) | 1106 | + | entries, readErr := dirFile.ReadDir(128) |
| 1039 | - | info, err := e.Info() | 1107 | + | if readErr != nil && readErr != io.EOF { |
| 1040 | - | if err != nil { | 1108 | + | return nil |
| 1041 | - | continue | 1109 | + | } |
| 1110 | + | for _, e := range entries { | |||
| 1111 | + | if err := contextErr(ctx); err != nil { | |||
| 1112 | + | return nil | |||
| 1113 | + | } | |||
| 1114 | + | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | |||
| 1115 | + | continue | |||
| 1116 | + | } | |||
| 1117 | + | p := filepath.Join(dir, e.Name()) | |||
| 1118 | + | info, err := e.Info() | |||
| 1119 | + | if err != nil { | |||
| 1120 | + | continue | |||
| 1121 | + | } | |||
| 1122 | + | files = append(files, rec{path: p, mod: info.ModTime()}) | |||
| 1123 | + | } | |||
| 1124 | + | if readErr == io.EOF { | |||
| 1125 | + | break | |||
| 1042 | } | 1126 | } | ||
| 1043 | - | files = append(files, rec{path: p, mod: info.ModTime()}) | |||
| 1044 | } | 1127 | } | ||
| 1045 | sort.Slice(files, func(i, j int) bool { | 1128 | sort.Slice(files, func(i, j int) bool { | ||
| 1046 | if !files[i].mod.Equal(files[j].mod) { | 1129 | if !files[i].mod.Equal(files[j].mod) { | ||
| @@ -1050,6 +1133,9 @@ func ListSessions(root, cwd string) []string { | |||||
| 1050 | }) | 1133 | }) | ||
| 1051 | out := make([]string, 0, len(files)) | 1134 | out := make([]string, 0, len(files)) | ||
| 1052 | for _, r := range files { | 1135 | for _, r := range files { | ||
| 1136 | + | if err := contextErr(ctx); err != nil { | |||
| 1137 | + | return nil | |||
| 1138 | + | } | |||
| 1053 | out = append(out, r.path) | 1139 | out = append(out, r.path) | ||
| 1054 | } | 1140 | } | ||
| 1055 | return out | 1141 | return out | ||
M packages/core/session_snapshot_test.go +43 -0
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 5 | 7 | "os" | |
| 6 | 8 | "path/filepath" | |
| 7 | 9 | "strings" | |
| @@ -376,6 +378,9 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) { | |||
| 376 | 378 | if err != nil { | |
| 377 | 379 | t.Fatal(err) | |
| 378 | 380 | } | |
| 381 | + | if summary := DescribeSession(childPath); !summary.HideFromSessions { | |
| 382 | + | t.Fatalf("DescribeSession hidden flag = %v, want true", summary.HideFromSessions) | |
| 383 | + | } | |
| 379 | 384 | if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath { | |
| 380 | 385 | t.Fatalf("DescribeSessions = %+v, want only parent", got) | |
| 381 | 386 | } | |
| @@ -388,6 +393,44 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) { | |||
| 388 | 393 | } | |
| 389 | 394 | } | |
| 390 | 395 | ||
| 396 | + | func TestForEachStrictJSONLLineContextStopsAfterCancellation(t *testing.T) { | |
| 397 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 398 | + | calls := 0 | |
| 399 | + | err := forEachStrictJSONLLineContext(ctx, strings.NewReader("first\nsecond\n"), func(line []byte, _ int) error { | |
| 400 | + | calls++ | |
| 401 | + | cancel() | |
| 402 | + | return nil | |
| 403 | + | }) | |
| 404 | + | if !errors.Is(err, context.Canceled) { | |
| 405 | + | t.Fatalf("context-aware JSONL read error = %v, want context.Canceled", err) | |
| 406 | + | } | |
| 407 | + | if calls != 1 { | |
| 408 | + | t.Fatalf("context-aware JSONL callback count = %d, want 1", calls) | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func TestDescribeSessionContextHonorsCancellation(t *testing.T) { | |
| 413 | + | root := t.TempDir() | |
| 414 | + | session, err := NewSession(root, "/workspace", "test", "model", "test") | |
| 415 | + | if err != nil { | |
| 416 | + | t.Fatal(err) | |
| 417 | + | } | |
| 418 | + | if err := session.AppendMessage(textMessage(provider.RoleUser, "cancel me")); err != nil { | |
| 419 | + | t.Fatal(err) | |
| 420 | + | } | |
| 421 | + | path := session.Path | |
| 422 | + | if err := session.Close(); err != nil { | |
| 423 | + | t.Fatal(err) | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 427 | + | cancel() | |
| 428 | + | summary := DescribeSessionContext(ctx, path) | |
| 429 | + | if summary.Path != path || summary.MessageCount != 0 { | |
| 430 | + | t.Fatalf("canceled summary = %+v, want path only", summary) | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 391 | 434 | func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) { | |
| 392 | 435 | root := t.TempDir() | |
| 393 | 436 | cwd := "/workspace/strict-tree" | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 5 | "os" | 7 | "os" | ||
| 6 | "path/filepath" | 8 | "path/filepath" | ||
| 7 | "strings" | 9 | "strings" | ||
| @@ -376,6 +378,9 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) { | |||||
| 376 | if err != nil { | 378 | if err != nil { | ||
| 377 | t.Fatal(err) | 379 | t.Fatal(err) | ||
| 378 | } | 380 | } | ||
| 381 | + | if summary := DescribeSession(childPath); !summary.HideFromSessions { | |||
| 382 | + | t.Fatalf("DescribeSession hidden flag = %v, want true", summary.HideFromSessions) | |||
| 383 | + | } | |||
| 379 | if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath { | 384 | if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath { | ||
| 380 | t.Fatalf("DescribeSessions = %+v, want only parent", got) | 385 | t.Fatalf("DescribeSessions = %+v, want only parent", got) | ||
| 381 | } | 386 | } | ||
| @@ -388,6 +393,44 @@ func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) { | |||||
| 388 | } | 393 | } | ||
| 389 | } | 394 | } | ||
| 390 | 395 | ||||
| 396 | + | func TestForEachStrictJSONLLineContextStopsAfterCancellation(t *testing.T) { | |||
| 397 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 398 | + | calls := 0 | |||
| 399 | + | err := forEachStrictJSONLLineContext(ctx, strings.NewReader("first\nsecond\n"), func(line []byte, _ int) error { | |||
| 400 | + | calls++ | |||
| 401 | + | cancel() | |||
| 402 | + | return nil | |||
| 403 | + | }) | |||
| 404 | + | if !errors.Is(err, context.Canceled) { | |||
| 405 | + | t.Fatalf("context-aware JSONL read error = %v, want context.Canceled", err) | |||
| 406 | + | } | |||
| 407 | + | if calls != 1 { | |||
| 408 | + | t.Fatalf("context-aware JSONL callback count = %d, want 1", calls) | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func TestDescribeSessionContextHonorsCancellation(t *testing.T) { | |||
| 413 | + | root := t.TempDir() | |||
| 414 | + | session, err := NewSession(root, "/workspace", "test", "model", "test") | |||
| 415 | + | if err != nil { | |||
| 416 | + | t.Fatal(err) | |||
| 417 | + | } | |||
| 418 | + | if err := session.AppendMessage(textMessage(provider.RoleUser, "cancel me")); err != nil { | |||
| 419 | + | t.Fatal(err) | |||
| 420 | + | } | |||
| 421 | + | path := session.Path | |||
| 422 | + | if err := session.Close(); err != nil { | |||
| 423 | + | t.Fatal(err) | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 427 | + | cancel() | |||
| 428 | + | summary := DescribeSessionContext(ctx, path) | |||
| 429 | + | if summary.Path != path || summary.MessageCount != 0 { | |||
| 430 | + | t.Fatalf("canceled summary = %+v, want path only", summary) | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | ||||
| 391 | func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) { | 434 | func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) { | ||
| 392 | root := t.TempDir() | 435 | root := t.TempDir() | ||
| 393 | cwd := "/workspace/strict-tree" | 436 | cwd := "/workspace/strict-tree" | ||