feat(cli): auto-fire /recap on return after sustained blur#215
Conversation
When the terminal regains focus after >= awayThresholdMinutes (default 5) of sustained blur, generate a `/recap`-style "where you left off" summary and append it to history. Off by default; enable via `general.showSessionRecap`. Adapted from the auto-recap-on-return feature in QwenLM/qwen-code's `useAwaySummary` hook (introduced in QwenLM#3434, finalized in QwenLM#3482) — but takes a focused slice rather than a full cherry-pick, since upstream's version churned through a sticky-banner placement that QwenLM#3482 then tore back out, and most of the conflict surface was unrelated layout work we don't have. What this lands: - `packages/core/src/services/sessionRecap.ts` — thin wrapper around `generateRecap` that pulls history off the GeminiClient and returns `{ text } | null`, matching upstream's API so the hook is portable. - `packages/cli/src/ui/hooks/useAwaySummary.ts` — port of the upstream hook. Uses our existing `HistoryItemRecap` (`type: 'recap'`) instead of the upstream-only `away_recap` type so manual `/recap` and the auto-fire path share one render path. - Settings: `general.showSessionRecap` (boolean, default false) and `general.sessionRecapAwayThresholdMinutes` (number, default 5). - Wired in `AppContainer.tsx` next to `useFocus()`; `isIdle` is derived from `streamingState === StreamingState.Idle`. - Mirrors Claude Code's dedup gating: needs at least 3 user messages total before firing, and at least 2 new user messages since the previous recap before another can fire — prevents back-to-back near-duplicate recaps after a brief alt-tab cycle with no work between. Cleanup: stripped vestigial `awayRecapItem` / `setAwayRecapItem` fields from `UIStateContext` that referenced a never-imported `HistoryItemAwayRecap` type. No consumers; the auto-fire path addItem's directly into history instead of holding a sticky banner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WalkthroughThis PR introduces automatic session recap generation that triggers when the terminal regains focus after a 5+ minute idle period. It adds settings configuration, a core generation service, a UI hook tracking focus state, and integration into the app container while updating related async patterns. ChangesSession Recap Feature
Sequence DiagramsequenceDiagram
participant Focus as Terminal Focus
participant Hook as useAwaySummary Hook
participant Idle as Idle State
participant Service as Recap Service
participant History as History Manager
participant Config as Config/Gemini
participant Display as History Display
Focus->>Hook: Terminal loses focus
Hook->>Hook: Record blur start time
Note over Hook: Waiting for threshold (5+ min)
Focus->>Hook: Terminal regains focus
Hook->>Hook: Check blur duration >= threshold
Hook->>Idle: Check if terminal is idle
Idle-->>Hook: isIdle = true
Hook->>Hook: Check message count gate<br/>(MIN_USER_MESSAGES_SINCE_LAST_RECAP)
Hook->>Service: generateSessionRecap(config, abortSignal)
Service->>Config: getGeminiClient().getHistory()
Config-->>Service: Conversation history
Service->>Service: Validate model + user roles present
Service->>Service: Call generateRecap()
Service-->>Hook: { text: "..." } or null
Hook->>History: addItem(recap, timestamp)
History->>History: Append RECAP message
History-->>Display: Recap appears in history
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly Related Issues
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Review rate limit: 0/5 reviews remaining, refill in 59 minutes and 57 seconds. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/cli/src/ui/hooks/useResumeCommand.ts (2)
58-67:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winKeep the dialog open until the session load succeeds.
closeResumeDialog()runs beforeloadSession(), so a missing or stale session closes the dialog and then returnsfalse. Move the close until aftersessionDatais confirmed, or reopen on the failure path.💡 Suggested fix
- // Close dialog immediately to prevent input capture during async operations. - closeResumeDialog(); - const cwd = config.getTargetDir(); const sessionService = new SessionService(cwd); const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return false; } + + // Close only after we know the resume can proceed. + closeResumeDialog();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/ui/hooks/useResumeCommand.ts` around lines 58 - 67, The dialog is being closed before the async session load completes, which hides the UI when loadSession(sessionId) returns null/stale; update the flow in useResumeCommand.ts so you call closeResumeDialog() only after SessionService(cwd).loadSession(sessionId) returns a valid sessionData (or alternatively call closeResumeDialog() after confirming sessionData and reopen or show an error if loadSession fails). Concretely, keep the dialog open until sessionData is truthy, using the existing SessionService and loadSession, and handle the failure path by not closing (or re-opening) the dialog and returning false.
24-34:⚠️ Potential issue | 🟠 MajorUpdate the
handleResumetype signature inUIActionsContextto match the async contract.
handleResumeis typed as(sessionId: string) => voidinUIActions, butuseResumeCommandreturns(sessionId: string) => Promise<boolean>. This mismatch breaks the intended contract where callers should await the result to determine if the session load succeeded. Additionally,handleResumecloses the dialog before confirming the session load (line 59 ofuseResumeCommand.tsprecedes line 63), so if the load fails, the dialog is already closed. The dialog state and the async flow must be coordinated so the dialog remains open until the outcome is known, or the close timing must be justified. UpdateUIActionsContext.handleResumeto returnPromise<boolean>and ensure callers likeDialogManagerproperly await and handle the result.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/cli/src/ui/hooks/useResumeCommand.ts` around lines 24 - 34, UIActionsContext's handleResume is currently declared as (sessionId: string) => void but useResumeCommand.handleResume returns Promise<boolean>, causing a type and behavioral mismatch; change the UIActions/UIActionsContext handleResume signature to (sessionId: string) => Promise<boolean>, update any implementations to return a Promise<boolean> (true on successful load, false on short-circuit/failure), and update callers such as DialogManager to await the result and only close the resume dialog after the Promise resolves (or keep the dialog open on failure) so dialog state and async flow are coordinated with useResumeCommand.handleResume.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/users/configuration/settings.md`:
- Around line 80-86: Add a new settings row documenting the numeric threshold
key general.sessionRecapAwayThresholdMinutes (and its default value) alongside
general.showSessionRecap: mention that it represents the minutes of away time
used to trigger the session recap (default 5), specify type number, a short
description like "Minutes away before auto-showing the session recap," and the
default value (5) so users can discover and tune the threshold.
In `@packages/cli/src/ui/hooks/useAwaySummary.ts`:
- Around line 150-179: The promise from generateSessionRecap can reject and
currently has no catch, causing unhandled rejections; wrap the async flow in a
try/catch or attach a .catch handler to the generateSessionRecap(...) chain to
swallow/log errors and ensure the finally block still runs; specifically, handle
failures from generateSessionRecap(controller.signal) before using
isIdleRef.current/addItem and before calling
config.getChatRecordingService?.()?.recordSlashCommand, and ensure
inFlightRef.current and recapPendingRef.current are cleared in the finally block
regardless of errors.
---
Outside diff comments:
In `@packages/cli/src/ui/hooks/useResumeCommand.ts`:
- Around line 58-67: The dialog is being closed before the async session load
completes, which hides the UI when loadSession(sessionId) returns null/stale;
update the flow in useResumeCommand.ts so you call closeResumeDialog() only
after SessionService(cwd).loadSession(sessionId) returns a valid sessionData (or
alternatively call closeResumeDialog() after confirming sessionData and reopen
or show an error if loadSession fails). Concretely, keep the dialog open until
sessionData is truthy, using the existing SessionService and loadSession, and
handle the failure path by not closing (or re-opening) the dialog and returning
false.
- Around line 24-34: UIActionsContext's handleResume is currently declared as
(sessionId: string) => void but useResumeCommand.handleResume returns
Promise<boolean>, causing a type and behavioral mismatch; change the
UIActions/UIActionsContext handleResume signature to (sessionId: string) =>
Promise<boolean>, update any implementations to return a Promise<boolean> (true
on successful load, false on short-circuit/failure), and update callers such as
DialogManager to await the result and only close the resume dialog after the
Promise resolves (or keep the dialog open on failure) so dialog state and async
flow are coordinated with useResumeCommand.handleResume.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 73a98359-0e7e-4d18-8b30-296e6a077e4e
📒 Files selected for processing (8)
docs/users/configuration/settings.mdpackages/cli/src/config/settingsSchema.tspackages/cli/src/ui/AppContainer.tsxpackages/cli/src/ui/hooks/useAwaySummary.tspackages/cli/src/ui/hooks/useResumeCommand.tspackages/core/src/core/openaiContentGenerator/pipeline.tspackages/core/src/index.tspackages/core/src/services/sessionRecap.ts
| | Setting | Type | Description | Default | | ||
| | ------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | ||
| | `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | | ||
| | `general.vimMode` | boolean | Enable Vim keybindings. | `false` | | ||
| | `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` | | ||
| | `general.showSessionRecap` | boolean | Auto-show a one-line "where you left off" recap when returning to the terminal after being away for 5+ minutes. Off by default. Use `/recap` to trigger manually regardless of this setting. | `false` | | ||
| | `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | |
There was a problem hiding this comment.
Document the threshold setting alongside general.showSessionRecap.
The implementation also reads general.sessionRecapAwayThresholdMinutes, but the settings table only lists the boolean toggle. Please add the threshold row so users can discover and tune the 5-minute default.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/users/configuration/settings.md` around lines 80 - 86, Add a new
settings row documenting the numeric threshold key
general.sessionRecapAwayThresholdMinutes (and its default value) alongside
general.showSessionRecap: mention that it represents the minutes of away time
used to trigger the session recap (default 5), specify type number, a short
description like "Minutes away before auto-showing the session recap," and the
default value (5) so users can discover and tune the threshold.
| void generateSessionRecap(config, controller.signal) | ||
| .then((recap) => { | ||
| if (controller.signal.aborted || !recap) return; | ||
| if (!isIdleRef.current) return; | ||
| const item: HistoryItemWithoutId = { | ||
| type: MessageType.RECAP, | ||
| text: recap.text, | ||
| }; | ||
| addItem(item, Date.now()); | ||
|
|
||
| // Mirror the recording the slash-command processor does for | ||
| // manual `/recap`, so the auto-fired recap also survives `/resume`. | ||
| // Only record the `result` phase — recording an `invocation` | ||
| // would replay a fake `> /recap` user line on resume. | ||
| try { | ||
| config.getChatRecordingService?.()?.recordSlashCommand({ | ||
| phase: 'result', | ||
| rawCommand: '/recap', | ||
| outputHistoryItems: [{ ...item } as Record<string, unknown>], | ||
| }); | ||
| } catch { | ||
| // Recap is best-effort — never let a recording failure surface. | ||
| } | ||
| }) | ||
| .finally(() => { | ||
| if (inFlightRef.current === controller) { | ||
| inFlightRef.current = null; | ||
| } | ||
| recapPendingRef.current = false; | ||
| }); |
There was a problem hiding this comment.
Handle recap-generation failures inside the async flow.
generateSessionRecap(...) can reject or throw here, but the current chain has no catch path. That turns a best-effort recap into an unhandled rejection instead of a silent no-op.
🔧 Suggested fix
- void generateSessionRecap(config, controller.signal)
- .then((recap) => {
- if (controller.signal.aborted || !recap) return;
- if (!isIdleRef.current) return;
- const item: HistoryItemWithoutId = {
- type: MessageType.RECAP,
- text: recap.text,
- };
- addItem(item, Date.now());
-
- // Mirror the recording the slash-command processor does for
- // manual `/recap`, so the auto-fired recap also survives `/resume`.
- // Only record the `result` phase — recording an `invocation`
- // would replay a fake `> /recap` user line on resume.
- try {
- config.getChatRecordingService?.()?.recordSlashCommand({
- phase: 'result',
- rawCommand: '/recap',
- outputHistoryItems: [{ ...item } as Record<string, unknown>],
- });
- } catch {
- // Recap is best-effort — never let a recording failure surface.
- }
- })
- .finally(() => {
- if (inFlightRef.current === controller) {
- inFlightRef.current = null;
- }
- recapPendingRef.current = false;
- });
+ void (async () => {
+ try {
+ const recap = await generateSessionRecap(config, controller.signal);
+ if (controller.signal.aborted || !recap || !isIdleRef.current) {
+ return;
+ }
+ const item: HistoryItemWithoutId = {
+ type: MessageType.RECAP,
+ text: recap.text,
+ };
+ addItem(item, Date.now());
+
+ // Mirror the recording the slash-command processor does for
+ // manual `/recap`, so the auto-fired recap also survives `/resume`.
+ // Only record the `result` phase — recording an `invocation`
+ // would replay a fake `> /recap` user line on resume.
+ try {
+ config.getChatRecordingService?.()?.recordSlashCommand({
+ phase: 'result',
+ rawCommand: '/recap',
+ outputHistoryItems: [{ ...item } as Record<string, unknown>],
+ });
+ } catch {
+ // Recap is best-effort — never let a recording failure surface.
+ }
+ } catch {
+ // Best-effort: swallow recap-generation failures.
+ } finally {
+ if (inFlightRef.current === controller) {
+ inFlightRef.current = null;
+ }
+ recapPendingRef.current = false;
+ }
+ })();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| void generateSessionRecap(config, controller.signal) | |
| .then((recap) => { | |
| if (controller.signal.aborted || !recap) return; | |
| if (!isIdleRef.current) return; | |
| const item: HistoryItemWithoutId = { | |
| type: MessageType.RECAP, | |
| text: recap.text, | |
| }; | |
| addItem(item, Date.now()); | |
| // Mirror the recording the slash-command processor does for | |
| // manual `/recap`, so the auto-fired recap also survives `/resume`. | |
| // Only record the `result` phase — recording an `invocation` | |
| // would replay a fake `> /recap` user line on resume. | |
| try { | |
| config.getChatRecordingService?.()?.recordSlashCommand({ | |
| phase: 'result', | |
| rawCommand: '/recap', | |
| outputHistoryItems: [{ ...item } as Record<string, unknown>], | |
| }); | |
| } catch { | |
| // Recap is best-effort — never let a recording failure surface. | |
| } | |
| }) | |
| .finally(() => { | |
| if (inFlightRef.current === controller) { | |
| inFlightRef.current = null; | |
| } | |
| recapPendingRef.current = false; | |
| }); | |
| void (async () => { | |
| try { | |
| const recap = await generateSessionRecap(config, controller.signal); | |
| if (controller.signal.aborted || !recap || !isIdleRef.current) { | |
| return; | |
| } | |
| const item: HistoryItemWithoutId = { | |
| type: MessageType.RECAP, | |
| text: recap.text, | |
| }; | |
| addItem(item, Date.now()); | |
| // Mirror the recording the slash-command processor does for | |
| // manual `/recap`, so the auto-fired recap also survives `/resume`. | |
| // Only record the `result` phase — recording an `invocation` | |
| // would replay a fake `> /recap` user line on resume. | |
| try { | |
| config.getChatRecordingService?.()?.recordSlashCommand({ | |
| phase: 'result', | |
| rawCommand: '/recap', | |
| outputHistoryItems: [{ ...item } as Record<string, unknown>], | |
| }); | |
| } catch { | |
| // Recap is best-effort — never let a recording failure surface. | |
| } | |
| } catch { | |
| // Best-effort: swallow recap-generation failures. | |
| } finally { | |
| if (inFlightRef.current === controller) { | |
| inFlightRef.current = null; | |
| } | |
| recapPendingRef.current = false; | |
| } | |
| })(); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/cli/src/ui/hooks/useAwaySummary.ts` around lines 150 - 179, The
promise from generateSessionRecap can reject and currently has no catch, causing
unhandled rejections; wrap the async flow in a try/catch or attach a .catch
handler to the generateSessionRecap(...) chain to swallow/log errors and ensure
the finally block still runs; specifically, handle failures from
generateSessionRecap(controller.signal) before using isIdleRef.current/addItem
and before calling config.getChatRecordingService?.()?.recordSlashCommand, and
ensure inFlightRef.current and recapPendingRef.current are cleared in the
finally block regardless of errors.
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
Summary
When the terminal regains focus after ≥
awayThresholdMinutes(default 5) of sustained blur, generate a/recap-style "where you left off" summary and append it to history. Off by default; opt in viageneral.showSessionRecap.Why a focused slice instead of a cherry-pick of upstream #3434 + #3478 + #3482: upstream churned through a sticky-banner placement in QwenLM#3478 that QwenLM#3482 then tore back out, and most of the cherry-pick conflict surface was unrelated layout work (DefaultAppLayout, ScreenReaderAppLayout, HistoryItemDisplay restructure) we don't have. Re-implementing the actual feature is cheaper than untangling that.
What this lands
packages/core/src/services/sessionRecap.ts— thin wrapper aroundgenerateRecapthat pulls history off the GeminiClient and returns{ text } | null, matching upstream's API.packages/cli/src/ui/hooks/useAwaySummary.ts— port of the upstream hook. Uses our existingHistoryItemRecap(type: 'recap') instead of the upstream-onlyaway_recaptype so manual/recapand the auto-fire path share one render path.general.showSessionRecap(boolean, defaultfalse) andgeneral.sessionRecapAwayThresholdMinutes(number, default5).AppContainer.tsxnext touseFocus();isIdleis derived fromstreamingState === StreamingState.Idle.Dedup gating
Mirrors Claude Code's behavior:
Prevents back-to-back near-duplicate recaps after a brief alt-tab cycle with no new work between.
Cleanup + extras included
awayRecapItem/setAwayRecapItemfields fromUIStateContextthat referenced a never-importedHistoryItemAwayRecaptype. No consumers; the auto-fire pathaddItem's directly into history instead of holding a sticky banner.pipeline.tsAbortSignal listener-leak fix from fix(cli): pin /recap above input and align defaults with fastModel QwenLM/qwen-code#3478 §3 wrapped in araiseAbortListenerCaphelper. Replaces the inlinesetMaxListeners(0, signal)we already had with a slightly more general form. Functionally equivalent.useResumeCommand.handleResumenow returnsPromise<boolean>so callers can gate cleanup that should only happen on a successful session switch (no current consumers of the boolean — preparatory).Test plan
npm run typecheckcleanvitest runforAppContainer.test.tsx(32 tests) +recapGenerator.test.ts(3 tests) — all passgeneral.showSessionRecap, work for 5+ user turns, blur the terminal for 6+ minutes, return — verify a single recap auto-fires.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
/recapcommand remains availableDocumentation