Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
90a67a7
fix(cli): improve slash command history feedback
DragonnZhang Aug 2, 2026
e7183bb
fix(cli): centralize auxiliary model guard
DragonnZhang Aug 2, 2026
facb38f
test(cli): cover model picker left key
DragonnZhang Aug 2, 2026
296961e
Merge branch 'main' into dragon/slash-command-feedback
wenshao Aug 2, 2026
d7a14bf
fix(cli): persist slash command invocation hiding across resume
DragonnZhang Aug 7, 2026
ce6fbb9
Merge remote-tracking branch 'upstream/main' into dragon/slash-comman…
DragonnZhang Aug 7, 2026
4b2b65f
Merge branch 'main' into dragon/slash-command-feedback
qwen-code-dev-bot Aug 7, 2026
8d19d46
fix: keep model picker feedback consistent
DragonnZhang Aug 7, 2026
81618a6
docs(cli): align design doc with /stats root-level hiding
qwen-code-dev-bot Aug 7, 2026
15227e1
fix(cli): preserve model picker history safely
DragonnZhang Aug 7, 2026
d1140e6
fix: preserve hidden command feedback on resume
DragonnZhang Aug 7, 2026
90f0145
fix(desktop): match slash results to invocations
DragonnZhang Aug 7, 2026
c1dfe04
fix: keep slash command feedback paired with invocations
Aug 7, 2026
b44d4d3
fix(desktop): bound slash result lookup to user turn
DragonnZhang Aug 7, 2026
deed974
test(cli): cover hidden command result replay on resume
DragonnZhang Aug 7, 2026
24b12bf
Merge main into dragon/slash-command-feedback
DragonnZhang Aug 11, 2026
c2ab3fc
Merge remote-tracking branch 'origin/main' into dragon/slash-command-…
qwen-code-dev-bot Aug 11, 2026
cf3cc8c
Merge branch 'main' into dragon/slash-command-feedback
qwen-code-dev-bot Aug 11, 2026
64fc673
fix(cli): guard dialog result recording
DragonnZhang Aug 11, 2026
751deb0
fix(cli,desktop): tighten slash-command feedback recording
qwen-code-dev-bot Aug 11, 2026
a3df7ee
Merge remote-tracking branch 'origin/main' into dragon/slash-command-…
qwen-code-dev-bot Aug 11, 2026
0502eed
chore(cli): merge main and reconcile /effort feedback recording
qwen-code-dev-bot Aug 12, 2026
cd49a42
fix(cli): guard partially persisted model switches
DragonnZhang Aug 12, 2026
92d1dc3
Merge branch 'main' into dragon/slash-command-feedback
qwen-code-dev-bot Aug 12, 2026
b6b0cc9
test(cli): cover isPickerOnlyModelInvocation regex boundaries directly
qwen-code-dev-bot Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/design/slash-command-feedback.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 29 additions & 2 deletions packages/cli/src/ui/components/ModelDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1254,8 +1254,8 @@ describe('<ModelDialog />', () => {
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();

Expand All @@ -1272,6 +1272,13 @@ describe('<ModelDialog />', () => {
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({
Expand All @@ -1282,6 +1289,26 @@ describe('<ModelDialog />', () => {
paste: false,
sequence: '',
});
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
expect(props.onClose).toHaveBeenCalledTimes(1);
});
Comment on lines +1413 to +1415

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that the double-Escape latch test could pin the recorder call count as well as live history. The production latch behavior is unchanged and no current defect was reproduced here. Under the round-5 Critical-only guidance, I am deferring this test follow-up and leaving the thread unresolved.


it('does not report the primary model when closing an auxiliary picker', () => {
const { props, mockHistoryManager } = renderComponent({
isFastModelMode: true,
});
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved

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

Expand Down
30 changes: 29 additions & 1 deletion packages/cli/src/ui/components/ModelDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,34 @@ export function ModelDialog({
)
: '';

const closeWithoutSelection = useCallback(() => {
if (
!isFastModelMode &&
!isVoiceModelMode &&
!isVisionModelMode &&
!isCompactionModelMode &&
!isImageModelMode
) {
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
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 (
Expand All @@ -667,7 +695,7 @@ export function ModelDialog({
isCompactionModelMode ||
isImageModelMode))
) {
onClose();
closeWithoutSelection();
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
}
},
{ isActive: true },
Expand Down
214 changes: 212 additions & 2 deletions packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,34 @@ 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();
const mockClearItems = vi.fn();
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({});
Expand All @@ -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),
Expand Down Expand Up @@ -718,6 +737,197 @@ describe('useSlashCommandProcessor', () => {
});

describe('Action Result Handling', () => {
it.each([
['/auth status', 'auth', ['connect', 'login'], 'auth'],
['/connect', 'auth', ['connect', 'login'], 'auth'],
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
['/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' },
Comment on lines +1056 to +1058

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that the processor seam would benefit from a direct phase-result assertion for hidden invocations. This is a test-coverage gap rather than a confirmed current behavior defect, and the PR is already at review round 5, so I am deferring it under the Critical-only guidance. Leaving the thread unresolved.

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',
Expand Down
Loading
Loading