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",