Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ac2945a
feat(cli): Add model toggle hotkey (Ctrl+F)
Jul 21, 2026
f7179d7
fix(cli): resolve target auth type for cross-provider model toggle
Jul 21, 2026
8bf4189
refactor(cli): extract toggle decision logic into testable pure funct…
Jul 21, 2026
23cf916
fix(cli): narrow agent PTY toggle guard + add PTY tracking tests
Jul 21, 2026
63d423a
test(cli): add Ctrl+F delegation tests for AgentChatContent
Jul 21, 2026
934ca93
fix(cli): suppress Ctrl+F cursor-right in non-vim mode + fix test name
Jul 22, 2026
4b59e75
fix(cli): add handleToggleKeypress to InputPrompt test mocks
Jul 22, 2026
e85e8f9
test(cli): cover Ctrl+F suppression branch + invalidation scenario
Jul 22, 2026
0e43f0f
fix(cli): isolate switchModel rejection from post-switch side effects
Jul 22, 2026
956e9bf
test(cli): extract setAgentShellFocused mock for future assertions
Jul 22, 2026
f2ddbb1
fix(cli): localize model toggle runtime messages + gate debug log
Jul 24, 2026
0a45b20
fix(cli): document cross-provider toggleModel disambiguation + add am…
Jul 24, 2026
21e82a4
fix(cli): resolve merge conflicts with upstream/main in InputPrompt t…
Jul 28, 2026
50e819a
fix(cli): restore deferUntilIdle for Ctrl+Q queue + remove provenance…
Jul 28, 2026
c5eeee8
fix(cli): resolve merge conflicts with main in InputPrompt submit + p…
qwen-code-dev-bot Jul 29, 2026
ec47b37
fix(cli): add handleToggleKeypress to test mocks in InputPrompt.test.tsx
Jul 30, 2026
69ae108
Merge upstream/main into feat/model-toggle-hotkey
Aug 1, 2026
ef729cc
fix(cli): address review feedback on model toggle hotkey
Aug 1, 2026
a7701b1
fix(cli): block model toggle only for the active agent's PTY
Aug 1, 2026
f74c6b7
test(cli): address review feedback on test files
Aug 1, 2026
e238a00
fix(cli): address CI review feedback on model toggle hotkey
Aug 1, 2026
2331c9b
test(cli): add mutant survivor tests for CI review suggestions
Aug 1, 2026
ecd7488
fix(cli): address CI review feedback on model toggle hotkey
Aug 2, 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
3 changes: 2 additions & 1 deletion docs/users/reference/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
| `Ctrl+B` | While a foreground shell command is running: promote it to a background task. The child keeps running, the agent's turn unblocks, and the shell appears in `/tasks` + the Background tasks dialog. No-op when no shell is executing — Ctrl+B then falls through to its prompt-area binding (cursor-left). |
| `Alt/Option+M` | Toggle Markdown output between rich rendered previews and raw/source mode. On macOS, the terminal must send Option as Meta. |
| `Shift+Tab` (`Tab` on Windows) | Cycle approval modes (`plan` → `default` → `auto-edit` → `auto` → `yolo`) |
| `Ctrl+F` | Toggle between the current model and the alternate model set in `model.toggleModel`. Requires `model.toggleModel` to be configured in settings. Only active when no shell is focused. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Ctrl+F is documented with two conflicting meanings in this file. The General section (this line) says "Ctrl+F → toggle model", but the Input Prompt section (line 39) still says "Ctrl+F / Right Arrow → move cursor right". Users reading both sections will be confused.

Consider following the Ctrl+B pattern, which documents both behaviors: "promote to background task ... No-op when no shell is executing — Ctrl+B then falls through to its prompt-area binding (cursor-left)." E.g.:

Toggle between the current model and the alternate model set in model.toggleModel. Requires model.toggleModel to be configured. When not configured, Ctrl+F falls through to its input-prompt binding (cursor-right).

— qwen3.7-max via Qwen Code /review


## Input Prompt

Expand All @@ -35,7 +36,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
| `Esc` (double press) | Clear the input prompt. |
| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. |
| `Right Arrow` | Move the cursor one character to the right. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Ctrl+F cursor-right is removed from docs unconditionally, but the actual behavior is only suppressed when model.toggleModel is configured. Users who don't set toggleModel still have working Ctrl+F cursor-right (via text-buffer.ts:2663), but the docs no longer mention it.

Concrete cost: users reading the shortcuts reference may stop using a working readline binding, or file bug reports about "missing" cursor-right functionality.

Consider adding a parenthetical: Right Arrow (Ctrl+F when model toggle is not configured) or a note below: "Ctrl+F also moves the cursor right when model.toggleModel is not configured."

— qwen3.7-max via Qwen Code /review

| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
| `Ctrl+K` | Delete from the cursor to the end of the line. |
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
Expand Down
119 changes: 119 additions & 0 deletions integration-tests/interactive/model-toggle-hotkey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

/**
* E2E test for the Ctrl+F model toggle hotkey.
*
* Sends a raw Ctrl+F byte (0x06) through the PTY — the same byte
* a non-kitty PTY would send for Ctrl+F — and asserts the "Switched
* to" message appears in the TUI output.
*
* NOTE: PTY-based interactive tests don't work on Windows (node-pty
* limitation). Skipped via IS_WINDOWS guard.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from '../test-helper.js';
Comment on lines +1 to +19

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Integration test is outside every npm workspace — npm test never collects it

This test file lives in integration-tests/interactive/, which is not part of any npm workspace's vitest.config.ts. The project's npm test command never runs it. Combined with the fact that the Integration Tests (CLI, No Sandbox) CI job was skipped at this commit, the model-toggle E2E behavior is unverified by any automated gate.

Concrete cost: a regression in can-toggle-model.ts or resolve-toggle-model.ts would not be caught until a maintainer manually runs the integration suite.

Consider adding unit-level tests within packages/cli that exercise the full toggle flow through AppContainer's handler (mocking config.switchModel), or ensure the CI integration job runs reliably.

— qwen3.7-max via Qwen Code /review


const IS_WINDOWS = process.platform === 'win32';

(IS_WINDOWS ? describe.skip : describe)('model toggle hotkey', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This integration test lives outside every npm workspace, so the per-workspace vitest run (e.g. npm test in packages/cli) never collects it — the test-efficacy probe marked it unreachable. — Concrete cost: if the CI job that runs the interactive integration suite (npm run test:integration:interactive:sandbox:none) is skipped or misconfigured, the Ctrl+F toggle has no automated regression gate and this test silently never executes. Fix: confirm CI runs that suite and collects this file (no code change needed if confirmed).

中文说明

该集成测试位于所有 npm workspace 之外,因此按 workspace 运行的 vitest run(例如 packages/cli 中的 npm test)永远不会收集它——测试有效性探针将其标记为 unreachable(不可达)。— 具体代价:如果运行交互式集成测试套件的 CI 任务(npm run test:integration:interactive:sandbox:none)被跳过或配置错误,Ctrl+F 切换将没有任何自动化回归门禁,该测试会静默地从不执行。修复:确认 CI 运行了该套件并收集此文件(若已确认则无需改代码)。

— qwen3.8-max-preview via Qwen Code /review

let rig: TestRig;
let savedLang: string | undefined;

beforeEach(async () => {
// Save original value so afterEach can restore it.
savedLang = process.env['QWEN_CODE_LANG'];
// Pre-seed locale so waitForText('Type your message') is stable
// regardless of the host's locale settings.
process.env['QWEN_CODE_LANG'] = 'en';

rig = new TestRig();
await rig.setup('model-toggle-hotkey-e2e', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
model: {
name: 'model-a',
toggleModel: 'model-b',
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] E2E test uses synthetic model IDs (model-a, model-b) that don't exist in the model registry. config.switchModel(authType, 'model-b') calls modelRegistry.getModel(authType, 'model-b') which returns undefined, causing switchModel to throw "Model 'model-b' not found for authType '...'". The .catch() handler then emits "Failed to switch to model-b: ..." — but the test asserts waitForText('Switched to model-b'), which will never match.

The TestRig.setup() method only writes a settings.json file — there is no mechanism to register model-a or model-b in the model registry. This test cannot pass in any CI or local environment.

Suggested change
},
settings: {
model: {
name: 'qwen-max',
toggleModel: 'qwen-plus',
},
},

Use real model IDs from the registry, or set up a modelProviders entry in the test settings that registers the test models.

— qwen3.7-max via Qwen Code /review

modelProviders: {
openai: [{ id: 'model-a' }, { id: 'model-b' }],
},
},
});
});

afterEach(async () => {
// Restore original value instead of blindly deleting.
if (savedLang !== undefined) {
process.env['QWEN_CODE_LANG'] = savedLang;
} else {
delete process.env['QWEN_CODE_LANG'];
}
await rig.cleanup();
});

it('should toggle model on Ctrl+F and show Switched to message', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The test only covers the idle/prompt state. No test verifies that Ctrl+F is a no-op when a shell command is executing. — Concrete cost: a future refactor could break the shell-focus guard, causing Ctrl+F to toggle the model while a shell is running, with no test to catch it.

— qwen3.7-max via Qwen Code /review

const { ptyProcess, promise } = rig.runInteractive();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The test never kills the PTY it spawns. rig.runInteractive() spawns a PTY child (test-helper.tspty.spawn) whose exit promise only resolves on ptyProcess.onExit; neither the test body nor afterEach (which only restores QWEN_CODE_LANG and calls rig.cleanup()rm -rf testDir) ever calls ptyProcess.kill() or awaits the promise. — Concrete cost: each run orphans one CLI child process holding memory and an open PTY fd; across the interactive suite in CI these accumulate. Sibling tests (file-system-interactive.test.ts, external-context-auto-recall.test.ts, etc.) wrap the body in try/finally { ptyProcess.kill(); await promise; }. Fix: match that pattern (wrap the body in try { … } finally { ptyProcess.kill(); await promise; }).

中文说明

该测试从不杀死它启动的 PTY。rig.runInteractive() 会启动一个 PTY 子进程(test-helper.tspty.spawn),其退出 promise 只在 ptyProcess.onExit 时 resolve;测试主体和 afterEach(仅恢复 QWEN_CODE_LANG 并调用 rig.cleanup()rm -rf testDir)都没有调用 ptyProcess.kill() 或 await 该 promise。— 具体代价:每次运行都会遗留一个孤儿 CLI 子进程,占用内存和一个打开的 PTY fd;在 CI 的整个交互式套件中这些会累积。同类测试(file-system-interactive.test.tsexternal-context-auto-recall.test.ts 等)会用 try/finally { ptyProcess.kill(); await promise; } 包裹主体。修复:采用相同模式(用 try { … } finally { ptyProcess.kill(); await promise; } 包裹主体)。

— qwen3.8-max-preview via Qwen Code /review


try {
let output = '';
ptyProcess.onData((data) => {
output += data;
});

// If the CLI exits early (crash, OOM), surface the exit code in the
// failure message instead of a generic waitForText timeout.
let earlyExit: string | null = null;
void promise.then(({ exitCode, output: exitOutput }) => {
earlyExit = `CLI exited with code ${exitCode}. Last output:\n${exitOutput.slice(-500)}`;
});

// Wait for CLI to be ready
const isReady = await rig.waitForText('Type your message', 30000);
expect(
isReady,
earlyExit ?? 'CLI did not start in interactive mode',
).toBe(true);

// Raw Ctrl+F byte (0x06). In a PTY without kitty-protocol
// negotiation this is what the terminal sends for Ctrl+F;
// Ink decodes it as Key({ctrl: true, name: 'f'}).
const CTRL_F = '\x06';

// Toggle to toggleModel (model-b) and wait for the info message
ptyProcess.write(CTRL_F);
const switchedToModelB = await rig.waitForText(
'Switched to model-b',
10000,
);
expect(
switchedToModelB,
earlyExit ??
`Expected 'Switched to model-b' after first toggle. Output:\n${output}`,
).toBe(true);

// Toggle back to original model (model-a)
ptyProcess.write(CTRL_F);
const switchedToModelA = await rig.waitForText(
'Switched to model-a',
10000,
);
expect(
switchedToModelA,
earlyExit ??
`Expected 'Switched to model-a' after second toggle. Output:\n${output}`,
).toBe(true);
} finally {
ptyProcess.kill();
await promise;
}
});
});
Loading