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

@ -329,9 +329,13 @@ const SETTINGS_SCHEMA = {
label: 'Show Session Recap',
category: 'General',
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:
'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,
},
gitCoAuthor: {

View file

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

View file

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

View file

@ -18,7 +18,7 @@ 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');
return t('Generate a one-line session recap now');
},
action: async (
context: CommandContext,
@ -65,7 +65,7 @@ export const recapCommand: SlashCommand = {
type: 'away_recap',
text: recap.text,
};
context.ui.addItem(item, Date.now());
context.ui.setAwayRecapItem(item);
return;
}

View file

@ -11,6 +11,7 @@ import type {
HistoryItemWithoutId,
HistoryItem,
HistoryItemBtw,
HistoryItemAwayRecap,
ConfirmationRequest,
} from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
@ -75,6 +76,10 @@ export interface CommandContext {
cancelBtw: () => void;
/** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */
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). */
isIdleRef: MutableRefObject<boolean>;
/**

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
import { useEffect, useRef } from 'react';
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;
@ -15,7 +15,7 @@ export interface UseAwaySummaryOptions {
config: Config | null;
isFocused: 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.
*/
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 recapPendingRef = useRef(false);
@ -78,7 +78,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
type: 'away_recap',
text: recap.text,
};
addItem(item, Date.now());
setAwayRecapItem(item);
})
.finally(() => {
if (inFlightRef.current === controller) {
@ -86,7 +86,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
}
recapPendingRef.current = false;
});
}, [enabled, config, isFocused, isIdle, addItem]);
}, [enabled, config, isFocused, isIdle, setAwayRecapItem]);
useEffect(
() => () => {

View file

@ -25,7 +25,13 @@ export interface UseResumeCommandResult {
isResumeDialogOpen: boolean;
openResumeDialog: () => 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(
@ -44,9 +50,9 @@ export function useResumeCommand(
const { config, historyManager, startNewSession, remount } = options ?? {};
const handleResume = useCallback(
async (sessionId: string) => {
async (sessionId: string): Promise<boolean> => {
if (!config || !historyManager || !startNewSession) {
return;
return false;
}
// Close dialog immediately to prevent input capture during async operations.
@ -57,7 +63,7 @@ export function useResumeCommand(
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return;
return false;
}
// Start new session in UI context.
@ -87,6 +93,7 @@ export function useResumeCommand(
// Refresh terminal UI.
remount?.();
return true;
},
[closeResumeDialog, config, historyManager, startNewSession, remount],
);

View file

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

View file

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

View file

@ -24,6 +24,8 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
setBtwItem: (_item) => {},
cancelBtw: () => {},
btwAbortControllerRef: { current: null },
awayRecapItem: null,
setAwayRecapItem: (_item) => {},
isIdleRef: { current: true },
toggleVimEnabled: async () => false,
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
* period of inactivity (or via /recap). Rendered in dim color so it is
* visually distinct from real assistant replies.
* period of inactivity (or via /recap). Rendered as a sticky banner above
* the input box (NOT part of the scrolling history), so it is intentionally
* excluded from the HistoryItemWithoutId union.
*/
export type HistoryItemAwayRecap = HistoryItemBase & {
type: 'away_recap';
@ -483,7 +484,6 @@ export type HistoryItemWithoutId =
| HistoryItemInsightProgress
| HistoryItemBtw
| HistoryItemMemorySaved
| HistoryItemAwayRecap
| HistoryItemUserPromptSubmitBlocked
| HistoryItemStopHookLoop
| HistoryItemStopHookSystemMessage