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);
+ });
+});