diff --git a/.gitignore b/.gitignore index 97e8d466cf..2240537996 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ tmp/ # code graph skills .venv .codegraph +.qwen/computer-use/installed.json diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 05eda0d5cd..f72e161dfb 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -111,6 +111,7 @@ Settings are organized into categories. Most settings should be placed within th | `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | | `ui.renderMode` | string | Default Markdown display mode. Use `"render"` for rich visual previews or `"raw"` to show source-oriented Markdown by default. Toggle during a session with `Alt/Option+M`; on macOS the terminal must send Option as Meta. See [Markdown Rendering](../features/markdown-rendering). | `"render"` | | `ui.showCitations` | boolean | Show citations for generated text in the chat. | `false` | +| `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` | | `ui.compactMode` | boolean | Hide tool output and thinking for a cleaner view. Toggle with `Ctrl+O` during a session or via the Settings dialog. Tool approval prompts are never hidden, even in compact mode. The setting persists across sessions. | `false` | | `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` | | `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 90a764e31e..c893f6caa6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -712,6 +712,27 @@ const SETTINGS_SCHEMA = { description: 'Hide helpful tips in the UI', showInDialog: true, }, + history: { + type: 'object', + label: 'History', + category: 'UI', + requiresRestart: false, + default: {}, + description: 'History display settings.', + showInDialog: false, + properties: { + collapseOnResume: { + type: 'boolean', + label: 'Collapse On Resume', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Whether to collapse history by default when resuming a session.', + showInDialog: false, + }, + }, + }, showLineNumbers: { type: 'boolean', label: 'Show Line Numbers in Code', diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 5252645288..331144df28 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2035,4 +2035,24 @@ export default { in: 'ent.', out: 'sort.', 'In/Out': 'Ent/Sort', + + // === History collapse/expand commands === + 'Set history to collapse by default when resuming a session': + 'Establir la història per reduir-se per defecte en reprendre una sessió', + 'Set history to expand by default when resuming a session': + "Establir la història per expandir-se per defecte en reprendre una sessió", + 'Expand the currently collapsed history transcript': + 'Expandir la transcripció de la història actualment reduïda', + 'Control history display preferences and visibility': + 'Controlar les preferències de visualització de la història i la visibilitat', + 'History will be collapsed by default for future resumed sessions.': + 'La història es reduirà per defecte per a futures sessions represes.', + 'History will be expanded by default for future resumed sessions.': + "La història s'expandirà per defecte per a futures sessions represes.", + 'History is already expanded in this session.': + 'La història ja està expandida en aquesta sessió.', + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now': + 'Ús: /history collapse-on-resume|expand-on-resume|expand-now', + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': + 'Història reduïda: {{n}} missatges ocults. Utilitzeu /history expand-now per mostrar.', }; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index b205f4bae7..bd28162d7a 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -2035,6 +2035,26 @@ export default { 'Weitere Dream-Läufe können als gesperrt übersprungen werden, bis der nächste Stale-Sweep der Sitzung die Datei bereinigt.', "The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.": 'Das Scheduler-Gate hat den Zeitstempel dieses Dream-Laufs nicht gesehen; der nächste Dream-Zyklus kann früher als üblich erneut starten.', + // === History collapse/expand commands === + 'Set history to collapse by default when resuming a session': + 'Set history to collapse by default when resuming a session', + 'Set history to expand by default when resuming a session': + 'Set history to expand by default when resuming a session', + 'Expand the currently collapsed history transcript': + 'Expand the currently collapsed history transcript', + 'Control history display preferences and visibility': + 'Control history display preferences and visibility', + 'History will be collapsed by default for future resumed sessions.': + 'History will be collapsed by default for future resumed sessions.', + 'History will be expanded by default for future resumed sessions.': + 'History will be expanded by default for future resumed sessions.', + 'History is already expanded in this session.': + 'History is already expanded in this session.', + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now': + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now', + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': + 'Geschichte eingeklappt: {{n}} Nachrichten ausgeblendet. Verwenden Sie /history expand-now zum Anzeigen.', + // === Same-as-English optimization === 'Agents:': 'Agenten:', Prompt: 'Eingabe', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index dd69fdfa12..317752051e 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1004,6 +1004,26 @@ export default { 'Terminal "{{terminal}}" is not supported yet.': 'Terminal "{{terminal}}" is not supported yet.', + // === History collapse/expand commands === + 'Set history to collapse by default when resuming a session': + 'Set history to collapse by default when resuming a session', + 'Set history to expand by default when resuming a session': + 'Set history to expand by default when resuming a session', + 'Expand the currently collapsed history transcript': + 'Expand the currently collapsed history transcript', + 'Control history display preferences and visibility': + 'Control history display preferences and visibility', + 'History will be collapsed by default for future resumed sessions.': + 'History will be collapsed by default for future resumed sessions.', + 'History will be expanded by default for future resumed sessions.': + 'History will be expanded by default for future resumed sessions.', + 'History is already expanded in this session.': + 'History is already expanded in this session.', + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now': + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now', + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.', + // ============================================================================ // Commands - Language // ============================================================================ diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 737a247388..49e2ba81da 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2034,6 +2034,26 @@ export default { '% context used': '% de contexte utilisé', 'Context exceeds limit! Use /compress or /clear to reduce.': 'Le contexte dépasse la limite ! Utilisez /compress ou /clear pour le réduire.', + // === History collapse/expand commands === + 'Set history to collapse by default when resuming a session': + 'Set history to collapse by default when resuming a session', + 'Set history to expand by default when resuming a session': + 'Set history to expand by default when resuming a session', + 'Expand the currently collapsed history transcript': + 'Expand the currently collapsed history transcript', + 'Control history display preferences and visibility': + 'Control history display preferences and visibility', + 'History will be collapsed by default for future resumed sessions.': + 'History will be collapsed by default for future resumed sessions.', + 'History will be expanded by default for future resumed sessions.': + 'History will be expanded by default for future resumed sessions.', + 'History is already expanded in this session.': + 'History is already expanded in this session.', + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now': + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now', + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': + 'Historique réduit : {{n}} messages masqués. Utilisez /history expand-now pour afficher.', + // === Same-as-English optimization === Auth: 'Authentification', Auto: 'Automatique', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index cd9e3bcbf4..b8dd0e1d5c 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1799,6 +1799,26 @@ export default { '次回のセッション期限切れクリーンアップでファイルが削除されるまで、以降の dream はロック中としてスキップされる可能性があります。', "The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.": 'スケジューラーゲートがこの dream のタイムスタンプを認識しませんでした。次の dream サイクルは通常より早く再実行される可能性があります。', + // === History collapse/expand commands === + 'Set history to collapse by default when resuming a session': + 'Set history to collapse by default when resuming a session', + 'Set history to expand by default when resuming a session': + 'Set history to expand by default when resuming a session', + 'Expand the currently collapsed history transcript': + 'Expand the currently collapsed history transcript', + 'Control history display preferences and visibility': + 'Control history display preferences and visibility', + 'History will be collapsed by default for future resumed sessions.': + 'History will be collapsed by default for future resumed sessions.', + 'History will be expanded by default for future resumed sessions.': + 'History will be expanded by default for future resumed sessions.', + 'History is already expanded in this session.': + 'History is already expanded in this session.', + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now': + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now', + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': + '履歴を折りたたみました:{{n}} 件のメッセージが非表示です。/history expand-now で表示します。', + // === Same-as-English optimization === ' (not in model registry)': '(モデルレジストリにありません)', 'Attribution: commit': 'コミットの帰属表示', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index e74043465e..9e0a44f35a 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -2021,6 +2021,26 @@ export default { 'Dreams posteriores podem ser ignorados como bloqueados até que a próxima varredura de sessões obsoletas limpe o arquivo.', "The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.": 'O gate do agendador não viu o timestamp deste dream; o próximo ciclo de dream pode disparar novamente antes do normal.', + // === History collapse/expand commands === + 'Set history to collapse by default when resuming a session': + 'Set history to collapse by default when resuming a session', + 'Set history to expand by default when resuming a session': + 'Set history to expand by default when resuming a session', + 'Expand the currently collapsed history transcript': + 'Expand the currently collapsed history transcript', + 'Control history display preferences and visibility': + 'Control history display preferences and visibility', + 'History will be collapsed by default for future resumed sessions.': + 'History will be collapsed by default for future resumed sessions.', + 'History will be expanded by default for future resumed sessions.': + 'History will be expanded by default for future resumed sessions.', + 'History is already expanded in this session.': + 'History is already expanded in this session.', + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now': + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now', + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': + 'Histórico recolhido: {{n}} mensagens ocultas. Use /history expand-now para mostrar.', + // === Same-as-English optimization === '(workspace)': '(espaço de trabalho)', 'Ref:': 'Referência:', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 97d40499d3..4aa3704a2a 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -2012,6 +2012,26 @@ export default { 'Последующие dream-запуски могут пропускаться как заблокированные, пока следующая очистка устаревших сессий не удалит файл.', "The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.": 'Планировщик не увидел временную метку этого dream-запуска; следующий цикл dream может запуститься раньше обычного.', + // === History collapse/expand commands === + 'Set history to collapse by default when resuming a session': + 'Set history to collapse by default when resuming a session', + 'Set history to expand by default when resuming a session': + 'Set history to expand by default when resuming a session', + 'Expand the currently collapsed history transcript': + 'Expand the currently collapsed history transcript', + 'Control history display preferences and visibility': + 'Control history display preferences and visibility', + 'History will be collapsed by default for future resumed sessions.': + 'History will be collapsed by default for future resumed sessions.', + 'History will be expanded by default for future resumed sessions.': + 'History will be expanded by default for future resumed sessions.', + 'History is already expanded in this session.': + 'History is already expanded in this session.', + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now': + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now', + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': + 'История свёрнута: {{n}} сообщений скрыто. Используйте /history expand-now для отображения.', + // === Same-as-English optimization === ' (not in model registry)': ' (не в реестре моделей)', 'start server': 'запустить сервер', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 1a1835be6c..1e91b1528c 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1815,6 +1815,24 @@ export default { '\u2191 tabs \u00B7 r to cycle dates \u00B7 esc to close': '\u2191 tab 切換標籤 \u00B7 r 切換時間範圍 \u00B7 esc 關閉', + // === History collapse/expand commands === + 'Set history to collapse by default when resuming a session': + '恢復會話時預設摺疊歷史記錄', + 'Set history to expand by default when resuming a session': + '恢復會話時預設展開歷史記錄', + 'Expand the currently collapsed history transcript': '展開當前摺疊的歷史記錄', + 'Control history display preferences and visibility': + '控制歷史記錄顯示偏好和可見性', + 'History will be collapsed by default for future resumed sessions.': + '未來恢復的會話將預設摺疊歷史記錄。', + 'History will be expanded by default for future resumed sessions.': + '未來恢復的會話將預設展開歷史記錄。', + 'History is already expanded in this session.': '當前會話的歷史記錄已展開。', + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now': + '用法:/history collapse-on-resume|expand-on-resume|expand-now', + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': + '歷史記錄已摺疊:{{n}} 條訊息已隱藏。使用 /history expand-now 展開。', + // === Same-as-English optimization === ' (not in model registry)': '(不在模型註冊表中)', 'start server': '啟動伺服器', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 5b424496d7..c9ff0db048 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2014,6 +2014,24 @@ export default { in: '输入', out: '输出', + // === History collapse/expand commands === + 'Set history to collapse by default when resuming a session': + '恢复会话时默认折叠历史记录', + 'Set history to expand by default when resuming a session': + '恢复会话时默认展开历史记录', + 'Expand the currently collapsed history transcript': '展开当前折叠的历史记录', + 'Control history display preferences and visibility': + '控制历史记录显示偏好和可见性', + 'History will be collapsed by default for future resumed sessions.': + '未来恢复的会话将默认折叠历史记录。', + 'History will be expanded by default for future resumed sessions.': + '未来恢复的会话将默认展开历史记录。', + 'History is already expanded in this session.': '当前会话的历史记录已展开。', + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now': + '用法:/history collapse-on-resume|expand-on-resume|expand-now', + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.': + '历史记录已折叠:{{n}} 条消息已隐藏。使用 /history expand-now 展开。', + // === Same-as-English optimization === ' (not in model registry)': '(不在模型注册表中)', 'start server': '启动服务器', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 311b66638a..5d23b38cd6 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -34,6 +34,7 @@ import { forkCommand } from '../ui/commands/forkCommand.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 { importConfigCommand } from '../ui/commands/importConfigCommand.js'; @@ -130,6 +131,7 @@ export class BuiltinCommandLoader implements ICommandLoader { exportCommand, extensionsCommand, helpCommand, + historyCommand, hooksCommand, resolvedIdeCommand, importConfigCommand, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index b2afd4cae8..9c17c69726 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -49,6 +49,7 @@ export const createMockCommandContext = ( } as any, // Cast because Logger is a class. }, ui: { + history: [], addItem: vi.fn(), clear: vi.fn(), setDebugMessage: vi.fn(), @@ -60,6 +61,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.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 02a9250614..821bc39a0a 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -340,7 +340,10 @@ describe('AppContainer State Management', () => { vimEnabled: false, vimMode: 'NORMAL', }); - mockedUseSessionStats.mockReturnValue({ stats: {} }); + mockedUseSessionStats.mockReturnValue({ + stats: {}, + seedPromptCount: vi.fn(), + }); mockedUseTextBuffer.mockReturnValue({ text: '', setText: vi.fn(), @@ -2843,6 +2846,105 @@ describe('AppContainer State Management', () => { expect(lastCall[2]).toBe(1); }); + it('loads a collapsed summary into history on cold-boot resume when collapseOnResume is enabled', async () => { + const historyManager = { + history: [] as HistoryItem[], + addItem: vi.fn(), + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn((items: HistoryItem[]) => { + historyManager.history = items; + }), + truncateToItem: vi.fn(), + }; + mockedUseHistory.mockReturnValue(historyManager); + + const resumeSessionData = { + conversation: { + sessionId: 'session-1', + projectHash: 'test-project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-01T00:00:01Z', + messages: [ + { + uuid: 'u1', + parentUuid: null, + sessionId: 'session-1', + timestamp: '2024-01-01T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + cwd: '/test/workspace', + version: '1.0.0', + }, + { + uuid: 'a1', + parentUuid: 'u1', + sessionId: 'session-1', + timestamp: '2024-01-01T00:00:01Z', + type: 'assistant', + message: { role: 'model', parts: [{ text: 'world' }] }, + cwd: '/test/workspace', + version: '1.0.0', + }, + ], + }, + filePath: '/tmp/session.jsonl', + lastCompletedUuid: 'a1', + }; + + vi.spyOn(mockConfig, 'getContentGenerator').mockReturnValue({ + useSummarizedThinking: vi.fn(() => false), + } as unknown as ReturnType); + vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined); + vi.spyOn(mockConfig, 'getResumedSessionData').mockReturnValue( + resumeSessionData as ReturnType< + typeof mockConfig.getResumedSessionData + >, + ); + vi.spyOn(mockConfig, 'loadPausedBackgroundAgents').mockResolvedValue([]); + + mockSettings = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + history: { + collapseOnResume: true, + }, + }, + }, + } as LoadedSettings; + + render( + , + ); + + await vi.waitFor(() => { + expect(historyManager.loadHistory).toHaveBeenCalled(); + }); + + expect(historyManager.loadHistory).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ display: { kind: 'collapse-summary' } }), + ]), + ); + expect(historyManager.history.at(-1)).toMatchObject({ + type: 'info', + display: { kind: 'collapse-summary' }, + }); + expect( + historyManager.history + .slice(0, -1) + .every((item) => item.display?.suppressOnRestore === true), + ).toBe(true); + }); + it('does not remeasure footer height for sticky todo status-only updates', async () => { // Scoped stub: makeFakeConfig().initialize() rejects on React's // double-mount, which leaks async renders and destabilizes the diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a1cc171940..c94407d4c2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -67,9 +67,12 @@ import { readWorktreeSessionMarker, isSessionRuntimeActive, } from '@qwen-code/qwen-code-core'; -import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js'; +import { + applyCollapsePolicyAndSummary, + buildResumedHistoryItems, + expandCollapsedHistory, +} from './utils/resumeHistoryUtils.js'; import { loadLowlight } from './utils/lowlightLoader.js'; -import { restoreGoalFromHistory } from './utils/restoreGoal.js'; import { getStickyTodos, getStickyTodoMaxVisibleItems, @@ -123,6 +126,7 @@ import { computeApiTruncationIndex, isRealUserTurn, } from './utils/historyMapping.js'; +import { restoreGoalFromHistory } from './utils/restoreGoal.js'; import { useVimModeState, useVimModeActions, @@ -591,9 +595,13 @@ export const AppContainer = (props: AppContainerProps) => { const resumedSessionData = config.getResumedSessionData(); if (resumedSessionData) { - const historyItems = buildResumedHistoryItems( - resumedSessionData, - config, + const rawItems = buildResumedHistoryItems(resumedSessionData, config); + const collapseOnResume = + settings.merged.ui?.history?.collapseOnResume ?? false; + + const historyItems = applyCollapsePolicyAndSummary( + rawItems, + collapseOnResume, ); historyManager.loadHistory(historyItems); @@ -1090,6 +1098,7 @@ export const AppContainer = (props: AppContainerProps) => { handleResume, } = useResumeCommand({ config, + settings, historyManager, startNewSession, setSessionName, @@ -1098,6 +1107,7 @@ export const AppContainer = (props: AppContainerProps) => { const { handleBranch } = useBranchCommand({ config, + settings, historyManager, startNewSession, setSessionName, @@ -1257,10 +1267,11 @@ export const AppContainer = (props: AppContainerProps) => { } = useSlashCommandProcessor( config, settings, + historyManager.history, historyManager.addItem, historyManager.clearItems, historyManager.loadHistory, - remountStaticHistory, + refreshStatic, toggleVimEnabled, isProcessing, setIsProcessing, @@ -2675,7 +2686,12 @@ export const AppContainer = (props: AppContainerProps) => { !(option === 'both' && hasRestoreFailure) ) { const originalHistory = historyManager.history; - const originalLength = originalHistory.length; + const hasSummary = originalHistory.some( + (h) => h.display?.kind === 'collapse-summary', + ); + const effectiveLength = hasSummary + ? originalHistory.length - 1 + : originalHistory.length; let targetTurnIndex = 0; for (const h of originalHistory) { @@ -2685,7 +2701,11 @@ export const AppContainer = (props: AppContainerProps) => { geminiClient.truncateHistory(apiTruncateIndex); - const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); + // Strip suppressOnRestore flags and filter out collapse-summary items + // so rewound items remain visible without stale summary text + const truncatedUi = expandCollapsedHistory( + originalHistory.filter((h) => h.id < userItem.id), + ); historyManager.loadHistory(truncatedUi); refreshStatic(); @@ -2706,7 +2726,7 @@ export const AppContainer = (props: AppContainerProps) => { config.getChatRecordingService()?.rewindRecording( targetTurnIndex, - { truncatedCount: originalLength - truncatedUi.length }, + { truncatedCount: effectiveLength - truncatedUi.length }, !hasRestoreFailure ? config .getFileHistoryService() diff --git a/packages/cli/src/ui/commands/historyCommand.test.ts b/packages/cli/src/ui/commands/historyCommand.test.ts new file mode 100644 index 0000000000..6d73f7fea9 --- /dev/null +++ b/packages/cli/src/ui/commands/historyCommand.test.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { historyCommand } from './historyCommand.js'; +import { MessageType, type HistoryItem } from '../types.js'; +import type { CommandContext } from './types.js'; + +describe('historyCommand', () => { + let mockHistory: HistoryItem[]; + let mockLoadHistory: ReturnType; + let mockRefreshStatic: ReturnType; + let mockSettings: { setValue: ReturnType }; + let mockContext: CommandContext; + + beforeEach(() => { + mockHistory = [ + { id: 1, type: 'user', text: 'hello' } as HistoryItem, + { id: 2, type: 'gemini', text: 'hi' } as HistoryItem, + ]; + mockLoadHistory = vi.fn((newHistory) => { + mockHistory = newHistory; + }); + mockRefreshStatic = vi.fn(); + mockSettings = { + setValue: vi.fn(), + }; + + mockContext = { + ui: { + history: mockHistory, + loadHistory: mockLoadHistory, + refreshStatic: mockRefreshStatic, + }, + services: { + settings: mockSettings, + }, + } as unknown as CommandContext; + }); + + it('collapse-on-resume sets the user preference', async () => { + const collapseCommand = historyCommand.subCommands!.find( + (c) => c.name === 'collapse-on-resume', + )!; + const result = await collapseCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('History will be collapsed by default'), + }); + expect(mockSettings.setValue).toHaveBeenCalledWith( + 'User', + 'ui.history.collapseOnResume', + true, + ); + }); + + it('expand-on-resume sets the user preference', async () => { + const expandCommand = historyCommand.subCommands!.find( + (c) => c.name === 'expand-on-resume', + )!; + const result = await expandCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('History will be expanded by default'), + }); + expect(mockSettings.setValue).toHaveBeenCalledWith( + 'User', + 'ui.history.collapseOnResume', + false, + ); + }); + + it('expand-now removes suppressOnRestore and drops summary', async () => { + const expandNowCommand = historyCommand.subCommands!.find( + (c) => c.name === 'expand-now', + )!; + // Setup collapsed state + mockHistory = [ + { + id: 1, + type: 'user', + text: 'hello', + display: { suppressOnRestore: true }, + } as HistoryItem, + { + id: 2, + type: 'gemini', + text: 'hi', + display: { suppressOnRestore: true }, + } as HistoryItem, + { + id: 3, + type: MessageType.INFO, + text: 'History collapsed: 2 messages hidden.', + display: { kind: 'collapse-summary' }, + } as HistoryItem, + ]; + mockContext.ui.history = mockHistory; + + const result = await expandNowCommand.action!(mockContext, ''); + + expect(result).toBeUndefined(); + expect(mockLoadHistory).toHaveBeenCalledWith([ + expect.objectContaining({ id: 1, display: undefined }), + expect.objectContaining({ id: 2, display: undefined }), + ]); + expect(mockRefreshStatic).toHaveBeenCalled(); + }); + + it('expand-now returns already expanded when expanding an uncollapsed session', async () => { + const expandNowCommand = historyCommand.subCommands!.find( + (c) => c.name === 'expand-now', + )!; + const result = await expandNowCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'History is already expanded in this session.', + }); + expect(mockLoadHistory).not.toHaveBeenCalled(); + expect(mockRefreshStatic).not.toHaveBeenCalled(); + }); + + it('returns usage error for unknown subcommand', async () => { + const result = await historyCommand.action!(mockContext, 'unknown'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /history collapse-on-resume|expand-on-resume|expand-now', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/historyCommand.ts b/packages/cli/src/ui/commands/historyCommand.ts new file mode 100644 index 0000000000..a519958601 --- /dev/null +++ b/packages/cli/src/ui/commands/historyCommand.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, MessageActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; +import { SettingScope } from '../../config/settings.js'; +import { expandCollapsedHistory } from '../utils/resumeHistoryUtils.js'; + +const collapseOnResumeCommand: SlashCommand = { + name: 'collapse-on-resume', + get description() { + return t('Set history to collapse by default when resuming a session'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + action: (context): MessageActionReturn | void => { + const { settings } = context.services; + + settings.setValue(SettingScope.User, 'ui.history.collapseOnResume', true); + + return { + type: 'message', + messageType: 'info', + content: t( + 'History will be collapsed by default for future resumed sessions.', + ), + }; + }, +}; + +const expandOnResumeCommand: SlashCommand = { + name: 'expand-on-resume', + get description() { + return t('Set history to expand by default when resuming a session'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + action: (context): MessageActionReturn | void => { + const { settings } = context.services; + + settings.setValue(SettingScope.User, 'ui.history.collapseOnResume', false); + + return { + type: 'message', + messageType: 'info', + content: t( + 'History will be expanded by default for future resumed sessions.', + ), + }; + }, +}; + +const expandNowCommand: SlashCommand = { + name: 'expand-now', + get description() { + return t('Expand the currently collapsed history 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 in this session.'), + }; + } + + // Remove suppressOnRestore from all items and drop collapse summary items. + const updated = expandCollapsedHistory(history); + loadHistory(updated); + refreshStatic(); + // No return — the loadHistory/refreshStatic calls handle the UI update + }, +}; + +export const historyCommand: SlashCommand = { + name: 'history', + get description() { + return t('Control history display preferences and visibility'); + }, + argumentHint: 'collapse-on-resume|expand-on-resume|expand-now', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + subCommands: [ + collapseOnResumeCommand, + expandOnResumeCommand, + expandNowCommand, + ], + action: async () => ({ + type: 'message', + messageType: 'error', + content: t( + 'Usage: /history collapse-on-resume|expand-on-resume|expand-now', + ), + }), +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 31d08fc760..042ac279d9 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -53,6 +53,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. */ @@ -86,6 +88,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.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index b972583ce9..ae0028c08c 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -511,6 +511,42 @@ describe('', () => { expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(53); }); + it('filters out suppressed history items from rendering', async () => { + staticItemsSpy.mockClear(); + historyItemDisplayPropsSpy.mockClear(); + const history = [ + { type: 'user' as const, id: 1, text: 'hello' }, + { + type: 'gemini' as const, + id: 2, + text: 'hi', + display: { suppressOnRestore: true }, + }, + { + type: 'info' as const, + id: 3, + text: 'History collapsed: 1 messages hidden.', + display: { kind: 'collapse-summary' as const }, + }, + ]; + const uiState = createUIState({ history }); + renderMainContent(uiState); + expect(historyItemDisplayPropsSpy).toHaveBeenCalled(); + const renderedHistoryItems = historyItemDisplayPropsSpy.mock.calls.map( + (c) => c[0].item, + ); + // Unsuppressed user message and collapse-summary should render (summary has no suppressOnRestore) + expect(renderedHistoryItems).toHaveLength(2); + expect(renderedHistoryItems.find((i) => i.id === 1)).toMatchObject({ + id: 1, + }); + expect(renderedHistoryItems.find((i) => i.id === 3)).toMatchObject({ + id: 3, + }); + // Suppressed gemini item should NOT render + expect(renderedHistoryItems.find((i) => i.id === 2)).toBeUndefined(); + }); + it('does NOT reset progressive replay when only currentModel changes (PR #4119 regression guard)', async () => { // Wenshao's review on PR #4119: if AppContainer splits the model-change // wiring into two separate effects (setCurrentModel first, refreshStatic diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 0034ac59d5..501e0a3310 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -12,6 +12,7 @@ import { ShowMoreLines } from './ShowMoreLines.js'; import { Notifications } from './Notifications.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; @@ -104,6 +105,7 @@ const virtualIsStaticItem = (item: HistoryItem) => item.id > 0; export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); + const uiActions = useUIActions(); const { compactMode, compactInline } = useCompactMode(); const { pendingHistoryItems, @@ -114,6 +116,12 @@ export const MainContent = () => { historyRemountKey, } = uiState; + // Filter out items whose display is suppressed (e.g. /history collapse). + 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 // on the tool_group's own state, and mergeable groups don't change force- @@ -141,7 +149,7 @@ export const MainContent = () => { if (!compactMode || !uiState.useTerminalBuffer || compactInline) return EMPTY_ABSORBED_CALL_IDS; const absorbed = new Set(); - for (const item of uiState.history) { + for (const item of visibleHistory) { if (item.type !== 'tool_group') continue; if ( isForceExpandGroup( @@ -170,7 +178,7 @@ export const MainContent = () => { }, [ compactMode, compactInline, - uiState.history, + visibleHistory, uiState.embeddedShellFocused, uiState.activePtyId, uiState.useTerminalBuffer, @@ -191,22 +199,22 @@ export const MainContent = () => { const prevMergedHistoryRef = useRef(null); const mergedHistory = useMemo(() => { if (!compactMode) { - prevMergedHistoryRef.current = uiState.history; - return uiState.history; + prevMergedHistoryRef.current = visibleHistory; + return visibleHistory; } // is append-only: merging reduces item count, which // can't handle without clearTerminal + remount (the flash we're fixing). if (!uiState.useTerminalBuffer) { - prevMergedHistoryRef.current = uiState.history; - return uiState.history; + prevMergedHistoryRef.current = visibleHistory; + return visibleHistory; } // compactInline: user opted into per-group display without cross-group merging. if (compactInline) { - prevMergedHistoryRef.current = uiState.history; - return uiState.history; + prevMergedHistoryRef.current = visibleHistory; + return visibleHistory; } const next = mergeCompactToolGroups( - uiState.history, + visibleHistory, uiState.embeddedShellFocused, uiState.activePtyId, absorbedCallIds, @@ -227,7 +235,7 @@ export const MainContent = () => { }, [ compactMode, compactInline, - uiState.history, + visibleHistory, uiState.useTerminalBuffer, uiState.embeddedShellFocused, uiState.activePtyId, @@ -240,7 +248,7 @@ export const MainContent = () => { // multiple groups are merged, the first group's summary wins (see below). const summaryByCallId = useMemo(() => { const map = new Map(); - for (const item of uiState.history) { + for (const item of visibleHistory) { if (item.type === 'tool_use_summary') { for (const callId of item.precedingToolUseIds) { // First summary wins — earlier summaries represent the opening @@ -252,7 +260,7 @@ export const MainContent = () => { } } return map; - }, [uiState.history]); + }, [visibleHistory]); const isSummaryAbsorbed = useCallback( (item: HistoryItem | HistoryItemWithoutId): boolean => { @@ -286,6 +294,35 @@ export const MainContent = () => { [summaryByCallId, uiState.useTerminalBuffer, compactInline], ); + // Ink's is append-only: once an item is rendered to the terminal + // buffer, it cannot be replaced. In compact mode, when a new tool_group is + // merged into a previous one, the merged result has FEWER items than the + // raw history. Static would not re-render the older items even though their + // content changed, so we explicitly call refreshStatic() to clear the + // terminal and re-render the merged view. + // + // Detection: if history length grew but mergedHistory length did NOT grow + // proportionally (i.e., a merge consolidated items), trigger a refresh. + const prevHistoryLengthRef = useRef(visibleHistory.length); + const prevMergedLengthRef = useRef(mergedHistory.length); + useEffect(() => { + if (!compactMode) { + prevHistoryLengthRef.current = visibleHistory.length; + prevMergedLengthRef.current = mergedHistory.length; + return; + } + const prevHLen = prevHistoryLengthRef.current; + const currHLen = visibleHistory.length; + const prevMLen = prevMergedLengthRef.current; + const currMLen = mergedHistory.length; + // History grew, but merged length stayed same or shrank → a merge happened. + if (currHLen > prevHLen && currMLen <= prevMLen) { + uiActions.refreshStatic(); + } + prevHistoryLengthRef.current = currHLen; + prevMergedLengthRef.current = currMLen; + }, [compactMode, visibleHistory, mergedHistory, uiActions]); + // Virtual viewport path short-circuits below before any of the // -only machinery is needed. The offsets / progressive-replay // state still computes because it lives at the top of the component, but diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 1fe8372d2d..5d012234a4 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -100,7 +100,7 @@ export interface UIActions { // Resume session dialog openResumeDialog: () => void; closeResumeDialog: () => void; - handleResume: (sessionId: string) => void; + handleResume: (sessionId: string) => Promise; // Branch (fork) session handleBranch: (name?: string) => Promise; // Delete session dialog diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 8568834fb1..7c8e896c6d 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -151,6 +151,7 @@ describe('useSlashCommandProcessor', () => { handleResume: vi.fn(), handleBranch: vi.fn().mockResolvedValue(undefined), openDeleteDialog: vi.fn(), + openDiffDialog: vi.fn(), quit: mockSetQuittingMessages, setDebugMessage: vi.fn(), dispatchExtensionStateUpdate: vi.fn(), @@ -161,7 +162,6 @@ describe('useSlashCommandProcessor', () => { openMcpDialog: vi.fn(), openHooksDialog: vi.fn(), openRewindSelector: vi.fn(), - openDiffDialog: vi.fn(), }); beforeEach(() => { @@ -199,6 +199,7 @@ describe('useSlashCommandProcessor', () => { useSlashCommandProcessor( mockConfig, settings, + [], // mock history array mockAddItem, mockClearItems, mockLoadHistory, @@ -220,8 +221,11 @@ describe('useSlashCommandProcessor', () => { }; describe('Initialization and Command Loading', () => { - it('should initialize CommandService with all required loaders', () => { - setupProcessorHook(); + it('should initialize CommandService with all required loaders', async () => { + const result = setupProcessorHook(); + await waitFor(() => { + expect(result.current.slashCommands).toBeDefined(); + }); expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig); @@ -518,19 +522,175 @@ describe('useSlashCommandProcessor', () => { expect(mockOpenModelDialog).toHaveBeenCalled(); }); - it('should handle "dialog: memory" action', async () => { - const command = createTestCommand({ + it('awaits direct resume session switching before returning handled', async () => { + const actions = createMockActions(); + let resolveResume: (() => void) | undefined; + actions.handleResume = vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve; + }), + ); + + const resumeCommand = createTestCommand({ + name: 'resume-direct', + action: vi.fn().mockResolvedValue({ + type: 'dialog', + dialog: 'resume', + sessionId: 'session-123', + }), + }); + mockBuiltinLoadCommands.mockResolvedValue(Object.freeze([resumeCommand])); + + const { result } = renderHook(() => + useSlashCommandProcessor( + mockConfig, + mockSettings, + [], + mockAddItem, + mockClearItems, + mockLoadHistory, + vi.fn(), + vi.fn(), + false, + vi.fn(), + { current: true }, + vi.fn(), + actions, + new Map(), + true, + null, + mockUpdateItem, + ), + ); + + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + let settled = false; + const promise = result.current + .handleSlashCommand('/resume-direct') + .then(() => { + settled = true; + }); + + await waitFor(() => { + expect(actions.handleResume).toHaveBeenCalledWith('session-123'); + }); + expect(settled).toBe(false); + + resolveResume?.(); + await act(async () => { + await promise; + }); + expect(settled).toBe(true); + }); + + it('shows info feedback for collapse-on-resume command', async () => { + const historyCmd = createTestCommand({ + name: 'history', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: + 'History will be collapsed by default for future resumed sessions.', + }), + }); + const result = setupProcessorHook([historyCmd]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/history collapse-on-resume'); + }); + + // Should have 2 calls: user message + info feedback + expect(mockAddItem).toHaveBeenCalledTimes(2); + expect(mockAddItem).toHaveBeenNthCalledWith( + 1, + { + type: MessageType.USER, + text: '/history collapse-on-resume', + sentToModel: false, + }, + expect.any(Number), + ); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, + { + type: MessageType.INFO, + text: 'History will be collapsed by default for future resumed sessions.', + }, + expect.any(Number), + ); + }); + + it('expand-now command updates history without info feedback', async () => { + const historyCmd = createTestCommand({ + name: 'history', + subCommands: [ + { + name: 'expand-now', + description: 'Expand collapsed history', + kind: CommandKind.BUILT_IN, + action: vi.fn().mockResolvedValue(undefined), + }, + ], + }); + const result = setupProcessorHook([historyCmd]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/history expand-now'); + }); + + // User message added, no info feedback (action returns void) + expect(mockAddItem).toHaveBeenCalledTimes(1); + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.USER, + text: '/history expand-now', + sentToModel: false, + }, + expect.any(Number), + ); + }); + + it('opens memory dialog when command returns dialog:memory', async () => { + const actions = createMockActions(); + const memoryCommand = createTestCommand({ name: 'memorycmd', action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'memory' }), }); - const result = setupProcessorHook([command]); + mockBuiltinLoadCommands.mockResolvedValue(Object.freeze([memoryCommand])); + + const { result } = renderHook(() => + useSlashCommandProcessor( + mockConfig, + mockSettings, + [], + mockAddItem, + mockClearItems, + mockLoadHistory, + vi.fn(), + vi.fn(), + false, + vi.fn(), + { current: true }, + vi.fn(), + actions, + new Map(), + true, + null, + mockUpdateItem, + ), + ); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/memorycmd'); }); - expect(mockOpenMemoryDialog).toHaveBeenCalled(); + expect(actions.openMemoryDialog).toHaveBeenCalled(); }); it('should pass interactive execution mode to command actions', async () => { @@ -1430,6 +1590,7 @@ describe('useSlashCommandProcessor', () => { useSlashCommandProcessor( mockConfig, mockSettings, + [], // mock history array mockAddItem, mockClearItems, mockLoadHistory, @@ -1481,6 +1642,7 @@ describe('useSlashCommandProcessor', () => { useSlashCommandProcessor( mockConfig, mockSettings, + [], // mock history array mockAddItem, mockClearItems, mockLoadHistory, @@ -1547,6 +1709,7 @@ describe('useSlashCommandProcessor', () => { useSlashCommandProcessor( mockConfig, mockSettings, + [], // mock history array mockAddItem, mockClearItems, mockLoadHistory, @@ -1617,6 +1780,7 @@ describe('useSlashCommandProcessor', () => { return useSlashCommandProcessor( mockConfig, mockSettings, + [], // mock history array mockAddItem, mockClearItems, mockLoadHistory, @@ -1676,6 +1840,7 @@ describe('useSlashCommandProcessor', () => { useSlashCommandProcessor( mockConfig, mockSettings, + [], // mock history array mockAddItem, mockClearItems, mockLoadHistory, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 2c8a0d8047..41f1fc9c96 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -7,6 +7,7 @@ import { useCallback, useMemo, + useLayoutEffect, useEffect, useRef, useState, @@ -93,6 +94,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'delete', 'branch', 'btw', + 'history', ]); export interface SlashCommandProcessorActions { @@ -108,7 +110,7 @@ export interface SlashCommandProcessorActions { openPermissionsDialog: () => void; openApprovalModeDialog: () => void; openResumeDialog: (matchedSessions?: SessionListItem[]) => void; - handleResume: (sessionId: string) => void; + handleResume: (sessionId: string) => Promise; handleBranch: (name?: string) => Promise; openDeleteDialog: () => void; quit: (messages: HistoryItem[]) => void; @@ -133,6 +135,7 @@ export interface SlashCommandProcessorActions { export const useSlashCommandProcessor = ( config: Config | null, settings: LoadedSettings, + history: HistoryItem[], addItem: UseHistoryManagerReturn['addItem'], clearItems: UseHistoryManagerReturn['clearItems'], loadHistory: UseHistoryManagerReturn['loadHistory'], @@ -149,6 +152,13 @@ export const useSlashCommandProcessor = ( updateItem: UseHistoryManagerReturn['updateItem'], setSessionName?: (name: string | null) => void, ) => { + // Ref avoids adding `history` to the commandContext useMemo deps, + // which would cause a full context rebuild on every history append. + const historyRef = useRef(history); + useLayoutEffect(() => { + historyRef.current = history; + }, [history]); + const { stats: sessionStats, startNewSession } = useSessionStats(); const [commands, setCommands] = useState([]); const [recentCommands, setRecentCommands] = useState< @@ -322,6 +332,9 @@ export const useSlashCommandProcessor = ( logger, }, ui: { + get history() { + return historyRef.current; + }, addItem, clear: () => { cancelBtw(); @@ -331,6 +344,7 @@ export const useSlashCommandProcessor = ( setSessionName?.(null); }, loadHistory, + refreshStatic, setDebugMessage: actions.setDebugMessage, pendingItem, setPendingItem, @@ -765,7 +779,7 @@ export const useSlashCommandProcessor = ( return { type: 'handled' }; case 'resume': if (result.sessionId) { - actions.handleResume(result.sessionId); + await actions.handleResume(result.sessionId); } else { actions.openResumeDialog(result.matchedSessions); } diff --git a/packages/cli/src/ui/hooks/useBranchCommand.test.ts b/packages/cli/src/ui/hooks/useBranchCommand.test.ts index bafd60f3ba..59b638232a 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.test.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.test.ts @@ -8,11 +8,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useBranchCommand } from './useBranchCommand.js'; import { restoreGoalFromHistory } from '../utils/restoreGoal.js'; +import type { LoadedSettings } from '../../config/settings.js'; vi.mock('../utils/restoreGoal.js', () => ({ restoreGoalFromHistory: vi.fn(() => ({ restored: false })), })); +const mockSettings = { + merged: { ui: { history: { collapseOnResume: false } } }, +} as unknown as LoadedSettings; + describe('useBranchCommand', () => { let forkSession: ReturnType; let loadSession: ReturnType; @@ -33,6 +38,7 @@ describe('useBranchCommand', () => { const makeOptions = () => ({ config, + settings: mockSettings, historyManager: { clearItems, loadHistory, addItem }, startNewSession: startNewSessionUI, setSessionName, @@ -486,4 +492,35 @@ describe('useBranchCommand', () => { expect.any(Number), ); }); + + it('applies collapse policy when collapseOnResume is true', async () => { + const settingsWithCollapse = { + merged: { ui: { history: { collapseOnResume: true } } }, + } as unknown as LoadedSettings; + + const { result } = renderHook(() => + useBranchCommand({ + ...makeOptions(), + settings: settingsWithCollapse, + }), + ); + await act(async () => { + await result.current.handleBranch('my-branch'); + }); + + // loadHistory should have been called with items that include + // suppressOnRestore and a collapse-summary item. + expect(loadHistory).toHaveBeenCalledTimes(1); + const loadedItems = loadHistory.mock.calls[0][0]; + expect(loadedItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + display: expect.objectContaining({ suppressOnRestore: true }), + }), + expect.objectContaining({ + display: expect.objectContaining({ kind: 'collapse-summary' }), + }), + ]), + ); + }); }); diff --git a/packages/cli/src/ui/hooks/useBranchCommand.ts b/packages/cli/src/ui/hooks/useBranchCommand.ts index b8dcbeac88..034ca69f75 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.ts @@ -13,9 +13,13 @@ import { SessionStartSource, computeUniqueBranchTitle, } from '@qwen-code/qwen-code-core'; -import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; +import { + buildResumedHistoryItems, + applyCollapsePolicyAndSummary, +} from '../utils/resumeHistoryUtils.js'; import { restoreGoalFromHistory } from '../utils/restoreGoal.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import type { LoadedSettings } from '../../config/settings.js'; import { t } from '../../i18n/index.js'; /** @@ -51,6 +55,7 @@ function deriveFirstPrompt(messages: ChatRecord[]): string { export interface UseBranchCommandOptions { config: Config | null; + settings: LoadedSettings; historyManager: Pick< UseHistoryManagerReturn, 'clearItems' | 'loadHistory' | 'addItem' @@ -149,7 +154,13 @@ export function useBranchCommand( // set immediately after the UI commits so any subsequent // failure (title, hook, remount, announce) skips the catch // block's core rollback. - const uiHistoryItems = buildResumedHistoryItems(resumed, config); + const rawItems = buildResumedHistoryItems(resumed, config); + const collapseOnResume = + options.settings.merged.ui?.history?.collapseOnResume ?? false; + const uiHistoryItems = applyCollapsePolicyAndSummary( + rawItems, + collapseOnResume, + ); startNewSession(newSessionId); historyManager.clearItems(); historyManager.loadHistory(uiHistoryItems); @@ -258,7 +269,14 @@ export function useBranchCommand( ); } }, - [config, historyManager, startNewSession, setSessionName, remount], + [ + config, + historyManager, + startNewSession, + setSessionName, + remount, + options.settings.merged.ui?.history?.collapseOnResume, + ], ); return { handleBranch }; diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index bc1d7afac6..3f7c8be2a0 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -10,8 +10,21 @@ import { BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE, useResumeCommand, } from './useResumeCommand.js'; +import { useHistory } from './useHistoryManager.js'; import { restoreGoalFromHistory } from '../utils/restoreGoal.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +const mockSettings = { + merged: { + ui: { + history: { + collapseOnResume: false, + }, + }, + }, +} as unknown as LoadedSettings; + const resumeMocks = vi.hoisted(() => { let resolveLoadSession: | ((value: { conversation: unknown } | undefined) => void) @@ -40,15 +53,24 @@ const resumeMocks = vi.hoisted(() => { }; }); -vi.mock('../utils/resumeHistoryUtils.js', () => ({ - buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]), -})); +vi.mock('../utils/resumeHistoryUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + buildResumedHistoryItems: vi.fn(() => [ + { id: 1, type: 'user', text: 'hi' }, + ]), + }; +}); vi.mock('../utils/restoreGoal.js', () => ({ restoreGoalFromHistory: vi.fn(() => ({ restored: false })), })); -vi.mock('@qwen-code/qwen-code-core', () => { +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const original = + await importOriginal(); class SessionService { constructor(_cwd: string) {} async loadSession(_sessionId: string) { @@ -65,19 +87,42 @@ vi.mock('@qwen-code/qwen-code-core', () => { } return { + ...original, SessionService, }; }); describe('useResumeCommand', () => { it('should initialize with dialog closed', () => { - const { result } = renderHook(() => useResumeCommand()); + const { result } = renderHook(() => + useResumeCommand({ + settings: mockSettings, + config: null, + historyManager: { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }, + startNewSession: vi.fn(), + }), + ); expect(result.current.isResumeDialogOpen).toBe(false); }); it('should open the dialog when openResumeDialog is called', () => { - const { result } = renderHook(() => useResumeCommand()); + const { result } = renderHook(() => + useResumeCommand({ + settings: mockSettings, + config: null, + historyManager: { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }, + startNewSession: vi.fn(), + }), + ); act(() => { result.current.openResumeDialog(); @@ -87,7 +132,18 @@ describe('useResumeCommand', () => { }); it('should close the dialog when closeResumeDialog is called', () => { - const { result } = renderHook(() => useResumeCommand()); + const { result } = renderHook(() => + useResumeCommand({ + settings: mockSettings, + config: null, + historyManager: { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }, + startNewSession: vi.fn(), + }), + ); // Open the dialog first act(() => { @@ -105,7 +161,21 @@ describe('useResumeCommand', () => { }); it('should maintain stable function references across renders', () => { - const { result, rerender } = renderHook(() => useResumeCommand()); + const historyManager = { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }; + const startNewSession = vi.fn(); + + const { result, rerender } = renderHook(() => + useResumeCommand({ + settings: mockSettings, + config: null, + historyManager, + startNewSession, + }), + ); const initialOpenFn = result.current.openResumeDialog; const initialCloseFn = result.current.closeResumeDialog; @@ -129,6 +199,7 @@ describe('useResumeCommand', () => { const { result } = renderHook(() => useResumeCommand({ config: null, + settings: mockSettings, historyManager, startNewSession, }), @@ -159,6 +230,7 @@ describe('useResumeCommand', () => { const resetMonitorRegistry = vi.fn(); const config = { + getSessionId: () => 'old-session-id', getTargetDir: () => '/tmp', getGeminiClient: () => geminiClient, startNewSession: vi.fn(), @@ -195,6 +267,7 @@ describe('useResumeCommand', () => { const { result } = renderHook(() => useResumeCommand({ config, + settings: mockSettings, historyManager, startNewSession, }), @@ -245,6 +318,92 @@ describe('useResumeCommand', () => { ); }); + it('applies collapseOnResume policy when resuming a session', async () => { + const startNewSession = vi.fn(); + const geminiClient = { + initialize: vi.fn(), + }; + const resetMonitorRegistry = vi.fn(); + + const config = { + getSessionId: () => 'old-session-id', + getTargetDir: () => '/tmp', + getGeminiClient: () => geminiClient, + startNewSession: vi.fn(), + getBackgroundTaskRegistry: () => ({ + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getBackgroundShellRegistry: () => ({ + getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getMonitorRegistry: () => ({ + getRunning: vi.fn().mockReturnValue([]), + reset: resetMonitorRegistry, + }), + getWorkflowRunRegistry: () => ({ + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: vi.fn(), + abortAll: vi.fn(), + }), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]), + getChatRecordingService: () => ({ rebuildTurnBoundaries: vi.fn() }), + getDebugLogger: () => ({ + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const settingsWithCollapse = { + merged: { + ui: { + history: { + collapseOnResume: true, + }, + }, + }, + } as unknown as LoadedSettings; + + const { result } = renderHook(() => { + const historyManager = useHistory(); + const resumeCommand = useResumeCommand({ + config, + settings: settingsWithCollapse, + historyManager, + startNewSession, + }); + return { historyManager, resumeCommand }; + }); + + let resumePromise: Promise | undefined; + act(() => { + resumePromise = result.current.resumeCommand.handleResume('session-3'); + }); + + resumeMocks.resolvePendingLoadSession({ + conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], + }); + await act(async () => { + await resumePromise; + }); + + // Verify that the history state contains the suppressed item and the summary item + const history = result.current.historyManager.history; + expect(history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + display: expect.objectContaining({ suppressOnRestore: true }), + }), + expect.objectContaining({ + display: expect.objectContaining({ kind: 'collapse-summary' }), + }), + ]), + ); + }); + it('adds a recovered-background-agents notice when paused agents are restored', async () => { const historyManager = { addItem: vi.fn(), @@ -260,6 +419,7 @@ describe('useResumeCommand', () => { .mockReturnValue('Recovered 2 interrupted background agents.'); const config = { + getSessionId: () => 'old-session-id', getTargetDir: () => '/tmp', getGeminiClient: () => geminiClient, startNewSession: vi.fn(), @@ -298,6 +458,7 @@ describe('useResumeCommand', () => { const { result } = renderHook(() => useResumeCommand({ config, + settings: mockSettings, historyManager, startNewSession, }), @@ -356,6 +517,7 @@ describe('useResumeCommand', () => { const { result } = renderHook(() => useResumeCommand({ config, + settings: mockSettings, historyManager, startNewSession, }), @@ -425,6 +587,7 @@ describe('useResumeCommand', () => { const { result } = renderHook(() => useResumeCommand({ config, + settings: mockSettings, historyManager, startNewSession, }), @@ -450,4 +613,89 @@ describe('useResumeCommand', () => { expect.any(Number), ); }); + + it('rolls core back to the old session when something fails after core swap but before UI swap', async () => { + const startNewSession = vi.fn(); + const geminiClient = { + initialize: vi + .fn() + .mockRejectedValueOnce(new Error('init boom')) + .mockResolvedValueOnce(undefined), + }; + + const config = { + getSessionId: () => 'old-session-id', + getTargetDir: () => '/tmp', + getGeminiClient: () => geminiClient, + startNewSession: vi.fn(), + getBackgroundTaskRegistry: () => ({ + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getBackgroundShellRegistry: () => ({ + getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getMonitorRegistry: () => ({ + getRunning: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), + getWorkflowRunRegistry: () => ({ + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: vi.fn(), + abortAll: vi.fn(), + }), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]), + getChatRecordingService: () => ({ rebuildTurnBoundaries: vi.fn() }), + getDebugLogger: () => ({ + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const historyManager = { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }; + + const { result } = renderHook(() => + useResumeCommand({ + config, + settings: mockSettings, + historyManager, + startNewSession, + }), + ); + + await act(async () => { + await result.current.handleResume('new-session-id'); + }); + + // Core was swapped to the new session, then rolled back to the old one. + expect(config.startNewSession).toHaveBeenNthCalledWith( + 1, + 'new-session-id', + expect.any(Object), + ); + expect(config.startNewSession).toHaveBeenNthCalledWith( + 2, + 'old-session-id', + undefined, + ); + // UI never swapped. + expect(startNewSession).not.toHaveBeenCalled(); + expect(historyManager.clearItems).not.toHaveBeenCalled(); + expect(historyManager.loadHistory).not.toHaveBeenCalled(); + // User sees the failure. + expect(historyManager.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringMatching(/Failed to resume session.*init boom/), + }), + expect.any(Number), + ); + }); }); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index c37b81aec0..6230a75fab 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -10,7 +10,10 @@ import { type Config, type SessionListItem, } from '@qwen-code/qwen-code-core'; -import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; +import { + buildResumedHistoryItems, + applyCollapsePolicyAndSummary, +} from '../utils/resumeHistoryUtils.js'; import { restoreGoalFromHistory } from '../utils/restoreGoal.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { MessageType, type HistoryItemWithoutId } from '../types.js'; @@ -18,9 +21,11 @@ import { hasBlockingBackgroundWork, resetBackgroundStateForSessionSwitch, } from '../utils/backgroundWorkUtils.js'; +import type { LoadedSettings } from '../../config/settings.js'; export interface UseResumeCommandOptions { config: Config | null; + settings: LoadedSettings; historyManager: Pick< UseHistoryManagerReturn, 'addItem' | 'clearItems' | 'loadHistory' @@ -49,7 +54,7 @@ const BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE = "Stop the current session's running background tasks before resuming another session."; export function useResumeCommand( - options?: UseResumeCommandOptions, + options: UseResumeCommandOptions, ): UseResumeCommandResult { const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); const [resumeMatchedSessions, setResumeMatchedSessions] = useState< @@ -69,99 +74,150 @@ export function useResumeCommand( setResumeMatchedSessions(undefined); }, []); - const { config, historyManager, startNewSession, setSessionName, remount } = - options ?? {}; + const { + config, + settings, + historyManager, + startNewSession, + setSessionName, + remount, + } = options; - const hasHistoryManager = !!historyManager; - const { addItem, clearItems, loadHistory } = historyManager || {}; + const { addItem, clearItems, loadHistory } = historyManager; const handleResume = useCallback( async (sessionId: string) => { - if (!config || !hasHistoryManager || !startNewSession) { + if (!config) { return; } if (hasBlockingBackgroundWork(config)) { - closeResumeDialog(); const blockedMessage: HistoryItemWithoutId = { type: MessageType.ERROR, text: BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE, }; - addItem?.(blockedMessage, Date.now()); + addItem(blockedMessage, Date.now()); + closeResumeDialog(); return; } // Close dialog immediately to prevent input capture during async operations. closeResumeDialog(); - const cwd = config.getTargetDir(); - const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadSession(sessionId); + const oldSessionId = config.getSessionId(); + let coreSwapped = false; + let uiSwapped = false; - if (!sessionData) { - return; - } - - // Start new session in UI context. - startNewSession(sessionId); - - // Restore session name tag from custom title. - const customTitle = sessionService.getSessionTitle(sessionId); - setSessionName?.(customTitle ?? null); - - // Reset UI history. - const uiHistoryItems = buildResumedHistoryItems(sessionData, config); - clearItems?.(); - loadHistory?.(uiHistoryItems); - - // Update session history core. - resetBackgroundStateForSessionSwitch(config); - config.startNewSession(sessionId, sessionData); - // Re-arm /goal: the in-memory activeGoalStore entry (if any) is stale - // after `config.startNewSession` rebuilds the hook system — its - // `setAt` was captured before /new, and its `hookId` points to a - // hook that no longer exists. The cold-boot path runs this same - // call in AppContainer; the runtime /resume path needs it too, - // otherwise the footer pill keeps ticking from the original setAt - // (visible as "几十秒" elapsed immediately after /new + /resume) and - // the Stop hook is silently dead until the user re-issues /goal. try { - if (addItem) restoreGoalFromHistory(uiHistoryItems, config, addItem); - } catch { - // Best-effort — never block resume on goal restoration. + const cwd = config.getTargetDir(); + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(sessionId); + + if (!sessionData) { + return; + } + + // Restore session name tag from custom title. + const customTitle = sessionService.getSessionTitle(sessionId); + + // Build UI history items. + const rawItems = buildResumedHistoryItems(sessionData, config); + const collapseOnResume = + settings.merged.ui?.history?.collapseOnResume ?? false; + + const uiHistoryItems = applyCollapsePolicyAndSummary( + rawItems, + collapseOnResume, + ); + + // 1. Swap core first. Matches useBranchCommand's core-before-UI + // pattern: if anything fails between core swap and UI swap, + // the catch block rolls core back to the old session so the + // user is not stranded with a half-live client. + resetBackgroundStateForSessionSwitch(config); + config.startNewSession(sessionId, sessionData); + coreSwapped = true; + + // Re-arm /goal: the in-memory activeGoalStore entry (if any) is stale + // after `config.startNewSession` rebuilds the hook system — its + // `setAt` was captured before /new, and its `hookId` points to a + // hook that no longer exists. The cold-boot path runs this same + // call in AppContainer; the runtime /resume path needs it too, + // otherwise the footer pill keeps ticking from the original setAt + // (visible as "几十秒" elapsed immediately after /new + /resume) and + // the Stop hook is silently dead until the user re-issues /goal. + try { + restoreGoalFromHistory(uiHistoryItems, config, addItem); + } catch { + // Best-effort — never block resume on goal restoration. + } + // Rebuild turn boundary tracking so rewind works within resumed sessions. + config + .getChatRecordingService() + ?.rebuildTurnBoundaries(sessionData.conversation.messages); + await config.getGeminiClient()?.initialize?.(); + + const recovered = await config.loadPausedBackgroundAgents(sessionId); + if (recovered.length > 0) { + const recoveredMessage: HistoryItemWithoutId = { + type: MessageType.INFO, + text: config + .getBackgroundAgentResumeService() + .buildRecoveredBackgroundAgentsNotice(recovered.length), + }; + addItem(recoveredMessage, Date.now()); + } + + // 2. Swap UI. Once this commits, rolling core back is unsafe — + // it would leave UI on the resumed session but recorder writing + // into the old JSONL (split-brain). + startNewSession(sessionId); + setSessionName?.(customTitle ?? null); + clearItems(); + loadHistory(uiHistoryItems); + uiSwapped = true; + + // SessionStart hook is handled during chat initialization so its + // additionalContext can be injected into the resumed model context. + + // Refresh terminal UI. + remount?.(); + } catch (error) { + if (coreSwapped && !uiSwapped) { + // Core switched to the resumed session but UI hasn't swapped + // yet — put core back on the old session, otherwise the + // recorder would keep writing new user messages into the + // orphaned session JSONL while UI still shows the old session. + try { + config.startNewSession(oldSessionId, undefined); + } catch (rollbackErr) { + config + .getDebugLogger() + .warn( + `Rollback after failed /resume init failed: ${rollbackErr}`, + ); + } + } + addItem( + { + type: MessageType.ERROR, + text: `Failed to resume session: ${error instanceof Error ? error.message : String(error)}`, + } as HistoryItemWithoutId, + Date.now(), + ); + closeResumeDialog(); + remount?.(); } - // Rebuild turn boundary tracking so rewind works within resumed sessions. - config - .getChatRecordingService() - ?.rebuildTurnBoundaries(sessionData.conversation.messages); - await config.getGeminiClient()?.initialize?.(); - - const recovered = await config.loadPausedBackgroundAgents(sessionId); - if (recovered.length > 0) { - const recoveredMessage: HistoryItemWithoutId = { - type: MessageType.INFO, - text: config - .getBackgroundAgentResumeService() - .buildRecoveredBackgroundAgentsNotice(recovered.length), - }; - addItem?.(recoveredMessage, Date.now()); - } - - // SessionStart hook is handled during chat initialization so its - // additionalContext can be injected into the resumed model context. - - // Refresh terminal UI. - remount?.(); }, [ closeResumeDialog, config, - hasHistoryManager, addItem, clearItems, loadHistory, startNewSession, setSessionName, remount, + settings.merged.ui?.history?.collapseOnResume, ], ); diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 52ba3701a2..e9380f61fb 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -14,10 +14,12 @@ import type { ExtensionUpdateAction } from '../state/extensions.js'; */ export function createNonInteractiveUI(): CommandContext['ui'] { return { + history: [], addItem: (_item, _timestamp) => 0, clear: () => {}, setDebugMessage: (_message) => {}, loadHistory: (_newHistory) => {}, + refreshStatic: () => {}, pendingItem: null, setPendingItem: (_item) => {}, btwItem: null, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 02831ed496..da81500cb3 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -91,6 +91,20 @@ export interface SummaryProps { export interface HistoryItemBase { text?: string; // Text content for user/gemini/info/error messages + /** 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 ui.history.collapseOnResume + * when resuming a session. + */ + suppressOnRestore?: boolean; + /** + * Identifies special display-only items, like the summary row added + * when history is collapsed. + */ + kind?: 'collapse-summary'; + }; } export type HistoryItemUser = HistoryItemBase & { diff --git a/packages/cli/src/ui/utils/historyMapping.test.ts b/packages/cli/src/ui/utils/historyMapping.test.ts index 8056defa10..21dd633a7d 100644 --- a/packages/cli/src/ui/utils/historyMapping.test.ts +++ b/packages/cli/src/ui/utils/historyMapping.test.ts @@ -424,5 +424,14 @@ describe('isRealUserTurn', () => { it('returns false for non-user items', () => { expect(isRealUserTurn(geminiItem(1))).toBe(false); + expect( + isRealUserTurn({ type: 'info', id: 1, text: 'info' } as HistoryItem), + ).toBe(false); + }); + + it('returns true for user items with suppressOnRestore', () => { + const item = userItem(1, 'hello world'); + item.display = { suppressOnRestore: true }; + expect(isRealUserTurn(item)).toBe(true); }); }); diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 1b136368a1..ceff81dbe1 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { buildResumedHistoryItems } from './resumeHistoryUtils.js'; +import { + buildResumedHistoryItems, + stripSuppressOnRestore, + expandCollapsedHistory, +} from './resumeHistoryUtils.js'; import { ToolCallStatus } from '../types.js'; import type { AnyDeclarativeTool, @@ -14,6 +18,7 @@ import type { ResumedSessionData, } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; +import type { HistoryItem } from '../types.js'; const makeConfig = (tools: Record) => ({ @@ -460,3 +465,122 @@ describe('resumeHistoryUtils', () => { expect(items[0]).not.toHaveProperty('sentToModel'); }); }); + +describe('stripSuppressOnRestore', () => { + it('returns item unchanged when display is undefined', () => { + const item = { id: 1, type: 'user', text: 'hello' } as HistoryItem; + expect(stripSuppressOnRestore(item)).toBe(item); + }); + + it('returns item unchanged when suppressOnRestore is absent', () => { + const item = { + id: 1, + type: 'user', + text: 'hello', + display: {}, + } as HistoryItem; + const result = stripSuppressOnRestore(item); + expect(result).toEqual({ + id: 1, + type: 'user', + text: 'hello', + display: {}, + }); + }); + + it('strips suppressOnRestore while preserving other display properties', () => { + const item = { + id: 1, + type: 'user', + text: 'hello', + display: { suppressOnRestore: true, kind: 'collapse-summary' }, + } as HistoryItem; + const result = stripSuppressOnRestore(item); + expect(result).toEqual({ + id: 1, + type: 'user', + text: 'hello', + display: { kind: 'collapse-summary' }, + }); + }); + + it('sets display to undefined when suppressOnRestore was the only property', () => { + const item = { + id: 1, + type: 'user', + text: 'hello', + display: { suppressOnRestore: true }, + } as HistoryItem; + const result = stripSuppressOnRestore(item); + expect(result).toEqual({ + id: 1, + type: 'user', + text: 'hello', + display: undefined, + }); + }); +}); + +describe('expandCollapsedHistory', () => { + it('returns empty array for empty input', () => { + expect(expandCollapsedHistory([])).toEqual([]); + }); + + it('filters out collapse-summary items and strips suppressOnRestore', () => { + const items = [ + { + id: 1, + type: 'user', + text: 'hello', + display: { suppressOnRestore: true }, + }, + { + id: 2, + type: 'gemini', + text: 'hi', + display: { suppressOnRestore: true }, + }, + { + id: 3, + type: 'info', + text: 'Summary', + display: { kind: 'collapse-summary' }, + }, + ] as HistoryItem[]; + const result = expandCollapsedHistory(items); + expect(result).toEqual([ + { id: 1, type: 'user', text: 'hello', display: undefined }, + { id: 2, type: 'gemini', text: 'hi', display: undefined }, + ]); + }); + + it('preserves items without suppressOnRestore', () => { + const items = [ + { id: 1, type: 'user', text: 'hello' }, + { + id: 2, + type: 'gemini', + text: 'hi', + display: { suppressOnRestore: true }, + }, + ] as HistoryItem[]; + const result = expandCollapsedHistory(items); + expect(result).toEqual([ + { id: 1, type: 'user', text: 'hello' }, + { id: 2, type: 'gemini', text: 'hi', display: undefined }, + ]); + }); + + it('handles items with both suppressOnRestore and kind', () => { + const items = [ + { + id: 1, + type: 'user', + text: 'hello', + display: { suppressOnRestore: true, kind: 'collapse-summary' }, + }, + ] as HistoryItem[]; + const result = expandCollapsedHistory(items); + expect(result).toEqual([]); + }); +}); diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 015150de36..b9abda777b 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -17,10 +17,12 @@ import type { } from '@qwen-code/qwen-code-core'; import type { HistoryItem, + HistoryItemInfo, HistoryItemWithoutId, IndividualToolCallDisplay, } from '../types.js'; -import { ToolCallStatus } from '../types.js'; +import { ToolCallStatus, MessageType } from '../types.js'; +import { t } from '../../i18n/index.js'; /** * Extracts text content from a Content object's parts (excluding thought parts). @@ -502,3 +504,78 @@ export function buildResumedHistoryItems( return items; } + +/** + * Applies the quiet-restore display policy to resumed history items. + * Marks each item with `display.suppressOnRestore` so the rendering layer + * skips them while the canonical history (used by /rewind turn mapping) is preserved. + */ +function applyResumeDisplayPolicy(items: HistoryItem[]): HistoryItem[] { + return items.map((item) => ({ + ...item, + display: { ...item.display, suppressOnRestore: true }, + })); +} + +/** + * Creates the summary INFO item shown when resume-time collapse suppresses + * the transcript display. + */ +function createHistoryCollapseSummaryItem( + messageCount: number, +): HistoryItemInfo & { display: { kind: 'collapse-summary' } } { + const n = String(messageCount); + return { + type: MessageType.INFO, + text: t( + 'History collapsed: {{n}} messages hidden. Use /history expand-now to show.', + { n }, + ), + display: { kind: 'collapse-summary' }, + }; +} + +/** + * Strips the suppressOnRestore flag from a history item's display property. + * Used when rewinding into collapsed history to ensure rewound items remain visible. + */ +export function stripSuppressOnRestore(item: HistoryItem): HistoryItem { + if (!item.display?.suppressOnRestore) return item; + const { suppressOnRestore: _, ...rest } = item.display; + return { + ...item, + display: Object.keys(rest).length > 0 ? rest : undefined, + }; +} + +/** + * Removes collapse-summary items and strips suppressOnRestore from the rest. + * Shared between the rewind path and the expand-now command. + */ +export function expandCollapsedHistory(items: HistoryItem[]): HistoryItem[] { + return items + .filter((item) => item.display?.kind !== 'collapse-summary') + .map(stripSuppressOnRestore); +} + +/** + * Helper to apply the collapse policy and append the summary item if needed. + */ +export function applyCollapsePolicyAndSummary( + rawItems: HistoryItem[], + collapseOnResume: boolean, +): HistoryItem[] { + if (!collapseOnResume) return rawItems; + + const uiHistoryItems = applyResumeDisplayPolicy(rawItems); + + if (rawItems.length > 0) { + const nextId = rawItems[rawItems.length - 1].id + 1; + return [ + ...uiHistoryItems, + { id: nextId, ...createHistoryCollapseSummaryItem(rawItems.length) }, + ]; + } + + return uiHistoryItems; +} diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 02095516c6..e2f10529ed 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -219,6 +219,17 @@ "type": "boolean", "default": false }, + "history": { + "description": "History display settings.", + "type": "object", + "properties": { + "collapseOnResume": { + "description": "Whether to collapse history by default when resuming a session.", + "type": "boolean", + "default": false + } + } + }, "showLineNumbers": { "description": "Show line numbers in the code output.", "type": "boolean",