From 90a67a7e7b5b7b04fade073a8e5a544e1ce6c86c Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:23:33 +0800 Subject: [PATCH 01/16] fix(cli): improve slash command history feedback --- docs/design/slash-command-feedback.md | 25 ++ .../src/ui/components/ModelDialog.test.tsx | 31 ++- .../cli/src/ui/components/ModelDialog.tsx | 30 ++- .../ui/hooks/slashCommandProcessor.test.ts | 214 +++++++++++++++++- .../cli/src/ui/hooks/slashCommandProcessor.ts | 57 ++++- 5 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 docs/design/slash-command-feedback.md diff --git a/docs/design/slash-command-feedback.md b/docs/design/slash-command-feedback.md new file mode 100644 index 00000000000..8e288a47206 --- /dev/null +++ b/docs/design/slash-command-feedback.md @@ -0,0 +1,25 @@ +# Slash command history feedback + +## Problem + +Interactive slash commands are added to the TUI history before their action is +known. Commands that only open a dialog can therefore leave a bare invocation +behind after the dialog closes. The model picker has the same problem when it +is dismissed without a selection. + +## Design + +- Do not add the built-in `/auth`, `/settings`, `/status`, `/help`, `/theme`, + `/editor`, or `/diff` invocations to visible TUI history. Bare `/effort`, + `/statusline`, and `/stats` pickers are hidden too. Their existing UI remains + unchanged, as do chat recording and slash-command telemetry. User and project + commands that override those names keep their invocation history. +- Resolve the command before adding its invocation so aliases use the canonical + command name for this decision. +- Preserve invocations for commands that directly perform work, change session + state, write data, or enter a management/security workflow. Argument-sensitive + commands only hide their bare picker form; for example, `/effort` is hidden + while `/effort high` remains visible. +- When the primary model picker is dismissed without a selection, add an info + message identifying the unchanged model. Successful selections keep their + existing feedback. diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 8a40e2627c5..0d70d41edfe 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -1254,8 +1254,8 @@ describe('', () => { expect(typeof childOnHighlight).toBe('function'); }); - it('calls onClose prop when "escape" key is pressed', () => { - const { props } = renderComponent(); + it('reports the unchanged model when "escape" closes the primary picker', () => { + const { props, mockHistoryManager } = renderComponent(); expect(mockedUseKeypress).toHaveBeenCalled(); @@ -1272,6 +1272,13 @@ describe('', () => { paste: false, sequence: '', }); + expect(mockHistoryManager.addItem).toHaveBeenCalledWith( + { + type: 'info', + text: `Kept model as ${DEFAULT_QWEN_MODEL}`, + }, + expect.any(Number), + ); expect(props.onClose).toHaveBeenCalledTimes(1); keyPressHandler({ @@ -1282,6 +1289,26 @@ describe('', () => { paste: false, sequence: '', }); + expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it('does not report the primary model when closing an auxiliary picker', () => { + const { props, mockHistoryManager } = renderComponent({ + isFastModelMode: true, + }); + + const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; + keyPressHandler({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); expect(props.onClose).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 94ef1850a00..1eb54201d2d 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -656,6 +656,34 @@ export function ModelDialog({ ) : ''; + const closeWithoutSelection = useCallback(() => { + if ( + !isFastModelMode && + !isVoiceModelMode && + !isVisionModelMode && + !isCompactionModelMode && + !isImageModelMode + ) { + uiState?.historyManager.addItem( + { + type: 'info', + text: t('Kept model as {{model}}', { model: preferredModelId }), + }, + Date.now(), + ); + } + onClose(); + }, [ + isCompactionModelMode, + isFastModelMode, + isImageModelMode, + isVisionModelMode, + isVoiceModelMode, + onClose, + preferredModelId, + uiState, + ]); + useKeypress( (key) => { if ( @@ -667,7 +695,7 @@ export function ModelDialog({ isCompactionModelMode || isImageModelMode)) ) { - onClose(); + closeWithoutSelection(); } }, { isActive: true }, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 1a4dcc2185e..04394b8b9bf 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -135,6 +135,23 @@ function createTestCommand( }; } +function createTestCommandPath(path: string): SlashCommand { + const names = path.split(' '); + let command = createTestCommand({ + name: names.at(-1) ?? path, + action: vi.fn(), + }); + + for (let index = names.length - 2; index >= 0; index--) { + command = createTestCommand({ + name: names[index], + subCommands: [command], + }); + } + + return command; +} + describe('useSlashCommandProcessor', () => { const mockAddItem = vi.fn(); const mockUpdateItem = vi.fn(); @@ -142,8 +159,10 @@ describe('useSlashCommandProcessor', () => { const mockLoadHistory = vi.fn(); const mockOpenThemeDialog = vi.fn(); const mockOpenAuthDialog = vi.fn(); + const mockOpenSettingsDialog = vi.fn(); const mockOpenMemoryDialog = vi.fn(); const mockOpenModelDialog = vi.fn(); + const mockOpenHelpDialog = vi.fn(); const mockSetQuittingMessages = vi.fn(); const mockConfig = makeFakeConfig({}); @@ -159,13 +178,13 @@ describe('useSlashCommandProcessor', () => { openThemeDialog: mockOpenThemeDialog, openEditorDialog: vi.fn(), openMemoryDialog: mockOpenMemoryDialog, - openSettingsDialog: vi.fn(), + openSettingsDialog: mockOpenSettingsDialog, openStatusLineDialog: vi.fn(), openModelDialog: mockOpenModelDialog, openTrustDialog: vi.fn(), openPermissionsDialog: vi.fn(), openApprovalModeDialog: vi.fn(), - openHelpDialog: vi.fn(), + openHelpDialog: mockOpenHelpDialog, openResumeDialog: vi.fn(), handleResume: vi.fn(), handleBranch: vi.fn().mockResolvedValue(undefined), @@ -718,6 +737,197 @@ describe('useSlashCommandProcessor', () => { }); describe('Action Result Handling', () => { + it.each([ + ['/auth status', 'auth', ['connect', 'login'], 'auth'], + ['/connect', 'auth', ['connect', 'login'], 'auth'], + ['/settings', 'settings', undefined, 'settings'], + ] as const)( + 'handles %s without adding the invocation to TUI history', + async (input, name, altNames, dialog) => { + const command = createTestCommand({ + name, + altNames: altNames ? [...altNames] : undefined, + action: vi.fn().mockResolvedValue({ type: 'dialog', dialog }), + }); + const result = setupProcessorHook([command]); + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); + + await act(async () => { + await result.current.handleSlashCommand(input); + }); + + expect(mockAddItem).not.toHaveBeenCalled(); + expect( + name === 'auth' ? mockOpenAuthDialog : mockOpenSettingsDialog, + ).toHaveBeenCalledTimes(1); + expect( + mockConfig.getChatRecordingService()?.recordSlashCommand, + ).toHaveBeenCalledWith({ + phase: 'invocation', + rawCommand: input, + sentToModel: false, + }); + }, + ); + + it.each(['/help', '/?'])( + 'opens %s without adding the invocation to TUI history', + async (input) => { + const command = createTestCommand({ + name: 'help', + altNames: ['?'], + action: vi.fn().mockResolvedValue({ + type: 'dialog', + dialog: 'help', + }), + }); + const result = setupProcessorHook([command]); + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); + + await act(async () => { + await result.current.handleSlashCommand(input); + }); + + expect(mockAddItem).not.toHaveBeenCalled(); + expect(mockOpenHelpDialog).toHaveBeenCalledTimes(1); + }, + ); + + it.each(['/diff', '/editor', '/theme'])( + 'hides the invocation for the %s panel', + async (input) => { + const command = createTestCommandPath(input.slice(1)); + const result = setupProcessorHook([command]); + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); + + await act(async () => { + await result.current.handleSlashCommand(input); + }); + + expect(mockAddItem).not.toHaveBeenCalled(); + }, + ); + + it.each(['/effort', '/stats', '/statusline'])( + 'hides the invocation for the bare %s picker', + async (input) => { + const [name] = input.slice(1).split(' '); + const command = createTestCommand({ name, action: vi.fn() }); + const result = setupProcessorHook([command]); + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); + + await act(async () => { + await result.current.handleSlashCommand(input); + }); + + expect(mockAddItem).not.toHaveBeenCalled(); + }, + ); + + it('hides the invocation for the /usage alias', async () => { + const command = createTestCommand({ + name: 'stats', + altNames: ['usage'], + action: vi.fn(), + }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/usage'); + }); + + expect(mockAddItem).not.toHaveBeenCalled(); + }); + + it.each([ + ['/effort high', 'effort'], + ['/statusline make it compact', 'statusline'], + ['/stats export', 'stats export'], + ])( + 'keeps the invocation for the direct action %s', + async (input, canonicalPath) => { + const command = createTestCommandPath(canonicalPath); + const result = setupProcessorHook([command]); + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); + + await act(async () => { + await result.current.handleSlashCommand(input); + }); + + expect(mockAddItem).toHaveBeenCalledTimes(1); + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.USER, text: input, sentToModel: false }, + expect.any(Number), + ); + }, + ); + + it('shows status output without adding the invocation to TUI history', async () => { + const command = createTestCommand({ + name: 'status', + altNames: ['about'], + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'status output', + }), + }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/about'); + }); + + expect(mockAddItem).toHaveBeenCalledTimes(1); + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.INFO, text: 'status output' }, + expect.any(Number), + ); + }); + + it('keeps the invocation for a file command that overrides status', async () => { + const command = createTestCommand( + { + name: 'status', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'custom status output', + }), + }, + CommandKind.FILE, + ); + const result = setupProcessorHook([], [command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/status'); + }); + + expect(mockAddItem).toHaveBeenNthCalledWith( + 1, + { type: MessageType.USER, text: '/status', sentToModel: false }, + expect.any(Number), + ); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, + { type: MessageType.INFO, text: 'custom status output' }, + expect.any(Number), + ); + }); + it('should handle "dialog: theme" action', async () => { const command = createTestCommand({ name: 'themecmd', diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 68922cba960..5cae7b3341c 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -119,8 +119,44 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'btw', 'history', ]); +const SLASH_COMMAND_ROOTS_HIDE_INVOCATION = new Set([ + 'auth', + 'diff', + 'editor', + 'help', + 'settings', + 'status', + 'theme', +]); +const BARE_SLASH_COMMANDS_HIDE_INVOCATION = new Set([ + 'effort', + 'stats', + 'statusline', +]); const MAX_EXTENSION_CONTENT_REFRESH_PASSES = 5; +function shouldHideSlashCommandInvocation( + command: SlashCommand | undefined, + canonicalPath: string[], + args: string, +): boolean { + if (command?.kind !== CommandKind.BUILT_IN) { + return false; + } + + const root = canonicalPath[0] ?? ''; + if (SLASH_COMMAND_ROOTS_HIDE_INVOCATION.has(root)) { + return true; + } + + const path = canonicalPath.join(' '); + if (BARE_SLASH_COMMANDS_HIDE_INVOCATION.has(path)) { + return args.trim() === ''; + } + + return false; +} + function getSkillCommandName(command: SlashCommand): string { return command.skillDetail?.name ?? command.name; } @@ -838,6 +874,12 @@ export const useSlashCommandProcessor = ( return false; } + const { + commandToExecute, + args, + canonicalPath: resolvedCommandPath, + } = parseSlashCommand(trimmed, commands); + const recordedItems: HistoryItemWithoutId[] = []; const recordItem = (item: HistoryItemWithoutId) => { recordedItems.push(item); @@ -859,7 +901,15 @@ export const useSlashCommandProcessor = ( const userMessageTimestamp = Date.now(); let invocationItemId = existingInvocationItemId; let invocationSentToModel = false; - if (!isBtwCommand(trimmed) && invocationItemId === undefined) { + if ( + !isBtwCommand(trimmed) && + !shouldHideSlashCommandInvocation( + commandToExecute, + resolvedCommandPath, + args, + ) && + invocationItemId === undefined + ) { invocationItemId = addItemWithRecording( { type: MessageType.USER, text: trimmed, sentToModel: false }, userMessageTimestamp, @@ -868,11 +918,6 @@ export const useSlashCommandProcessor = ( let hasError = false; let delegatedToRecursiveInvocation = false; - const { - commandToExecute, - args, - canonicalPath: resolvedCommandPath, - } = parseSlashCommand(trimmed, commands); const subcommand = resolvedCommandPath.length > 1 From e7183bbd82e2f0d3a47f2fa8d31a72c9e27062bf Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:40:06 +0800 Subject: [PATCH 02/16] fix(cli): centralize auxiliary model guard --- .../src/ui/components/ModelDialog.test.tsx | 41 +++++++++++-------- .../cli/src/ui/components/ModelDialog.tsx | 34 +++++---------- 2 files changed, 34 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 0d70d41edfe..f62c7ba8ede 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -1293,24 +1293,31 @@ describe('', () => { expect(props.onClose).toHaveBeenCalledTimes(1); }); - it('does not report the primary model when closing an auxiliary picker', () => { - const { props, mockHistoryManager } = renderComponent({ - isFastModelMode: true, - }); - - const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; - keyPressHandler({ - name: 'escape', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: '', - }); + it.each([ + [{ isFastModelMode: true }], + [{ isVoiceModelMode: true }], + [{ isVisionModelMode: true }], + [{ isCompactionModelMode: true }], + [{ isImageModelMode: true }], + ])( + 'does not report the primary model when closing an auxiliary picker (%j)', + (modeProps) => { + const { props, mockHistoryManager } = renderComponent(modeProps); + + const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; + keyPressHandler({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); - expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(props.onClose).toHaveBeenCalledTimes(1); + }, + ); it('updates initialIndex when config context changes', () => { const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 1eb54201d2d..1aa3ff60989 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -520,16 +520,17 @@ export function ModelDialog({ : isImageModelMode && parsedImageModelSetting ? parsedImageModelSetting.modelId : config?.getModel() || MAINLINE_CODER_MODEL; - // Check if current model is a runtime model - // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format - const activeRuntimeSnapshot = + const isAuxiliaryModelMode = isFastModelMode || isVoiceModelMode || isVisionModelMode || isCompactionModelMode || - isImageModelMode - ? undefined - : config?.getActiveRuntimeModelSnapshot?.(); + isImageModelMode; + // Check if current model is a runtime model + // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format + const activeRuntimeSnapshot = isAuxiliaryModelMode + ? undefined + : config?.getActiveRuntimeModelSnapshot?.(); const currentBaseUrl = config ?.getModelsConfig() .getGenerationConfig()?.baseUrl; @@ -657,13 +658,7 @@ export function ModelDialog({ : ''; const closeWithoutSelection = useCallback(() => { - if ( - !isFastModelMode && - !isVoiceModelMode && - !isVisionModelMode && - !isCompactionModelMode && - !isImageModelMode - ) { + if (!isAuxiliaryModelMode) { uiState?.historyManager.addItem( { type: 'info', @@ -674,11 +669,7 @@ export function ModelDialog({ } onClose(); }, [ - isCompactionModelMode, - isFastModelMode, - isImageModelMode, - isVisionModelMode, - isVoiceModelMode, + isAuxiliaryModelMode, onClose, preferredModelId, uiState, @@ -688,12 +679,7 @@ export function ModelDialog({ (key) => { if ( key.name === 'escape' || - (key.name === 'left' && - (isFastModelMode || - isVoiceModelMode || - isVisionModelMode || - isCompactionModelMode || - isImageModelMode)) + (key.name === 'left' && isAuxiliaryModelMode) ) { closeWithoutSelection(); } From facb38f71e4806c3faf02306b63f3221d663cca4 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:04:38 +0800 Subject: [PATCH 03/16] test(cli): cover model picker left key --- .../src/ui/components/ModelDialog.test.tsx | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index f62c7ba8ede..b6158674dbd 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -1293,20 +1293,42 @@ describe('', () => { expect(props.onClose).toHaveBeenCalledTimes(1); }); + it('does not close the primary picker on "left"', () => { + const { props, mockHistoryManager } = renderComponent(); + + const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; + keyPressHandler({ + name: 'left', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(props.onClose).not.toHaveBeenCalled(); + }); + it.each([ - [{ isFastModelMode: true }], - [{ isVoiceModelMode: true }], - [{ isVisionModelMode: true }], - [{ isCompactionModelMode: true }], - [{ isImageModelMode: true }], + [{ isFastModelMode: true }, 'escape'], + [{ isVoiceModelMode: true }, 'escape'], + [{ isVisionModelMode: true }, 'escape'], + [{ isCompactionModelMode: true }, 'escape'], + [{ isImageModelMode: true }, 'escape'], + [{ isFastModelMode: true }, 'left'], + [{ isVoiceModelMode: true }, 'left'], + [{ isVisionModelMode: true }, 'left'], + [{ isCompactionModelMode: true }, 'left'], + [{ isImageModelMode: true }, 'left'], ])( - 'does not report the primary model when closing an auxiliary picker (%j)', - (modeProps) => { + 'does not report the primary model when closing an auxiliary picker (%j, %s)', + (modeProps, keyName) => { const { props, mockHistoryManager } = renderComponent(modeProps); const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; keyPressHandler({ - name: 'escape', + name: keyName, ctrl: false, meta: false, shift: false, From d7a14bf46f4cba102c2cae2ff62041fd8e8594e5 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:37:50 +0000 Subject: [PATCH 04/16] fix(cli): persist slash command invocation hiding across resume --- docs/design/slash-command-feedback.md | 21 +++-- packages/cli/src/i18n/locales/ca.js | 1 + packages/cli/src/i18n/locales/de.js | 1 + packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/fr.js | 1 + packages/cli/src/i18n/locales/ja.js | 1 + packages/cli/src/i18n/locales/pt.js | 1 + packages/cli/src/i18n/locales/ru.js | 1 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + .../src/ui/components/ModelDialog.test.tsx | 70 ++++++++++++++++ .../cli/src/ui/components/ModelDialog.tsx | 12 ++- .../ui/hooks/slashCommandProcessor.test.ts | 82 ++++++++++++++++++- .../cli/src/ui/hooks/slashCommandProcessor.ts | 23 ++++-- .../src/ui/utils/resumeHistoryUtils.test.ts | 37 +++++++++ .../cli/src/ui/utils/resumeHistoryUtils.ts | 6 +- .../core/src/services/chatRecordingService.ts | 5 ++ 17 files changed, 246 insertions(+), 19 deletions(-) diff --git a/docs/design/slash-command-feedback.md b/docs/design/slash-command-feedback.md index 8e288a47206..d5064802133 100644 --- a/docs/design/slash-command-feedback.md +++ b/docs/design/slash-command-feedback.md @@ -11,15 +11,26 @@ is dismissed without a selection. - Do not add the built-in `/auth`, `/settings`, `/status`, `/help`, `/theme`, `/editor`, or `/diff` invocations to visible TUI history. Bare `/effort`, - `/statusline`, and `/stats` pickers are hidden too. Their existing UI remains - unchanged, as do chat recording and slash-command telemetry. User and project - commands that override those names keep their invocation history. + `/model`, `/statusline`, and `/stats` pickers are hidden too. Their existing + UI remains unchanged, as do chat recording and slash-command telemetry. User + and project commands that override those names keep their invocation + history. +- Root matches apply to the bare command only; subcommands keep their + invocation because they perform work (for example `/status paths` prints + session paths). - Resolve the command before adding its invocation so aliases use the canonical command name for this decision. - Preserve invocations for commands that directly perform work, change session state, write data, or enter a management/security workflow. Argument-sensitive commands only hide their bare picker form; for example, `/effort` is hidden - while `/effort high` remains visible. + while `/effort high` remains visible, and `/model` is hidden while + `/model ` remains visible. +- Record the hiding decision in the chat record (`hiddenInvocation`) so + `/resume`, `/branch`, and session previews reconstruct the same history the + live session displayed instead of bringing the bare invocation row back. - When the primary model picker is dismissed without a selection, add an info message identifying the unchanged model. Successful selections keep their - existing feedback. + existing feedback. The other pickers leave no trace when dismissed; the + model picker states the outcome explicitly because the active model is + session-critical and otherwise invisible in history, so a silent close would + leave it ambiguous whether the model changed. diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index b2a187ab95c..b75cb1f1866 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2825,4 +2825,5 @@ export default { "Els canvis del gestor d'habilitats automàtiques estan desactivats en mode segur.", 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': "Els canvis del gestor d'habilitats automàtiques només estan disponibles en espais de treball de confiança. Marca aquesta carpeta com a fiable amb `/trust` i torna-ho a provar.", + 'Kept model as {{model}}': 'Model mantingut com a {{model}}', }; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 42db5e6e59e..ae412b38db0 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -2307,4 +2307,5 @@ export default { 'Änderungen durch den Auto-Skill-Kurator sind im Sicherheitsmodus deaktiviert.', 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': 'Änderungen durch den Auto-Skill-Kurator sind nur in vertrauenswürdigen Arbeitsbereichen verfügbar. Stufen Sie diesen Ordner mit `/trust` als vertrauenswürdig ein und versuchen Sie es erneut.', + 'Kept model as {{model}}': 'Modell als {{model}} beibehalten', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index a478ee36853..77489f3baaa 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2817,4 +2817,5 @@ export default { 'Auto-skill curator changes are disabled in safe mode.', 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.', + 'Kept model as {{model}}': 'Kept model as {{model}}', }; diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 737ce36151b..7f2020b1f14 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2312,4 +2312,5 @@ export default { 'Les modifications du gestionnaire de compétences automatiques sont désactivées en mode sécurisé.', 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': 'Les modifications du gestionnaire de compétences automatiques ne sont disponibles que dans les espaces de travail approuvés. Marquez ce dossier comme approuvé avec `/trust`, puis réessayez.', + 'Kept model as {{model}}': 'Modèle conservé : {{model}}', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index a1ab99889ad..fb124f04375 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -2073,4 +2073,5 @@ export default { 'セーフモードでは自動スキル管理による変更は無効です。', 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': '自動スキル管理による変更は信頼済みのワークスペースでのみ利用できます。`/trust` でこのフォルダーを信頼してから、もう一度お試しください。', + 'Kept model as {{model}}': 'モデルは {{model}} のままです', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 1cca737f082..d00980ca9de 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -2291,4 +2291,5 @@ export default { 'As alterações do gerenciador de habilidades automáticas estão desativadas no modo seguro.', 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': 'As alterações do gerenciador de habilidades automáticas estão disponíveis apenas em espaços de trabalho confiáveis. Marque esta pasta como confiável usando `/trust` e tente novamente.', + 'Kept model as {{model}}': 'Modelo mantido como {{model}}', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index d2c524fc46d..fcbad1a68b6 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -2282,4 +2282,5 @@ export default { 'Изменения куратора автоматических навыков отключены в безопасном режиме.', 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': 'Изменения куратора автоматических навыков доступны только в доверенных рабочих пространствах. Сделайте эту папку доверенной с помощью `/trust` и повторите попытку.', + 'Kept model as {{model}}': 'Оставлена модель {{model}}', }; diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index be8a5d24461..bfc8ee84c43 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2396,4 +2396,5 @@ export default { '安全模式下禁止變更自動技能管理器。', 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': '只有受信任的工作區可以變更自動技能管理器。請透過 `/trust` 信任此資料夾後再試一次。', + 'Kept model as {{model}}': '模型保持為 {{model}}', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index fc3dcab0e16..ec090605c35 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2598,4 +2598,5 @@ export default { '安全模式下禁止更改自动技能管理器。', 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': '仅受信任的工作区可以更改自动技能管理器。请通过 `/trust` 信任此文件夹后重试。', + 'Kept model as {{model}}': '模型保持为 {{model}}', }; diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index b6158674dbd..30ca40d9996 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -1281,6 +1281,18 @@ describe('', () => { ); expect(props.onClose).toHaveBeenCalledTimes(1); + // A second Escape byte in the same stdin chunk must not double-report. + keyPressHandler({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); + expect(props.onClose).toHaveBeenCalledTimes(1); + keyPressHandler({ name: 'a', ctrl: false, @@ -1341,6 +1353,64 @@ describe('', () => { }, ); + it('reports the active runtime model when closing the primary picker', () => { + const { mockHistoryManager } = renderComponent({}, { + getModel: vi.fn(() => 'configured-model'), + getActiveRuntimeModelSnapshot: vi.fn(() => ({ + id: '$runtime|qwen-oauth|runtime-model', + authType: AuthType.QWEN_OAUTH, + modelId: 'runtime-model', + })), + } as unknown as Partial); + + const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; + keyPressHandler({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + + expect(mockHistoryManager.addItem).toHaveBeenCalledWith( + { type: 'info', text: 'Kept model as runtime-model' }, + expect.any(Number), + ); + }); + + it('does not report the unchanged model when a selection is made', async () => { + const switchModel = vi.fn().mockResolvedValue(undefined); + const { props, mockHistoryManager } = renderComponent({}, { + getModel: vi.fn(() => 'gpt-4'), + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + switchModel, + getAllConfiguredModels: vi.fn(() => [ + { + id: 'gpt-4', + label: 'GPT-4', + description: 'GPT-4 model', + authType: AuthType.USE_OPENAI, + }, + ]), + getContentGeneratorConfig: vi.fn(() => ({ + authType: AuthType.USE_OPENAI, + model: 'gpt-4', + })), + } as unknown as Partial); + + const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; + await childOnSelect(`${AuthType.USE_OPENAI}::gpt-4`); + + expect(props.onClose).toHaveBeenCalledTimes(1); + expect(mockHistoryManager.addItem).not.toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Kept model as'), + }), + expect.any(Number), + ); + }); + it('updates initialIndex when config context changes', () => { const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 1aa3ff60989..5a73a4f1afb 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -6,7 +6,7 @@ import type React from 'react'; import process from 'node:process'; -import { useCallback, useContext, useMemo, useState } from 'react'; +import { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { Box, Text } from 'ink'; import { AuthType, @@ -657,18 +657,26 @@ export function ModelDialog({ ) : ''; + // Escape can arrive twice in one stdin chunk before the parent unmounts + // the dialog; latch so the close feedback and onClose fire only once. + const closeLatchRef = useRef(false); const closeWithoutSelection = useCallback(() => { + if (closeLatchRef.current) return; + closeLatchRef.current = true; if (!isAuxiliaryModelMode) { uiState?.historyManager.addItem( { type: 'info', - text: t('Kept model as {{model}}', { model: preferredModelId }), + text: t('Kept model as {{model}}', { + model: activeRuntimeSnapshot?.modelId ?? preferredModelId, + }), }, Date.now(), ); } onClose(); }, [ + activeRuntimeSnapshot, isAuxiliaryModelMode, onClose, preferredModelId, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 04394b8b9bf..2cb90261c40 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -768,6 +768,7 @@ describe('useSlashCommandProcessor', () => { phase: 'invocation', rawCommand: input, sentToModel: false, + hiddenInvocation: true, }); }, ); @@ -814,7 +815,7 @@ describe('useSlashCommandProcessor', () => { }, ); - it.each(['/effort', '/stats', '/statusline'])( + it.each(['/effort', '/model', '/stats', '/statusline'])( 'hides the invocation for the bare %s picker', async (input) => { const [name] = input.slice(1).split(' '); @@ -850,6 +851,7 @@ describe('useSlashCommandProcessor', () => { it.each([ ['/effort high', 'effort'], + ['/model qwen3-max', 'model'], ['/statusline make it compact', 'statusline'], ['/stats export', 'stats export'], ])( @@ -873,6 +875,35 @@ describe('useSlashCommandProcessor', () => { }, ); + it.each(['/status paths', '/stats model'])( + 'keeps the invocation for the %s subcommand', + async (input) => { + const command = createTestCommandPath(input.slice(1)); + const result = setupProcessorHook([command]); + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); + + await act(async () => { + await result.current.handleSlashCommand(input); + }); + + expect(mockAddItem).toHaveBeenCalledTimes(1); + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.USER, text: input, sentToModel: false }, + expect.any(Number), + ); + expect( + mockConfig.getChatRecordingService()?.recordSlashCommand, + ).toHaveBeenCalledWith({ + phase: 'invocation', + rawCommand: input, + sentToModel: false, + hiddenInvocation: false, + }); + }, + ); + it('shows status output without adding the invocation to TUI history', async () => { const command = createTestCommand({ name: 'status', @@ -898,6 +929,14 @@ describe('useSlashCommandProcessor', () => { }); it('keeps the invocation for a file command that overrides status', async () => { + const builtinStatus = createTestCommand({ + name: 'status', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'builtin status output', + }), + }); const command = createTestCommand( { name: 'status', @@ -909,13 +948,15 @@ describe('useSlashCommandProcessor', () => { }, CommandKind.FILE, ); - const result = setupProcessorHook([], [command]); + const result = setupProcessorHook([builtinStatus], [command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/status'); }); + expect(command.action).toHaveBeenCalledTimes(1); + expect(builtinStatus.action).not.toHaveBeenCalled(); expect(mockAddItem).toHaveBeenNthCalledWith( 1, { type: MessageType.USER, text: '/status', sentToModel: false }, @@ -1353,6 +1394,41 @@ describe('useSlashCommandProcessor', () => { phase: 'invocation', rawCommand: '/filecmd', sentToModel: true, + hiddenInvocation: false, + }); + }); + + it('classifies a hidden invocation as model-sent when it submits a prompt', async () => { + const command = createTestCommand({ + name: 'status', + action: vi.fn().mockResolvedValue({ + type: 'submit_prompt', + content: [{ text: 'hidden but submitted' }], + }), + }); + + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + let actionResult; + await act(async () => { + actionResult = await result.current.handleSlashCommand('/status'); + }); + + expect(actionResult).toEqual({ + type: 'submit_prompt', + content: [{ text: 'hidden but submitted' }], + }); + expect(mockAddItem).not.toHaveBeenCalled(); + expect(mockUpdateItem).not.toHaveBeenCalled(); + const recorder = mockConfig.getChatRecordingService() as unknown as { + recordSlashCommand: ReturnType; + }; + expect(recorder.recordSlashCommand).toHaveBeenCalledWith({ + phase: 'invocation', + rawCommand: '/status', + sentToModel: true, + hiddenInvocation: true, }); }); @@ -1833,6 +1909,7 @@ describe('useSlashCommandProcessor', () => { phase: 'invocation', rawCommand: '/shellcmd', sentToModel: true, + hiddenInvocation: false, }); }); @@ -1885,6 +1962,7 @@ describe('useSlashCommandProcessor', () => { phase: 'invocation', rawCommand: '/actioncmd', sentToModel: true, + hiddenInvocation: false, }); }); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 5cae7b3341c..0cce55317ab 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -130,6 +130,7 @@ const SLASH_COMMAND_ROOTS_HIDE_INVOCATION = new Set([ ]); const BARE_SLASH_COMMANDS_HIDE_INVOCATION = new Set([ 'effort', + 'model', 'stats', 'statusline', ]); @@ -144,8 +145,12 @@ function shouldHideSlashCommandInvocation( return false; } - const root = canonicalPath[0] ?? ''; - if (SLASH_COMMAND_ROOTS_HIDE_INVOCATION.has(root)) { + // Bare-root match only: subcommands that produce output (e.g. `/status + // paths`) keep their invocation like any other work-performing command. + if ( + canonicalPath.length === 1 && + SLASH_COMMAND_ROOTS_HIDE_INVOCATION.has(canonicalPath[0] ?? '') + ) { return true; } @@ -901,15 +906,14 @@ export const useSlashCommandProcessor = ( const userMessageTimestamp = Date.now(); let invocationItemId = existingInvocationItemId; let invocationSentToModel = false; - if ( - !isBtwCommand(trimmed) && - !shouldHideSlashCommandInvocation( + const hideInvocation = + isBtwCommand(trimmed) || + shouldHideSlashCommandInvocation( commandToExecute, resolvedCommandPath, args, - ) && - invocationItemId === undefined - ) { + ); + if (!hideInvocation && invocationItemId === undefined) { invocationItemId = addItemWithRecording( { type: MessageType.USER, text: trimmed, sentToModel: false }, userMessageTimestamp, @@ -1332,8 +1336,8 @@ export const useSlashCommandProcessor = ( output.getAdditionalContext(), ); } + invocationSentToModel = true; if (invocationItemId !== undefined) { - invocationSentToModel = true; debugLogger.debug( `Marked slash command invocation as model-sent: /${resolvedCommandPath.join( ' ', @@ -1504,6 +1508,7 @@ export const useSlashCommandProcessor = ( phase: 'invocation', rawCommand: trimmed, sentToModel: invocationSentToModel, + hiddenInvocation: hideInvocation, }); const outputItems = recordedItems .filter((item) => item.type !== 'user') diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 0a6bbdcd993..e1c46b0bfbc 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -730,6 +730,43 @@ describe('resumeHistoryUtils', () => { ]); }); + it('skips hidden slash command invocations on resume', () => { + const conversation = { + messages: [ + { + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'invocation', + rawCommand: '/theme', + sentToModel: false, + hiddenInvocation: true, + }, + }, + { + type: 'assistant', + timestamp: '2026-01-15T19:00:00.000Z', + message: { parts: [{ text: 'Follow-up' } as Part] }, + }, + ], + } as unknown as ConversationRecord; + + const session: ResumedSessionData = { + conversation, + } as ResumedSessionData; + + const items = buildResumedHistoryItems(session, makeConfig({}), 40); + + expect(items).toEqual([ + { + id: 41, + type: 'gemini', + text: 'Follow-up', + timestamp: new Date('2026-01-15T19:00:00.000Z').getTime(), + }, + ]); + }); + it('preserves local-only slash command metadata on resume', () => { const conversation = { messages: [ diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 385898bf9de..0254d9a4d36 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -325,7 +325,11 @@ function convertToHistoryItems( | SlashCommandRecordPayload | undefined; if (!payload) continue; - if (payload.phase === 'invocation' && payload.rawCommand) { + if ( + payload.phase === 'invocation' && + payload.rawCommand && + !payload.hiddenInvocation + ) { const sentToModel = typeof payload.sentToModel === 'boolean' ? payload.sentToModel diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 6699e448e1b..814f7a0e608 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -458,6 +458,11 @@ export interface SlashCommandRecordPayload { rawCommand: string; /** Whether the visible slash-command invocation reached model history. */ sentToModel?: boolean; + /** + * Whether the UI intentionally hid this invocation from visible history, + * so resume/preview reconstruction skips the user row as well. + */ + hiddenInvocation?: boolean; /** * History items the UI displayed for this command, in the same shape used by * the CLI (without IDs). Stored as plain objects for replay on resume. From 8d19d46840eaf21455d6fb8ac6557a9fba28d76e Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:24:04 +0800 Subject: [PATCH 05/16] fix: keep model picker feedback consistent --- .../src/ui/components/ModelDialog.test.tsx | 79 +++++++++++++++++++ .../cli/src/ui/components/ModelDialog.tsx | 44 +++++++---- .../ui/hooks/slashCommandProcessor.test.ts | 28 +++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 7 +- 4 files changed, 140 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 30ca40d9996..8d077df2a33 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -78,6 +78,7 @@ const renderComponent = ( getGenerationConfig: vi.fn(() => ({ baseUrl: undefined })), })), getActiveRuntimeModelSnapshot: vi.fn(() => undefined), + getChatRecordingService: vi.fn(() => undefined), // --- Functions used by ClearcutLogger --- getUsageStatisticsEnabled: vi.fn(() => true), @@ -1305,6 +1306,32 @@ describe('', () => { expect(props.onClose).toHaveBeenCalledTimes(1); }); + it('records dismissal feedback for resumed history', () => { + const recordSlashCommand = vi.fn(); + const { mockHistoryManager } = renderComponent({}, { + getChatRecordingService: vi.fn(() => ({ recordSlashCommand })), + } as unknown as Partial); + + const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; + keyPressHandler({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + + expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [ + { type: 'info', text: `Kept model as ${DEFAULT_QWEN_MODEL}` }, + ], + }); + }); + it('does not close the primary picker on "left"', () => { const { props, mockHistoryManager } = renderComponent(); @@ -1411,6 +1438,58 @@ describe('', () => { ); }); + it('ignores escape while a model selection is in flight', async () => { + let resolveSwitch: (() => void) | undefined; + const switchModel = vi.fn( + () => + new Promise((resolve) => { + resolveSwitch = resolve; + }), + ); + const { props, mockHistoryManager } = renderComponent({}, { + getModel: vi.fn(() => 'gpt-4'), + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + switchModel, + getAllConfiguredModels: vi.fn(() => [ + { + id: 'gpt-4', + label: 'GPT-4', + description: 'GPT-4 model', + authType: AuthType.USE_OPENAI, + }, + ]), + getContentGeneratorConfig: vi.fn(() => ({ + authType: AuthType.USE_OPENAI, + model: 'gpt-4', + })), + } as unknown as Partial); + + const selection = mockedSelect.mock.calls[0][0].onSelect( + `${AuthType.USE_OPENAI}::gpt-4`, + ); + const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; + keyPressHandler({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + + expect(mockHistoryManager.addItem).not.toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Kept model as'), + }), + expect.any(Number), + ); + expect(props.onClose).not.toHaveBeenCalled(); + + resolveSwitch?.(); + await selection; + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + it('updates initialIndex when config context changes', () => { const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 5a73a4f1afb..f7094164593 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -660,23 +660,28 @@ export function ModelDialog({ // Escape can arrive twice in one stdin chunk before the parent unmounts // the dialog; latch so the close feedback and onClose fire only once. const closeLatchRef = useRef(false); + const selectionInFlightRef = useRef(false); const closeWithoutSelection = useCallback(() => { - if (closeLatchRef.current) return; + if (closeLatchRef.current || selectionInFlightRef.current) return; closeLatchRef.current = true; if (!isAuxiliaryModelMode) { - uiState?.historyManager.addItem( - { - type: 'info', - text: t('Kept model as {{model}}', { - model: activeRuntimeSnapshot?.modelId ?? preferredModelId, - }), - }, - Date.now(), - ); + const feedbackItem = { + type: 'info' as const, + text: t('Kept model as {{model}}', { + model: activeRuntimeSnapshot?.modelId ?? preferredModelId, + }), + }; + uiState?.historyManager.addItem(feedbackItem, Date.now()); + config?.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [feedbackItem], + }); } onClose(); }, [ activeRuntimeSnapshot, + config, isAuxiliaryModelMode, onClose, preferredModelId, @@ -970,13 +975,18 @@ export function ModelDialog({ selectedBaseUrl = parsed.baseUrl; } - await config.switchModel(selectedAuthType, modelId, { - ...(selectedAuthType !== authType && - selectedAuthType === AuthType.QWEN_OAUTH - ? { requireCachedCredentials: true } - : {}), - baseUrl: selectedBaseUrl, - }); + selectionInFlightRef.current = true; + try { + await config.switchModel(selectedAuthType, modelId, { + ...(selectedAuthType !== authType && + selectedAuthType === AuthType.QWEN_OAUTH + ? { requireCachedCredentials: true } + : {}), + baseUrl: selectedBaseUrl, + }); + } finally { + selectionInFlightRef.current = false; + } if (!isRuntime) { const event = new ModelSlashCommandEvent(modelId); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index b7359733fe7..399b10d883a 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -835,6 +835,34 @@ describe('useSlashCommandProcessor', () => { }, ); + it.each([ + '/model --fast', + '/model --voice --project', + '/model --vision --global', + '/model --compaction', + '/model --image', + '/stats 2026-01', + ])('hides the invocation for the picker-only form %s', async (input) => { + const [name] = input.slice(1).split(' '); + const command = createTestCommand({ name, action: vi.fn() }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand(input); + }); + + expect(mockAddItem).not.toHaveBeenCalled(); + expect( + mockConfig.getChatRecordingService()?.recordSlashCommand, + ).toHaveBeenCalledWith({ + phase: 'invocation', + rawCommand: input, + sentToModel: false, + hiddenInvocation: true, + }); + }); + it('hides the invocation for the /usage alias', async () => { const command = createTestCommand({ name: 'stats', diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index bea89911f12..7e24e318795 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -126,12 +126,12 @@ const SLASH_COMMAND_ROOTS_HIDE_INVOCATION = new Set([ 'help', 'settings', 'status', + 'stats', 'theme', ]); const BARE_SLASH_COMMANDS_HIDE_INVOCATION = new Set([ 'effort', 'model', - 'stats', 'statusline', ]); const MAX_EXTENSION_CONTENT_REFRESH_PASSES = 5; @@ -156,6 +156,11 @@ function shouldHideSlashCommandInvocation( const path = canonicalPath.join(' '); if (BARE_SLASH_COMMANDS_HIDE_INVOCATION.has(path)) { + if (path === 'model') { + return /^(\s*--(?:fast|voice|vision|compaction|image|project|global)\s*)*$/.test( + args, + ); + } return args.trim() === ''; } From 81618a6a450b0f5dcf72a615807aaea5228575ca Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 7 Aug 2026 08:01:56 +0000 Subject: [PATCH 06/16] docs(cli): align design doc with /stats root-level hiding --- docs/design/slash-command-feedback.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/slash-command-feedback.md b/docs/design/slash-command-feedback.md index d5064802133..f9c2cedd9f8 100644 --- a/docs/design/slash-command-feedback.md +++ b/docs/design/slash-command-feedback.md @@ -10,8 +10,8 @@ is dismissed without a selection. ## Design - Do not add the built-in `/auth`, `/settings`, `/status`, `/help`, `/theme`, - `/editor`, or `/diff` invocations to visible TUI history. Bare `/effort`, - `/model`, `/statusline`, and `/stats` pickers are hidden too. Their existing + `/editor`, `/diff`, or `/stats` invocations to visible TUI history. Bare + `/effort`, `/model`, and `/statusline` pickers are hidden too. Their existing UI remains unchanged, as do chat recording and slash-command telemetry. User and project commands that override those names keep their invocation history. From 15227e1841afa141fe9562b7577916b4a79e8945 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:16:23 +0800 Subject: [PATCH 07/16] fix(cli): preserve model picker history safely --- packages/cli/src/ui/commands/modelCommand.ts | 10 +++ .../src/ui/components/ModelDialog.test.tsx | 83 +++++++++++++++++++ .../cli/src/ui/components/ModelDialog.tsx | 36 ++++---- .../ui/hooks/slashCommandProcessor.test.ts | 31 +++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 26 +++++- .../qwen-agent-slash-history.test.ts | 15 +++- .../packages/shared/src/agent/qwen-agent.ts | 28 +++++-- 7 files changed, 200 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index b720595925a..d12a97e7187 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -47,6 +47,16 @@ const COMPACTION_MODEL_CONFIGURATION_HINT = const IMAGE_MODEL_CONFIGURATION_HINT = 'Configure a model with imageOnly: true, baseUrl, and envKey in settings.modelProviders. Run /model --image to select it.'; +const MODEL_PICKER_FLAG_PATTERN = + '(?:fast|voice|vision|compaction|image|project|global)'; +const MODEL_PICKER_ONLY_PATTERN = new RegExp( + `^(?:\\s*--${MODEL_PICKER_FLAG_PATTERN})*\\s*$`, +); + +export function isPickerOnlyModelInvocation(args: string): boolean { + return MODEL_PICKER_ONLY_PATTERN.test(args); +} + /** * Parse --project / --global scope flags from the argument string. * Returns the resolved scope override and the remaining args with flags stripped. diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 8d077df2a33..3a7f3fae03b 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -1436,6 +1436,89 @@ describe('', () => { }), expect.any(Number), ); + + const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; + keyPressHandler({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it('records successful model-switch feedback for resumed history', async () => { + const recordSlashCommand = vi.fn(); + const { mockHistoryManager } = renderComponent({}, { + getModel: vi.fn(() => 'gpt-4'), + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + switchModel: vi.fn().mockResolvedValue(undefined), + getAllConfiguredModels: vi.fn(() => [ + { + id: 'gpt-4', + label: 'GPT-4', + authType: AuthType.USE_OPENAI, + }, + ]), + getContentGeneratorConfig: vi.fn(() => ({ + authType: AuthType.USE_OPENAI, + model: 'gpt-4', + })), + getChatRecordingService: vi.fn(() => ({ recordSlashCommand })), + } as unknown as Partial); + + await act(async () => { + await mockedSelect.mock.calls[0][0].onSelect( + `${AuthType.USE_OPENAI}::gpt-4`, + ); + }); + + const feedbackItem = vi.mocked(mockHistoryManager.addItem).mock.calls[0][0]; + expect(feedbackItem.text).toContain('Using model: gpt-4'); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [feedbackItem], + }); + }); + + it('remains dismissible after a failed model switch', async () => { + const { props, mockHistoryManager } = renderComponent({}, { + getModel: vi.fn(() => 'gpt-4'), + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + switchModel: vi.fn().mockRejectedValue(new Error('network down')), + getAllConfiguredModels: vi.fn(() => [ + { + id: 'gpt-4', + label: 'GPT-4', + authType: AuthType.USE_OPENAI, + }, + ]), + } as unknown as Partial); + + await act(async () => { + await mockedSelect.mock.calls[0][0].onSelect( + `${AuthType.USE_OPENAI}::gpt-4`, + ); + }); + expect(props.onClose).not.toHaveBeenCalled(); + + mockedUseKeypress.mock.calls[0][0]({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + + expect(props.onClose).toHaveBeenCalledTimes(1); + expect(mockHistoryManager.addItem).toHaveBeenCalledWith( + { type: 'info', text: 'Kept model as gpt-4' }, + expect.any(Number), + ); }); it('ignores escape while a model selection is in flight', async () => { diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index f7094164593..d1c91d1db71 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -17,6 +17,7 @@ import { parseVisionModelSetting, resolveModelId, type AvailableModel as CoreAvailableModel, + type Config, type ContentGeneratorConfig, type InputModalities, } from '@qwen-code/qwen-code-core'; @@ -204,6 +205,7 @@ function hydrateApiKeyEnvFromSettings( } interface HandleModelSwitchSuccessParams { + config: Config; settings: ReturnType; uiState: UIState | null; after: ContentGeneratorConfig | undefined; @@ -215,6 +217,7 @@ interface HandleModelSwitchSuccessParams { } function handleModelSwitchSuccess({ + config, settings, uiState, after, @@ -242,20 +245,23 @@ function handleModelSwitchSuccess({ : persistScope === 'user' ? t(' (global)') : ''; - uiState?.historyManager.addItem( - { - type: 'info', - text: - `authType: ${effectiveAuthType ?? `(${t('none')})`}` + - `\n` + - `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` + - `\n` + - `Base URL: ${baseUrl}` + - `\n` + - `API key: ${maskedKey}`, - }, - Date.now(), - ); + const feedbackItem = { + type: 'info' as const, + text: + `authType: ${effectiveAuthType ?? `(${t('none')})`}` + + `\n` + + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` + + `\n` + + `Base URL: ${baseUrl}` + + `\n` + + `API key: ${maskedKey}`, + }; + uiState?.historyManager.addItem(feedbackItem, Date.now()); + config.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [feedbackItem], + }); } function formatContextWindow(size?: number): string { @@ -1013,6 +1019,7 @@ export function ModelDialog({ } handleModelSwitchSuccess({ + config, settings, uiState, after, @@ -1030,6 +1037,7 @@ export function ModelDialog({ isRuntime, persistScope, }); + closeLatchRef.current = true; onClose(); }, [ diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 399b10d883a..0bcc7c6b725 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -863,6 +863,37 @@ describe('useSlashCommandProcessor', () => { }); }); + it('reveals a picker-shaped invocation when argument validation fails', async () => { + const input = '/model --project --global'; + const command = createTestCommand({ + name: 'model', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'error', + content: 'Cannot use both --project and --global', + }), + }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand(input); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.USER, text: input, sentToModel: false }, + expect.any(Number), + ); + expect( + mockConfig.getChatRecordingService()?.recordSlashCommand, + ).toHaveBeenCalledWith({ + phase: 'invocation', + rawCommand: input, + sentToModel: false, + hiddenInvocation: false, + }); + }); + it('hides the invocation for the /usage alias', async () => { const command = createTestCommand({ name: 'stats', diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 7e24e318795..993a48df93b 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -76,6 +76,7 @@ import { } from '../utils/commandUtils.js'; import { clearScreen } from '../../utils/stdioHelpers.js'; import { useKeypress } from './useKeypress.js'; +import { isPickerOnlyModelInvocation } from '../commands/modelCommand.js'; import { type ExtensionUpdateAction, type ExtensionUpdateStatus, @@ -157,9 +158,7 @@ function shouldHideSlashCommandInvocation( const path = canonicalPath.join(' '); if (BARE_SLASH_COMMANDS_HIDE_INVOCATION.has(path)) { if (path === 'model') { - return /^(\s*--(?:fast|voice|vision|compaction|image|project|global)\s*)*$/.test( - args, - ); + return isPickerOnlyModelInvocation(args); } return args.trim() === ''; } @@ -914,7 +913,7 @@ export const useSlashCommandProcessor = ( const userMessageTimestamp = Date.now(); let invocationItemId = existingInvocationItemId; let invocationSentToModel = false; - const hideInvocation = + let hideInvocation = isBtwCommand(trimmed) || shouldHideSlashCommandInvocation( commandToExecute, @@ -928,6 +927,21 @@ export const useSlashCommandProcessor = ( ); } + const revealHiddenInvocation = () => { + if ( + resolvedCommandPath.join(' ') !== 'model' || + !hideInvocation || + invocationItemId !== undefined + ) { + return; + } + hideInvocation = false; + invocationItemId = addItemWithRecording( + { type: MessageType.USER, text: trimmed, sentToModel: false }, + userMessageTimestamp, + ); + }; + let hasError = false; let delegatedToRecursiveInvocation = false; @@ -1110,6 +1124,10 @@ export const useSlashCommandProcessor = ( toolArgs: result.toolArgs, }; case 'message': + // Picker-shaped commands can still reject their arguments + // before opening a dialog. Keep those failures paired with + // the invocation in both live and reconstructed history. + revealHiddenInvocation(); if (result.messageType === 'info') { addMessage({ type: MessageType.INFO, diff --git a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts index 90c4389dc58..6fc72499759 100644 --- a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts +++ b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts @@ -709,7 +709,11 @@ describe('QwenAgent slash command history', () => { timestamp: '2026-03-25T07:36:39.000Z', type: 'system', subtype: 'slash_command', - systemPayload: { phase: 'invocation', rawCommand: '/model' }, + systemPayload: { + phase: 'invocation', + rawCommand: '/model', + hiddenInvocation: true, + }, }, { uuid: 'model-result', @@ -721,7 +725,9 @@ describe('QwenAgent slash command history', () => { systemPayload: { phase: 'result', rawCommand: '/model', - outputHistoryItems: [], + outputHistoryItems: [ + { type: 'info', text: 'Kept model as qwen3-max' }, + ], }, }, { @@ -774,6 +780,11 @@ describe('QwenAgent slash command history', () => { message.timestamp, ]), ).toEqual([ + [ + 'assistant', + 'Kept model as qwen3-max', + Date.parse('2026-03-25T07:36:40.000Z'), + ], ['user', '/insight', Date.parse(insightInvocation)], [ 'assistant', diff --git a/packages/desktop/packages/shared/src/agent/qwen-agent.ts b/packages/desktop/packages/shared/src/agent/qwen-agent.ts index 37010558276..cbe55d98878 100644 --- a/packages/desktop/packages/shared/src/agent/qwen-agent.ts +++ b/packages/desktop/packages/shared/src/agent/qwen-agent.ts @@ -207,6 +207,7 @@ type HistoryCollector = { type SlashCommandInvocation = { rawCommand: string; timestamp: number; + hidden: boolean; }; const MID_TURN_QUEUE_DRAIN_METHOD = 'craft/drainMidTurnQueue'; @@ -3426,7 +3427,8 @@ export class QwenAgent extends BaseAgent { if (record.type === 'user') return true; if (record.type !== 'system' || record.subtype !== 'slash_command') return false; - return toRecord(record.systemPayload).phase === 'invocation'; + const payload = toRecord(record.systemPayload); + return payload.phase === 'invocation' && payload.hiddenInvocation !== true; } private async persistQwenTranscriptTextElements( @@ -4576,7 +4578,13 @@ export class QwenAgent extends BaseAgent { const timestamp = parseQwenTimestamp(record.timestamp) ?? Date.now(); if (phase === 'invocation') { const uuid = asString(record.uuid); - if (uuid) invocations.set(uuid, { rawCommand, timestamp }); + if (uuid) { + invocations.set(uuid, { + rawCommand, + timestamp, + hidden: payload.hiddenInvocation === true, + }); + } continue; } @@ -4597,13 +4605,15 @@ export class QwenAgent extends BaseAgent { seenResults.add(resultKey); const invocation = parentUuid ? invocations.get(parentUuid) : undefined; - const userContent = invocation?.rawCommand || rawCommand; - messages.push({ - id: `qwen-${sessionId}-slash-${++idCounter}`, - role: 'user', - content: userContent, - timestamp: invocation?.timestamp ?? timestamp, - }); + if (!invocation?.hidden) { + const userContent = invocation?.rawCommand || rawCommand; + messages.push({ + id: `qwen-${sessionId}-slash-${++idCounter}`, + role: 'user', + content: userContent, + timestamp: invocation?.timestamp ?? timestamp, + }); + } messages.push({ id: `qwen-${sessionId}-slash-${++idCounter}`, role: 'assistant', From d1140e6e65a0c497ed338108b43d195ad6a76a57 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:54:42 +0800 Subject: [PATCH 08/16] fix: preserve hidden command feedback on resume --- packages/cli/src/ui/AppContainer.tsx | 8 +- packages/cli/src/ui/auth/useAuth.test.ts | 15 ++- packages/cli/src/ui/auth/useAuth.ts | 23 ++-- .../src/ui/components/ModelDialog.test.tsx | 100 +++++++++++++----- .../cli/src/ui/components/ModelDialog.tsx | 68 ++++++------ .../ui/components/StatusLineDialog.test.tsx | 17 ++- .../src/ui/components/StatusLineDialog.tsx | 21 ++-- .../src/ui/hooks/use-effort-command.test.ts | 7 ++ .../cli/src/ui/hooks/use-effort-command.ts | 45 ++++---- .../src/ui/hooks/useEditorSettings.test.ts | 22 +++- .../cli/src/ui/hooks/useEditorSettings.ts | 22 ++-- .../cli/src/ui/hooks/useThemeCommand.test.ts | 45 +++++++- packages/cli/src/ui/hooks/useThemeCommand.ts | 25 +++-- .../qwen-agent-slash-history.test.ts | 17 ++- .../packages/shared/src/agent/qwen-agent.ts | 17 ++- 15 files changed, 317 insertions(+), 135 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index de5964922cd..096eb266f50 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1359,6 +1359,7 @@ export const AppContainer = (props: AppContainerProps) => { setThemeError, historyManager.addItem, initializationResult.themeError, + config, ); const { @@ -1418,7 +1419,12 @@ export const AppContainer = (props: AppContainerProps) => { openEditorDialog, handleEditorSelect, exitEditorDialog, - } = useEditorSettings(settings, setEditorError, historyManager.addItem); + } = useEditorSettings( + settings, + setEditorError, + historyManager.addItem, + config, + ); const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = useSettingsCommand(); diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts index 6182040916e..073348b86da 100644 --- a/packages/cli/src/ui/auth/useAuth.test.ts +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -58,7 +58,7 @@ const createSettings = () => ({ })), }); -const createConfig = () => { +const createConfig = (recordSlashCommand = vi.fn()) => { const modelsConfig = { syncAfterAuthRefresh: vi.fn(), }; @@ -68,6 +68,7 @@ const createConfig = () => { reloadModelProvidersConfig: vi.fn(), refreshAuth: vi.fn(async () => undefined), getModelsConfig: vi.fn(() => modelsConfig), + getChatRecordingService: vi.fn(() => ({ recordSlashCommand })), }; }; @@ -99,7 +100,8 @@ describe('useAuthCommand', () => { it('configures DeepSeek via the unified provider submit', async () => { const settings = createSettings(); - const config = createConfig(); + const recordSlashCommand = vi.fn(); + const config = createConfig(recordSlashCommand); const addItem = vi.fn(); const { result } = renderHook(() => @@ -139,6 +141,15 @@ describe('useAuthCommand', () => { }), expect.any(Number), ); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/auth', + outputHistoryItems: [ + expect.objectContaining({ + text: expect.stringContaining('Successfully configured DeepSeek'), + }), + ], + }); }); it('configures OpenRouter via the unified provider submit', async () => { diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 9113ed79553..26f34358c85 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -181,16 +181,19 @@ export const useAuthCommand = ( completeAuthentication(); - addItem( - { - type: MessageType.INFO, - text: t( - 'Successfully configured {{provider}}. Use /model to switch models.', - { provider: providerConfig.label }, - ), - }, - Date.now(), - ); + const feedbackItem: HistoryItemWithoutId & Record = { + type: MessageType.INFO, + text: t( + 'Successfully configured {{provider}}. Use /model to switch models.', + { provider: providerConfig.label }, + ), + }; + addItem(feedbackItem, Date.now()); + config.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/auth', + outputHistoryItems: [feedbackItem], + }); logAuth(config, new AuthEvent(protocol, 'manual', 'success')); } catch (error) { diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 3a7f3fae03b..1c3f6537813 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -60,6 +60,8 @@ const renderComponent = ( ...(settingsValue ?? {}), } as unknown as LoadedSettings; + const recordSlashCommand = vi.fn(); + const mockConfig = { // --- Functions used by ModelDialog --- getModel: vi.fn(() => DEFAULT_QWEN_MODEL), @@ -78,7 +80,7 @@ const renderComponent = ( getGenerationConfig: vi.fn(() => ({ baseUrl: undefined })), })), getActiveRuntimeModelSnapshot: vi.fn(() => undefined), - getChatRecordingService: vi.fn(() => undefined), + getChatRecordingService: vi.fn(() => ({ recordSlashCommand })), // --- Functions used by ClearcutLogger --- getUsageStatisticsEnabled: vi.fn(() => true), @@ -119,6 +121,7 @@ const renderComponent = ( mockConfig, mockSettings, mockHistoryManager, + recordSlashCommand, }; }; @@ -689,27 +692,30 @@ describe('', () => { it('stores authType-qualified selectors in fast model mode', async () => { const setFastModel = vi.fn(); - const { props, mockSettings } = renderComponent({ isFastModelMode: true }, { - getAuthType: vi.fn(() => AuthType.USE_ANTHROPIC), - getModel: vi.fn(() => 'claude-opus-4-7'), - getAllConfiguredModels: vi.fn(() => [ - { - id: 'deepseek-v4-flash', - label: 'deepseek-v4-flash', - authType: AuthType.USE_OPENAI, - }, - { - id: 'claude-opus-4-7', - label: 'claude-opus-4-7', + const { props, mockSettings, recordSlashCommand } = renderComponent( + { isFastModelMode: true }, + { + getAuthType: vi.fn(() => AuthType.USE_ANTHROPIC), + getModel: vi.fn(() => 'claude-opus-4-7'), + getAllConfiguredModels: vi.fn(() => [ + { + id: 'deepseek-v4-flash', + label: 'deepseek-v4-flash', + authType: AuthType.USE_OPENAI, + }, + { + id: 'claude-opus-4-7', + label: 'claude-opus-4-7', + authType: AuthType.USE_ANTHROPIC, + }, + ]), + getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.USE_ANTHROPIC, - }, - ]), - getContentGeneratorConfig: vi.fn(() => ({ - authType: AuthType.USE_ANTHROPIC, - model: 'claude-opus-4-7', - })), - setFastModel, - } as unknown as Partial); + model: 'claude-opus-4-7', + })), + setFastModel, + } as unknown as Partial, + ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; await childOnSelect(`${AuthType.USE_OPENAI}::deepseek-v4-flash`); @@ -720,13 +726,20 @@ describe('', () => { 'openai:deepseek-v4-flash', ); expect(setFastModel).toHaveBeenCalledWith('openai:deepseek-v4-flash'); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [ + { type: 'success', text: 'Fast Model: openai:deepseek-v4-flash' }, + ], + }); expect(props.onClose).toHaveBeenCalledTimes(1); }); it('stores authType-qualified selectors in vision model mode without switching models', async () => { const switchModel = vi.fn(); const setVisionModel = vi.fn(); - const { props, mockSettings } = renderComponent( + const { props, mockSettings, recordSlashCommand } = renderComponent( { isVisionModelMode: true }, { getAuthType: vi.fn(() => AuthType.USE_ANTHROPIC), @@ -763,6 +776,13 @@ describe('', () => { 'openai:qwen-vl-max', ); expect(setVisionModel).toHaveBeenCalledWith('openai:qwen-vl-max'); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [ + { type: 'success', text: 'Vision Model: openai:qwen-vl-max' }, + ], + }); expect(switchModel).not.toHaveBeenCalled(); expect(mockSettings.setValue).not.toHaveBeenCalledWith( SettingScope.User, @@ -775,7 +795,7 @@ describe('', () => { it('stores compaction model selector without switching models', async () => { const switchModel = vi.fn(); const setCompactionModel = vi.fn(); - const { props, mockSettings } = renderComponent( + const { props, mockSettings, recordSlashCommand } = renderComponent( { isCompactionModelMode: true }, { getAuthType: vi.fn(() => AuthType.USE_OPENAI), @@ -812,6 +832,16 @@ describe('', () => { 'openai:compaction-model', ); expect(setCompactionModel).toHaveBeenCalledWith('openai:compaction-model'); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [ + { + type: 'success', + text: 'Compaction Model: openai:compaction-model', + }, + ], + }); expect(switchModel).not.toHaveBeenCalled(); expect(props.onClose).toHaveBeenCalledTimes(1); }); @@ -820,9 +850,8 @@ describe('', () => { const setImageModel = vi.fn().mockResolvedValue(undefined); const baseUrl = 'https://images.example.com/api/v1'; const persisted = `openai:qwen-image-2.0\0${baseUrl}`; - const { props, mockSettings, getByText } = renderComponent( - { isImageModelMode: true }, - { + const { props, mockSettings, getByText, recordSlashCommand } = + renderComponent({ isImageModelMode: true }, { getAuthType: vi.fn(() => AuthType.USE_OPENAI), getAllConfiguredModels: vi.fn(() => [ { @@ -856,8 +885,7 @@ describe('', () => { : undefined, ), setImageModel, - } as unknown as Partial, - ); + } as unknown as Partial); expect(getByText('Select Image Model')).toBeDefined(); const selectProps = mockedSelect.mock.calls[0][0]; @@ -872,6 +900,13 @@ describe('', () => { persisted, ); expect(setImageModel).toHaveBeenCalledWith(persisted); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [ + { type: 'success', text: 'Image Model: openai:qwen-image-2.0' }, + ], + }); expect(props.onClose).toHaveBeenCalledTimes(1); }); @@ -969,7 +1004,7 @@ describe('', () => { it('stores the plain model id in voice model mode without switching models', async () => { const switchModel = vi.fn(); const setFastModel = vi.fn(); - const { props, mockSettings } = renderComponent( + const { props, mockSettings, recordSlashCommand } = renderComponent( { isVoiceModelMode: true }, { getAuthType: vi.fn(() => AuthType.USE_OPENAI), @@ -1006,6 +1041,13 @@ describe('', () => { ); expect(switchModel).not.toHaveBeenCalled(); expect(setFastModel).not.toHaveBeenCalled(); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [ + { type: 'success', text: 'Voice Model: qwen3-asr-flash' }, + ], + }); expect(mockSettings.setValue).not.toHaveBeenCalledWith( SettingScope.User, 'model.name', diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index d1c91d1db71..e6ce14290f8 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -34,6 +34,7 @@ import { formatUnsupportedVoiceModelMessage, isSelectableVoiceModel, } from '../voice/voice-model.js'; +import type { HistoryItemWithoutId } from '../types.js'; function formatModalities(modalities?: InputModalities): string { if (!modalities) return t('text-only'); @@ -667,6 +668,17 @@ export function ModelDialog({ // the dialog; latch so the close feedback and onClose fire only once. const closeLatchRef = useRef(false); const selectionInFlightRef = useRef(false); + const reportAuxiliaryModelSelection = useCallback( + (feedbackItem: HistoryItemWithoutId & Record) => { + uiState?.historyManager.addItem(feedbackItem, Date.now()); + config?.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [feedbackItem], + }); + }, + [config, uiState], + ); const closeWithoutSelection = useCallback(() => { if (closeLatchRef.current || selectionInFlightRef.current) return; closeLatchRef.current = true; @@ -776,13 +788,10 @@ export function ModelDialog({ : persistScope === 'user' ? t(' (global)') : ''; - uiState?.historyManager.addItem( - { - type: 'success', - text: `${t('Voice Model')}: ${voiceModel}${scopeSuffix}`, - }, - Date.now(), - ); + reportAuxiliaryModelSelection({ + type: 'success', + text: `${t('Voice Model')}: ${voiceModel}${scopeSuffix}`, + }); onClose(); return; } @@ -803,13 +812,10 @@ export function ModelDialog({ : persistScope === 'user' ? t(' (global)') : ''; - uiState?.historyManager.addItem( - { - type: 'success', - text: `${t('Fast Model')}: ${fastModel}${scopeSuffix}`, - }, - Date.now(), - ); + reportAuxiliaryModelSelection({ + type: 'success', + text: `${t('Fast Model')}: ${fastModel}${scopeSuffix}`, + }); onClose(); return; } @@ -850,13 +856,10 @@ export function ModelDialog({ : persistScope === 'user' ? t(' (global)') : ''; - uiState?.historyManager.addItem( - { - type: 'success', - text: `${t('Vision Model')}: ${visionModelDisplay}${scopeSuffix}${visionWarning}`, - }, - Date.now(), - ); + reportAuxiliaryModelSelection({ + type: 'success', + text: `${t('Vision Model')}: ${visionModelDisplay}${scopeSuffix}${visionWarning}`, + }); onClose(); return; } @@ -878,13 +881,10 @@ export function ModelDialog({ : persistScope === 'user' ? t(' (global)') : ''; - uiState?.historyManager.addItem( - { - type: 'success', - text: `${t('Compaction Model')}: ${compactionModelId}${scopeSuffix}`, - }, - Date.now(), - ); + reportAuxiliaryModelSelection({ + type: 'success', + text: `${t('Compaction Model')}: ${compactionModelId}${scopeSuffix}`, + }); onClose(); return; } @@ -915,13 +915,10 @@ export function ModelDialog({ : persistScope === 'user' ? t(' (global)') : ''; - uiState?.historyManager.addItem( - { - type: 'success', - text: `${t('Image Model')}: ${imageModelDisplay}${scopeSuffix}`, - }, - Date.now(), - ); + reportAuxiliaryModelSelection({ + type: 'success', + text: `${t('Image Model')}: ${imageModelDisplay}${scopeSuffix}`, + }); onClose(); return; } @@ -1054,6 +1051,7 @@ export function ModelDialog({ isImageModelMode, availableModelEntries, persistScope, + reportAuxiliaryModelSelection, ], ); diff --git a/packages/cli/src/ui/components/StatusLineDialog.test.tsx b/packages/cli/src/ui/components/StatusLineDialog.test.tsx index d3aa8b3b2ce..c94a86f7208 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.test.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.test.tsx @@ -118,11 +118,16 @@ describe('StatusLineDialog', () => { const addItem = vi.fn(); const onClose = vi.fn(); const onSaved = vi.fn(); + const recordSlashCommand = vi.fn(); + const recordingConfig = { + ...config, + getChatRecordingService: () => ({ recordSlashCommand }), + } as unknown as Config; const { stdin } = render( { }, expect.any(Number), ); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/statusline', + outputHistoryItems: [ + { + type: MessageType.INFO, + text: 'Status line preset saved to user settings.', + }, + ], + }); expect(onSaved).toHaveBeenCalledWith(settings.merged.ui?.statusLine); expect(onClose).toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/components/StatusLineDialog.tsx b/packages/cli/src/ui/components/StatusLineDialog.tsx index 62aa9c7d890..ea25b3414a2 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.tsx @@ -13,7 +13,7 @@ import { SettingScope } from '../../config/settings.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemWithoutId } from '../types.js'; import type { UIState } from '../contexts/UIStateContext.js'; import { MultiSelect, type MultiSelectItem } from './shared/MultiSelect.js'; import { @@ -178,15 +178,18 @@ export function StatusLineDialog({ const effectiveScope = getEffectiveStatusLineScope(settings); settings.setValue(effectiveScope, 'ui.statusLine', presetConfig); onSaved?.(presetConfig); - addItem( - { - type: MessageType.INFO, - text: `Status line preset saved to ${effectiveScope.toLowerCase()} settings.`, - }, - Date.now(), - ); + const feedbackItem: HistoryItemWithoutId & Record = { + type: MessageType.INFO, + text: `Status line preset saved to ${effectiveScope.toLowerCase()} settings.`, + }; + addItem(feedbackItem, Date.now()); + config.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/statusline', + outputHistoryItems: [feedbackItem], + }); onClose(); - }, [addItem, onClose, onSaved, presetConfig, settings]); + }, [addItem, config, onClose, onSaved, presetConfig, settings]); useKeypress( (key) => { diff --git a/packages/cli/src/ui/hooks/use-effort-command.test.ts b/packages/cli/src/ui/hooks/use-effort-command.test.ts index 02a025c98f6..828fa1d8d4e 100644 --- a/packages/cli/src/ui/hooks/use-effort-command.test.ts +++ b/packages/cli/src/ui/hooks/use-effort-command.test.ts @@ -64,9 +64,11 @@ describe('useEffortCommand', () => { it('confirms the requested tier in-chat on success', () => { const addItem = vi.fn(); + const recordSlashCommand = vi.fn(); config = { setReasoningEffort, getReasoningEffort: vi.fn().mockReturnValue('xhigh'), + getChatRecordingService: vi.fn(() => ({ recordSlashCommand })), } as unknown as Config; const { result } = renderHook(() => useEffortCommand(settings, config, addItem), @@ -79,6 +81,11 @@ describe('useEffortCommand', () => { expect(item.type).toBe('info'); expect(item.text).toContain('xhigh'); expect(item.text).toContain('requested'); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/effort', + outputHistoryItems: [item], + }); }); it('warns in-chat when thinking is disabled (tier did not take effect)', () => { diff --git a/packages/cli/src/ui/hooks/use-effort-command.ts b/packages/cli/src/ui/hooks/use-effort-command.ts index 8d4b653f776..38fcb6f477b 100644 --- a/packages/cli/src/ui/hooks/use-effort-command.ts +++ b/packages/cli/src/ui/hooks/use-effort-command.ts @@ -50,29 +50,28 @@ export const useEffortCommand = ( // for future sessions, but say it won't take effect until thinking is // re-enabled; otherwise confirm the requested tier. if (addItem) { - if (config.getReasoningEffort() !== effort) { - addItem( - { - type: MessageType.INFO, - text: t( - 'Reasoning effort set to {{tier}}, but thinking is currently disabled — it will take effect when thinking is re-enabled.', - { tier: effort }, - ), - }, - Date.now(), - ); - } else { - addItem( - { - type: MessageType.INFO, - text: t( - 'Reasoning effort: {{tier}} (requested; the effective tier depends on the active provider/model).', - { tier: effort }, - ), - }, - Date.now(), - ); - } + const feedbackItem: HistoryItemWithoutId & Record = + config.getReasoningEffort() !== effort + ? { + type: MessageType.INFO, + text: t( + 'Reasoning effort set to {{tier}}, but thinking is currently disabled — it will take effect when thinking is re-enabled.', + { tier: effort }, + ), + } + : { + type: MessageType.INFO, + text: t( + 'Reasoning effort: {{tier}} (requested; the effective tier depends on the active provider/model).', + { tier: effort }, + ), + }; + addItem(feedbackItem, Date.now()); + config.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/effort', + outputHistoryItems: [feedbackItem], + }); } } finally { setIsEffortDialogOpen(false); diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.ts b/packages/cli/src/ui/hooks/useEditorSettings.test.ts index 8059e4b2a6a..45112afb8c3 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.ts +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.ts @@ -21,6 +21,7 @@ import { SettingScope } from '../../config/settings.js'; import { MessageType, type HistoryItemWithoutId } from '../types.js'; import { type EditorType, + type Config, checkHasEditorType, allowEditorTypeInSandbox, } from '@qwen-code/qwen-code-core'; @@ -95,8 +96,17 @@ describe('useEditorSettings', () => { }); it('should handle editor selection successfully', () => { + const recordSlashCommand = vi.fn(); + const config = { + getChatRecordingService: () => ({ recordSlashCommand }), + } as unknown as Config; const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + useEditorSettings( + mockLoadedSettings, + mockSetEditorError, + mockAddItem, + config, + ), ); const editorType: EditorType = 'vscode'; @@ -120,6 +130,16 @@ describe('useEditorSettings', () => { }, expect.any(Number), ); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/editor', + outputHistoryItems: [ + { + type: MessageType.INFO, + text: 'Editor preference set to "vscode" in User settings.', + }, + ], + }); expect(mockSetEditorError).toHaveBeenCalledWith(null); expect(result.current.isEditorDialogOpen).toBe(false); diff --git a/packages/cli/src/ui/hooks/useEditorSettings.ts b/packages/cli/src/ui/hooks/useEditorSettings.ts index 4903240b418..6836a456f13 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.ts +++ b/packages/cli/src/ui/hooks/useEditorSettings.ts @@ -7,7 +7,7 @@ import { useState, useCallback } from 'react'; import type { LoadedSettings, SettingScope } from '../../config/settings.js'; import { type HistoryItemWithoutId, MessageType } from '../types.js'; -import type { EditorType } from '@qwen-code/qwen-code-core'; +import type { Config, EditorType } from '@qwen-code/qwen-code-core'; import { allowEditorTypeInSandbox, checkHasEditorType, @@ -27,6 +27,7 @@ export const useEditorSettings = ( loadedSettings: LoadedSettings, setEditorError: (error: string | null) => void, addItem: (item: HistoryItemWithoutId, timestamp: number) => void, + config?: Config, ): UseEditorSettingsReturn => { const [isEditorDialogOpen, setIsEditorDialogOpen] = useState(false); @@ -46,20 +47,23 @@ export const useEditorSettings = ( try { loadedSettings.setValue(scope, 'general.preferredEditor', editorType); - addItem( - { - type: MessageType.INFO, - text: `Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`, - }, - Date.now(), - ); + const feedbackItem: HistoryItemWithoutId & Record = { + type: MessageType.INFO, + text: `Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`, + }; + addItem(feedbackItem, Date.now()); + config?.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/editor', + outputHistoryItems: [feedbackItem], + }); setEditorError(null); setIsEditorDialogOpen(false); } catch (error) { setEditorError(`Failed to set editor preference: ${error}`); } }, - [loadedSettings, setEditorError, addItem], + [loadedSettings, setEditorError, addItem, config], ); const exitEditorDialog = useCallback(() => { diff --git a/packages/cli/src/ui/hooks/useThemeCommand.test.ts b/packages/cli/src/ui/hooks/useThemeCommand.test.ts index 65d3650c83e..025515481cb 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.test.ts @@ -4,18 +4,61 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { act } from 'react'; import { renderHook } from '@testing-library/react'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { useThemeCommand } from './useThemeCommand.js'; import { themeManager } from '../themes/theme-manager.js'; +import { MessageType } from '../types.js'; +import type { Config } from '@qwen-code/qwen-code-core'; +import process from 'node:process'; describe('useThemeCommand', () => { + const previousNoColor = process.env['NO_COLOR']; + beforeEach(() => { vi.restoreAllMocks(); themeManager.setActiveTheme('Qwen Dark'); + delete process.env['NO_COLOR']; + }); + + afterEach(() => { + if (previousNoColor === undefined) delete process.env['NO_COLOR']; + else process.env['NO_COLOR'] = previousNoColor; + }); + + it('records the NO_COLOR feedback for transcript reconstruction', () => { + process.env['NO_COLOR'] = '1'; + const recordSlashCommand = vi.fn(); + const addItem = vi.fn(); + const config = { + getChatRecordingService: () => ({ recordSlashCommand }), + } as unknown as Config; + const settings = { + merged: {}, + user: { settings: {} }, + workspace: { settings: {} }, + } as unknown as LoadedSettings; + + const { result } = renderHook(() => + useThemeCommand(settings, vi.fn(), addItem, null, config), + ); + + act(() => result.current.openThemeDialog()); + + const feedbackItem = { + type: MessageType.INFO, + text: 'Theme configuration unavailable due to NO_COLOR env variable.', + }; + expect(addItem).toHaveBeenCalledWith(feedbackItem, expect.any(Number)); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/theme', + outputHistoryItems: [feedbackItem], + }); + expect(result.current.isThemeDialogOpen).toBe(false); }); it('restores previous theme on cancel (Esc)', () => { diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index e55cf1377fa..de869ed445b 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -10,6 +10,7 @@ import type { LoadedSettings, SettingScope } from '../../config/settings.js'; // import { type HistoryItemWithoutId, MessageType } from '../types.js'; import process from 'node:process'; import { t } from '../../i18n/index.js'; +import type { Config } from '@qwen-code/qwen-code-core'; interface UseThemeCommandReturn { isThemeDialogOpen: boolean; @@ -26,6 +27,7 @@ export const useThemeCommand = ( setThemeError: (error: string | null) => void, addItem: (item: HistoryItemWithoutId, timestamp: number) => void, initialThemeError: string | null, + config?: Config, ): UseThemeCommandReturn => { const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(!!initialThemeError); @@ -35,22 +37,25 @@ export const useThemeCommand = ( const openThemeDialog = useCallback(() => { if (process.env['NO_COLOR']) { - addItem( - { - type: MessageType.INFO, - text: t( - 'Theme configuration unavailable due to NO_COLOR env variable.', - ), - }, - Date.now(), - ); + const feedbackItem: HistoryItemWithoutId & Record = { + type: MessageType.INFO, + text: t( + 'Theme configuration unavailable due to NO_COLOR env variable.', + ), + }; + addItem(feedbackItem, Date.now()); + config?.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/theme', + outputHistoryItems: [feedbackItem], + }); return; } // The theme may temporarily change while navigating the list; keep the // original value to restore it if user cancels with Esc/Ctrl+C. setThemeBeforeDialogOpen(themeManager.getActiveTheme().name); setIsThemeDialogOpen(true); - }, [addItem]); + }, [addItem, config]); const applyTheme = useCallback( (themeName: string | undefined) => { diff --git a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts index 6fc72499759..0d14f5c2a36 100644 --- a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts +++ b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts @@ -716,12 +716,25 @@ describe('QwenAgent slash command history', () => { }, }, { - uuid: 'model-result', + uuid: 'model-open-result', parentUuid: 'model-invocation', sessionId, timestamp: '2026-03-25T07:36:40.000Z', type: 'system', subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [], + }, + }, + { + uuid: 'model-result', + parentUuid: 'model-open-result', + sessionId, + timestamp: '2026-03-25T07:36:41.000Z', + type: 'system', + subtype: 'slash_command', systemPayload: { phase: 'result', rawCommand: '/model', @@ -783,7 +796,7 @@ describe('QwenAgent slash command history', () => { [ 'assistant', 'Kept model as qwen3-max', - Date.parse('2026-03-25T07:36:40.000Z'), + Date.parse('2026-03-25T07:36:41.000Z'), ], ['user', '/insight', Date.parse(insightInvocation)], [ diff --git a/packages/desktop/packages/shared/src/agent/qwen-agent.ts b/packages/desktop/packages/shared/src/agent/qwen-agent.ts index cbe55d98878..2d50f8330b2 100644 --- a/packages/desktop/packages/shared/src/agent/qwen-agent.ts +++ b/packages/desktop/packages/shared/src/agent/qwen-agent.ts @@ -4549,6 +4549,7 @@ export class QwenAgent extends BaseAgent { const transcriptPath = getQwenTranscriptPath(sessionId, cwd); if (!existsSync(transcriptPath)) return []; + const recordsByUuid = new Map(); const invocations = new Map(); const seenResults = new Set(); const messages: Message[] = []; @@ -4567,6 +4568,9 @@ export class QwenAgent extends BaseAgent { continue; } + const uuid = asString(record.uuid); + if (uuid) recordsByUuid.set(uuid, record); + if (record.type !== 'system' || record.subtype !== 'slash_command') continue; @@ -4577,7 +4581,6 @@ export class QwenAgent extends BaseAgent { const phase = asString(payload.phase); const timestamp = parseQwenTimestamp(record.timestamp) ?? Date.now(); if (phase === 'invocation') { - const uuid = asString(record.uuid); if (uuid) { invocations.set(uuid, { rawCommand, @@ -4604,7 +4607,17 @@ export class QwenAgent extends BaseAgent { if (seenResults.has(resultKey)) continue; seenResults.add(resultKey); - const invocation = parentUuid ? invocations.get(parentUuid) : undefined; + let ancestorUuid = parentUuid; + const visited = new Set(); + let invocation: SlashCommandInvocation | undefined; + while (ancestorUuid && !visited.has(ancestorUuid)) { + visited.add(ancestorUuid); + invocation = invocations.get(ancestorUuid); + if (invocation) break; + ancestorUuid = asString( + recordsByUuid.get(ancestorUuid)?.parentUuid, + ); + } if (!invocation?.hidden) { const userContent = invocation?.rawCommand || rawCommand; messages.push({ From 90f0145353f0e96818a0bfaf58cafe062721ca54 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:03:25 +0800 Subject: [PATCH 09/16] fix(desktop): match slash results to invocations --- .../qwen-agent-slash-history.test.ts | 45 ++++++++++++++++++- .../packages/shared/src/agent/qwen-agent.ts | 19 +++++--- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts index 0d14f5c2a36..e3338bfc2c1 100644 --- a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts +++ b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts @@ -693,7 +693,7 @@ describe('QwenAgent slash command history', () => { agent.destroy(); }); - it('adds slash command invocations when their result produced output', () => { + it('adds only matched slash command invocations when results produce output', () => { const runtimeRoot = mkdtempSync(join(tmpdir(), 'qwen-runtime-')); const cwd = mkdtempSync(join(tmpdir(), 'qwen-cwd-')); tempRoots.push(runtimeRoot, cwd); @@ -769,6 +769,39 @@ describe('QwenAgent slash command history', () => { ], }, }, + { + uuid: 'theme-result', + parentUuid: 'insight-result', + sessionId, + timestamp: '2026-03-25T07:36:54.000Z', + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/theme', + outputHistoryItems: [ + { + type: 'error', + text: 'Theme changes are disabled when NO_COLOR is set.', + }, + ], + }, + }, + { + uuid: 'auth-result', + parentUuid: 'startup-record', + sessionId, + timestamp: '2026-03-25T07:36:55.000Z', + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/auth', + outputHistoryItems: [ + { type: 'info', text: 'Authenticated successfully.' }, + ], + }, + }, ]); const agent = createAgent(cwd); @@ -804,6 +837,16 @@ describe('QwenAgent slash command history', () => { 'This may take a couple minutes. Sit tight!', Date.parse(insightResult), ], + [ + 'assistant', + 'Theme changes are disabled when NO_COLOR is set.', + Date.parse('2026-03-25T07:36:54.000Z'), + ], + [ + 'assistant', + 'Authenticated successfully.', + Date.parse('2026-03-25T07:36:55.000Z'), + ], ]); expect(messages[0]?.textElements).toBeUndefined(); }); diff --git a/packages/desktop/packages/shared/src/agent/qwen-agent.ts b/packages/desktop/packages/shared/src/agent/qwen-agent.ts index 2d50f8330b2..c4a368021bd 100644 --- a/packages/desktop/packages/shared/src/agent/qwen-agent.ts +++ b/packages/desktop/packages/shared/src/agent/qwen-agent.ts @@ -4610,21 +4610,28 @@ export class QwenAgent extends BaseAgent { let ancestorUuid = parentUuid; const visited = new Set(); let invocation: SlashCommandInvocation | undefined; + const resultCommandName = rawCommand.split(/\s+/, 1)[0]; while (ancestorUuid && !visited.has(ancestorUuid)) { visited.add(ancestorUuid); - invocation = invocations.get(ancestorUuid); - if (invocation) break; + const candidate = invocations.get(ancestorUuid); + if (candidate) { + if ( + candidate.rawCommand.split(/\s+/, 1)[0] === resultCommandName + ) { + invocation = candidate; + } + break; + } ancestorUuid = asString( recordsByUuid.get(ancestorUuid)?.parentUuid, ); } - if (!invocation?.hidden) { - const userContent = invocation?.rawCommand || rawCommand; + if (invocation && !invocation.hidden) { messages.push({ id: `qwen-${sessionId}-slash-${++idCounter}`, role: 'user', - content: userContent, - timestamp: invocation?.timestamp ?? timestamp, + content: invocation.rawCommand, + timestamp: invocation.timestamp, }); } messages.push({ From c1dfe0489b7d5ee21ade11dbad64236ae3698071 Mon Sep 17 00:00:00 2001 From: Qwen Autofix Date: Fri, 7 Aug 2026 17:07:37 +0000 Subject: [PATCH 10/16] fix: keep slash command feedback paired with invocations - Record message-type command results through the recording wrapper so rejections and errors land in the result record and replay on resume - Move the NO_COLOR /theme rejection into the command action so the result record is written after the invocation, and keep that invocation visible because the command prints feedback instead of opening the dialog - Skip the /auth result record when the dialog auto-opened without an /auth invocation to pair with - Guard the model picker against re-entering a selection while a switch is already in flight - Walk only uuid -> parentUuid links when matching desktop slash results to invocations instead of retaining every parsed transcript record --- docs/design/slash-command-feedback.md | 4 ++ packages/cli/src/ui/auth/useAuth.test.ts | 31 ++++++++ packages/cli/src/ui/auth/useAuth.ts | 20 ++++-- .../cli/src/ui/commands/themeCommand.test.ts | 25 ++++++- packages/cli/src/ui/commands/themeCommand.ts | 29 ++++++-- .../src/ui/components/ModelDialog.test.tsx | 44 ++++++++++++ .../cli/src/ui/components/ModelDialog.tsx | 1 + .../ui/hooks/slashCommandProcessor.test.ts | 70 +++++++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 34 +++++---- .../packages/shared/src/agent/qwen-agent.ts | 11 +-- 10 files changed, 237 insertions(+), 32 deletions(-) diff --git a/docs/design/slash-command-feedback.md b/docs/design/slash-command-feedback.md index f9c2cedd9f8..076a7fffb81 100644 --- a/docs/design/slash-command-feedback.md +++ b/docs/design/slash-command-feedback.md @@ -25,6 +25,10 @@ is dismissed without a selection. commands only hide their bare picker form; for example, `/effort` is hidden while `/effort high` remains visible, and `/model` is hidden while `/model ` remains visible. +- Commands that fail before opening their dialog keep the invocation paired + with the failure message: `/theme` under `NO_COLOR` is not hidden because it + prints feedback instead of opening the picker, and a hidden picker-shaped + `/model` invocation is revealed when its arguments are rejected. - Record the hiding decision in the chat record (`hiddenInvocation`) so `/resume`, `/branch`, and session previews reconstruct the same history the live session displayed instead of bringing the bare invocation row back. diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts index 073348b86da..2f24997abdf 100644 --- a/packages/cli/src/ui/auth/useAuth.test.ts +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -114,6 +114,10 @@ describe('useAuthCommand', () => { modelIds: ['deepseek-v4-flash', 'deepseek-v4-pro'], }; + act(() => { + result.current.openAuthDialog(); + }); + await act(async () => { await result.current.handleProviderSubmit(deepseekProvider, inputs); }); @@ -152,6 +156,33 @@ describe('useAuthCommand', () => { }); }); + it('keeps live feedback but skips the /auth record when the dialog auto-opened', async () => { + const settings = createSettings(); + const recordSlashCommand = vi.fn(); + const config = createConfig(recordSlashCommand); + const addItem = vi.fn(); + + const { result } = renderHook(() => + useAuthCommand(settings as never, config as never, addItem), + ); + + await act(async () => { + await result.current.handleProviderSubmit(deepseekProvider, { + baseUrl: resolveBaseUrl(deepseekProvider), + apiKey: 'sk-deepseek', + modelIds: ['deepseek-v4-flash'], + }); + }); + + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Successfully configured DeepSeek'), + }), + expect.any(Number), + ); + expect(recordSlashCommand).not.toHaveBeenCalled(); + }); + it('configures OpenRouter via the unified provider submit', async () => { const settings = createSettings(); const config = createConfig(); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 26f34358c85..64d65a3aa2d 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -15,7 +15,7 @@ import { type ProviderConfig, type ProviderSetupInputs, } from '@qwen-code/qwen-code-core'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { LoadedSettings } from '../../config/settings.js'; import { createLoadedSettingsAdapter } from '../../config/loadedSettingsAdapter.js'; import { useQwenAuth } from '../hooks/useQwenAuth.js'; @@ -106,6 +106,10 @@ export const useAuthCommand = ( isAuthenticating, ); + // The dialog also auto-opens at startup when unauthenticated; only a + // command-opened dialog has an /auth invocation record to pair with. + const openedViaCommandRef = useRef(false); + // -- Shared helpers ------------------------------------------------------- const onAuthError = useCallback( @@ -189,11 +193,14 @@ export const useAuthCommand = ( ), }; addItem(feedbackItem, Date.now()); - config.getChatRecordingService?.()?.recordSlashCommand({ - phase: 'result', - rawCommand: '/auth', - outputHistoryItems: [feedbackItem], - }); + if (openedViaCommandRef.current) { + openedViaCommandRef.current = false; + config.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/auth', + outputHistoryItems: [feedbackItem], + }); + } logAuth(config, new AuthEvent(protocol, 'manual', 'success')); } catch (error) { @@ -208,6 +215,7 @@ export const useAuthCommand = ( // -- Dialog open / close / cancel ---------------------------------------- const openAuthDialog = useCallback(() => { + openedViaCommandRef.current = true; setIsAuthDialogOpen(true); }, []); diff --git a/packages/cli/src/ui/commands/themeCommand.test.ts b/packages/cli/src/ui/commands/themeCommand.test.ts index 2a537bccd86..1ad3dc6b6f0 100644 --- a/packages/cli/src/ui/commands/themeCommand.test.ts +++ b/packages/cli/src/ui/commands/themeCommand.test.ts @@ -4,16 +4,24 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import process from 'node:process'; import { themeCommand } from './themeCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; describe('themeCommand', () => { let mockContext: CommandContext; + const previousNoColor = process.env['NO_COLOR']; beforeEach(() => { mockContext = createMockCommandContext(); + delete process.env['NO_COLOR']; + }); + + afterEach(() => { + if (previousNoColor === undefined) delete process.env['NO_COLOR']; + else process.env['NO_COLOR'] = previousNoColor; }); it('should return a dialog action to open the theme dialog', () => { @@ -31,6 +39,21 @@ describe('themeCommand', () => { }); }); + it('returns a message instead of opening the dialog when NO_COLOR is set', () => { + if (!themeCommand.action) { + throw new Error('The theme command must have an action.'); + } + process.env['NO_COLOR'] = '1'; + + const result = themeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Theme configuration unavailable due to NO_COLOR env variable.', + }); + }); + it('should have the correct name and description', () => { expect(themeCommand.name).toBe('theme'); expect(themeCommand.description).toBe('change the theme'); diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts index b7353105798..520a26ab61a 100644 --- a/packages/cli/src/ui/commands/themeCommand.ts +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; +import process from 'node:process'; +import type { + MessageActionReturn, + OpenDialogActionReturn, + SlashCommand, +} from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; @@ -15,8 +20,22 @@ export const themeCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, - action: (_context, _args): OpenDialogActionReturn => ({ - type: 'dialog', - dialog: 'theme', - }), + action: (_context, _args): OpenDialogActionReturn | MessageActionReturn => { + // Reject before opening the dialog: with NO_COLOR the theme picker + // cannot run, and returning a message lets the processor record the + // feedback after the invocation record instead of before it. + if (process.env['NO_COLOR']) { + return { + type: 'message', + messageType: 'info', + content: t( + 'Theme configuration unavailable due to NO_COLOR env variable.', + ), + }; + } + return { + type: 'dialog', + dialog: 'theme', + }; + }, }; diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 1c3f6537813..ea2fecefd09 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -1615,6 +1615,50 @@ describe('', () => { expect(props.onClose).toHaveBeenCalledTimes(1); }); + it('ignores a second selection while a model switch is in flight', async () => { + let resolveSwitch: (() => void) | undefined; + const switchModel = vi.fn( + () => + new Promise((resolve) => { + resolveSwitch = resolve; + }), + ); + const { props, mockHistoryManager, recordSlashCommand } = renderComponent( + {}, + { + getModel: vi.fn(() => 'gpt-4'), + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + switchModel, + getAllConfiguredModels: vi.fn(() => [ + { + id: 'gpt-4', + label: 'GPT-4', + description: 'GPT-4 model', + authType: AuthType.USE_OPENAI, + }, + ]), + getContentGeneratorConfig: vi.fn(() => ({ + authType: AuthType.USE_OPENAI, + model: 'gpt-4', + })), + } as unknown as Partial, + ); + + const onSelect = mockedSelect.mock.calls[0][0].onSelect; + const firstSelection = onSelect(`${AuthType.USE_OPENAI}::gpt-4`); + await onSelect(`${AuthType.USE_OPENAI}::gpt-4`); + + expect(switchModel).toHaveBeenCalledTimes(1); + + resolveSwitch?.(); + await firstSelection; + + expect(switchModel).toHaveBeenCalledTimes(1); + expect(props.onClose).toHaveBeenCalledTimes(1); + expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).toHaveBeenCalledTimes(1); + }); + it('updates initialIndex when config context changes', () => { const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index e6ce14290f8..328a6222a5a 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -744,6 +744,7 @@ export function ModelDialog({ const handleSelect = useCallback( async (selected: string) => { + if (selectionInFlightRef.current) return; setErrorMessage(null); const selectedEntry = availableModelEntries.find( ({ authType: t2, model, isRuntime, snapshotId }) => { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 0bcc7c6b725..be9756e5574 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -5,6 +5,7 @@ */ import { act, renderHook, waitFor } from '@testing-library/react'; +import process from 'node:process'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { useSlashCommandProcessor, @@ -894,6 +895,75 @@ describe('useSlashCommandProcessor', () => { }); }); + it('keeps the invocation for /theme when NO_COLOR blocks the dialog', async () => { + process.env['NO_COLOR'] = '1'; + try { + const command = createTestCommand({ + name: 'theme', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: + 'Theme configuration unavailable due to NO_COLOR env variable.', + }), + }); + const result = setupProcessorHook([command]); + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); + + await act(async () => { + await result.current.handleSlashCommand('/theme'); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.USER, text: '/theme', sentToModel: false }, + expect.any(Number), + ); + expect( + mockConfig.getChatRecordingService()?.recordSlashCommand, + ).toHaveBeenCalledWith({ + phase: 'invocation', + rawCommand: '/theme', + sentToModel: false, + hiddenInvocation: false, + }); + } finally { + delete process.env['NO_COLOR']; + } + }); + + it('records the rejection message in the result record for resume', async () => { + const input = '/model --project --global'; + const command = createTestCommand({ + name: 'model', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'error', + content: 'Cannot use both --project and --global', + }), + }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand(input); + }); + + expect( + mockConfig.getChatRecordingService()?.recordSlashCommand, + ).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: input, + outputHistoryItems: [ + { + type: MessageType.ERROR, + text: 'Cannot use both --project and --global', + }, + ], + }); + }); + it('hides the invocation for the /usage alias', async () => { const command = createTestCommand({ name: 'stats', diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 993a48df93b..22eb1617c05 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -14,6 +14,7 @@ import { type MutableRefObject, } from 'react'; import { type PartListUnion } from '@google/genai'; +import process from 'node:process'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { ArenaDialogType } from './useArenaCommand.js'; import { @@ -152,6 +153,12 @@ function shouldHideSlashCommandInvocation( canonicalPath.length === 1 && SLASH_COMMAND_ROOTS_HIDE_INVOCATION.has(canonicalPath[0] ?? '') ) { + // NO_COLOR prevents the theme dialog from opening, so /theme prints + // feedback instead and keeps its invocation like any work-performing + // command. + if (canonicalPath[0] === 'theme' && process.env['NO_COLOR']) { + return false; + } return true; } @@ -1129,23 +1136,20 @@ export const useSlashCommandProcessor = ( // the invocation in both live and reconstructed history. revealHiddenInvocation(); if (result.messageType === 'info') { - addMessage({ - type: MessageType.INFO, - content: result.content, - timestamp: new Date(), - }); + addItemWithRecording( + { type: MessageType.INFO, text: result.content }, + Date.now(), + ); } else if (result.messageType === 'warning') { - addMessage({ - type: MessageType.WARNING, - content: result.content, - timestamp: new Date(), - }); + addItemWithRecording( + { type: MessageType.WARNING, text: result.content }, + Date.now(), + ); } else { - addMessage({ - type: MessageType.ERROR, - content: result.content, - timestamp: new Date(), - }); + addItemWithRecording( + { type: MessageType.ERROR, text: result.content }, + Date.now(), + ); } return { type: 'handled' }; case 'goal_control': { diff --git a/packages/desktop/packages/shared/src/agent/qwen-agent.ts b/packages/desktop/packages/shared/src/agent/qwen-agent.ts index c4a368021bd..fa928c57d80 100644 --- a/packages/desktop/packages/shared/src/agent/qwen-agent.ts +++ b/packages/desktop/packages/shared/src/agent/qwen-agent.ts @@ -4549,7 +4549,7 @@ export class QwenAgent extends BaseAgent { const transcriptPath = getQwenTranscriptPath(sessionId, cwd); if (!existsSync(transcriptPath)) return []; - const recordsByUuid = new Map(); + const parentUuidByUuid = new Map(); const invocations = new Map(); const seenResults = new Set(); const messages: Message[] = []; @@ -4569,7 +4569,10 @@ export class QwenAgent extends BaseAgent { } const uuid = asString(record.uuid); - if (uuid) recordsByUuid.set(uuid, record); + if (uuid) { + const parentUuidValue = asString(record.parentUuid); + if (parentUuidValue) parentUuidByUuid.set(uuid, parentUuidValue); + } if (record.type !== 'system' || record.subtype !== 'slash_command') continue; @@ -4622,9 +4625,7 @@ export class QwenAgent extends BaseAgent { } break; } - ancestorUuid = asString( - recordsByUuid.get(ancestorUuid)?.parentUuid, - ); + ancestorUuid = parentUuidByUuid.get(ancestorUuid); } if (invocation && !invocation.hidden) { messages.push({ From b44d4d3183dcb27bba7a01e134b47c2441e7cfe0 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:24:57 +0800 Subject: [PATCH 11/16] fix(desktop): bound slash result lookup to user turn --- .../qwen-agent-slash-history.test.ts | 122 ++++++++++++++++++ .../packages/shared/src/agent/qwen-agent.ts | 3 + 2 files changed, 125 insertions(+) diff --git a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts index e3338bfc2c1..441ce38f9e4 100644 --- a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts +++ b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts @@ -851,6 +851,128 @@ describe('QwenAgent slash command history', () => { expect(messages[0]?.textElements).toBeUndefined(); }); + it('stops orphan result lookup at its user turn while preserving multi-hop results', () => { + const runtimeRoot = mkdtempSync(join(tmpdir(), 'qwen-runtime-')); + const cwd = mkdtempSync(join(tmpdir(), 'qwen-cwd-')); + tempRoots.push(runtimeRoot, cwd); + process.env.QWEN_RUNTIME_DIR = runtimeRoot; + + const sessionId = '0867dd2d-bcc6-44a1-9728-2740015de6d5'; + const recapInvocation = '2026-03-25T08:00:00.000Z'; + const doctorInvocation = '2026-03-25T08:01:00.000Z'; + writeQwenTranscript(runtimeRoot, cwd, sessionId, [ + { + uuid: 'recap-invocation', + sessionId, + timestamp: recapInvocation, + type: 'system', + subtype: 'slash_command', + systemPayload: { phase: 'invocation', rawCommand: '/recap' }, + }, + { + uuid: 'recap-result', + parentUuid: 'recap-invocation', + sessionId, + timestamp: '2026-03-25T08:00:01.000Z', + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/recap', + outputHistoryItems: [{ type: 'info', text: 'Manual recap' }], + }, + }, + { + uuid: 'away-summary-user', + parentUuid: 'recap-result', + sessionId, + timestamp: '2026-03-25T08:00:02.000Z', + type: 'user', + message: { role: 'user', content: 'Summarize while I am away' }, + }, + { + uuid: 'away-summary-result', + parentUuid: 'away-summary-user', + sessionId, + timestamp: '2026-03-25T08:00:03.000Z', + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/recap', + outputHistoryItems: [{ type: 'info', text: 'Automatic recap' }], + }, + }, + { + uuid: 'doctor-invocation', + parentUuid: 'away-summary-result', + sessionId, + timestamp: doctorInvocation, + type: 'system', + subtype: 'slash_command', + systemPayload: { phase: 'invocation', rawCommand: '/doctor' }, + }, + { + uuid: 'doctor-open-result', + parentUuid: 'doctor-invocation', + sessionId, + timestamp: '2026-03-25T08:01:01.000Z', + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/doctor', + outputHistoryItems: [], + }, + }, + { + uuid: 'doctor-result', + parentUuid: 'doctor-open-result', + sessionId, + timestamp: '2026-03-25T08:01:02.000Z', + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/doctor', + outputHistoryItems: [{ type: 'info', text: 'Doctor complete' }], + }, + }, + ]); + + const agent = createAgent(cwd); + const messages = ( + agent as unknown as QwenHistoryInternals + ).mergeSlashCommandInvocationMessages(sessionId, [], cwd); + agent.destroy(); + + expect( + messages.map((message) => [ + message.role, + message.content, + message.timestamp, + ]), + ).toEqual([ + ['user', '/recap', Date.parse(recapInvocation)], + [ + 'assistant', + 'Manual recap', + Date.parse('2026-03-25T08:00:01.000Z'), + ], + [ + 'assistant', + 'Automatic recap', + Date.parse('2026-03-25T08:00:03.000Z'), + ], + ['user', '/doctor', Date.parse(doctorInvocation)], + [ + 'assistant', + 'Doctor complete', + Date.parse('2026-03-25T08:01:02.000Z'), + ], + ]); + }); + it('does not derive text elements from Qwen user history without metadata', () => { const cwd = mkdtempSync(join(tmpdir(), 'qwen-cwd-')); tempRoots.push(cwd); diff --git a/packages/desktop/packages/shared/src/agent/qwen-agent.ts b/packages/desktop/packages/shared/src/agent/qwen-agent.ts index fa928c57d80..ea47e13572c 100644 --- a/packages/desktop/packages/shared/src/agent/qwen-agent.ts +++ b/packages/desktop/packages/shared/src/agent/qwen-agent.ts @@ -4550,6 +4550,7 @@ export class QwenAgent extends BaseAgent { if (!existsSync(transcriptPath)) return []; const parentUuidByUuid = new Map(); + const userRecordUuids = new Set(); const invocations = new Map(); const seenResults = new Set(); const messages: Message[] = []; @@ -4570,6 +4571,7 @@ export class QwenAgent extends BaseAgent { const uuid = asString(record.uuid); if (uuid) { + if (record.type === 'user') userRecordUuids.add(uuid); const parentUuidValue = asString(record.parentUuid); if (parentUuidValue) parentUuidByUuid.set(uuid, parentUuidValue); } @@ -4616,6 +4618,7 @@ export class QwenAgent extends BaseAgent { const resultCommandName = rawCommand.split(/\s+/, 1)[0]; while (ancestorUuid && !visited.has(ancestorUuid)) { visited.add(ancestorUuid); + if (userRecordUuids.has(ancestorUuid)) break; const candidate = invocations.get(ancestorUuid); if (candidate) { if ( From deed9740d2e52783628a2e3ea4f0d40814707956 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:33:04 +0000 Subject: [PATCH 12/16] test(cli): cover hidden command result replay on resume --- .../src/ui/utils/resumeHistoryUtils.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 1b82c79a72e..f2729875196 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -731,7 +731,7 @@ describe('resumeHistoryUtils', () => { ]); }); - it('skips hidden slash command invocations on resume', () => { + it('skips hidden slash command invocations but replays their results on resume', () => { const conversation = { messages: [ { @@ -739,11 +739,22 @@ describe('resumeHistoryUtils', () => { subtype: 'slash_command', systemPayload: { phase: 'invocation', - rawCommand: '/theme', + rawCommand: '/model', sentToModel: false, hiddenInvocation: true, }, }, + { + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [ + { type: 'info', text: 'Kept model as qwen3-max' }, + ], + }, + }, { type: 'assistant', timestamp: '2026-01-15T19:00:00.000Z', @@ -759,8 +770,9 @@ describe('resumeHistoryUtils', () => { const items = buildResumedHistoryItems(session, makeConfig({}), 40); expect(items).toEqual([ + { id: 41, type: 'info', text: 'Kept model as qwen3-max' }, { - id: 41, + id: 42, type: 'gemini', text: 'Follow-up', timestamp: new Date('2026-01-15T19:00:00.000Z').getTime(), From 64fc673550e21367abfb805c27671f2743c81ae7 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:14:40 +0800 Subject: [PATCH 13/16] fix(cli): guard dialog result recording --- packages/cli/src/ui/auth/useAuth.test.ts | 28 ++++++++ packages/cli/src/ui/auth/useAuth.ts | 1 + .../src/ui/components/ModelDialog.test.tsx | 66 +++++++++++++++++++ .../cli/src/ui/components/ModelDialog.tsx | 7 +- 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts index 2f24997abdf..6e01d19852b 100644 --- a/packages/cli/src/ui/auth/useAuth.test.ts +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -183,6 +183,34 @@ describe('useAuthCommand', () => { expect(recordSlashCommand).not.toHaveBeenCalled(); }); + it('clears the /auth recording latch when a command-opened dialog closes', async () => { + const settings = createSettings(); + const recordSlashCommand = vi.fn(); + const config = createConfig(recordSlashCommand); + const addItem = vi.fn(); + + const { result } = renderHook(() => + useAuthCommand(settings as never, config as never, addItem), + ); + + act(() => { + result.current.openAuthDialog(); + result.current.closeAuthDialog(); + result.current.onAuthError('later unauthorized'); + }); + + await act(async () => { + await result.current.handleProviderSubmit(deepseekProvider, { + baseUrl: resolveBaseUrl(deepseekProvider), + apiKey: 'sk-deepseek', + modelIds: ['deepseek-v4-flash'], + }); + }); + + expect(addItem).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).not.toHaveBeenCalled(); + }); + it('configures OpenRouter via the unified provider submit', async () => { const settings = createSettings(); const config = createConfig(); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 64d65a3aa2d..e143867779f 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -220,6 +220,7 @@ export const useAuthCommand = ( }, []); const closeAuthDialog = useCallback(() => { + openedViaCommandRef.current = false; setIsAuthDialogOpen(false); setAuthError(null); }, []); diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index ea2fecefd09..a772fa43ca4 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -910,6 +910,72 @@ describe('', () => { expect(props.onClose).toHaveBeenCalledTimes(1); }); + it('ignores duplicate input while an image model selection is in flight', async () => { + let resolveSetImageModel: (() => void) | undefined; + const setImageModel = vi.fn( + () => + new Promise((resolve) => { + resolveSetImageModel = resolve; + }), + ); + const baseUrl = 'https://images.example.com/api/v1'; + const persisted = `openai:qwen-image-2.0\0${baseUrl}`; + const { props, mockSettings, mockHistoryManager, recordSlashCommand } = + renderComponent({ isImageModelMode: true }, { + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + getAllConfiguredModels: vi.fn(() => [ + { + id: 'qwen-image-2.0', + label: 'Qwen Image 2.0', + authType: AuthType.USE_OPENAI, + baseUrl, + envKey: 'IMAGE_API_KEY', + imageOnly: true, + }, + ]), + resolveImageGenerationModel: vi.fn(() => ({ + model: 'qwen-image-2.0', + baseUrl, + apiKeyEnv: 'IMAGE_API_KEY', + })), + setImageModel, + } as unknown as Partial); + + const onSelect = mockedSelect.mock.calls[0][0].onSelect; + const selection = onSelect( + `${AuthType.USE_OPENAI}::qwen-image-2.0\0${baseUrl}`, + ); + await onSelect(`${AuthType.USE_OPENAI}::qwen-image-2.0\0${baseUrl}`); + mockedUseKeypress.mock.calls[0][0]({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + + expect(setImageModel).toHaveBeenCalledTimes(1); + expect(mockSettings.setValue).toHaveBeenCalledTimes(1); + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(recordSlashCommand).not.toHaveBeenCalled(); + expect(props.onClose).not.toHaveBeenCalled(); + + resolveSetImageModel?.(); + await selection; + + expect(setImageModel).toHaveBeenCalledTimes(1); + expect(mockSettings.setValue).toHaveBeenCalledTimes(1); + expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).toHaveBeenCalledTimes(1); + expect(props.onClose).toHaveBeenCalledTimes(1); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'imageModel', + persisted, + ); + }); + it('keeps the selected baseUrl for same-provider duplicate vision model ids', async () => { const switchModel = vi.fn(); const setVisionModel = vi.fn(); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 328a6222a5a..2a6574a9c80 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -909,7 +909,12 @@ export function ModelDialog({ } const scope = resolvePersistScope(settings, persistScope); settings.setValue(scope, 'imageModel', imageModel); - await config.setImageModel(imageModel); + selectionInFlightRef.current = true; + try { + await config.setImageModel(imageModel); + } finally { + selectionInFlightRef.current = false; + } const scopeSuffix = persistScope === 'workspace' ? t(' (this project)') From 751deb0c64ddb8391c381f179e2b6931b4fa32e5 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 11 Aug 2026 14:28:18 +0000 Subject: [PATCH 14/16] fix(cli,desktop): tighten slash-command feedback recording - Pin the revealed invocation as the first history item in the argument-validation reveal test, matching the processor's ordering. - Derive the /model picker-flag pattern from a single shared flag list. - Pair each transcript invocation with at most one result during desktop reconstruction, so a later same-name orphan result (e.g. an auto-fired recap) cannot re-emit an already-paired invocation's user row. - Retarget the textElements guard at the synthesized slash user row. --- packages/cli/src/ui/commands/modelCommand.ts | 12 ++- .../ui/hooks/slashCommandProcessor.test.ts | 3 +- .../qwen-agent-slash-history.test.ts | 80 ++++++++++++++++++- .../packages/shared/src/agent/qwen-agent.ts | 25 ++++-- 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index d12a97e7187..3c51244ddda 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -47,8 +47,16 @@ const COMPACTION_MODEL_CONFIGURATION_HINT = const IMAGE_MODEL_CONFIGURATION_HINT = 'Configure a model with imageOnly: true, baseUrl, and envKey in settings.modelProviders. Run /model --image to select it.'; -const MODEL_PICKER_FLAG_PATTERN = - '(?:fast|voice|vision|compaction|image|project|global)'; +const MODEL_PICKER_FLAGS = [ + 'fast', + 'voice', + 'vision', + 'compaction', + 'image', + 'project', + 'global', +] as const; +const MODEL_PICKER_FLAG_PATTERN = `(?:${MODEL_PICKER_FLAGS.join('|')})`; const MODEL_PICKER_ONLY_PATTERN = new RegExp( `^(?:\\s*--${MODEL_PICKER_FLAG_PATTERN})*\\s*$`, ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 0eb01c71d1c..bab2ad49c5c 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -881,7 +881,8 @@ describe('useSlashCommandProcessor', () => { await result.current.handleSlashCommand(input); }); - expect(mockAddItem).toHaveBeenCalledWith( + expect(mockAddItem).toHaveBeenNthCalledWith( + 1, { type: MessageType.USER, text: input, sentToModel: false }, expect.any(Number), ); diff --git a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts index 9896e1b77ea..2118a29f964 100644 --- a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts +++ b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts @@ -918,7 +918,7 @@ describe('QwenAgent slash command history', () => { Date.parse('2026-03-25T07:36:55.000Z'), ], ]); - expect(messages[0]?.textElements).toBeUndefined(); + expect(messages[1]?.textElements).toBeUndefined(); }); it('stops orphan result lookup at its user turn while preserving multi-hop results', () => { @@ -1043,6 +1043,84 @@ describe('QwenAgent slash command history', () => { ]); }); + it('emits the invocation row once when a same-name orphan result follows the paired result', () => { + const runtimeRoot = mkdtempSync(join(tmpdir(), 'qwen-runtime-')); + const cwd = mkdtempSync(join(tmpdir(), 'qwen-cwd-')); + tempRoots.push(runtimeRoot, cwd); + process.env.QWEN_RUNTIME_DIR = runtimeRoot; + + const sessionId = '7f2c9a14-5b1e-4f7a-9d3c-2e8b6a4c1f05'; + const recapInvocation = '2026-03-25T09:00:00.000Z'; + writeQwenTranscript(runtimeRoot, cwd, sessionId, [ + { + uuid: 'recap-invocation', + sessionId, + timestamp: recapInvocation, + type: 'system', + subtype: 'slash_command', + systemPayload: { phase: 'invocation', rawCommand: '/recap' }, + }, + { + uuid: 'recap-result', + parentUuid: 'recap-invocation', + sessionId, + timestamp: '2026-03-25T09:00:01.000Z', + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/recap', + outputHistoryItems: [{ type: 'info', text: 'Manual recap' }], + }, + }, + { + uuid: 'assistant-record', + parentUuid: 'recap-result', + sessionId, + timestamp: '2026-03-25T09:00:02.000Z', + type: 'assistant', + message: { role: 'assistant', content: 'Continuing the session' }, + }, + { + uuid: 'away-recap-result', + parentUuid: 'assistant-record', + sessionId, + timestamp: '2026-03-25T09:10:00.000Z', + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/recap', + outputHistoryItems: [ + { type: 'away_recap', text: 'Automatic recap' }, + ], + }, + }, + ]); + + const agent = createAgent(cwd); + const messages = ( + agent as unknown as QwenHistoryInternals + ).mergeSlashCommandInvocationMessages(sessionId, [], cwd); + agent.destroy(); + + expect( + messages.map((message) => [ + message.role, + message.content, + message.timestamp, + ]), + ).toEqual([ + ['user', '/recap', Date.parse(recapInvocation)], + ['assistant', 'Manual recap', Date.parse('2026-03-25T09:00:01.000Z')], + [ + 'assistant', + 'Automatic recap', + Date.parse('2026-03-25T09:10:00.000Z'), + ], + ]); + }); + it('does not derive text elements from Qwen user history without metadata', () => { const cwd = mkdtempSync(join(tmpdir(), 'qwen-cwd-')); tempRoots.push(cwd); diff --git a/packages/desktop/packages/shared/src/agent/qwen-agent.ts b/packages/desktop/packages/shared/src/agent/qwen-agent.ts index 733a37fc238..069beb7ee6f 100644 --- a/packages/desktop/packages/shared/src/agent/qwen-agent.ts +++ b/packages/desktop/packages/shared/src/agent/qwen-agent.ts @@ -4603,6 +4603,9 @@ export class QwenAgent extends BaseAgent { const userRecordUuids = new Set(); const invocations = new Map(); const seenResults = new Set(); + // An invocation pairs with at most one result, so a later same-name + // orphan result cannot re-emit an already-paired invocation's user row. + const consumedInvocations = new Set(); const messages: Message[] = []; let idCounter = 0; @@ -4665,6 +4668,7 @@ export class QwenAgent extends BaseAgent { let ancestorUuid = parentUuid; const visited = new Set(); let invocation: SlashCommandInvocation | undefined; + let invocationUuid: string | undefined; const resultCommandName = rawCommand.split(/\s+/, 1)[0]; while (ancestorUuid && !visited.has(ancestorUuid)) { visited.add(ancestorUuid); @@ -4672,21 +4676,26 @@ export class QwenAgent extends BaseAgent { const candidate = invocations.get(ancestorUuid); if (candidate) { if ( - candidate.rawCommand.split(/\s+/, 1)[0] === resultCommandName + candidate.rawCommand.split(/\s+/, 1)[0] === resultCommandName && + !consumedInvocations.has(ancestorUuid) ) { invocation = candidate; + invocationUuid = ancestorUuid; } break; } ancestorUuid = parentUuidByUuid.get(ancestorUuid); } - if (invocation && !invocation.hidden) { - messages.push({ - id: `qwen-${sessionId}-slash-${++idCounter}`, - role: 'user', - content: invocation.rawCommand, - timestamp: invocation.timestamp, - }); + if (invocation && invocationUuid) { + consumedInvocations.add(invocationUuid); + if (!invocation.hidden) { + messages.push({ + id: `qwen-${sessionId}-slash-${++idCounter}`, + role: 'user', + content: invocation.rawCommand, + timestamp: invocation.timestamp, + }); + } } messages.push({ id: `qwen-${sessionId}-slash-${++idCounter}`, From cd49a42eaf5ea6c164933c81f5bc8aea8e8765ed Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:45:09 +0800 Subject: [PATCH 15/16] fix(cli): guard partially persisted model switches --- .../src/ui/components/ModelDialog.test.tsx | 58 +++++++++++++++++++ .../cli/src/ui/components/ModelDialog.tsx | 52 ++++++++++------- 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index a772fa43ca4..f3d7b5ba669 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -1725,6 +1725,64 @@ describe('', () => { expect(recordSlashCommand).toHaveBeenCalledTimes(1); }); + it('does not retry or report an unchanged model after persistence fails', async () => { + const switchModel = vi.fn().mockResolvedValue(undefined); + const setValue = vi.fn(() => { + const error = new Error('settings are read-only'); + Object.assign(error, { code: 'EACCES' }); + throw error; + }); + const { props, getByText, mockHistoryManager, recordSlashCommand } = + renderComponent( + {}, + { + getModel: vi.fn(() => 'old-model'), + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + switchModel, + getAllConfiguredModels: vi.fn(() => [ + { + id: 'gpt-4', + label: 'GPT-4', + description: 'GPT-4 model', + authType: AuthType.USE_OPENAI, + }, + ]), + getContentGeneratorConfig: vi.fn(() => ({ + authType: AuthType.USE_OPENAI, + model: 'gpt-4', + })), + } as unknown as Partial, + { setValue }, + ); + + const onSelect = mockedSelect.mock.calls[0][0].onSelect; + await act(async () => { + await onSelect(`${AuthType.USE_OPENAI}::gpt-4`); + }); + + expect( + getByText((text) => + text.includes('Model switched, but the selection could not be saved.'), + ), + ).toBeDefined(); + + await onSelect(`${AuthType.USE_OPENAI}::gpt-4`); + mockedUseKeypress.mock.calls[0][0]({ + name: 'escape', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + + expect(switchModel).toHaveBeenCalledTimes(1); + expect(setValue).toHaveBeenCalledTimes(1); + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(recordSlashCommand).not.toHaveBeenCalled(); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + it('updates initialIndex when config context changes', () => { const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 2a6574a9c80..dbfbb62cfc5 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -668,6 +668,7 @@ export function ModelDialog({ // the dialog; latch so the close feedback and onClose fire only once. const closeLatchRef = useRef(false); const selectionInFlightRef = useRef(false); + const selectionCommittedRef = useRef(false); const reportAuxiliaryModelSelection = useCallback( (feedbackItem: HistoryItemWithoutId & Record) => { uiState?.historyManager.addItem(feedbackItem, Date.now()); @@ -682,7 +683,7 @@ export function ModelDialog({ const closeWithoutSelection = useCallback(() => { if (closeLatchRef.current || selectionInFlightRef.current) return; closeLatchRef.current = true; - if (!isAuxiliaryModelMode) { + if (!isAuxiliaryModelMode && !selectionCommittedRef.current) { const feedbackItem = { type: 'info' as const, text: t('Kept model as {{model}}', { @@ -744,7 +745,7 @@ export function ModelDialog({ const handleSelect = useCallback( async (selected: string) => { - if (selectionInFlightRef.current) return; + if (selectionInFlightRef.current || selectionCommittedRef.current) return; setErrorMessage(null); const selectedEntry = availableModelEntries.find( ({ authType: t2, model, isRuntime, snapshotId }) => { @@ -993,6 +994,7 @@ export function ModelDialog({ : {}), baseUrl: selectedBaseUrl, }); + selectionCommittedRef.current = true; } finally { selectionInFlightRef.current = false; } @@ -1021,25 +1023,33 @@ export function ModelDialog({ return; } - handleModelSwitchSuccess({ - config, - settings, - uiState, - after, - effectiveAuthType, - effectiveModelId, - // Persist the selected provider's baseUrl so the right provider is - // restored next launch when several share the same id. Pair it with the - // same resolved config that effectiveModelId comes from (`after`) so the - // persisted (model.name, model.baseUrl) stays consistent even if - // switchModel transforms the id; fall back to the picker entry's - // baseUrl. Runtime models are keyed by snapshot id, so no disambiguator. - effectiveBaseUrl: isRuntime - ? undefined - : (after?.baseUrl ?? selectedEntry?.model.baseUrl), - isRuntime, - persistScope, - }); + try { + handleModelSwitchSuccess({ + config, + settings, + uiState, + after, + effectiveAuthType, + effectiveModelId, + // Persist the selected provider's baseUrl so the right provider is + // restored next launch when several share the same id. Pair it with the + // same resolved config that effectiveModelId comes from (`after`) so the + // persisted (model.name, model.baseUrl) stays consistent even if + // switchModel transforms the id; fall back to the picker entry's + // baseUrl. Runtime models are keyed by snapshot id, so no disambiguator. + effectiveBaseUrl: isRuntime + ? undefined + : (after?.baseUrl ?? selectedEntry?.model.baseUrl), + isRuntime, + persistScope, + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + setErrorMessage( + `${t('Model switched, but the selection could not be saved.')}\n\n${errorMessage}`, + ); + return; + } closeLatchRef.current = true; onClose(); }, From b6b0cc95379e15e9dbb522f600658a7d1a699e3a Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Wed, 12 Aug 2026 12:03:00 +0000 Subject: [PATCH 16/16] test(cli): cover isPickerOnlyModelInvocation regex boundaries directly --- .../cli/src/ui/commands/modelCommand.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 5d39b862622..6ce34f0ba56 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { modelCommand } from './modelCommand.js'; +import { modelCommand, isPickerOnlyModelInvocation } from './modelCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { SettingScope } from '../../config/settings.js'; @@ -2643,3 +2643,35 @@ describe('modelCommand', () => { }); }); }); + +describe('isPickerOnlyModelInvocation', () => { + it.each([ + '', + ' ', + '--fast', + '--voice', + '--vision', + '--compaction', + '--image', + '--project', + '--global', + '--fast --project', + '--vision --global', + ' --fast --voice ', + ])('treats %j as picker-only', (args) => { + expect(isPickerOnlyModelInvocation(args)).toBe(true); + }); + + it.each([ + '--fast qwen3-coder-flash', + '--vision qwen-vl-max', + '--project qwen-max', + '--fast --global qwen-max', + '--invalid-flag', + '--fastx', + 'qwen-max', + 'qwen-max write a one-off prompt', + ])('treats %j as not picker-only', (args) => { + expect(isPickerOnlyModelInvocation(args)).toBe(false); + }); +});