diff --git a/docs/design/session-recap/session-recap-design.md b/docs/design/session-recap/session-recap-design.md index d3fe6fde9d..af93fcc813 100644 --- a/docs/design/session-recap/session-recap-design.md +++ b/docs/design/session-recap/session-recap-design.md @@ -1,6 +1,6 @@ # Session Recap Design -> A one-line "where did I leave off" summary surfaced when the user +> A brief (1-2 sentence) "where did I leave off" summary surfaced when the user > returns to an idle session, either on demand (`/recap`) or after the > terminal has been blurred for 5+ minutes. @@ -11,7 +11,7 @@ pages of history to remember **what they were doing and what came next** is a real friction point. Just reloading messages does not solve this UX problem. -The goal is to proactively surface a one-line recap when the user +The goal is to proactively surface a brief 1-2 sentence recap when the user returns: - **High-level task** (what they are doing) → **next step** (what to do next). @@ -41,7 +41,7 @@ command ignores that setting. │ isIdle = streamingState === Idle │ │ │ │ │ ├─→ useAwaySummary({enabled, config, isFocused, isIdle, │ -│ │ │ setAwayRecapItem}) │ +│ │ │ addItem}) │ │ │ └─→ 5 min blur timer + idle/dedupe gates │ │ │ │ │ │ │ ↓ │ @@ -57,25 +57,25 @@ command ignores that setting. │ GeminiClient.generateContent │ │ (fastModel + tools:[]) │ │ │ -│ setAwayRecapItem({type: 'away_recap', text}) │ -│ └─→ DefaultAppLayout renders AwayRecapMessage │ -│ as a sticky banner above the Composer │ -│ (dim color + "※ recap:" prefix) │ +│ addItem({type: 'away_recap', text}) ─→ HistoryItemDisplay │ +│ └─ AwayRecapMessage rendered inline like any other history │ +│ item (※ + bold "recap: " + italic content, all dim); │ +│ scrolls naturally with the conversation. Mirrors Claude │ +│ Code's away_summary system message. │ └────────────────────────────────────────────────────────────────────────┘ ``` ### Files -| File | Responsibility | -| ------------------------------------------------------------ | --------------------------------------------------- | -| `packages/core/src/services/sessionRecap.ts` | One-shot LLM call + history filter + tag extraction | -| `packages/cli/src/ui/hooks/useAwaySummary.ts` | Auto-trigger React hook | -| `packages/cli/src/ui/commands/recapCommand.ts` | `/recap` manual entry point | -| `packages/cli/src/ui/components/messages/StatusMessages.tsx` | `AwayRecapMessage` dim renderer (`※ recap:` prefix) | -| `packages/cli/src/ui/types.ts` | `HistoryItemAwayRecap` type | -| `packages/cli/src/ui/layouts/DefaultAppLayout.tsx` | Sticky-banner placement above the Composer | -| `packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx` | Same placement under screen-reader mode | -| `packages/cli/src/config/settingsSchema.ts` | `general.showSessionRecap` setting | +| File | Responsibility | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| `packages/core/src/services/sessionRecap.ts` | One-shot LLM call + history filter + tag extraction | +| `packages/cli/src/ui/hooks/useAwaySummary.ts` | Auto-trigger React hook | +| `packages/cli/src/ui/commands/recapCommand.ts` | `/recap` manual entry point | +| `packages/cli/src/ui/components/messages/StatusMessages.tsx` | `AwayRecapMessage` renderer (`※` + bold `recap:` + italic content, all dim) | +| `packages/cli/src/ui/types.ts` | `HistoryItemAwayRecap` type | +| `packages/cli/src/ui/components/HistoryItemDisplay.tsx` | Dispatches `away_recap` history items to the renderer | +| `packages/cli/src/config/settingsSchema.ts` | `general.showSessionRecap` + `general.sessionRecapAwayThresholdMinutes` settings | ## Prompt Design @@ -93,7 +93,7 @@ recap, not a leak. Bullets below correspond 1:1 with `RECAP_SYSTEM_PROMPT`: -- Exactly one short sentence (≤ 80 chars), plain prose (no markdown / lists / headings). +- Under 40 words, 1-2 plain sentences (no markdown / lists / headings). For Chinese, treat the budget as roughly 80 characters total. - First sentence: the high-level task. Then: the concrete next step. - Explicitly forbid: listing what was done, reciting tool calls, status reports. - Match the dominant language of the conversation (English or Chinese). @@ -128,7 +128,7 @@ the model's reasoning preamble is worse than showing no recap at all. | ------------------- | ------------------------------ | ----------------------------------------------------- | | `model` | `getFastModel() ?? getModel()` | Recap doesn't need a frontier model | | `tools` | `[]` | One-shot query, no tool use | -| `maxOutputTokens` | `300` | Headroom for one short sentence + tags | +| `maxOutputTokens` | `300` | Headroom for 1-2 short sentences + tags | | `temperature` | `0.3` | Mostly deterministic, with a bit of natural variation | | `systemInstruction` | The recap-only prompt above | Replaces the main agent's role definition | @@ -170,17 +170,18 @@ response. | `recapPendingRef` | Whether an LLM call is in flight | | `inFlightRef` | The current in-flight `AbortController` | -`useEffect` deps: `[enabled, config, isFocused, isIdle, setAwayRecapItem]`. +`useEffect` deps: `[enabled, config, isFocused, isIdle, addItem, thresholdMs]`. -| Event | Action | -| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `!enabled \|\| !config` | Abort in-flight call + clear `inFlightRef` + clear `blurredAtRef` | -| `!isFocused` and `blurredAtRef === null` | Set `blurredAtRef = Date.now()` | -| `isFocused` and `blurredAtRef === null` | Return early (no blur cycle to handle — first render or right after a brief-blur reset) | -| `isFocused` and blur duration < 5 min | Clear `blurredAtRef`, wait for next blur cycle | -| `isFocused` and blur ≥ 5 min and `recapPendingRef` | Return (dedupe) | -| `isFocused` and blur ≥ 5 min and `!isIdle` | **Preserve** `blurredAtRef` and wait for the turn to finish (`isIdle` is in the deps, so the effect re-fires when streaming completes) | -| `isFocused` and all conditions met | Clear `blurredAtRef`, set `recapPendingRef = true`, create `AbortController`, send the LLM request | +| Event | Action | +| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `!enabled \|\| !config` | Abort in-flight call + clear `inFlightRef` + clear `blurredAtRef` | +| `!isFocused` and `blurredAtRef === null` | Set `blurredAtRef = Date.now()` | +| `isFocused` and `blurredAtRef === null` | Return early (no blur cycle to handle — first render or right after a brief-blur reset) | +| `isFocused` and blur duration < 5 min | Clear `blurredAtRef`, wait for next blur cycle | +| `isFocused` and blur ≥ 5 min and `recapPendingRef` | Return (dedupe) | +| `isFocused` and blur ≥ 5 min and `!isIdle` | **Preserve** `blurredAtRef` and wait for the turn to finish (`isIdle` is in the deps, so the effect re-fires when streaming completes) | +| `isFocused` and blur ≥ 5 min and `shouldFireRecap` returns false | Clear `blurredAtRef` and return — conversation hasn't moved enough since the last recap (≥ 2 user turns required, mirrors Claude Code) | +| `isFocused` and all conditions met | Clear `blurredAtRef`, set `recapPendingRef = true`, create `AbortController`, send the LLM request | The `.then` callback **re-checks** `isIdleRef.current`: if the user has started a new turn while the LLM was running, the late-arriving recap @@ -205,10 +206,11 @@ and a null `pendingItem`. ### User-facing knobs -| Setting | Default | Notes | -| -------------------------- | ------- | ----------------------------------------------------------------- | -| `general.showSessionRecap` | `false` | Auto-trigger only. Manual `/recap` ignores this. | -| `fastModel` | unset | Recommended (e.g. `qwen3-coder-flash`) for fast and cheap recaps. | +| Setting | Default | Notes | +| ------------------------------------------ | ------- | ----------------------------------------------------------------------------------- | +| `general.showSessionRecap` | `false` | Auto-trigger only. Manual `/recap` ignores this. | +| `general.sessionRecapAwayThresholdMinutes` | `5` | Minutes blurred before auto-recap fires on focus-in. Matches Claude Code's default. | +| `fastModel` | unset | Recommended (e.g. `qwen3-coder-flash`) for fast and cheap recaps. | ### Model fallback diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 4499742859..a1fce9231d 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -77,15 +77,16 @@ Settings are organized into categories. All settings should be placed within the #### general -| 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` | -| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | -| `general.defaultFileEncoding` | string | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` | +| 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. Off by default. Use `/recap` to trigger manually regardless of this setting. | `false` | +| `general.sessionRecapAwayThresholdMinutes` | number | Minutes the terminal must be blurred before an auto-recap fires on focus-in. Only used when `showSessionRecap` is enabled. | `5` | +| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | +| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | +| `general.defaultFileEncoding` | string | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` | #### output diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ba9bd48697..82818bf80d 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -335,7 +335,17 @@ const SETTINGS_SCHEMA = { // Manual `/recap` works regardless. default: false, description: - '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.', + 'Auto-show a one-line "where you left off" recap when returning to the terminal after being away. Off by default. Use /recap to trigger manually regardless of this setting.', + showInDialog: true, + }, + sessionRecapAwayThresholdMinutes: { + type: 'number', + label: 'Session Recap Away Threshold (minutes)', + category: 'General', + requiresRestart: false, + default: 5, + description: + "How many minutes the terminal must be blurred before an auto-recap fires on the next focus-in. Matches Claude Code's default of 5 minutes; raise if you briefly alt-tab and do not want recaps to pile up.", showInDialog: true, }, gitCoAuthor: { diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 2abf7be99f..3ea3e65b87 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -59,8 +59,6 @@ export const createMockCommandContext = ( setBtwItem: vi.fn(), cancelBtw: vi.fn(), btwAbortControllerRef: { current: null }, - awayRecapItem: null, - setAwayRecapItem: vi.fn(), isIdleRef: { current: true }, loadHistory: vi.fn(), toggleVimEnabled: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 96531d179f..96408de551 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -570,7 +570,7 @@ export const AppContainer = (props: AppContainerProps) => { isResumeDialogOpen, openResumeDialog, closeResumeDialog, - handleResume: handleResumeInner, + handleResume, } = useResumeCommand({ config, historyManager, @@ -658,8 +658,6 @@ export const AppContainer = (props: AppContainerProps) => { btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, commandContext, shellConfirmationRequest, confirmationRequest, @@ -681,22 +679,6 @@ export const AppContainer = (props: AppContainerProps) => { logger, ); - // Wrap handleResume so the sticky recap from the previous session - // doesn't carry over into the new one. Only clear after the inner - // handler confirms a session was actually loaded — otherwise (no - // session data, missing deps) we'd drop the current session's recap - // for no reason. - const handleResume = useCallback( - async (sessionId: string): Promise => { - const switched = await handleResumeInner(sessionId); - if (switched) { - setAwayRecapItem(null); - } - return switched; - }, - [handleResumeInner, setAwayRecapItem], - ); - // onDebugMessage should log to debug logfile, not update footer debugMessage const onDebugMessage = useCallback( (message: string) => { @@ -1248,7 +1230,7 @@ export const AppContainer = (props: AppContainerProps) => { setControlsHeight(fullFooterMeasurement.height); } } - }, [buffer, terminalWidth, terminalHeight, awayRecapItem, btwItem]); + }, [buffer, terminalWidth, terminalHeight, btwItem]); // agentViewState is declared earlier (before handleFinalSubmit) so it // is available for input routing. Referenced here for layout computation. @@ -1281,7 +1263,10 @@ export const AppContainer = (props: AppContainerProps) => { config, isFocused, isIdle: streamingState === StreamingState.Idle, - setAwayRecapItem, + addItem: historyManager.addItem, + history: historyManager.history, + awayThresholdMinutes: + settings.merged.general?.sessionRecapAwayThresholdMinutes, }); // Context file names computation @@ -2101,8 +2086,6 @@ export const AppContainer = (props: AppContainerProps) => { btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, nightly, branchName, sessionStats, @@ -2209,8 +2192,6 @@ export const AppContainer = (props: AppContainerProps) => { btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, nightly, branchName, sessionStats, diff --git a/packages/cli/src/ui/commands/recapCommand.ts b/packages/cli/src/ui/commands/recapCommand.ts index 0380919c25..a56b52533b 100644 --- a/packages/cli/src/ui/commands/recapCommand.ts +++ b/packages/cli/src/ui/commands/recapCommand.ts @@ -65,7 +65,7 @@ export const recapCommand: SlashCommand = { type: 'away_recap', text: recap.text, }; - context.ui.setAwayRecapItem(item); + context.ui.addItem(item, Date.now()); return; } diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index aa5065be98..f851857c9e 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -11,7 +11,6 @@ import type { HistoryItemWithoutId, HistoryItem, HistoryItemBtw, - HistoryItemAwayRecap, ConfirmationRequest, } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; @@ -76,10 +75,6 @@ export interface CommandContext { cancelBtw: () => void; /** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */ btwAbortControllerRef: MutableRefObject; - /** The current away-recap item rendered as a sticky banner above the input box. */ - awayRecapItem: HistoryItemAwayRecap | null; - /** Sets the away-recap item independently of the main history. */ - setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void; /** Ref to whether the agent stream is currently idle (no model turn in flight). */ isIdleRef: MutableRefObject; /** diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f9ea0afba0..a37544db03 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -28,6 +28,7 @@ import { ErrorMessage, RetryCountdownMessage, SuccessMessage, + AwayRecapMessage, } from './messages/StatusMessages.js'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; @@ -285,6 +286,9 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'memory_saved' && ( )} + {itemForDisplay.type === 'away_recap' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/messages/StatusMessages.tsx b/packages/cli/src/ui/components/messages/StatusMessages.tsx index e4be0b79cd..a754935581 100644 --- a/packages/cli/src/ui/components/messages/StatusMessages.tsx +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -125,11 +125,22 @@ export const RetryCountdownMessage: React.FC = ({ text }) => ( /> ); +// Mirrors Claude Code's away-summary rendering: a `※` prefix in a fixed +// 2-column gutter, then bold "recap: " label and italic content, all +// dim-colored. Rendered as a regular history item so it scrolls with +// the conversation instead of pinning above the input. export const AwayRecapMessage: React.FC = ({ text }) => ( - + + + + + + + recap:{' '} + + + {text} + + + ); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 7922723b41..a060074169 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -8,7 +8,6 @@ import { createContext, useContext } from 'react'; import type { HistoryItem, HistoryItemBtw, - HistoryItemAwayRecap, ThoughtSummary, ShellConfirmationRequest, ConfirmationRequest, @@ -111,8 +110,6 @@ export interface UIState { btwItem: HistoryItemBtw | null; setBtwItem: (item: HistoryItemBtw | null) => void; cancelBtw: () => void; - awayRecapItem: HistoryItemAwayRecap | null; - setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void; nightly: boolean; branchName: string | undefined; sessionStats: SessionStatsState; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index e7fc91a0b2..4ccba41929 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -31,7 +31,6 @@ import type { Message, HistoryItemWithoutId, HistoryItemBtw, - HistoryItemAwayRecap, SlashCommandProcessorResult, HistoryItem, ConfirmationRequest, @@ -156,9 +155,6 @@ export const useSlashCommandProcessor = ( const [btwItem, setBtwItem] = useState(null); const btwAbortControllerRef = useRef(null); - const [awayRecapItem, setAwayRecapItem] = - useState(null); - const cancelBtw = useCallback(() => { btwAbortControllerRef.current?.abort(); btwAbortControllerRef.current = null; @@ -272,7 +268,6 @@ export const useSlashCommandProcessor = ( addItem, clear: () => { cancelBtw(); - setAwayRecapItem(null); clearItems(); clearScreen(); refreshStatic(); @@ -285,8 +280,6 @@ export const useSlashCommandProcessor = ( setBtwItem, cancelBtw, btwAbortControllerRef, - awayRecapItem, - setAwayRecapItem, isIdleRef, toggleVimEnabled, setGeminiMdFileCount, @@ -319,8 +312,6 @@ export const useSlashCommandProcessor = ( btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, toggleVimEnabled, sessionShellAllowlist, setGeminiMdFileCount, @@ -794,8 +785,6 @@ export const useSlashCommandProcessor = ( btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, commandContext, shellConfirmationRequest, confirmationRequest, diff --git a/packages/cli/src/ui/hooks/useAwaySummary.test.ts b/packages/cli/src/ui/hooks/useAwaySummary.test.ts new file mode 100644 index 0000000000..d5c28076de --- /dev/null +++ b/packages/cli/src/ui/hooks/useAwaySummary.test.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import * as core from '@qwen-code/qwen-code-core'; +import { useAwaySummary } from './useAwaySummary.js'; +import type { HistoryItem } from '../types.js'; + +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual< + typeof import('@qwen-code/qwen-code-core') + >('@qwen-code/qwen-code-core'); + return { + ...actual, + generateSessionRecap: vi.fn(), + }; +}); + +const generateSessionRecapMock = vi.mocked(core.generateSessionRecap); + +function makeConfig(recordSlashCommand = vi.fn()) { + return { + getChatRecordingService: vi.fn().mockReturnValue({ + recordSlashCommand, + }), + } as unknown as core.Config; +} + +function userMsg(text: string): HistoryItem { + return { id: Math.random(), type: 'user', text }; +} + +const THREE_USER_HISTORY: HistoryItem[] = [ + userMsg('one'), + userMsg('two'), + userMsg('three'), +]; + +beforeEach(() => { + vi.useFakeTimers(); + generateSessionRecapMock.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useAwaySummary', () => { + it('records the auto-fired recap to chatRecordingService so it survives /resume', async () => { + const recordSlashCommand = vi.fn(); + const config = makeConfig(recordSlashCommand); + const addItem = vi.fn(); + generateSessionRecapMock.mockResolvedValue({ + text: 'recap text', + modelUsed: 'fast', + }); + + // Mount blurred to set the away-start timestamp. + const { rerender } = renderHook( + ({ isFocused }: { isFocused: boolean }) => + useAwaySummary({ + enabled: true, + config, + isFocused, + isIdle: true, + addItem, + history: THREE_USER_HISTORY, + awayThresholdMinutes: 0.1, // 6 s + }), + { initialProps: { isFocused: false } }, + ); + + // Advance past the threshold while still blurred. + vi.advanceTimersByTime(7000); + + // Focus comes back — should kick off the LLM call. + rerender({ isFocused: true }); + + // Drain the resolved promise + microtasks. + await vi.waitFor(() => { + expect(addItem).toHaveBeenCalledTimes(1); + }); + + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'away_recap', text: 'recap text' }), + expect.any(Number), + ); + expect(recordSlashCommand).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'result', + rawCommand: '/recap', + outputHistoryItems: [ + expect.objectContaining({ type: 'away_recap', text: 'recap text' }), + ], + }), + ); + }); + + it('skips the recap when shouldFireRecap returns false (no new user turns since last recap)', async () => { + const recordSlashCommand = vi.fn(); + const config = makeConfig(recordSlashCommand); + const addItem = vi.fn(); + generateSessionRecapMock.mockResolvedValue({ + text: 'should not appear', + modelUsed: 'fast', + }); + + const historyWithRecentRecap: HistoryItem[] = [ + ...THREE_USER_HISTORY, + { id: 999, type: 'away_recap', text: 'previous recap' }, + // Fewer than 2 user messages since the recap → gated. + userMsg('only one new turn'), + ]; + + const { rerender } = renderHook( + ({ isFocused }: { isFocused: boolean }) => + useAwaySummary({ + enabled: true, + config, + isFocused, + isIdle: true, + addItem, + history: historyWithRecentRecap, + awayThresholdMinutes: 0.1, + }), + { initialProps: { isFocused: false } }, + ); + + vi.advanceTimersByTime(7000); + rerender({ isFocused: true }); + + // Give any pending microtasks a chance to flush — they shouldn't. + await Promise.resolve(); + await Promise.resolve(); + + expect(generateSessionRecapMock).not.toHaveBeenCalled(); + expect(addItem).not.toHaveBeenCalled(); + expect(recordSlashCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAwaySummary.ts b/packages/cli/src/ui/hooks/useAwaySummary.ts index ff58b365a1..1c251ad41e 100644 --- a/packages/cli/src/ui/hooks/useAwaySummary.ts +++ b/packages/cli/src/ui/hooks/useAwaySummary.ts @@ -6,16 +6,62 @@ import { useEffect, useRef } from 'react'; import { generateSessionRecap, type Config } from '@qwen-code/qwen-code-core'; -import type { HistoryItemAwayRecap } from '../types.js'; +import type { + HistoryItem, + HistoryItemAwayRecap, + HistoryItemWithoutId, +} from '../types.js'; -const AWAY_THRESHOLD_MS = 5 * 60 * 1000; +const DEFAULT_AWAY_THRESHOLD_MINUTES = 5; + +// Dedup thresholds, matching Claude Code's `Sc1`/`Rc1`: +// - need at least MIN_USER_MESSAGES_TO_FIRE user turns total +// - if a recap is already in history, need at least +// MIN_USER_MESSAGES_SINCE_LAST_RECAP new user turns since then before +// another can fire. Prevents back-to-back recaps when the user briefly +// alt-tabs twice without doing any new work in between. +const MIN_USER_MESSAGES_TO_FIRE = 3; +const MIN_USER_MESSAGES_SINCE_LAST_RECAP = 2; export interface UseAwaySummaryOptions { enabled: boolean; config: Config | null; isFocused: boolean; isIdle: boolean; - setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void; + addItem: (item: HistoryItemWithoutId, baseTimestamp: number) => number; + /** + * The current chat history. Read at fire time only (via a ref) to apply + * the dedup gate; not added to the effect's deps so it doesn't re-fire + * on every history change. + */ + history: HistoryItem[]; + /** + * Minutes the terminal must be blurred before an auto-recap fires on + * the next focus-in. Falsy / non-positive values fall back to the + * 5-minute default (matching Claude Code). + */ + awayThresholdMinutes?: number; +} + +/** + * Whether enough new user activity has happened since the last recap to + * justify another one. Mirrors Claude Code's `Ic1` gate. + */ +function shouldFireRecap(history: HistoryItem[]): boolean { + let userMessageCount = 0; + let lastRecapIndex = -1; + for (let i = 0; i < history.length; i++) { + const item = history[i]; + if (item.type === 'user') userMessageCount++; + if (item.type === 'away_recap') lastRecapIndex = i; + } + if (userMessageCount < MIN_USER_MESSAGES_TO_FIRE) return false; + if (lastRecapIndex === -1) return true; + let userSinceLast = 0; + for (let i = lastRecapIndex + 1; i < history.length; i++) { + if (history[i].type === 'user') userSinceLast++; + } + return userSinceLast >= MIN_USER_MESSAGES_SINCE_LAST_RECAP; } /** @@ -27,7 +73,15 @@ export interface UseAwaySummaryOptions { * a single back-and-forth produces at most one recap. */ export function useAwaySummary(options: UseAwaySummaryOptions): void { - const { enabled, config, isFocused, isIdle, setAwayRecapItem } = options; + const { + enabled, + config, + isFocused, + isIdle, + addItem, + history, + awayThresholdMinutes, + } = options; const blurredAtRef = useRef(null); const recapPendingRef = useRef(false); @@ -36,6 +90,18 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { const isIdleRef = useRef(isIdle); isIdleRef.current = isIdle; + // Latest history snapshot, read at fire time only — keeps history out + // of the effect's deps so we don't re-evaluate on every message. + const historyRef = useRef(history); + historyRef.current = history; + + const thresholdMs = + (awayThresholdMinutes && awayThresholdMinutes > 0 + ? awayThresholdMinutes + : DEFAULT_AWAY_THRESHOLD_MINUTES) * + 60 * + 1000; + useEffect(() => { if (!enabled || !config) { inFlightRef.current?.abort(); @@ -54,7 +120,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { const blurredAt = blurredAtRef.current; if (blurredAt === null) return; - if (Date.now() - blurredAt < AWAY_THRESHOLD_MS) { + if (Date.now() - blurredAt < thresholdMs) { // Brief blur; reset and wait for the next away cycle. blurredAtRef.current = null; return; @@ -65,6 +131,14 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { // (with isIdle in the deps) when the streaming turn finishes. if (!isIdleRef.current) return; + // Skip if the conversation hasn't moved enough since the last recap — + // a brief alt-tab cycle right after a recap shouldn't produce a near- + // duplicate one. + if (!shouldFireRecap(historyRef.current)) { + blurredAtRef.current = null; + return; + } + blurredAtRef.current = null; recapPendingRef.current = true; const controller = new AbortController(); @@ -78,7 +152,21 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { type: 'away_recap', text: recap.text, }; - setAwayRecapItem(item); + 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], + }); + } catch { + // Recap is best-effort — never let a recording failure surface. + } }) .finally(() => { if (inFlightRef.current === controller) { @@ -86,7 +174,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { } recapPendingRef.current = false; }); - }, [enabled, config, isFocused, isIdle, setAwayRecapItem]); + }, [enabled, config, isFocused, isIdle, addItem, thresholdMs]); useEffect( () => () => { diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index ee144c4ece..ce3a6305e8 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -167,9 +167,7 @@ describe('useResumeCommand', () => { act(() => { // Start resume but do not await it yet — we want to assert the dialog // closes immediately before the async session load completes. - resumePromise = result.current.handleResume('session-2') as unknown as - | Promise - | undefined; + resumePromise = result.current.handleResume('session-2'); }); expect(result.current.isResumeDialogOpen).toBe(false); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 3b25e0054e..81c2de9626 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -26,12 +26,12 @@ export interface UseResumeCommandResult { openResumeDialog: () => void; closeResumeDialog: () => void; /** - * Resolves to `true` when the target session was actually loaded, or - * `false` when the call short-circuited (missing dependencies or no - * session data found). Callers can use the boolean to gate cleanup - * that should only happen on a successful session switch. + * Async — the implementation awaits SessionService and SessionStart hooks. + * Callers that need to chain post-resume work should `await` it; pure + * fire-and-forget callers (the resume dialog's `onSelect`) can ignore the + * promise. */ - handleResume: (sessionId: string) => Promise; + handleResume: (sessionId: string) => Promise; } export function useResumeCommand( @@ -50,9 +50,9 @@ export function useResumeCommand( const { config, historyManager, startNewSession, remount } = options ?? {}; const handleResume = useCallback( - async (sessionId: string): Promise => { + async (sessionId: string) => { if (!config || !historyManager || !startNewSession) { - return false; + return; } // Close dialog immediately to prevent input capture during async operations. @@ -63,7 +63,7 @@ export function useResumeCommand( const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { - return false; + return; } // Start new session in UI context. @@ -93,7 +93,6 @@ export function useResumeCommand( // Refresh terminal UI. remount?.(); - return true; }, [closeResumeDialog, config, historyManager, startNewSession, remount], ); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 918090828e..88efdbefda 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -12,7 +12,6 @@ import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; import { BtwMessage } from '../components/messages/BtwMessage.js'; -import { AwayRecapMessage } from '../components/messages/StatusMessages.js'; import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; import { AgentChatView } from '../components/agent-view/AgentChatView.js'; import { AgentComposer } from '../components/agent-view/AgentComposer.js'; @@ -70,11 +69,6 @@ export const DefaultAppLayout: React.FC = () => { ) : ( <> - {uiState.awayRecapItem && ( - - - - )} {uiState.btwItem && ( { @@ -36,11 +35,6 @@ export const ScreenReaderAppLayout: React.FC = () => { ) : ( <> - {uiState.awayRecapItem && ( - - - - )} {uiState.btwItem && ( {}, cancelBtw: () => {}, btwAbortControllerRef: { current: null }, - awayRecapItem: null, - setAwayRecapItem: (_item) => {}, isIdleRef: { current: true }, toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 21c65bb221..f66abf6755 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -390,9 +390,9 @@ export type HistoryItemBtw = HistoryItemBase & { /** * Away-summary recap shown when the user returns to the session after a - * period of inactivity (or via /recap). Rendered as a sticky banner above - * the input box (NOT part of the scrolling history), so it is intentionally - * excluded from the HistoryItemWithoutId union. + * period of inactivity (or via /recap). Rendered inline as a regular + * history item (matching Claude Code's away_summary message); scrolls + * with the conversation, no sticky pinning. */ export type HistoryItemAwayRecap = HistoryItemBase & { type: 'away_recap'; @@ -484,6 +484,7 @@ export type HistoryItemWithoutId = | HistoryItemInsightProgress | HistoryItemBtw | HistoryItemMemorySaved + | HistoryItemAwayRecap | HistoryItemUserPromptSubmitBlocked | HistoryItemStopHookLoop | HistoryItemStopHookSystemMessage diff --git a/packages/core/src/services/sessionRecap.ts b/packages/core/src/services/sessionRecap.ts index be15547490..077a5fa954 100644 --- a/packages/core/src/services/sessionRecap.ts +++ b/packages/core/src/services/sessionRecap.ts @@ -14,22 +14,16 @@ const RECENT_MESSAGE_WINDOW = 30; const RECAP_SYSTEM_PROMPT = `You generate session recaps for a programming assistant CLI. -You receive the most recent turns of a conversation between a user and an -assistant. The user has stepped away and is now returning. Your sole job is -to remind them where they left off so they can resume quickly. +The user stepped away and is coming back. Recap in under 40 words, 1-2 plain sentences, no markdown. Lead with the overall goal and current task, then the one next action. Skip root-cause narrative, fix internals, secondary to-dos, and em-dash tangents. -Content rules: -- Exactly ONE sentence. Hard cap: 80 characters. Plain prose, no bullets, no headings, no markdown. -- Combine the high-level task and the concrete next step into a single sentence. -- Do NOT list what was done, recite tool calls, or include status reports. -- Match the dominant language of the conversation (English or Chinese). +Match the dominant language of the conversation (English or Chinese). For Chinese, treat the budget as roughly 80 characters total. Output format — strict: - Wrap your recap in ... tags. - Put NOTHING outside the tags. No preamble, no reasoning, no closing remarks. Example: -Debugging the auth retry race condition; next, add deterministic timing to the test.`; +Debugging the auth retry race condition. Next: add deterministic timing to the integration test.`; const RECAP_USER_PROMPT = 'Generate the recap now. Wrap it in .... Nothing outside the tags.'; @@ -43,9 +37,10 @@ export interface SessionRecapResult { } /** - * Generate a one-sentence "where did I leave off" summary of the current + * Generate a 1-2 sentence "where did I leave off" summary of the current * session. Uses the configured fast model (falls back to main model) with - * tools disabled and a very small generation budget. + * tools disabled and a very small generation budget. Prompt mirrors + * Claude Code's away-summary prompt for behavioral parity. * * Returns null on any failure — recap is best-effort and must never break * the main flow or surface errors to the user. diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 53ebea1ff6..a8bf45d7fe 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -52,10 +52,15 @@ "default": true }, "showSessionRecap": { - "description": "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.", + "description": "Auto-show a one-line \"where you left off\" recap when returning to the terminal after being away. Off by default. Use /recap to trigger manually regardless of this setting.", "type": "boolean", "default": false }, + "sessionRecapAwayThresholdMinutes": { + "description": "How many minutes the terminal must be blurred before an auto-recap fires on the next focus-in. Matches Claude Code's default of 5 minutes; raise if you briefly alt-tab and do not want recaps to pile up.", + "type": "number", + "default": 5 + }, "gitCoAuthor": { "description": "Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.", "type": "boolean",