diff --git a/docs/design/session-recap/session-recap-design.md b/docs/design/session-recap/session-recap-design.md index 00c024d1b..d3fe6fde9 100644 --- a/docs/design/session-recap/session-recap-design.md +++ b/docs/design/session-recap/session-recap-design.md @@ -1,6 +1,6 @@ # 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 > 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 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: - **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 guarantee identical behavior. The auto-trigger is gated by -`general.showSessionRecap` (default: on); the manual command ignores -that setting. +`general.showSessionRecap` (default: off — explicit opt-in, so ambient +LLM calls are never silently added to a user's bill); the manual +command ignores that setting. ## Architecture @@ -39,8 +40,8 @@ that setting. │ isFocused = useFocus() │ │ isIdle = streamingState === Idle │ │ │ │ -│ ├─→ useAwaySummary({enabled, config, isFocused, isIdle, addItem})│ -│ │ │ │ +│ ├─→ useAwaySummary({enabled, config, isFocused, isIdle, │ +│ │ │ setAwayRecapItem}) │ │ │ └─→ 5 min blur timer + idle/dedupe gates │ │ │ │ │ │ │ ↓ │ @@ -56,9 +57,10 @@ that setting. │ GeminiClient.generateContent │ │ (fastModel + tools:[]) │ │ │ -│ addItem({type: 'away_recap', text}) ─→ HistoryItemDisplay │ -│ └─ AwayRecapMessage │ -│ (dim color + ❯ prefix) │ +│ setAwayRecapItem({type: 'away_recap', text}) │ +│ └─→ DefaultAppLayout renders AwayRecapMessage │ +│ 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/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/components/messages/StatusMessages.tsx` | `AwayRecapMessage` dim renderer (`※ recap:` prefix) | | `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 | ## Prompt Design @@ -90,7 +93,7 @@ recap, not a leak. 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. - Explicitly forbid: listing what was done, reciting tool calls, status reports. - 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 -| 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 | +| Parameter | Value | Reason | +| ------------------- | ------------------------------ | ----------------------------------------------------- | +| `model` | `getFastModel() ?? getModel()` | Recap doesn't need a frontier model | +| `tools` | `[]` | One-shot query, no tool use | +| `maxOutputTokens` | `300` | Headroom for one short sentence + tags | +| `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 @@ -167,7 +170,7 @@ response. | `recapPendingRef` | Whether an LLM call is in flight | | `inFlightRef` | The current in-flight `AbortController` | -`useEffect` deps: `[enabled, config, isFocused, isIdle, addItem]`. +`useEffect` deps: `[enabled, config, isFocused, isIdle, setAwayRecapItem]`. | Event | Action | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | @@ -204,7 +207,7 @@ and a null `pendingItem`. | 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. | ### Model fallback diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index b43df951a..449974285 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -77,15 +77,15 @@ Settings are organized into categories. All settings should be placed within the #### general -| Setting | Type | Description | Default | -| ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `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"` | +| Setting | Type | Description | Default | +| ------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `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 | 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.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"` | #### output diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 1f5e7c867..5ba31859c 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -24,7 +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` | +| `/recap` | Generate a one-line session recap now | `/recap` | | `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore ` | ### 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 back through pages of history. -| Command | Description | -| -------- | ------------------------------------------------ | -| `/recap` | Generate and show a 1-3 sentence session summary | +| Command | Description | +| -------- | ------------------------------------------ | +| `/recap` | Generate and show a one-line session recap | **How it works:** diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0feddd5b6..ba9bd4869 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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: { diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 3ea3e65b8..2abf7be99 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -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(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index adf2b48e3..96531d179 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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 => { + 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, diff --git a/packages/cli/src/ui/commands/recapCommand.ts b/packages/cli/src/ui/commands/recapCommand.ts index 6a239c4d7..0380919c2 100644 --- a/packages/cli/src/ui/commands/recapCommand.ts +++ b/packages/cli/src/ui/commands/recapCommand.ts @@ -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; } diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index f851857c9..aa5065be9 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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; + /** 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; /** diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index a37544db0..f9ea0afba 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -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 = ({ {itemForDisplay.type === 'memory_saved' && ( )} - {itemForDisplay.type === 'away_recap' && ( - - )} ); }; diff --git a/packages/cli/src/ui/components/messages/StatusMessages.tsx b/packages/cli/src/ui/components/messages/StatusMessages.tsx index 147b1bf9b..e4be0b79c 100644 --- a/packages/cli/src/ui/components/messages/StatusMessages.tsx +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -128,7 +128,7 @@ export const RetryCountdownMessage: React.FC = ({ text }) => ( export const AwayRecapMessage: React.FC = ({ text }) => ( diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a06007416..7922723b4 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -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; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 4ccba4192..e7fc91a0b 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -31,6 +31,7 @@ import type { Message, HistoryItemWithoutId, HistoryItemBtw, + HistoryItemAwayRecap, SlashCommandProcessorResult, HistoryItem, ConfirmationRequest, @@ -155,6 +156,9 @@ export const useSlashCommandProcessor = ( const [btwItem, setBtwItem] = useState(null); const btwAbortControllerRef = useRef(null); + const [awayRecapItem, setAwayRecapItem] = + useState(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, diff --git a/packages/cli/src/ui/hooks/useAwaySummary.ts b/packages/cli/src/ui/hooks/useAwaySummary.ts index 693bb1969..ff58b365a 100644 --- a/packages/cli/src/ui/hooks/useAwaySummary.ts +++ b/packages/cli/src/ui/hooks/useAwaySummary.ts @@ -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(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( () => () => { diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 04edc21ea..3b25e0054 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -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; } 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 => { 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], ); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 88efdbefd..918090828 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -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 = () => { ) : ( <> + {uiState.awayRecapItem && ( + + + + )} {uiState.btwItem && ( { @@ -35,6 +36,11 @@ export const ScreenReaderAppLayout: React.FC = () => { ) : ( <> + {uiState.awayRecapItem && ( + + + + )} {uiState.btwItem && ( {}, cancelBtw: () => {}, btwAbortControllerRef: { current: null }, + awayRecapItem: null, + setAwayRecapItem: (_item) => {}, isIdleRef: { current: true }, toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index b89d9d33b..21c65bb22 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -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 diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 1af663a51..505880c07 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { setMaxListeners } from 'node:events'; import type OpenAI from 'openai'; import { type GenerateContentParameters, @@ -15,6 +16,23 @@ import type { OpenAICompatibleProvider } from './provider/index.js'; import { OpenAIContentConverter } from './converter.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 * 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; this.converter.setModel(effectiveModel); this.converter.setModalities(this.contentGeneratorConfig.modalities ?? {}); + raiseAbortListenerCap(request.config?.abortSignal); return this.executeWithErrorHandling( request, userPromptId, @@ -87,6 +106,7 @@ export class ContentGenerationPipeline { const effectiveModel = request.model || this.contentGeneratorConfig.model; this.converter.setModel(effectiveModel); this.converter.setModalities(this.contentGeneratorConfig.modalities ?? {}); + raiseAbortListenerCap(request.config?.abortSignal); return this.executeWithErrorHandling( request, userPromptId, diff --git a/packages/core/src/services/sessionRecap.ts b/packages/core/src/services/sessionRecap.ts index 64e62c651..be1554749 100644 --- a/packages/core/src/services/sessionRecap.ts +++ b/packages/core/src/services/sessionRecap.ts @@ -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. 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. +- Exactly ONE sentence. Hard cap: 80 characters. Plain prose, no bullets, no headings, no markdown. +- Combine the high-level task and the concrete next step into a single sentence. - Do NOT list what was done, recite tool calls, or include status reports. - 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. Example: -Investigating intermittent CI failures in the auth retry logic. The next step is to add deterministic timing to the integration test so the race condition reproduces locally.`; +Debugging the auth retry race condition; next, add deterministic timing to the test.`; const RECAP_USER_PROMPT = 'Generate the recap now. Wrap it in .... 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 * tools disabled and a very small generation budget. * diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index dd414ee71..53ebea1ff 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -52,9 +52,9 @@ "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.", + "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", - "default": true + "default": false }, "gitCoAuthor": { "description": "Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.",