fix(cli): pin /recap above input and align defaults with fastModel (#3478)
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

* fix(cli): pin /recap above input box and align defaults with fastModel

The recap rendered as a regular history item, so as soon as the model
streamed a new reply the "where you left off" reminder scrolled out of
view. Move it to a sticky banner anchored just above the Composer
(matching how btwItem is rendered) so it stays visible across turns.

While reworking the surface, also:
- Replace the chevron prefix with `※ recap:` so it reads as a labeled
  recap line instead of a generic dim message.
- Mirror the placement in ScreenReaderAppLayout so screen-reader users
  see it in the same logical position.
- Drop HistoryItemAwayRecap from the HistoryItemWithoutId union — it
  is no longer addItem-able, and leaving it in invited silent no-op
  bugs where addItem(awayRecap) would compile but render nothing.
- Clear the banner on /clear, /reset, /new and on /resume into a
  different session, so a recap from a previous context doesn't bleed
  into a freshly started one.
- Re-measure the controls box when the banner appears or disappears
  (its height changes by a couple of lines) so the main content area
  recomputes availableTerminalHeight and stays laid out correctly.

Auto-trigger now defaults to "on iff fastModel is configured" rather
than unconditionally on. Running an ambient background recap on the
main coding model is too costly and slow to be a sane default; tying
it to fastModel means the feature is silently opt-in for users who
have set up a cheap fast model. An explicit `general.showSessionRecap`
override still wins either way, and `/recap` itself is unaffected.

Sharpen the slash-command description to match the new behavior.

* fix(core): silence AbortSignal listener-leak warning in OpenAI pipeline

Every chat.completions.create call wires up an abort listener on the
incoming AbortSignal, and several layers — retryWithBackoff, the
LoggingContentGenerator wrapper, the SDK's own internal stream/fetch
plumbing — register their own listeners against the same signal. Five
retry attempts plus those layers comfortably exceed Node's default
10-listener cap and produce a MaxListenersExceededWarning. With
features that share or compose signals (e.g., recap + followup
speculation firing on the same response cycle), even a higher cap
gets blown past.

The signals here are per-request and short-lived, so the accumulation
is structural rather than a real memory leak — they get GC'd as soon
as the request settles. setMaxListeners(0, signal) at the SDK boundary
disables the warning for these specific signals only, without masking
any genuine leak elsewhere in the process. Idempotent and confined to
the one place where retry-bound API calls cross into the SDK.

* fix(core): tighten recap to a single sentence within 80 chars

The 1-3 sentence budget reliably wrapped onto two lines in the sticky
banner above the input box, which made it visually heavy for what is
supposed to be a glanceable reminder. Constrain the prompt to exactly
one sentence with a hard 80-char cap, and merge the "high-level task
+ next step" rule into a single sentence instead of two adjacent ones.

Also sweep the docs (settings, commands, design) so the user-facing
copy and the internal design notes match the new format.

* fix(cli): apply review feedback for recap PR

Two issues from review:

- The schema description for `general.showSessionRecap` still said
  "1-3 sentence summary" while the prompt, docs, and slash-command
  copy already say "one-line". Aligns the text in settingsSchema.ts
  and the regenerated VSCode JSON schema.

- The /resume wrapper cleared the sticky recap synchronously, before
  the inner handler had a chance to discover that no session data
  was available. On a no-op resume the user would still lose the
  current recap. Make `useResumeCommand.handleResume` return
  Promise<boolean> reporting whether a session actually loaded, and
  only clear the recap on a confirmed switch.

* fix(cli): default showSessionRecap to false and drop fastModel heuristic

The earlier "enabled iff fastModel is configured" default made it hard
for users to answer the simple question "is auto-recap on for me right
now?" — the answer depended on a setting from a different category,
and setting/unsetting fastModel silently changed recap behavior.

Revert to a plain boolean with a conservative off-by-default:

- Auto-trigger fires only when the user explicitly sets
  `general.showSessionRecap: true`.
- Manual `/recap` keeps working regardless (that's a user-initiated
  call, not an ambient one).
- Users never get ambient LLM calls billed to their main coding model
  without having opted in.

Aligns settings.md, design doc, and the regenerated JSON schema.
This commit is contained in:
Shaojin Wen 2026-04-20 23:58:19 +08:00 committed by GitHub
parent 4d1d430390
commit 52c7a3d0ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 152 additions and 66 deletions

View file

@ -1,6 +1,6 @@
# Session Recap Design # Session Recap Design
> A 1-3 sentence "where did I leave off" summary surfaced when the user > A one-line "where did I leave off" summary surfaced when the user
> returns to an idle session, either on demand (`/recap`) or after the > returns to an idle session, either on demand (`/recap`) or after the
> terminal has been blurred for 5+ minutes. > terminal has been blurred for 5+ minutes.
@ -11,7 +11,7 @@ pages of history to remember **what they were doing and what came next**
is a real friction point. Just reloading messages does not solve this is a real friction point. Just reloading messages does not solve this
UX problem. UX problem.
The goal is to proactively surface a 1-3 sentence recap when the user The goal is to proactively surface a one-line recap when the user
returns: returns:
- **High-level task** (what they are doing) → **next step** (what to do next). - **High-level task** (what they are doing) → **next step** (what to do next).
@ -28,8 +28,9 @@ returns:
Both paths funnel into a single function — `generateSessionRecap()` — to Both paths funnel into a single function — `generateSessionRecap()` — to
guarantee identical behavior. The auto-trigger is gated by guarantee identical behavior. The auto-trigger is gated by
`general.showSessionRecap` (default: on); the manual command ignores `general.showSessionRecap` (default: off — explicit opt-in, so ambient
that setting. LLM calls are never silently added to a user's bill); the manual
command ignores that setting.
## Architecture ## Architecture
@ -39,8 +40,8 @@ that setting.
│ isFocused = useFocus() │ │ isFocused = useFocus() │
│ isIdle = streamingState === Idle │ │ isIdle = streamingState === Idle │
│ │ │ │ │ │
│ ├─→ useAwaySummary({enabled, config, isFocused, isIdle, addItem}) │ ├─→ useAwaySummary({enabled, config, isFocused, isIdle,
│ │ │ │ │ │ setAwayRecapItem})
│ │ └─→ 5 min blur timer + idle/dedupe gates │ │ │ └─→ 5 min blur timer + idle/dedupe gates │
│ │ │ │ │ │ │ │
│ │ ↓ │ │ │ ↓ │
@ -56,9 +57,10 @@ that setting.
│ GeminiClient.generateContent │ │ GeminiClient.generateContent │
│ (fastModel + tools:[]) │ │ (fastModel + tools:[]) │
│ │ │ │
│ addItem({type: 'away_recap', text}) ─→ HistoryItemDisplay │ │ setAwayRecapItem({type: 'away_recap', text}) │
│ └─ AwayRecapMessage │ │ └─→ DefaultAppLayout renders AwayRecapMessage │
│ (dim color + prefix) │ │ as a sticky banner above the Composer │
│ (dim color + "※ recap:" prefix) │
└────────────────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────────┘
``` ```
@ -69,9 +71,10 @@ that setting.
| `packages/core/src/services/sessionRecap.ts` | One-shot LLM call + history filter + tag extraction | | `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/hooks/useAwaySummary.ts` | Auto-trigger React hook |
| `packages/cli/src/ui/commands/recapCommand.ts` | `/recap` manual entry point | | `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/components/messages/StatusMessages.tsx` | `AwayRecapMessage` dim renderer (`※ recap:` prefix) |
| `packages/cli/src/ui/types.ts` | `HistoryItemAwayRecap` type | | `packages/cli/src/ui/types.ts` | `HistoryItemAwayRecap` type |
| `packages/cli/src/ui/components/HistoryItemDisplay.tsx` | Renderer dispatch | | `packages/cli/src/ui/layouts/DefaultAppLayout.tsx` | Sticky-banner placement above the Composer |
| `packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx` | Same placement under screen-reader mode |
| `packages/cli/src/config/settingsSchema.ts` | `general.showSessionRecap` setting | | `packages/cli/src/config/settingsSchema.ts` | `general.showSessionRecap` setting |
## Prompt Design ## Prompt Design
@ -90,7 +93,7 @@ recap, not a leak.
Bullets below correspond 1:1 with `RECAP_SYSTEM_PROMPT`: Bullets below correspond 1:1 with `RECAP_SYSTEM_PROMPT`:
- 1 to 3 short sentences, plain prose (no markdown / lists / headings). - Exactly one short sentence (≤ 80 chars), plain prose (no markdown / lists / headings).
- First sentence: the high-level task. Then: the concrete next step. - First sentence: the high-level task. Then: the concrete next step.
- Explicitly forbid: listing what was done, reciting tool calls, status reports. - Explicitly forbid: listing what was done, reciting tool calls, status reports.
- Match the dominant language of the conversation (English or Chinese). - Match the dominant language of the conversation (English or Chinese).
@ -121,13 +124,13 @@ the model's reasoning preamble is worse than showing no recap at all.
### Call Parameters ### Call Parameters
| Parameter | Value | Reason | | Parameter | Value | Reason |
| ------------------- | ------------------------------ | ---------------------------------------------------------------- | | ------------------- | ------------------------------ | ----------------------------------------------------- |
| `model` | `getFastModel() ?? getModel()` | Recap doesn't need a frontier model | | `model` | `getFastModel() ?? getModel()` | Recap doesn't need a frontier model |
| `tools` | `[]` | One-shot query, no tool use | | `tools` | `[]` | One-shot query, no tool use |
| `maxOutputTokens` | `300` | Enough for 1-3 sentences + tags; larger would encourage rambling | | `maxOutputTokens` | `300` | Headroom for one short sentence + tags |
| `temperature` | `0.3` | Mostly deterministic, with a bit of natural variation | | `temperature` | `0.3` | Mostly deterministic, with a bit of natural variation |
| `systemInstruction` | The recap-only prompt above | Replaces the main agent's role definition | | `systemInstruction` | The recap-only prompt above | Replaces the main agent's role definition |
## History Filtering ## History Filtering
@ -167,7 +170,7 @@ response.
| `recapPendingRef` | Whether an LLM call is in flight | | `recapPendingRef` | Whether an LLM call is in flight |
| `inFlightRef` | The current in-flight `AbortController` | | `inFlightRef` | The current in-flight `AbortController` |
`useEffect` deps: `[enabled, config, isFocused, isIdle, addItem]`. `useEffect` deps: `[enabled, config, isFocused, isIdle, setAwayRecapItem]`.
| Event | Action | | Event | Action |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
@ -204,7 +207,7 @@ and a null `pendingItem`.
| Setting | Default | Notes | | Setting | Default | Notes |
| -------------------------- | ------- | ----------------------------------------------------------------- | | -------------------------- | ------- | ----------------------------------------------------------------- |
| `general.showSessionRecap` | `true` | Auto-trigger only. Manual `/recap` ignores this. | | `general.showSessionRecap` | `false` | Auto-trigger only. Manual `/recap` ignores this. |
| `fastModel` | unset | Recommended (e.g. `qwen3-coder-flash`) for fast and cheap recaps. | | `fastModel` | unset | Recommended (e.g. `qwen3-coder-flash`) for fast and cheap recaps. |
### Model fallback ### Model fallback

View file

@ -77,15 +77,15 @@ Settings are organized into categories. All settings should be placed within the
#### general #### general
| Setting | Type | Description | Default | | Setting | Type | Description | Default |
| ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | ------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | | `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | | `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
| `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` | | `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.showSessionRecap` | boolean | Auto-show a one-line "where you left off" recap when returning to the terminal after being away for 5+ minutes. Off by default. Use `/recap` to trigger manually regardless of this setting. | `false` |
| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | | `general.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.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"` | | `general.defaultFileEncoding` | string | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` |
#### output #### output

View file

@ -24,7 +24,7 @@ These commands help you save, restore, and summarize work progress.
| `/summary` | Generate project summary based on conversation history | `/summary` | | `/summary` | Generate project summary based on conversation history | `/summary` |
| `/compress` | Replace chat history with summary to save Tokens | `/compress` | | `/compress` | Replace chat history with summary to save Tokens | `/compress` |
| `/resume` | Resume a previous conversation session | `/resume` | | `/resume` | Resume a previous conversation session | `/resume` |
| `/recap` | Show a 1-3 sentence "where you left off" summary | `/recap` | | `/recap` | Generate a one-line session recap now | `/recap` |
| `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore <ID>` | | `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore <ID>` |
### 1.2 Interface and Workspace Control ### 1.2 Interface and Workspace Control
@ -163,9 +163,9 @@ The `/recap` command generates a short "where you left off" summary of the
current session, so you can resume an old conversation without scrolling current session, so you can resume an old conversation without scrolling
back through pages of history. back through pages of history.
| Command | Description | | Command | Description |
| -------- | ------------------------------------------------ | | -------- | ------------------------------------------ |
| `/recap` | Generate and show a 1-3 sentence session summary | | `/recap` | Generate and show a one-line session recap |
**How it works:** **How it works:**

View file

@ -329,9 +329,13 @@ const SETTINGS_SCHEMA = {
label: 'Show Session Recap', label: 'Show Session Recap',
category: 'General', category: 'General',
requiresRestart: false, requiresRestart: false,
default: true, // Off by default — an ambient background LLM call isn't something
// users should be opted into silently, especially when `fastModel`
// is unset and the call would land on the main coding model.
// Manual `/recap` works regardless.
default: false,
description: 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.', 'Auto-show a one-line "where you left off" recap when returning to the terminal after being away for 5+ minutes. Off by default. Use /recap to trigger manually regardless of this setting.',
showInDialog: true, showInDialog: true,
}, },
gitCoAuthor: { gitCoAuthor: {

View file

@ -59,6 +59,8 @@ export const createMockCommandContext = (
setBtwItem: vi.fn(), setBtwItem: vi.fn(),
cancelBtw: vi.fn(), cancelBtw: vi.fn(),
btwAbortControllerRef: { current: null }, btwAbortControllerRef: { current: null },
awayRecapItem: null,
setAwayRecapItem: vi.fn(),
isIdleRef: { current: true }, isIdleRef: { current: true },
loadHistory: vi.fn(), loadHistory: vi.fn(),
toggleVimEnabled: vi.fn(), toggleVimEnabled: vi.fn(),

View file

@ -570,7 +570,7 @@ export const AppContainer = (props: AppContainerProps) => {
isResumeDialogOpen, isResumeDialogOpen,
openResumeDialog, openResumeDialog,
closeResumeDialog, closeResumeDialog,
handleResume, handleResume: handleResumeInner,
} = useResumeCommand({ } = useResumeCommand({
config, config,
historyManager, historyManager,
@ -658,6 +658,8 @@ export const AppContainer = (props: AppContainerProps) => {
btwItem, btwItem,
setBtwItem, setBtwItem,
cancelBtw, cancelBtw,
awayRecapItem,
setAwayRecapItem,
commandContext, commandContext,
shellConfirmationRequest, shellConfirmationRequest,
confirmationRequest, confirmationRequest,
@ -679,6 +681,22 @@ export const AppContainer = (props: AppContainerProps) => {
logger, logger,
); );
// Wrap handleResume so the sticky recap from the previous session
// doesn't carry over into the new one. Only clear after the inner
// handler confirms a session was actually loaded — otherwise (no
// session data, missing deps) we'd drop the current session's recap
// for no reason.
const handleResume = useCallback(
async (sessionId: string): Promise<boolean> => {
const switched = await handleResumeInner(sessionId);
if (switched) {
setAwayRecapItem(null);
}
return switched;
},
[handleResumeInner, setAwayRecapItem],
);
// onDebugMessage should log to debug logfile, not update footer debugMessage // onDebugMessage should log to debug logfile, not update footer debugMessage
const onDebugMessage = useCallback( const onDebugMessage = useCallback(
(message: string) => { (message: string) => {
@ -1230,7 +1248,7 @@ export const AppContainer = (props: AppContainerProps) => {
setControlsHeight(fullFooterMeasurement.height); setControlsHeight(fullFooterMeasurement.height);
} }
} }
}, [buffer, terminalWidth, terminalHeight]); }, [buffer, terminalWidth, terminalHeight, awayRecapItem, btwItem]);
// agentViewState is declared earlier (before handleFinalSubmit) so it // agentViewState is declared earlier (before handleFinalSubmit) so it
// is available for input routing. Referenced here for layout computation. // is available for input routing. Referenced here for layout computation.
@ -1259,11 +1277,11 @@ export const AppContainer = (props: AppContainerProps) => {
useBracketedPaste(); useBracketedPaste();
useAwaySummary({ useAwaySummary({
enabled: settings.merged.general?.showSessionRecap ?? true, enabled: settings.merged.general?.showSessionRecap ?? false,
config, config,
isFocused, isFocused,
isIdle: streamingState === StreamingState.Idle, isIdle: streamingState === StreamingState.Idle,
addItem: historyManager.addItem, setAwayRecapItem,
}); });
// Context file names computation // Context file names computation
@ -2083,6 +2101,8 @@ export const AppContainer = (props: AppContainerProps) => {
btwItem, btwItem,
setBtwItem, setBtwItem,
cancelBtw, cancelBtw,
awayRecapItem,
setAwayRecapItem,
nightly, nightly,
branchName, branchName,
sessionStats, sessionStats,
@ -2189,6 +2209,8 @@ export const AppContainer = (props: AppContainerProps) => {
btwItem, btwItem,
setBtwItem, setBtwItem,
cancelBtw, cancelBtw,
awayRecapItem,
setAwayRecapItem,
nightly, nightly,
branchName, branchName,
sessionStats, sessionStats,

View file

@ -18,7 +18,7 @@ export const recapCommand: SlashCommand = {
name: 'recap', name: 'recap',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
get description() { get description() {
return t('Show a 1-3 sentence summary of where you left off'); return t('Generate a one-line session recap now');
}, },
action: async ( action: async (
context: CommandContext, context: CommandContext,
@ -65,7 +65,7 @@ export const recapCommand: SlashCommand = {
type: 'away_recap', type: 'away_recap',
text: recap.text, text: recap.text,
}; };
context.ui.addItem(item, Date.now()); context.ui.setAwayRecapItem(item);
return; return;
} }

View file

@ -11,6 +11,7 @@ import type {
HistoryItemWithoutId, HistoryItemWithoutId,
HistoryItem, HistoryItem,
HistoryItemBtw, HistoryItemBtw,
HistoryItemAwayRecap,
ConfirmationRequest, ConfirmationRequest,
} from '../types.js'; } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js'; import type { LoadedSettings } from '../../config/settings.js';
@ -75,6 +76,10 @@ export interface CommandContext {
cancelBtw: () => void; cancelBtw: () => void;
/** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */ /** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */
btwAbortControllerRef: MutableRefObject<AbortController | null>; btwAbortControllerRef: MutableRefObject<AbortController | null>;
/** The current away-recap item rendered as a sticky banner above the input box. */
awayRecapItem: HistoryItemAwayRecap | null;
/** Sets the away-recap item independently of the main history. */
setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void;
/** Ref to whether the agent stream is currently idle (no model turn in flight). */ /** Ref to whether the agent stream is currently idle (no model turn in flight). */
isIdleRef: MutableRefObject<boolean>; isIdleRef: MutableRefObject<boolean>;
/** /**

View file

@ -28,7 +28,6 @@ import {
ErrorMessage, ErrorMessage,
RetryCountdownMessage, RetryCountdownMessage,
SuccessMessage, SuccessMessage,
AwayRecapMessage,
} from './messages/StatusMessages.js'; } from './messages/StatusMessages.js';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
@ -286,9 +285,6 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'memory_saved' && ( {itemForDisplay.type === 'memory_saved' && (
<MemorySavedMessage item={itemForDisplay} /> <MemorySavedMessage item={itemForDisplay} />
)} )}
{itemForDisplay.type === 'away_recap' && (
<AwayRecapMessage text={itemForDisplay.text} />
)}
</Box> </Box>
); );
}; };

View file

@ -128,7 +128,7 @@ export const RetryCountdownMessage: React.FC<StatusTextProps> = ({ text }) => (
export const AwayRecapMessage: React.FC<StatusTextProps> = ({ text }) => ( export const AwayRecapMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage <StatusMessage
text={text} text={text}
prefix="" prefix="※ recap:"
prefixColor={theme.text.secondary} prefixColor={theme.text.secondary}
textColor={theme.text.secondary} textColor={theme.text.secondary}
/> />

View file

@ -8,6 +8,7 @@ import { createContext, useContext } from 'react';
import type { import type {
HistoryItem, HistoryItem,
HistoryItemBtw, HistoryItemBtw,
HistoryItemAwayRecap,
ThoughtSummary, ThoughtSummary,
ShellConfirmationRequest, ShellConfirmationRequest,
ConfirmationRequest, ConfirmationRequest,
@ -110,6 +111,8 @@ export interface UIState {
btwItem: HistoryItemBtw | null; btwItem: HistoryItemBtw | null;
setBtwItem: (item: HistoryItemBtw | null) => void; setBtwItem: (item: HistoryItemBtw | null) => void;
cancelBtw: () => void; cancelBtw: () => void;
awayRecapItem: HistoryItemAwayRecap | null;
setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void;
nightly: boolean; nightly: boolean;
branchName: string | undefined; branchName: string | undefined;
sessionStats: SessionStatsState; sessionStats: SessionStatsState;

View file

@ -31,6 +31,7 @@ import type {
Message, Message,
HistoryItemWithoutId, HistoryItemWithoutId,
HistoryItemBtw, HistoryItemBtw,
HistoryItemAwayRecap,
SlashCommandProcessorResult, SlashCommandProcessorResult,
HistoryItem, HistoryItem,
ConfirmationRequest, ConfirmationRequest,
@ -155,6 +156,9 @@ export const useSlashCommandProcessor = (
const [btwItem, setBtwItem] = useState<HistoryItemBtw | null>(null); const [btwItem, setBtwItem] = useState<HistoryItemBtw | null>(null);
const btwAbortControllerRef = useRef<AbortController | null>(null); const btwAbortControllerRef = useRef<AbortController | null>(null);
const [awayRecapItem, setAwayRecapItem] =
useState<HistoryItemAwayRecap | null>(null);
const cancelBtw = useCallback(() => { const cancelBtw = useCallback(() => {
btwAbortControllerRef.current?.abort(); btwAbortControllerRef.current?.abort();
btwAbortControllerRef.current = null; btwAbortControllerRef.current = null;
@ -268,6 +272,7 @@ export const useSlashCommandProcessor = (
addItem, addItem,
clear: () => { clear: () => {
cancelBtw(); cancelBtw();
setAwayRecapItem(null);
clearItems(); clearItems();
clearScreen(); clearScreen();
refreshStatic(); refreshStatic();
@ -280,6 +285,8 @@ export const useSlashCommandProcessor = (
setBtwItem, setBtwItem,
cancelBtw, cancelBtw,
btwAbortControllerRef, btwAbortControllerRef,
awayRecapItem,
setAwayRecapItem,
isIdleRef, isIdleRef,
toggleVimEnabled, toggleVimEnabled,
setGeminiMdFileCount, setGeminiMdFileCount,
@ -312,6 +319,8 @@ export const useSlashCommandProcessor = (
btwItem, btwItem,
setBtwItem, setBtwItem,
cancelBtw, cancelBtw,
awayRecapItem,
setAwayRecapItem,
toggleVimEnabled, toggleVimEnabled,
sessionShellAllowlist, sessionShellAllowlist,
setGeminiMdFileCount, setGeminiMdFileCount,
@ -785,6 +794,8 @@ export const useSlashCommandProcessor = (
btwItem, btwItem,
setBtwItem, setBtwItem,
cancelBtw, cancelBtw,
awayRecapItem,
setAwayRecapItem,
commandContext, commandContext,
shellConfirmationRequest, shellConfirmationRequest,
confirmationRequest, confirmationRequest,

View file

@ -6,7 +6,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { generateSessionRecap, type Config } from '@qwen-code/qwen-code-core'; import { generateSessionRecap, type Config } from '@qwen-code/qwen-code-core';
import type { HistoryItemAwayRecap, HistoryItemWithoutId } from '../types.js'; import type { HistoryItemAwayRecap } from '../types.js';
const AWAY_THRESHOLD_MS = 5 * 60 * 1000; const AWAY_THRESHOLD_MS = 5 * 60 * 1000;
@ -15,7 +15,7 @@ export interface UseAwaySummaryOptions {
config: Config | null; config: Config | null;
isFocused: boolean; isFocused: boolean;
isIdle: boolean; isIdle: boolean;
addItem: (item: HistoryItemWithoutId, baseTimestamp: number) => number; setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void;
} }
/** /**
@ -27,7 +27,7 @@ export interface UseAwaySummaryOptions {
* a single back-and-forth produces at most one recap. * a single back-and-forth produces at most one recap.
*/ */
export function useAwaySummary(options: UseAwaySummaryOptions): void { export function useAwaySummary(options: UseAwaySummaryOptions): void {
const { enabled, config, isFocused, isIdle, addItem } = options; const { enabled, config, isFocused, isIdle, setAwayRecapItem } = options;
const blurredAtRef = useRef<number | null>(null); const blurredAtRef = useRef<number | null>(null);
const recapPendingRef = useRef(false); const recapPendingRef = useRef(false);
@ -78,7 +78,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
type: 'away_recap', type: 'away_recap',
text: recap.text, text: recap.text,
}; };
addItem(item, Date.now()); setAwayRecapItem(item);
}) })
.finally(() => { .finally(() => {
if (inFlightRef.current === controller) { if (inFlightRef.current === controller) {
@ -86,7 +86,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
} }
recapPendingRef.current = false; recapPendingRef.current = false;
}); });
}, [enabled, config, isFocused, isIdle, addItem]); }, [enabled, config, isFocused, isIdle, setAwayRecapItem]);
useEffect( useEffect(
() => () => { () => () => {

View file

@ -25,7 +25,13 @@ export interface UseResumeCommandResult {
isResumeDialogOpen: boolean; isResumeDialogOpen: boolean;
openResumeDialog: () => void; openResumeDialog: () => void;
closeResumeDialog: () => void; closeResumeDialog: () => void;
handleResume: (sessionId: string) => void; /**
* Resolves to `true` when the target session was actually loaded, or
* `false` when the call short-circuited (missing dependencies or no
* session data found). Callers can use the boolean to gate cleanup
* that should only happen on a successful session switch.
*/
handleResume: (sessionId: string) => Promise<boolean>;
} }
export function useResumeCommand( export function useResumeCommand(
@ -44,9 +50,9 @@ export function useResumeCommand(
const { config, historyManager, startNewSession, remount } = options ?? {}; const { config, historyManager, startNewSession, remount } = options ?? {};
const handleResume = useCallback( const handleResume = useCallback(
async (sessionId: string) => { async (sessionId: string): Promise<boolean> => {
if (!config || !historyManager || !startNewSession) { if (!config || !historyManager || !startNewSession) {
return; return false;
} }
// Close dialog immediately to prevent input capture during async operations. // Close dialog immediately to prevent input capture during async operations.
@ -57,7 +63,7 @@ export function useResumeCommand(
const sessionData = await sessionService.loadSession(sessionId); const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) { if (!sessionData) {
return; return false;
} }
// Start new session in UI context. // Start new session in UI context.
@ -87,6 +93,7 @@ export function useResumeCommand(
// Refresh terminal UI. // Refresh terminal UI.
remount?.(); remount?.();
return true;
}, },
[closeResumeDialog, config, historyManager, startNewSession, remount], [closeResumeDialog, config, historyManager, startNewSession, remount],
); );

View file

@ -12,6 +12,7 @@ import { DialogManager } from '../components/DialogManager.js';
import { Composer } from '../components/Composer.js'; import { Composer } from '../components/Composer.js';
import { ExitWarning } from '../components/ExitWarning.js'; import { ExitWarning } from '../components/ExitWarning.js';
import { BtwMessage } from '../components/messages/BtwMessage.js'; import { BtwMessage } from '../components/messages/BtwMessage.js';
import { AwayRecapMessage } from '../components/messages/StatusMessages.js';
import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
import { AgentChatView } from '../components/agent-view/AgentChatView.js'; import { AgentChatView } from '../components/agent-view/AgentChatView.js';
import { AgentComposer } from '../components/agent-view/AgentComposer.js'; import { AgentComposer } from '../components/agent-view/AgentComposer.js';
@ -69,6 +70,11 @@ export const DefaultAppLayout: React.FC = () => {
</Box> </Box>
) : ( ) : (
<> <>
{uiState.awayRecapItem && (
<Box marginX={2} width={uiState.mainAreaWidth}>
<AwayRecapMessage text={uiState.awayRecapItem.text} />
</Box>
)}
{uiState.btwItem && ( {uiState.btwItem && (
<Box marginX={2} width={uiState.mainAreaWidth}> <Box marginX={2} width={uiState.mainAreaWidth}>
<BtwMessage <BtwMessage

View file

@ -13,6 +13,7 @@ import { Composer } from '../components/Composer.js';
import { Footer } from '../components/Footer.js'; import { Footer } from '../components/Footer.js';
import { ExitWarning } from '../components/ExitWarning.js'; import { ExitWarning } from '../components/ExitWarning.js';
import { BtwMessage } from '../components/messages/BtwMessage.js'; import { BtwMessage } from '../components/messages/BtwMessage.js';
import { AwayRecapMessage } from '../components/messages/StatusMessages.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
export const ScreenReaderAppLayout: React.FC = () => { export const ScreenReaderAppLayout: React.FC = () => {
@ -35,6 +36,11 @@ export const ScreenReaderAppLayout: React.FC = () => {
</Box> </Box>
) : ( ) : (
<> <>
{uiState.awayRecapItem && (
<Box marginX={2} width={uiState.mainAreaWidth}>
<AwayRecapMessage text={uiState.awayRecapItem.text} />
</Box>
)}
{uiState.btwItem && ( {uiState.btwItem && (
<Box marginX={2} width={uiState.mainAreaWidth}> <Box marginX={2} width={uiState.mainAreaWidth}>
<BtwMessage <BtwMessage

View file

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

View file

@ -390,8 +390,9 @@ export type HistoryItemBtw = HistoryItemBase & {
/** /**
* Away-summary recap shown when the user returns to the session after a * 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 * period of inactivity (or via /recap). Rendered as a sticky banner above
* visually distinct from real assistant replies. * the input box (NOT part of the scrolling history), so it is intentionally
* excluded from the HistoryItemWithoutId union.
*/ */
export type HistoryItemAwayRecap = HistoryItemBase & { export type HistoryItemAwayRecap = HistoryItemBase & {
type: 'away_recap'; type: 'away_recap';
@ -483,7 +484,6 @@ export type HistoryItemWithoutId =
| HistoryItemInsightProgress | HistoryItemInsightProgress
| HistoryItemBtw | HistoryItemBtw
| HistoryItemMemorySaved | HistoryItemMemorySaved
| HistoryItemAwayRecap
| HistoryItemUserPromptSubmitBlocked | HistoryItemUserPromptSubmitBlocked
| HistoryItemStopHookLoop | HistoryItemStopHookLoop
| HistoryItemStopHookSystemMessage | HistoryItemStopHookSystemMessage

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { setMaxListeners } from 'node:events';
import type OpenAI from 'openai'; import type OpenAI from 'openai';
import { import {
type GenerateContentParameters, type GenerateContentParameters,
@ -15,6 +16,23 @@ import type { OpenAICompatibleProvider } from './provider/index.js';
import { OpenAIContentConverter } from './converter.js'; import { OpenAIContentConverter } from './converter.js';
import type { ErrorHandler, RequestContext } from './errorHandler.js'; import type { ErrorHandler, RequestContext } from './errorHandler.js';
/**
* The OpenAI SDK adds an abort listener for every `chat.completions.create`
* call, and several layers (retryWithBackoff, LoggingContentGenerator, the
* SDK's internal stream/fetch wrappers) each register their own listeners
* on the same per-request AbortSignal. With 5 retries the count comfortably
* exceeds Node's default 10-listener leak warning and on top of that,
* concurrent code paths (e.g., recap + followup speculation) can share or
* compose signals, pushing it past any small cap.
*
* These signals are per-request and short-lived (GC'd when the request
* settles), so accumulation here is structural, not a memory leak. Disable
* the warning entirely for them. Idempotent.
*/
function raiseAbortListenerCap(signal: AbortSignal | undefined): void {
if (signal) setMaxListeners(0, signal);
}
/** /**
* Error thrown when the API returns an error embedded as stream content * Error thrown when the API returns an error embedded as stream content
* instead of a proper HTTP error. Some providers (e.g., certain OpenAI-compatible * instead of a proper HTTP error. Some providers (e.g., certain OpenAI-compatible
@ -59,6 +77,7 @@ export class ContentGenerationPipeline {
const effectiveModel = request.model || this.contentGeneratorConfig.model; const effectiveModel = request.model || this.contentGeneratorConfig.model;
this.converter.setModel(effectiveModel); this.converter.setModel(effectiveModel);
this.converter.setModalities(this.contentGeneratorConfig.modalities ?? {}); this.converter.setModalities(this.contentGeneratorConfig.modalities ?? {});
raiseAbortListenerCap(request.config?.abortSignal);
return this.executeWithErrorHandling( return this.executeWithErrorHandling(
request, request,
userPromptId, userPromptId,
@ -87,6 +106,7 @@ export class ContentGenerationPipeline {
const effectiveModel = request.model || this.contentGeneratorConfig.model; const effectiveModel = request.model || this.contentGeneratorConfig.model;
this.converter.setModel(effectiveModel); this.converter.setModel(effectiveModel);
this.converter.setModalities(this.contentGeneratorConfig.modalities ?? {}); this.converter.setModalities(this.contentGeneratorConfig.modalities ?? {});
raiseAbortListenerCap(request.config?.abortSignal);
return this.executeWithErrorHandling( return this.executeWithErrorHandling(
request, request,
userPromptId, userPromptId,

View file

@ -19,9 +19,8 @@ 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. to remind them where they left off so they can resume quickly.
Content rules: Content rules:
- Exactly 1 to 3 short sentences. Plain prose, no bullets, no headings, no markdown. - Exactly ONE sentence. Hard cap: 80 characters. Plain prose, no bullets, no headings, no markdown.
- First: the high-level task what they are building, debugging, or investigating. - Combine the high-level task and the concrete next step into a single sentence.
- Then: the concrete next step.
- Do NOT list what was done, recite tool calls, or include status reports. - Do NOT list what was done, recite tool calls, or include status reports.
- Match the dominant language of the conversation (English or Chinese). - Match the dominant language of the conversation (English or Chinese).
@ -30,7 +29,7 @@ Output format — strict:
- Put NOTHING outside the tags. No preamble, no reasoning, no closing remarks. - Put NOTHING outside the tags. No preamble, no reasoning, no closing remarks.
Example: 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>`; <recap>Debugging the auth retry race condition; next, add deterministic timing to the test.</recap>`;
const RECAP_USER_PROMPT = const RECAP_USER_PROMPT =
'Generate the recap now. Wrap it in <recap>...</recap>. Nothing outside the tags.'; 'Generate the recap now. Wrap it in <recap>...</recap>. Nothing outside the tags.';
@ -44,7 +43,7 @@ export interface SessionRecapResult {
} }
/** /**
* Generate a 1-3 sentence "where did I leave off" summary of the current * Generate a one-sentence "where did I leave off" summary of the current
* session. Uses the configured fast model (falls back to main model) with * session. Uses the configured fast model (falls back to main model) with
* tools disabled and a very small generation budget. * tools disabled and a very small generation budget.
* *

View file

@ -52,9 +52,9 @@
"default": true "default": true
}, },
"showSessionRecap": { "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.", "description": "Auto-show a one-line \"where you left off\" recap when returning to the terminal after being away for 5+ minutes. Off by default. Use /recap to trigger manually regardless of this setting.",
"type": "boolean", "type": "boolean",
"default": true "default": false
}, },
"gitCoAuthor": { "gitCoAuthor": {
"description": "Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.", "description": "Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.",