From fe23de6da73b1375ad74c0872967418c122bfd4d Mon Sep 17 00:00:00 2001 From: Gove2004 <3425519400@qq.com> Date: Tue, 12 May 2026 16:16:44 +0800 Subject: [PATCH 01/39] feat(cli): add --quiet-restore flag to suppress history output on session resume --- packages/cli/src/config/config.ts | 10 +++++++++ packages/cli/src/gemini.test.tsx | 1 + packages/cli/src/ui/AppContainer.tsx | 21 ++++++++++++++----- packages/cli/src/ui/hooks/useResumeCommand.ts | 18 +++++++++++++--- packages/core/src/config/config.ts | 12 +++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 84dd7d2e4c1..1b8c765b895 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -158,6 +158,8 @@ export interface CliArgs { continue: boolean | undefined; /** Resume a specific session by its ID */ resume: string | undefined; + /** Suppress printing historical messages when resuming a session */ + quietRestore: boolean | undefined; /** Specify a session ID without session resumption */ sessionId: string | undefined; /** @@ -807,6 +809,13 @@ export async function parseArguments(): Promise { description: 'Resume a specific session by its ID. Use without an ID to show session picker.', }) + .option('quiet-restore', { + type: 'boolean', + description: + 'When resuming a session, suppress printing historical messages to the terminal. ' + + 'Shows a brief summary instead. The conversation context is still fully loaded for the model.', + default: false, + }) .option('session-id', { type: 'string', description: 'Specify a session ID for this run.', @@ -1614,6 +1623,7 @@ export async function loadCliConfig( const configParams: ConfigParameters = { sessionId, sessionData, + quietRestore: argv.quietRestore ?? false, embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL, sandbox: sandboxConfig, targetDir: cwd, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 5aaa832fc85..965d4023362 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -761,6 +761,7 @@ describe('gemini.tsx main function kitty protocol', () => { includePartialMessages: undefined, continue: undefined, resume: undefined, + quietRestore: undefined, coreTools: undefined, excludeTools: undefined, disabledSlashCommands: undefined, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b970093f150..dc0a66e801d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -488,11 +488,22 @@ export const AppContainer = (props: AppContainerProps) => { const resumedSessionData = config.getResumedSessionData(); if (resumedSessionData) { - const historyItems = buildResumedHistoryItems( - resumedSessionData, - config, - ); - historyManager.loadHistory(historyItems); + if (config.isQuietRestore()) { + const messageCount = resumedSessionData.conversation.messages.length; + historyManager.addItem( + { + type: MessageType.INFO, + text: `Resumed session with ${messageCount} message${messageCount !== 1 ? 's' : ''}. History display suppressed (--quiet-restore).`, + }, + Date.now(), + ); + } else { + const historyItems = buildResumedHistoryItems( + resumedSessionData, + config, + ); + historyManager.loadHistory(historyItems); + } // Re-arm any `/goal` that was active when the prior session ended. try { diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index e52d000dde1..7d2064106cf 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -111,9 +111,21 @@ export function useResumeCommand( setSessionName?.(customTitle ?? null); // Reset UI history. - const uiHistoryItems = buildResumedHistoryItems(sessionData, config); - clearItems?.(); - loadHistory?.(uiHistoryItems); + if (config.isQuietRestore()) { + clearItems?.(); + const messageCount = sessionData.conversation.messages.length; + addItem?.( + { + type: MessageType.INFO, + text: `Resumed session with ${messageCount} message${messageCount !== 1 ? 's' : ''}. History display suppressed (--quiet-restore).`, + } as Omit, + Date.now(), + ); + } else { + const uiHistoryItems = buildResumedHistoryItems(sessionData, config); + clearItems?.(); + loadHistory?.(uiHistoryItems); + } // Update session history core. resetBackgroundStateForSessionSwitch(config); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 94e769a555a..19621928151 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -434,6 +434,12 @@ export interface AgentsCollabSettings { export interface ConfigParameters { sessionId?: string; sessionData?: ResumedSessionData; + /** + * If true, suppresses printing historical messages when resuming a session. + * A brief summary is shown instead. The conversation context is still + * fully loaded for the model. + */ + quietRestore?: boolean; embeddingModel?: string; sandbox?: SandboxConfig; targetDir: string; @@ -679,6 +685,7 @@ const DEFAULT_BARE_CORE_TOOLS = [ export class Config { private sessionId: string; private sessionData?: ResumedSessionData; + private quietRestore: boolean; private debugLogger: DebugLogger; private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; @@ -844,6 +851,7 @@ export class Config { constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); this.sessionData = params.sessionData; + this.quietRestore = params.quietRestore ?? false; setDebugLogSession(this); this.debugLogger = createDebugLogger(); this.embeddingModel = params.embeddingModel ?? DEFAULT_QWEN_EMBEDDING_MODEL; @@ -1813,6 +1821,10 @@ export class Config { return this.sessionData; } + isQuietRestore(): boolean { + return this.quietRestore; + } + shouldLoadMemoryFromIncludeDirectories(): boolean { return this.loadMemoryFromIncludeDirectories; } From 22e4ea76b4a0eaceb689a648eaf004111b424631 Mon Sep 17 00:00:00 2001 From: Gove2004 <3425519400@qq.com> Date: Tue, 12 May 2026 17:40:22 +0800 Subject: [PATCH 02/39] fix: preserve history state for /rewind while suppressing rendering --- packages/cli/src/ui/AppContainer.tsx | 21 +++++++++++++------ .../cli/src/ui/components/MainContent.tsx | 15 ++++++++----- packages/cli/src/ui/hooks/useResumeCommand.ts | 18 +++++++++++----- packages/cli/src/ui/types.ts | 2 ++ 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index dc0a66e801d..81ab6fa8f0c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -488,6 +488,21 @@ export const AppContainer = (props: AppContainerProps) => { const resumedSessionData = config.getResumedSessionData(); if (resumedSessionData) { + const historyItems = buildResumedHistoryItems( + resumedSessionData, + config, + ); + + if (config.isQuietRestore()) { + // Mark restored items as hidden so they are not rendered but + // remain in historyManager.history for /rewind turn mapping. + for (const item of historyItems) { + item.hidden = true; + } + } + + historyManager.loadHistory(historyItems); + if (config.isQuietRestore()) { const messageCount = resumedSessionData.conversation.messages.length; historyManager.addItem( @@ -497,12 +512,6 @@ export const AppContainer = (props: AppContainerProps) => { }, Date.now(), ); - } else { - const historyItems = buildResumedHistoryItems( - resumedSessionData, - config, - ); - historyManager.loadHistory(historyItems); } // Re-arm any `/goal` that was active when the prior session ended. diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 8281012ccb6..9e42564a29d 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -339,11 +339,16 @@ export const MainContent = () => { // because it is gone from pendingHistoryItems but not yet in the Static // slice. Chunked replay is still used for large remount gaps (Ctrl+O on a // long session) where the gap is >> CHUNK_SIZE. - const visibleHistoryItemsWithSourceCopyOffsets = - historyItemsWithSourceCopyOffsets.length - replayCount <= - PROGRESSIVE_REPLAY_CHUNK_SIZE - ? historyItemsWithSourceCopyOffsets - : historyItemsWithSourceCopyOffsets.slice(0, replayCount); + // Filter out hidden items (e.g. from --quiet-restore) so they are not + // rendered but remain in historyManager.history for /rewind turn mapping. + const visibleHistoryItemsWithSourceCopyOffsets = useMemo(() => { + const filtered = historyItemsWithSourceCopyOffsets.filter( + ({ item }) => !item.hidden, + ); + return filtered.length - replayCount <= PROGRESSIVE_REPLAY_CHUNK_SIZE + ? filtered + : filtered.slice(0, replayCount); + }, [historyItemsWithSourceCopyOffsets, replayCount]); return ( <> diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 7d2064106cf..8a0f76efd6c 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -111,8 +111,20 @@ export function useResumeCommand( setSessionName?.(customTitle ?? null); // Reset UI history. + const uiHistoryItems = buildResumedHistoryItems(sessionData, config); + + if (config.isQuietRestore()) { + // Mark restored items as hidden so they are not rendered but + // remain in historyManager.history for /rewind turn mapping. + for (const item of uiHistoryItems) { + item.hidden = true; + } + } + + clearItems?.(); + loadHistory?.(uiHistoryItems); + if (config.isQuietRestore()) { - clearItems?.(); const messageCount = sessionData.conversation.messages.length; addItem?.( { @@ -121,10 +133,6 @@ export function useResumeCommand( } as Omit, Date.now(), ); - } else { - const uiHistoryItems = buildResumedHistoryItems(sessionData, config); - clearItems?.(); - loadHistory?.(uiHistoryItems); } // Update session history core. diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index f040b3d2dc1..f08263a7f57 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -90,6 +90,8 @@ export interface SummaryProps { export interface HistoryItemBase { text?: string; // Text content for user/gemini/info/error messages + /** If true, the item is kept in history for turn mapping but not rendered. */ + hidden?: boolean; } export type HistoryItemUser = HistoryItemBase & { From fa68f3eecafddf25e09a5c8f0452dd58d1422b6e Mon Sep 17 00:00:00 2001 From: Gove2004 <3425519400@qq.com> Date: Tue, 12 May 2026 19:15:48 +0800 Subject: [PATCH 03/39] refactor: model quiet-restore as display policy with shared utilities --- packages/cli/src/ui/AppContainer.tsx | 33 +++++-------- .../cli/src/ui/components/MainContent.tsx | 49 ++++++++++--------- packages/cli/src/ui/hooks/useResumeCommand.ts | 29 +++++------ packages/cli/src/ui/types.ts | 10 +++- .../cli/src/ui/utils/resumeHistoryUtils.ts | 32 +++++++++++- 5 files changed, 89 insertions(+), 64 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 81ab6fa8f0c..2147cf6fa66 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -61,9 +61,11 @@ import { type WaitingToolCall, ToolNames, } from '@qwen-code/qwen-code-core'; -import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js'; -import { loadLowlight } from './utils/lowlightLoader.js'; -import { restoreGoalFromHistory } from './utils/restoreGoal.js'; +import { + buildResumedHistoryItems, + applyResumeDisplayPolicy, + createQuietRestoreSummaryItem, +} from './utils/resumeHistoryUtils.js'; import { getStickyTodos, getStickyTodoMaxVisibleItems, @@ -488,28 +490,17 @@ export const AppContainer = (props: AppContainerProps) => { const resumedSessionData = config.getResumedSessionData(); if (resumedSessionData) { - const historyItems = buildResumedHistoryItems( - resumedSessionData, - config, - ); - - if (config.isQuietRestore()) { - // Mark restored items as hidden so they are not rendered but - // remain in historyManager.history for /rewind turn mapping. - for (const item of historyItems) { - item.hidden = true; - } - } - + const rawItems = buildResumedHistoryItems(resumedSessionData, config); + const historyItems = applyResumeDisplayPolicy(rawItems, { + quietRestore: config.isQuietRestore(), + }); historyManager.loadHistory(historyItems); if (config.isQuietRestore()) { - const messageCount = resumedSessionData.conversation.messages.length; historyManager.addItem( - { - type: MessageType.INFO, - text: `Resumed session with ${messageCount} message${messageCount !== 1 ? 's' : ''}. History display suppressed (--quiet-restore).`, - }, + createQuietRestoreSummaryItem( + resumedSessionData.conversation.messages.length, + ), Date.now(), ); } diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 9e42564a29d..13890328356 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -156,6 +156,14 @@ export const MainContent = () => { ], ); + // Filter out items whose display is suppressed (e.g. --quiet-restore). + // Canonical consumers (/rewind, turn mapping) use the full historyManager.history; + // rendering consumers use this visible subset. + const visibleMergedHistory = useMemo( + () => mergedHistory.filter((item) => !item.display?.suppressOnRestore), + [mergedHistory], + ); + // Build a callId → summary lookup from `tool_use_summary` history items so // compact-mode tool groups can render a semantic label instead of a generic // "Tool × N" line. A summary is indexed under every callId it covers; when @@ -235,7 +243,7 @@ export const MainContent = () => { useMemo(() => { let runningOffsets = createEmptySourceCopyOffsets(); - const items = mergedHistory.map((item) => { + const items = visibleMergedHistory.map((item) => { if (item.type === 'gemini') { runningOffsets = createEmptySourceCopyOffsets(); const offsets = cloneSourceCopyOffsets(runningOffsets); @@ -260,7 +268,7 @@ export const MainContent = () => { historyItemsWithSourceCopyOffsets: items, pendingStartSourceCopyOffsets: cloneSourceCopyOffsets(runningOffsets), }; - }, [mergedHistory]); + }, [visibleMergedHistory]); const pendingHistoryItemsWithSourceCopyOffsets = useMemo(() => { let runningOffsets = cloneSourceCopyOffsets(pendingStartSourceCopyOffsets); @@ -289,18 +297,18 @@ export const MainContent = () => { // Progressive Static replay (issue #3899). `replayCount` is the number of // history items currently passed to . It catches up to - // mergedHistory.length either in one shot (small lag) or chunk-by-chunk + // visibleMergedHistory.length either in one shot (small lag) or chunk-by-chunk // through setImmediate (large lag, e.g., post-Ctrl+O remount of a 500-item // session). // - // Note: source-copy offsets are computed across the FULL mergedHistory + // Note: source-copy offsets are computed across the visible merged history // above so each code block keeps its stable copy index even when only a // prefix is visible; we slice the post-offset array here. const [replayCount, setReplayCount] = useState(() => - initialReplayCount(mergedHistory.length), + initialReplayCount(visibleMergedHistory.length), ); - const mergedLengthRef = useRef(mergedHistory.length); - mergedLengthRef.current = mergedHistory.length; + const visibleLengthRef = useRef(visibleMergedHistory.length); + visibleLengthRef.current = visibleMergedHistory.length; // The reset MUST happen during render (not in an effect): historyRemountKey // also drives the key below, and Ink remounts Static synchronously @@ -314,23 +322,23 @@ export const MainContent = () => { const [lastRemountKey, setLastRemountKey] = useState(historyRemountKey); if (lastRemountKey !== historyRemountKey) { setLastRemountKey(historyRemountKey); - setReplayCount(initialReplayCount(mergedLengthRef.current)); + setReplayCount(initialReplayCount(visibleLengthRef.current)); } useEffect(() => { - if (replayCount >= mergedHistory.length) return; - const remaining = mergedHistory.length - replayCount; + if (replayCount >= visibleMergedHistory.length) return; + const remaining = visibleMergedHistory.length - replayCount; if (remaining <= PROGRESSIVE_REPLAY_CHUNK_SIZE) { - setReplayCount(mergedHistory.length); + setReplayCount(visibleMergedHistory.length); return; } const handle = setImmediate(() => { setReplayCount((c) => - Math.min(c + PROGRESSIVE_REPLAY_CHUNK_SIZE, mergedLengthRef.current), + Math.min(c + PROGRESSIVE_REPLAY_CHUNK_SIZE, visibleLengthRef.current), ); }); return () => clearImmediate(handle); - }, [replayCount, mergedHistory.length]); + }, [replayCount, visibleMergedHistory.length]); // Render the full list when the tail gap is small (≤ CHUNK_SIZE). This // covers the normal append path: a pending item finalizes, replayCount is @@ -339,16 +347,11 @@ export const MainContent = () => { // because it is gone from pendingHistoryItems but not yet in the Static // slice. Chunked replay is still used for large remount gaps (Ctrl+O on a // long session) where the gap is >> CHUNK_SIZE. - // Filter out hidden items (e.g. from --quiet-restore) so they are not - // rendered but remain in historyManager.history for /rewind turn mapping. - const visibleHistoryItemsWithSourceCopyOffsets = useMemo(() => { - const filtered = historyItemsWithSourceCopyOffsets.filter( - ({ item }) => !item.hidden, - ); - return filtered.length - replayCount <= PROGRESSIVE_REPLAY_CHUNK_SIZE - ? filtered - : filtered.slice(0, replayCount); - }, [historyItemsWithSourceCopyOffsets, replayCount]); + const visibleHistoryItemsWithSourceCopyOffsets = + historyItemsWithSourceCopyOffsets.length - replayCount <= + PROGRESSIVE_REPLAY_CHUNK_SIZE + ? historyItemsWithSourceCopyOffsets + : historyItemsWithSourceCopyOffsets.slice(0, replayCount); return ( <> diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 8a0f76efd6c..9b576f94b18 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -10,8 +10,11 @@ import { type Config, type SessionListItem, } from '@qwen-code/qwen-code-core'; -import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; -import { restoreGoalFromHistory } from '../utils/restoreGoal.js'; +import { + buildResumedHistoryItems, + applyResumeDisplayPolicy, + createQuietRestoreSummaryItem, +} from '../utils/resumeHistoryUtils.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { MessageType, type HistoryItem } from '../types.js'; import { @@ -111,26 +114,18 @@ export function useResumeCommand( setSessionName?.(customTitle ?? null); // Reset UI history. - const uiHistoryItems = buildResumedHistoryItems(sessionData, config); - - if (config.isQuietRestore()) { - // Mark restored items as hidden so they are not rendered but - // remain in historyManager.history for /rewind turn mapping. - for (const item of uiHistoryItems) { - item.hidden = true; - } - } - + const rawItems = buildResumedHistoryItems(sessionData, config); + const uiHistoryItems = applyResumeDisplayPolicy(rawItems, { + quietRestore: config.isQuietRestore(), + }); clearItems?.(); loadHistory?.(uiHistoryItems); if (config.isQuietRestore()) { - const messageCount = sessionData.conversation.messages.length; addItem?.( - { - type: MessageType.INFO, - text: `Resumed session with ${messageCount} message${messageCount !== 1 ? 's' : ''}. History display suppressed (--quiet-restore).`, - } as Omit, + createQuietRestoreSummaryItem( + sessionData.conversation.messages.length, + ), Date.now(), ); } diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index f08263a7f57..9a28f55a092 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -90,8 +90,14 @@ export interface SummaryProps { export interface HistoryItemBase { text?: string; // Text content for user/gemini/info/error messages - /** If true, the item is kept in history for turn mapping but not rendered. */ - hidden?: boolean; + /** Display-only flags that do not affect canonical history semantics. */ + display?: { + /** + * If true, the item is kept in history for turn mapping but not + * rendered in the restored transcript. Set by --quiet-restore. + */ + suppressOnRestore?: boolean; + }; } export type HistoryItemUser = HistoryItemBase & { diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 2de73a5b163..c0c924d9819 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -20,7 +20,7 @@ import type { HistoryItemWithoutId, IndividualToolCallDisplay, } from '../types.js'; -import { ToolCallStatus } from '../types.js'; +import { ToolCallStatus, MessageType } from '../types.js'; /** * Extracts text content from a Content object's parts (excluding thought parts). @@ -484,3 +484,33 @@ export function buildResumedHistoryItems( return items; } + +/** + * Applies the quiet-restore display policy to resumed history items. + * When `quietRestore` is true, marks each item with + * `display.suppressOnRestore` so the rendering layer skips them while + * the canonical history (used by /rewind turn mapping) is preserved. + */ +export function applyResumeDisplayPolicy( + items: HistoryItem[], + options: { quietRestore?: boolean }, +): HistoryItem[] { + if (!options.quietRestore) return items; + return items.map((item) => ({ + ...item, + display: { ...item.display, suppressOnRestore: true }, + })); +} + +/** + * Creates the summary INFO item shown when --quiet-restore suppresses + * the restored transcript. + */ +export function createQuietRestoreSummaryItem( + messageCount: number, +): Omit { + return { + type: MessageType.INFO, + text: `Resumed session with ${messageCount} message${messageCount !== 1 ? 's' : ''}. History display suppressed (--quiet-restore).`, + }; +} From a731631c8851e374bab36b4f73a718d7000f8aca Mon Sep 17 00:00:00 2001 From: Gove2004 <3425519400@qq.com> Date: Tue, 12 May 2026 21:30:32 +0800 Subject: [PATCH 04/39] refactor: replace --quiet-restore with /history collapse|expand slash command --- packages/cli/src/config/config.ts | 10 -- packages/cli/src/gemini.test.tsx | 1 - .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/test-utils/mockCommandContext.ts | 2 + packages/cli/src/ui/AppContainer.tsx | 31 +---- .../cli/src/ui/commands/historyCommand.ts | 117 ++++++++++++++++++ packages/cli/src/ui/commands/types.ts | 4 + .../cli/src/ui/components/MainContent.tsx | 40 +++--- .../ui/hooks/slashCommandProcessor.test.ts | 5 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 + packages/cli/src/ui/hooks/useResumeCommand.ts | 20 +-- .../src/ui/noninteractive/nonInteractiveUi.ts | 2 + .../cli/src/ui/utils/resumeHistoryUtils.ts | 25 +--- packages/core/src/config/config.ts | 12 -- 14 files changed, 168 insertions(+), 107 deletions(-) create mode 100644 packages/cli/src/ui/commands/historyCommand.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 1b8c765b895..84dd7d2e4c1 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -158,8 +158,6 @@ export interface CliArgs { continue: boolean | undefined; /** Resume a specific session by its ID */ resume: string | undefined; - /** Suppress printing historical messages when resuming a session */ - quietRestore: boolean | undefined; /** Specify a session ID without session resumption */ sessionId: string | undefined; /** @@ -809,13 +807,6 @@ export async function parseArguments(): Promise { description: 'Resume a specific session by its ID. Use without an ID to show session picker.', }) - .option('quiet-restore', { - type: 'boolean', - description: - 'When resuming a session, suppress printing historical messages to the terminal. ' + - 'Shows a brief summary instead. The conversation context is still fully loaded for the model.', - default: false, - }) .option('session-id', { type: 'string', description: 'Specify a session ID for this run.', @@ -1623,7 +1614,6 @@ export async function loadCliConfig( const configParams: ConfigParameters = { sessionId, sessionData, - quietRestore: argv.quietRestore ?? false, embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL, sandbox: sandboxConfig, targetDir: cwd, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 965d4023362..5aaa832fc85 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -761,7 +761,6 @@ describe('gemini.tsx main function kitty protocol', () => { includePartialMessages: undefined, continue: undefined, resume: undefined, - quietRestore: undefined, coreTools: undefined, excludeTools: undefined, disabledSlashCommands: undefined, diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 8d5593fd9d6..0bad59a7912 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -30,6 +30,7 @@ import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { goalCommand } from '../ui/commands/goalCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { historyCommand } from '../ui/commands/historyCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; @@ -117,6 +118,7 @@ export class BuiltinCommandLoader implements ICommandLoader { exportCommand, extensionsCommand, helpCommand, + historyCommand, hooksCommand, resolvedIdeCommand, initCommand, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index a63ebb48479..0912b04c6e5 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -51,6 +51,7 @@ export const createMockCommandContext = ( } as any, // Cast because Logger is a class. }, ui: { + history: [], addItem: vi.fn(), clear: vi.fn(), setDebugMessage: vi.fn(), @@ -62,6 +63,7 @@ export const createMockCommandContext = ( btwAbortControllerRef: { current: null }, isIdleRef: { current: true }, loadHistory: vi.fn(), + refreshStatic: vi.fn(), toggleVimEnabled: vi.fn(), extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2147cf6fa66..cc4685889d3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -61,11 +61,7 @@ import { type WaitingToolCall, ToolNames, } from '@qwen-code/qwen-code-core'; -import { - buildResumedHistoryItems, - applyResumeDisplayPolicy, - createQuietRestoreSummaryItem, -} from './utils/resumeHistoryUtils.js'; +import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js'; import { getStickyTodos, getStickyTodoMaxVisibleItems, @@ -490,28 +486,12 @@ export const AppContainer = (props: AppContainerProps) => { const resumedSessionData = config.getResumedSessionData(); if (resumedSessionData) { - const rawItems = buildResumedHistoryItems(resumedSessionData, config); - const historyItems = applyResumeDisplayPolicy(rawItems, { - quietRestore: config.isQuietRestore(), - }); + const historyItems = buildResumedHistoryItems( + resumedSessionData, + config, + ); historyManager.loadHistory(historyItems); - if (config.isQuietRestore()) { - historyManager.addItem( - createQuietRestoreSummaryItem( - resumedSessionData.conversation.messages.length, - ), - Date.now(), - ); - } - - // Re-arm any `/goal` that was active when the prior session ended. - try { - restoreGoalFromHistory(historyItems, config, historyManager.addItem); - } catch { - // Restore is best-effort — never block resume on it. - } - const recovered = await config.loadPausedBackgroundAgents( config.getSessionId(), ); @@ -1077,6 +1057,7 @@ export const AppContainer = (props: AppContainerProps) => { } = useSlashCommandProcessor( config, settings, + historyManager.history, historyManager.addItem, historyManager.clearItems, historyManager.loadHistory, diff --git a/packages/cli/src/ui/commands/historyCommand.ts b/packages/cli/src/ui/commands/historyCommand.ts new file mode 100644 index 00000000000..7c879c89b13 --- /dev/null +++ b/packages/cli/src/ui/commands/historyCommand.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, MessageActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import { t } from '../../i18n/index.js'; +import { createHistoryCollapseSummaryItem } from '../utils/resumeHistoryUtils.js'; + +const collapseCommand: SlashCommand = { + name: 'collapse', + get description() { + return t('Collapse the transcript into a summary row'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + action: (context): MessageActionReturn | void => { + const { history, loadHistory, addItem, refreshStatic } = context.ui; + + // Count items that are not already suppressed and are NOT collapse summaries. + const visibleCount = history.filter( + (item) => + !item.display?.suppressOnRestore && + !( + item.type === MessageType.INFO && + item.text?.startsWith('History collapsed:') + ), + ).length; + + if (visibleCount === 0) { + return { + type: 'message', + messageType: 'info', + content: t('History is already collapsed.'), + }; + } + + // Mark all items as suppressed. + const updated = history.map((item) => ({ + ...item, + display: { ...item.display, suppressOnRestore: true }, + })); + loadHistory(updated); + + // Add summary item. + addItem(createHistoryCollapseSummaryItem(visibleCount), Date.now()); + refreshStatic(); + }, +}; + +const expandCommand: SlashCommand = { + name: 'expand', + get description() { + return t('Expand the full transcript'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + action: (context): MessageActionReturn | void => { + const { history, loadHistory, refreshStatic } = context.ui; + + const hasSuppressed = history.some( + (item) => item.display?.suppressOnRestore, + ); + + if (!hasSuppressed) { + return { + type: 'message', + messageType: 'info', + content: t('History is already expanded.'), + }; + } + + // Remove suppressOnRestore from all items and drop collapse summary items. + const updated = history + .filter( + (item) => + !( + item.type === MessageType.INFO && + item.text?.startsWith('History collapsed:') + ), + ) + .map((item) => ({ + ...item, + display: { ...item.display, suppressOnRestore: false }, + })); + loadHistory(updated); + refreshStatic(); + }, +}; + +export const historyCommand: SlashCommand = { + name: 'history', + get description() { + return t('Control history display (collapse/expand)'); + }, + argumentHint: 'collapse|expand', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + subCommands: [collapseCommand, expandCommand], + action: async (context, args) => { + const sub = args.trim().toLowerCase(); + if (sub === 'collapse') { + return collapseCommand.action?.(context, args); + } + if (sub === 'expand') { + return expandCommand.action?.(context, args); + } + return { + type: 'message', + messageType: 'error', + content: t('Usage: /history collapse|expand'), + }; + }, +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index ee2c1836efd..ebf7620e08a 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -55,6 +55,8 @@ export interface CommandContext { }; // UI state and history management ui: { + /** The current history items. */ + history: HistoryItem[]; /** Adds a new item to the history display. */ addItem: UseHistoryManagerReturn['addItem']; /** Clears all history items and the console screen. */ @@ -88,6 +90,8 @@ export interface CommandContext { * @param history The array of history items to load. */ loadHistory: UseHistoryManagerReturn['loadHistory']; + /** Refreshes the static history display in Ink. */ + refreshStatic: () => void; toggleVimEnabled: () => Promise; setGeminiMdFileCount: (count: number) => void; reloadCommands: () => void | Promise; diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 13890328356..16bd3a28de8 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -96,10 +96,18 @@ export const MainContent = () => { historyRemountKey, } = uiState; + // Filter out items whose display is suppressed (e.g. /history collapse). + // Canonical consumers (/rewind, turn mapping) use the full historyManager.history; + // rendering consumers use this visible subset. + const visibleHistory = useMemo( + () => uiState.history.filter((item) => !item.display?.suppressOnRestore), + [uiState.history], + ); + // Set of callIds whose label is absorbed by a compact-mode tool_group header. - // Computed from RAW history (not merged) — force-expand status depends only + // Computed from visible history (not merged) — force-expand status depends only // on the tool_group's own state, and mergeable groups don't change force- - // expand status when merged. Iterating raw history avoids a circular + // expand status when merged. Iterating visible history avoids a circular // dependency with mergedHistory (which receives absorbedCallIds). // // In compact mode, non-force-expanded tool_groups render via @@ -112,7 +120,7 @@ export const MainContent = () => { const absorbedCallIds = useMemo(() => { const absorbed = new Set(); if (!compactMode) return absorbed; - for (const item of uiState.history) { + for (const item of visibleHistory) { if (item.type !== 'tool_group') continue; if ( isForceExpandGroup( @@ -128,7 +136,7 @@ export const MainContent = () => { return absorbed; }, [ compactMode, - uiState.history, + visibleHistory, uiState.embeddedShellFocused, uiState.activePtyId, ]); @@ -137,33 +145,25 @@ export const MainContent = () => { // absorbed call IDs are dropped during merge so refreshStatic fires; // summaries for force-expanded (non-absorbed) groups pass through so // HistoryItemDisplay can render them as standalone `●