feat(cli): add session recap with /recap and auto-show on return (#3434)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(cli): add session recap with /recap and auto-show on return

Users often open an old session days later and need to scroll through
pages to remember where they left off. This change adds a short
"where did I leave off" recap — a 1-3 sentence summary generated by
the fast model — so they can resume without re-reading the history.

Two triggers:
- /recap: manual slash command.
- Auto: when the terminal has been blurred for 5+ minutes and gets
  focused again (uses the existing DECSET 1004 focus protocol via
  useFocus). Gated on streamingState === Idle so it never interrupts
  an active turn. Only fires once per blur cycle.

The recap is rendered in dim color with a chevron prefix, visually
distinct from assistant replies. A new `general.showSessionRecap`
setting controls the auto-trigger (default on). /recap works
independent of the setting.

Implementation notes:
- generateSessionRecap uses fastModel (falls back to main model),
  tools: [], maxOutputTokens: 300, and a tight system prompt. It
  strips tool calls / responses from history before sending — tool
  responses can hold 10K+ tokens of file content that drown the recap
  in irrelevant detail. The 30-message window respects turn boundaries
  (slice never starts on a dangling model/tool response).
- Output is wrapped in <recap>...</recap> tags; the extractor returns
  empty (skips render) if the tag is missing, preventing model
  reasoning from leaking into the UI.
- All failures are silent (return null) and logged via a scoped
  debugLogger; recap is best-effort and must never break main flow.
- /recap refuses to run while a turn is pending.

* fix(cli): abort in-flight recap when showSessionRecap is disabled

If the user disables showSessionRecap while an auto-recap LLM call is
already in flight, the previous code returned early without aborting.
The pending .then would still pass its idle/abort guards and append the
recap, producing an unwanted message after the user has opted out.

Abort the controller and clear it eagerly so the resolved promise no
longer adds to history.

* fix(cli): gate /recap and auto-recap on streaming idle state

Two related issues from review:

1. /recap was only refusing when ui.pendingItem was set, but a normal
   model reply runs with streamingState === Responding and a null
   pendingItem. Invoking /recap mid-stream would generate a recap from
   a partial conversation and insert it between the user prompt and
   the assistant reply.

2. useAwaySummary cleared blurredAtRef before checking isIdle, so if
   focus returned during a still-streaming turn (after a >5min blur)
   the recap was permanently dropped — there was no later retry when
   the turn became idle, because isIdle was not in the effect deps.

Fixes:
- Expose isIdleRef on CommandContext.ui (mirrors btwAbortControllerRef
  pattern). Plumb it from AppContainer through useSlashCommandProcessor.
- recapCommand now refuses when isIdleRef.current is false OR
  pendingItem is non-null.
- useAwaySummary preserves blurredAtRef on the !isIdle bail and adds
  isIdle to the effect deps, so the trigger re-evaluates when the
  current turn finishes.
- Brief blurs (< AWAY_THRESHOLD_MS) still reset blurredAtRef.

Also seeds isIdleRef in nonInteractiveUi and mockCommandContext so the
new field has a sensible default outside the interactive UI.

* docs: document /recap command, showSessionRecap setting, and design

- User docs: add /recap to the Session and Project Management table in
  features/commands.md and a dedicated subsection covering manual use,
  the auto-trigger, the dim-color rendering, and the fast-model tip.
- User docs: add general.showSessionRecap row to the configuration
  settings reference.
- Design doc: docs/design/session-recap/session-recap-design.md covers
  motivation, the two trigger paths, the per-file architecture, prompt
  design with the <recap> tag and three-tier extractor, history
  filtering rationale (functionResponse can be 10K+ tokens), the
  useAwaySummary state machine, the isIdleRef gating for /recap, model
  selection, observability, and out-of-scope items.

* fix(core): exclude thought parts from session recap context

filterToDialog kept any non-empty text part, but @google/genai's Part
type also marks model reasoning with part.thought / part.thoughtSignature.
That hidden chain-of-thought was being fed to the recap LLM and could
get summarized as if it were user-visible dialogue.

Drop parts where either flag is set. Update the design doc's
History 过滤 section to call this out alongside the existing
tool-call/response rationale.

* docs(session-recap): correct debug-logging guidance, fill in state machine, sharpen UX wording

Audit of the session recap docs against the implementation found three
issues worth fixing:

- Design doc claimed debug logs were enabled via a QWEN_CODE_DEBUG_LOGGING
  env var. That var does not exist; debug logs are written to
  ~/.qwen/debug/<sessionId>.txt by default, gated by QWEN_DEBUG_LOG_FILE.
  Replace with the accurate path + opt-out behavior, and tell the reader
  to grep for the [SESSION_RECAP] tag.
- Design doc's useAwaySummary state machine table was missing the
  isFocused && blurredAtRef === null path (taken on first render and
  right after a brief-blur reset). Add the row.
- User doc's "Refuses to run ... failures are silent" line conflated the
  inline-error refusal with silent generation failures, and "(when the
  conversation is idle)" used internal jargon. Split the two cases and
  spell out what "idle" means, including the wait-then-fire behavior
  when focus returns mid-turn.

* docs(session-recap): correctly describe /recap vs auto-trigger failure modes

The previous wording said "Generation/network failures are silent — the
recap simply does not appear", but recapCommand returns a user-facing
info message ("Not enough conversation context for a recap yet.") in
exactly that path, and also returns inline messages for the
config-not-loaded and busy-turn guards.

Only the auto-trigger path is truly silent (it just skips addItem when
generateSessionRecap returns null). Split the two paths in the doc so
the manual command's "always responds with something" behavior is
distinguished from the auto-trigger's no-op-on-failure behavior.

* docs(session-recap): align prompt-rules section with the actual prompt

Two doc-vs-code mismatches in the design doc's "System Prompt" section,
caught with the same lens as yiliang114's failure-mode review:

- The bullet list claimed RECAP_SYSTEM_PROMPT forbids "推测用户意图"
  and "用 'you' 称呼用户". Those rules existed in an early draft but
  were dropped when the <recap> tag rules were added; the current
  prompt has no such restrictions. Replace with the actual rules and
  add a "与 RECAP_SYSTEM_PROMPT 一一对应" marker so future edits stay
  in sync.
- The doc said systemInstruction "覆盖" the main agent prompt. True
  for the agent prompt portion, but GeminiClient.generateContent
  internally calls getCustomSystemPrompt which appends user memory
  (QWEN.md / 自动 memory) as a suffix. Spell that out — the final
  system prompt is recap prompt + user memory, which is actually
  useful project context for the recap.

* docs(session-recap): translate design doc to English

The repo convention for docs/design is English (7 of 8 existing files;
auto-memory/memory-system.md is the only Chinese one). The first version
of this design doc followed the auto-memory example, which turned out
to be the wrong sample.

Translate to English while preserving the existing structure, the
state-machine table, the prompt-vs-doc 1:1 alignment, the
QWEN_DEBUG_LOG_FILE description, and the failure-mode notes added in
prior commits.

* fix(cli): drop empty info return from /recap interactive success path

The interactive success path inserts the away_recap history item
directly via ui.addItem and then returned `{type: 'message',
messageType: 'info', content: ''}`. The slash-command processor's
'message' case unconditionally calls addMessage, which adds another
HistoryItemInfo with empty text. The empty info renders as nothing
(StatusMessage early-returns null), but it still bloats the in-memory
history list and shows up in /export and saved sessions.

Return void on the interactive success path and on the abort path so
the processor's `if (result)` check skips the message-handler branch
entirely. Widen the action's return type to `void | SlashCommandActionReturn`
to match (same shape as btwCommand).
This commit is contained in:
Shaojin Wen 2026-04-19 21:38:48 +08:00 committed by GitHub
parent 528fcfcff8
commit 60a6dfc14c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 702 additions and 4 deletions

View file

@ -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 `<recap>...</recap>`; nothing outside the tags.
### Structured Output + Extraction
The model is instructed to wrap its answer in `<recap>...</recap>`:
```
<recap>Refactoring loopDetectionService.ts to address long-session OOM. Next step is to implement option B.</recap>
```
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 `<recap>...</recap>` (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/<sessionId>.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. |

View file

@ -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"` |

View file

@ -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 <ID>` |
### 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 <model>` (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:

View file

@ -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',

View file

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

View file

@ -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(),

View file

@ -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;

View file

@ -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<void | SlashCommandActionReturn> => {
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 };
},
};

View file

@ -75,6 +75,8 @@ export interface CommandContext {
cancelBtw: () => void;
/** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */
btwAbortControllerRef: MutableRefObject<AbortController | null>;
/** Ref to whether the agent stream is currently idle (no model turn in flight). */
isIdleRef: MutableRefObject<boolean>;
/**
* Loads a new set of history items, replacing the current history.
*

View file

@ -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<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'memory_saved' && (
<MemorySavedMessage item={itemForDisplay} />
)}
{itemForDisplay.type === 'away_recap' && (
<AwayRecapMessage text={itemForDisplay.text} />
)}
</Box>
);
};

View file

@ -124,3 +124,12 @@ export const RetryCountdownMessage: React.FC<StatusTextProps> = ({ text }) => (
textColor={theme.text.secondary}
/>
);
export const AwayRecapMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix=""
prefixColor={theme.text.secondary}
textColor={theme.text.secondary}
/>
);

View file

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

View file

@ -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<boolean>,
isProcessing: boolean,
setIsProcessing: (isProcessing: boolean) => void,
isIdleRef: MutableRefObject<boolean>,
setGeminiMdFileCount: (count: number) => void,
actions: SlashCommandProcessorActions,
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
@ -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,
],
);

View file

@ -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<number | null>(null);
const recapPendingRef = useRef(false);
const inFlightRef = useRef<AbortController | null>(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();
},
[],
);
}

View file

@ -24,6 +24,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
setBtwItem: (_item) => {},
cancelBtw: () => {},
btwAbortControllerRef: { current: null },
isIdleRef: { current: true },
toggleVimEnabled: async () => false,
setGeminiMdFileCount: (_count) => {},
reloadCommands: () => {},

View file

@ -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

View file

@ -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';

View file

@ -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 <recap>...</recap> tags.
- Put NOTHING outside the tags. No preamble, no reasoning, no closing remarks.
Example:
<recap>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.</recap>`;
const RECAP_USER_PROMPT =
'Generate the recap now. Wrap it in <recap>...</recap>. Nothing outside the tags.';
const RECAP_OPEN_TAG = '<recap>';
const RECAP_TAG_RE = /<recap>([\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<SessionRecapResult | null> {
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 <recap>...</recap> 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);
}

View file

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