diff --git a/docs/design/session-recap/session-recap-design.md b/docs/design/session-recap/session-recap-design.md new file mode 100644 index 000000000..00c024d1b --- /dev/null +++ b/docs/design/session-recap/session-recap-design.md @@ -0,0 +1,239 @@ +# Session Recap Design + +> A 1-3 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. + +## Overview + +When a user `/resume`s an old session days later, scrolling back through +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 1-3 sentence recap when the user +returns: + +- **High-level task** (what they are doing) → **next step** (what to do next). +- Visually distinct from real assistant replies, so it is never mistaken + for new model output. +- **Best-effort**: failures must be silent and never break the main flow. + +## Triggers + +| Trigger | Conditions | Implementation | +| ---------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| **Manual** | User runs `/recap` | `recapCommand.ts` calls the same underlying service | +| **Auto** | Terminal blurred (DECSET 1004 focus protocol) for ≥ 5 min + focus returns + stream is `Idle` | `useAwaySummary.ts` — 5min blur timer + `useFocus` event listener | + +Both paths funnel into a single function — `generateSessionRecap()` — to +guarantee identical behavior. The auto-trigger is gated by +`general.showSessionRecap` (default: on); the manual command ignores +that setting. + +## Architecture + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ AppContainer.tsx │ +│ isFocused = useFocus() │ +│ isIdle = streamingState === Idle │ +│ │ │ +│ ├─→ useAwaySummary({enabled, config, isFocused, isIdle, addItem})│ +│ │ │ │ +│ │ └─→ 5 min blur timer + idle/dedupe gates │ +│ │ │ │ +│ │ ↓ │ +│ └─→ recapCommand (slash) ─→ generateSessionRecap(config, signal) │ +│ │ │ +│ ↓ │ +│ ┌─────────────────────────┐ │ +│ │ packages/core/services/ │ │ +│ │ sessionRecap.ts │ │ +│ └─────────────────────────┘ │ +│ │ │ +│ ↓ │ +│ GeminiClient.generateContent │ +│ (fastModel + tools:[]) │ +│ │ +│ addItem({type: 'away_recap', text}) ─→ HistoryItemDisplay │ +│ └─ AwayRecapMessage │ +│ (dim color + ❯ prefix) │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +### 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 | +| `packages/cli/src/ui/types.ts` | `HistoryItemAwayRecap` type | +| `packages/cli/src/ui/components/HistoryItemDisplay.tsx` | Renderer dispatch | +| `packages/cli/src/config/settingsSchema.ts` | `general.showSessionRecap` setting | + +## Prompt Design + +### System Prompt + +`generationConfig.systemInstruction` replaces the main agent's system +prompt for this single call, so the model behaves only as a recap +generator and not as a coding assistant. + +Note that `GeminiClient.generateContent()` internally runs the prompt +through `getCustomSystemPrompt()`, which appends the user's memory +(QWEN.md / managed auto-memory) as a suffix. The final system prompt is +therefore `recap prompt + user memory` — useful project context for the +recap, not a leak. + +Bullets below correspond 1:1 with `RECAP_SYSTEM_PROMPT`: + +- 1 to 3 short sentences, plain prose (no markdown / lists / headings). +- 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). +- Wrap output in `...`; nothing outside the tags. + +### Structured Output + Extraction + +The model is instructed to wrap its answer in `...`: + +``` +Refactoring loopDetectionService.ts to address long-session OOM. Next step is to implement option B. +``` + +Why: some models (GLM family, reasoning models) write a "thinking" +paragraph before the final answer. Returning the raw text would leak +that reasoning into the UI. + +`extractRecap()` has three fallback tiers: + +1. Both tags present: take what is between `...` (preferred). +2. Only the open tag (e.g. `maxOutputTokens` truncated the close tag): + take everything after the open tag. +3. Tag missing entirely: return empty string → service returns `null` + → UI renders nothing. + +The third tier is "skip rather than show the wrong thing" — surfacing +the model's reasoning preamble is worse than showing no recap at all. + +### Call Parameters + +| Parameter | Value | Reason | +| ------------------- | ------------------------------ | ---------------------------------------------------------------- | +| `model` | `getFastModel() ?? getModel()` | Recap doesn't need a frontier model | +| `tools` | `[]` | One-shot query, no tool use | +| `maxOutputTokens` | `300` | Enough for 1-3 sentences + tags; larger would encourage rambling | +| `temperature` | `0.3` | Mostly deterministic, with a bit of natural variation | +| `systemInstruction` | The recap-only prompt above | Replaces the main agent's role definition | + +## History Filtering + +`geminiClient.getChat().getHistory()` returns a `Content[]` that +includes: + +- `user` / `model` text messages +- `model` `functionCall` parts +- `user` `functionResponse` parts (which can hold full file contents) +- `model` thought parts (`part.thought` / `part.thoughtSignature`, + the model's hidden reasoning) + +`filterToDialog()` keeps only `user` / `model` parts that have **non-empty +text and are not thoughts**. Two reasons: + +- **Tool calls / responses**: a single `functionResponse` can be 10K+ + tokens. 30 such messages would drown the recap LLM in irrelevant + detail, both wasting tokens and biasing the recap toward + implementation noise like "called X tool to read Y file". +- **Thought parts**: carry the model's internal reasoning. Including + them risks treating hidden chain-of-thought as dialogue and + surfacing it in the recap text. + +After dropping empty messages, `takeRecentDialog` slices to the last 30 +messages and refuses to start the slice on a dangling model/tool +response. + +## Concurrency and Edge Cases + +### Auto-trigger hook state machine + +`useAwaySummary` keeps three refs: + +| Ref | Meaning | +| ----------------- | ------------------------------------------------- | +| `blurredAtRef` | Blur start time (not cleared until focus returns) | +| `recapPendingRef` | Whether an LLM call is in flight | +| `inFlightRef` | The current in-flight `AbortController` | + +`useEffect` deps: `[enabled, config, isFocused, isIdle, addItem]`. + +| 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 | + +The `.then` callback **re-checks** `isIdleRef.current`: if the user has +started a new turn while the LLM was running, the late-arriving recap +is dropped to avoid inserting it mid-turn. + +The `.finally` clears `recapPendingRef`, and clears `inFlightRef` only +if `inFlightRef.current === controller` (so it doesn't overwrite a +newer controller). + +A second `useEffect` aborts the in-flight controller on unmount. + +### `/recap` gating + +`CommandContext.ui.isIdleRef` exposes the current stream state +(mirroring the existing `btwAbortControllerRef` pattern). In +interactive mode, `recapCommand` refuses when `!isIdleRef.current` +**or** `pendingItem !== null`. `pendingItem` alone is insufficient +because a normal model reply runs with `streamingState === Responding` +and a null `pendingItem`. + +## Configuration and Model Selection + +### User-facing knobs + +| Setting | Default | Notes | +| -------------------------- | ------- | ----------------------------------------------------------------- | +| `general.showSessionRecap` | `true` | Auto-trigger only. Manual `/recap` ignores this. | +| `fastModel` | unset | Recommended (e.g. `qwen3-coder-flash`) for fast and cheap recaps. | + +### Model fallback + +`config.getFastModel() ?? config.getModel()`: + +- User has a `fastModel` set and it is valid for the current auth type + → use `fastModel`. +- Otherwise → fall back to the main session model (works, just costlier + and slower). + +## Observability + +`createDebugLogger('SESSION_RECAP')` emits: + +- caught exceptions from the recap path (`debugLogger.warn`). + +All failures are **fully transparent** to the user — recap is an +auxiliary feature and never throws into the UI. Developers can grep for +the `[SESSION_RECAP]` tag in the debug log file: written by default to +`~/.qwen/debug/.txt` (`latest.txt` symlinks to the current +session); disable via `QWEN_DEBUG_LOG_FILE=0`. + +## Out of Scope + +| Item | Why not | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Progress UI for `/recap` (spinner / pendingItem) | 3-5 second wait is tolerable; adds complexity. | +| Automated tests | Service is small (~150 lines), end-to-end tested manually first; unit tests can land in a separate PR. | +| Localized prompts | The system prompt is for the model; English is the most reliable substrate. The model selects the output language from the conversation. | +| `QWEN_CODE_ENABLE_AWAY_SUMMARY` env var | Claude Code uses it to keep the feature on when telemetry is disabled; Qwen Code's current telemetry model doesn't need this. | +| Auto-recap on `/resume` completion | A natural follow-up but needs a hook point in `useResumeCommand`; out of scope for this PR. | diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 3bf959329..a1fadcbc3 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -82,6 +82,7 @@ Settings are organized into categories. All settings should be placed within the | `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 | Show a 1-3 sentence summary of where you left off when returning to the terminal after being away for 5+ minutes. Use `/recap` to trigger manually. | `true` | | `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"` | diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 35b8288ff..1f5e7c867 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -24,6 +24,7 @@ These commands help you save, restore, and summarize work progress. | `/summary` | Generate project summary based on conversation history | `/summary` | | `/compress` | Replace chat history with summary to save Tokens | `/compress` | | `/resume` | Resume a previous conversation session | `/resume` | +| `/recap` | Show a 1-3 sentence "where you left off" summary | `/recap` | | `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore ` | ### 1.2 Interface and Workspace Control @@ -156,7 +157,58 @@ The `/btw` command allows you to ask quick side questions without interrupting o > > Use `/btw` when you need a quick answer without derailing your main task. It's especially useful for clarifying concepts, checking facts, or getting quick explanations while staying focused on your primary workflow. -### 1.7 Information, Settings, and Help +### 1.7 Session Recap (`/recap`) + +The `/recap` command generates a short "where you left off" summary of the +current session, so you can resume an old conversation without scrolling +back through pages of history. + +| Command | Description | +| -------- | ------------------------------------------------ | +| `/recap` | Generate and show a 1-3 sentence session summary | + +**How it works:** + +- Uses the configured fast model (`fastModel` setting) when available, falling + back to the main session model. A small, cheap model is enough for a recap. +- The recent conversation (up to 30 messages, text only — tool calls and tool + responses are filtered out) is sent to the model with a tight system prompt. +- The recap is rendered in dim color with a `❯` prefix so it stands apart + from real assistant replies. +- Refuses with an inline error if a model turn is in flight or another command + is processing. If there is no usable conversation, or the underlying + generation fails, `/recap` shows a short info message instead of a recap — + the manual command always responds with something. + +**Auto-trigger when returning from being away:** + +If the terminal is blurred for **5+ minutes** and gets focused again, a recap +is generated and shown automatically (only when no model response is in +progress; otherwise it waits for the current turn to finish and then fires). +Unlike the manual command, the auto-trigger is fully silent on failure: if +generation errors or there is nothing to summarize, no message is added to +the history. Controlled by the `general.showSessionRecap` setting +(default: `true`); the manual `/recap` command always works regardless of +this setting. + +**Example:** + +``` +> /recap + +❯ Refactoring loopDetectionService.ts to address long-session OOM caused by + unbounded streamContentHistory and contentStats. The next step is to + implement option B (LRU sliding window with FNV-1a) pending confirmation. +``` + +> [!tip] +> +> Configure a fast model via `/model --fast ` (e.g. +> `qwen3-coder-flash`) to make `/recap` fast and cheap. Set +> `general.showSessionRecap` to `false` to opt out of the auto-trigger +> while keeping the manual command available. + +### 1.8 Information, Settings, and Help Commands for obtaining information and performing system settings. @@ -171,7 +223,7 @@ Commands for obtaining information and performing system settings. | `/copy` | Copy last output content to clipboard | `/copy` | | `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | -### 1.8 Common Shortcuts +### 1.9 Common Shortcuts | Shortcut | Function | Note | | ------------------ | ----------------------- | ---------------------- | @@ -181,7 +233,7 @@ Commands for obtaining information and performing system settings. | `Ctrl/cmd+Z` | Undo input | Text editing | | `Ctrl/cmd+Shift+Z` | Redo input | Text editing | -### 1.9 CLI Auth Subcommands +### 1.10 CLI Auth Subcommands In addition to the in-session `/auth` slash command, Qwen Code provides standalone CLI subcommands for managing authentication directly from the terminal: diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 5f0b6aeff..675bb07d8 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -324,6 +324,16 @@ const SETTINGS_SCHEMA = { 'Enable automatic update checks and installations on startup.', showInDialog: true, }, + showSessionRecap: { + type: 'boolean', + label: 'Show Session Recap', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Show a 1-3 sentence summary of where you left off when returning to the terminal after being away for 5+ minutes. Use /recap to trigger manually.', + showInDialog: true, + }, gitCoAuthor: { type: 'boolean', label: 'Attribution: commit', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 73c944c64..2ed1fab9d 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -40,6 +40,7 @@ import { planCommand } from '../ui/commands/planCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { trustCommand } from '../ui/commands/trustCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; +import { recapCommand } from '../ui/commands/recapCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; @@ -118,6 +119,7 @@ export class BuiltinCommandLoader implements ICommandLoader { permissionsCommand, ...(this.config?.getFolderTrust() ? [trustCommand] : []), quitCommand, + recapCommand, restoreCommand(this.config), resumeCommand, skillsCommand, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index d6a6c3e6d..3ea3e65b8 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -59,6 +59,7 @@ export const createMockCommandContext = ( setBtwItem: vi.fn(), cancelBtw: vi.fn(), btwAbortControllerRef: { current: null }, + isIdleRef: { current: true }, loadHistory: vi.fn(), toggleVimEnabled: vi.fn(), extensionsUpdateState: new Map(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 71307432b..105e8a82a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -91,6 +91,7 @@ import { isBtwCommand } from './utils/commandUtils.js'; import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; +import { useAwaySummary } from './hooks/useAwaySummary.js'; import { useBracketedPaste } from './hooks/useBracketedPaste.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { keyMatchers, Command } from './keyMatchers.js'; @@ -669,6 +670,7 @@ export const AppContainer = (props: AppContainerProps) => { toggleVimEnabled, isProcessing, setIsProcessing, + isIdleRef, setGeminiMdFileCount, slashCommandActions, extensionsUpdateStateInternal, @@ -1252,6 +1254,14 @@ export const AppContainer = (props: AppContainerProps) => { const isFocused = useFocus(); useBracketedPaste(); + useAwaySummary({ + enabled: settings.merged.general?.showSessionRecap ?? true, + config, + isFocused, + isIdle: streamingState === StreamingState.Idle, + addItem: historyManager.addItem, + }); + // Context file names computation const contextFileNames = useMemo(() => { const fromSettings = settings.merged.context?.fileName; diff --git a/packages/cli/src/ui/commands/recapCommand.ts b/packages/cli/src/ui/commands/recapCommand.ts new file mode 100644 index 000000000..6a239c4d7 --- /dev/null +++ b/packages/cli/src/ui/commands/recapCommand.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type SlashCommand, + type CommandContext, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import { generateSessionRecap } from '@qwen-code/qwen-code-core'; +import type { HistoryItemAwayRecap } from '../types.js'; +import { t } from '../../i18n/index.js'; + +export const recapCommand: SlashCommand = { + name: 'recap', + kind: CommandKind.BUILT_IN, + get description() { + return t('Show a 1-3 sentence summary of where you left off'); + }, + action: async ( + context: CommandContext, + ): Promise => { + const { config } = context.services; + const abortSignal = context.abortSignal ?? new AbortController().signal; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + if (context.executionMode === 'interactive') { + const turnInFlight = + !context.ui.isIdleRef.current || context.ui.pendingItem !== null; + if (turnInFlight) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Cannot run /recap while another operation is in progress.', + ), + }; + } + } + + const recap = await generateSessionRecap(config, abortSignal); + + if (abortSignal.aborted) return; + + if (!recap) { + return { + type: 'message', + messageType: 'info', + content: t('Not enough conversation context for a recap yet.'), + }; + } + + if (context.executionMode === 'interactive') { + const item: HistoryItemAwayRecap = { + type: 'away_recap', + text: recap.text, + }; + context.ui.addItem(item, Date.now()); + return; + } + + return { type: 'message', messageType: 'info', content: recap.text }; + }, +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index cc897edb6..39da70f5c 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -75,6 +75,8 @@ export interface CommandContext { cancelBtw: () => void; /** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */ btwAbortControllerRef: MutableRefObject; + /** Ref to whether the agent stream is currently idle (no model turn in flight). */ + isIdleRef: MutableRefObject; /** * Loads a new set of history items, replacing the current history. * diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f9ea0afba..a37544db0 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 ad7ff65a4..147b1bf9b 100644 --- a/packages/cli/src/ui/components/messages/StatusMessages.tsx +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -124,3 +124,12 @@ export const RetryCountdownMessage: React.FC = ({ text }) => ( textColor={theme.text.secondary} /> ); + +export const AwayRecapMessage: React.FC = ({ text }) => ( + +); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 4b4d61d17..0893c8e28 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -152,6 +152,7 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // toggleVimEnabled false, // isProcessing setIsProcessing, + { current: true }, // isIdleRef vi.fn(), // setGeminiMdFileCount { openAuthDialog: mockOpenAuthDialog, @@ -965,6 +966,7 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // toggleVimEnabled false, // isProcessing vi.fn(), // setIsProcessing + { current: true }, // isIdleRef vi.fn(), // setGeminiMdFileCount { openAuthDialog: mockOpenAuthDialog, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index d9f908295..bd45f1579 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useMemo, useEffect, useRef, useState } from 'react'; +import { + useCallback, + useMemo, + useEffect, + useRef, + useState, + type MutableRefObject, +} from 'react'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { ArenaDialogType } from './useArenaCommand.js'; @@ -104,6 +111,7 @@ export const useSlashCommandProcessor = ( toggleVimEnabled: () => Promise, isProcessing: boolean, setIsProcessing: (isProcessing: boolean) => void, + isIdleRef: MutableRefObject, setGeminiMdFileCount: (count: number) => void, actions: SlashCommandProcessorActions, extensionsUpdateState: Map, @@ -272,6 +280,7 @@ export const useSlashCommandProcessor = ( setBtwItem, cancelBtw, btwAbortControllerRef, + isIdleRef, toggleVimEnabled, setGeminiMdFileCount, reloadCommands, @@ -308,6 +317,7 @@ export const useSlashCommandProcessor = ( setGeminiMdFileCount, reloadCommands, extensionsUpdateState, + isIdleRef, ], ); diff --git a/packages/cli/src/ui/hooks/useAwaySummary.ts b/packages/cli/src/ui/hooks/useAwaySummary.ts new file mode 100644 index 000000000..693bb1969 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAwaySummary.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef } from 'react'; +import { generateSessionRecap, type Config } from '@qwen-code/qwen-code-core'; +import type { HistoryItemAwayRecap, HistoryItemWithoutId } from '../types.js'; + +const AWAY_THRESHOLD_MS = 5 * 60 * 1000; + +export interface UseAwaySummaryOptions { + enabled: boolean; + config: Config | null; + isFocused: boolean; + isIdle: boolean; + addItem: (item: HistoryItemWithoutId, baseTimestamp: number) => number; +} + +/** + * Generates and displays a 1-3 sentence "where you left off" recap when the + * user returns to a terminal that has been blurred for ≥ AWAY_THRESHOLD_MS. + * + * Best-effort: silently no-ops on disabled, unavailable config, in-flight + * turn, or any generation failure. The recap is debounced per blur cycle — + * a single back-and-forth produces at most one recap. + */ +export function useAwaySummary(options: UseAwaySummaryOptions): void { + const { enabled, config, isFocused, isIdle, addItem } = options; + + const blurredAtRef = useRef(null); + const recapPendingRef = useRef(false); + const inFlightRef = useRef(null); + + const isIdleRef = useRef(isIdle); + isIdleRef.current = isIdle; + + useEffect(() => { + if (!enabled || !config) { + inFlightRef.current?.abort(); + inFlightRef.current = null; + blurredAtRef.current = null; + return; + } + + if (!isFocused) { + if (blurredAtRef.current === null) { + blurredAtRef.current = Date.now(); + } + return; + } + + const blurredAt = blurredAtRef.current; + if (blurredAt === null) return; + + if (Date.now() - blurredAt < AWAY_THRESHOLD_MS) { + // Brief blur; reset and wait for the next away cycle. + blurredAtRef.current = null; + return; + } + + if (recapPendingRef.current) return; + // Wait for idle; do NOT clear blurredAtRef so this effect re-fires + // (with isIdle in the deps) when the streaming turn finishes. + if (!isIdleRef.current) return; + + blurredAtRef.current = null; + recapPendingRef.current = true; + const controller = new AbortController(); + inFlightRef.current = controller; + + void generateSessionRecap(config, controller.signal) + .then((recap) => { + if (controller.signal.aborted || !recap) return; + if (!isIdleRef.current) return; + const item: HistoryItemAwayRecap = { + type: 'away_recap', + text: recap.text, + }; + addItem(item, Date.now()); + }) + .finally(() => { + if (inFlightRef.current === controller) { + inFlightRef.current = null; + } + recapPendingRef.current = false; + }); + }, [enabled, config, isFocused, isIdle, addItem]); + + useEffect( + () => () => { + inFlightRef.current?.abort(); + }, + [], + ); +} diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index dbdf4e2e3..782506fd8 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -24,6 +24,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] { setBtwItem: (_item) => {}, cancelBtw: () => {}, btwAbortControllerRef: { current: null }, + isIdleRef: { current: true }, toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, reloadCommands: () => {}, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 8c4fb355b..9e49aa93e 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -387,6 +387,16 @@ export type HistoryItemBtw = HistoryItemBase & { btw: BtwProps; }; +/** + * Away-summary recap shown when the user returns to the session after a + * period of inactivity (or via /recap). Rendered in dim color so it is + * visually distinct from real assistant replies. + */ +export type HistoryItemAwayRecap = HistoryItemBase & { + type: 'away_recap'; + text: string; +}; + /** * UserPromptSubmit hook blocked event. * Displayed when a UserPromptSubmit hook blocks the user's prompt. @@ -472,6 +482,7 @@ export type HistoryItemWithoutId = | HistoryItemInsightProgress | HistoryItemBtw | HistoryItemMemorySaved + | HistoryItemAwayRecap | HistoryItemUserPromptSubmitBlocked | HistoryItemStopHookLoop | HistoryItemStopHookSystemMessage diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8aefe8ed0..ecb244df4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -136,6 +136,7 @@ export * from './services/fileDiscoveryService.js'; export * from './services/fileSystemService.js'; export * from './services/gitService.js'; export * from './services/gitWorktreeService.js'; +export * from './services/sessionRecap.js'; export * from './services/sessionService.js'; export * from './services/shellExecutionService.js'; diff --git a/packages/core/src/services/sessionRecap.ts b/packages/core/src/services/sessionRecap.ts new file mode 100644 index 000000000..64e62c651 --- /dev/null +++ b/packages/core/src/services/sessionRecap.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('SESSION_RECAP'); + +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. + +Content rules: +- Exactly 1 to 3 short sentences. Plain prose, no bullets, no headings, no markdown. +- First: the high-level task — what they are building, debugging, or investigating. +- Then: the concrete next step. +- Do NOT list what was done, recite tool calls, or include status reports. +- Match the dominant language of the conversation (English or Chinese). + +Output format — strict: +- Wrap your recap in ... tags. +- Put NOTHING outside the tags. No preamble, no reasoning, no closing remarks. + +Example: +Investigating intermittent CI failures in the auth retry logic. The next step is to add deterministic timing to the integration test so the race condition reproduces locally.`; + +const RECAP_USER_PROMPT = + 'Generate the recap now. Wrap it in .... Nothing outside the tags.'; + +const RECAP_OPEN_TAG = ''; +const RECAP_TAG_RE = /([\s\S]*?)<\/recap>/i; + +export interface SessionRecapResult { + text: string; + modelUsed: string; +} + +/** + * Generate a 1-3 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. + * + * Returns null on any failure — recap is best-effort and must never break + * the main flow or surface errors to the user. + */ +export async function generateSessionRecap( + config: Config, + abortSignal: AbortSignal, +): Promise { + try { + const geminiClient = config.getGeminiClient(); + if (!geminiClient) return null; + + const fullHistory = geminiClient.getChat().getHistory(); + if (fullHistory.length < 2) return null; + + const dialog = filterToDialog(fullHistory); + const recentHistory = takeRecentDialog(dialog, RECENT_MESSAGE_WINDOW); + if (recentHistory.length === 0) return null; + + const model = config.getFastModel() ?? config.getModel(); + + const response = await geminiClient.generateContent( + [ + ...recentHistory, + { role: 'user', parts: [{ text: RECAP_USER_PROMPT }] }, + ], + { + systemInstruction: RECAP_SYSTEM_PROMPT, + tools: [], + maxOutputTokens: 300, + temperature: 0.3, + }, + abortSignal, + model, + ); + + if (abortSignal.aborted) return null; + + const raw = (response.candidates?.[0]?.content?.parts ?? []) + .map((part) => part.text) + .filter((t): t is string => typeof t === 'string') + .join('') + .trim(); + + if (!raw) return null; + + const text = extractRecap(raw); + if (!text) return null; + + return { text, modelUsed: model }; + } catch (err) { + debugLogger.warn( + `Recap generation failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } +} + +/** + * Extract the recap from a model response. Models often emit reasoning + * before the actual answer; the ... tag lets us isolate the + * useful part. If the close tag is missing (e.g., hit token limit mid-output), + * take everything after the open tag. If the open tag is missing entirely, + * return empty — better to skip than show the reasoning preamble. + */ +function extractRecap(raw: string): string { + const tagged = RECAP_TAG_RE.exec(raw); + if (tagged?.[1]) return tagged[1].trim(); + + const openIdx = raw.toLowerCase().indexOf(RECAP_OPEN_TAG); + if (openIdx === -1) return ''; + return raw.slice(openIdx + RECAP_OPEN_TAG.length).trim(); +} + +/** + * Strip tool calls, tool responses, and the model's hidden reasoning from + * history; keep only user prompts and the model's user-visible text replies. + * + * - A single tool response can hold a 10K-token file dump that drowns the + * recap LLM in irrelevant detail. + * - "Thought" parts (`part.thought` / `part.thoughtSignature`) carry the + * model's internal reasoning. Including them would leak hidden chain-of- + * thought into the recap context and risk surfacing it as user-facing + * summary text. + * + * Each remaining message keeps only its visible text parts, and messages + * with no remaining parts are dropped entirely. + */ +function filterToDialog(history: Content[]): Content[] { + const out: Content[] = []; + for (const msg of history) { + if (msg.role !== 'user' && msg.role !== 'model') continue; + const textParts = (msg.parts ?? []).filter( + (part) => + typeof part?.text === 'string' && + part.text.trim() !== '' && + !part.thought && + !part.thoughtSignature, + ); + if (textParts.length === 0) continue; + out.push({ role: msg.role, parts: textParts }); + } + return out; +} + +/** + * Take the most recent N messages while preserving turn structure: never + * start the slice on a tool/model response that would dangle without its + * preceding user message. + */ +function takeRecentDialog(history: Content[], windowSize: number): Content[] { + if (history.length <= windowSize) return history; + let start = history.length - windowSize; + while (start < history.length && history[start]?.role !== 'user') { + start++; + } + return history.slice(start); +} diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 8413aacec..7411bea06 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -51,6 +51,11 @@ "type": "boolean", "default": true }, + "showSessionRecap": { + "description": "Show a 1-3 sentence summary of where you left off when returning to the terminal after being away for 5+ minutes. Use /recap to trigger manually.", + "type": "boolean", + "default": true + }, "gitCoAuthor": { "description": "Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.", "type": "boolean",