Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 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. |

## 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. |
| `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
113 changes: 113 additions & 0 deletions integration-tests/interactive/model-toggle-hotkey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @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';

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

(IS_WINDOWS ? describe.skip : describe)('model toggle hotkey', () => {
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',
},
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 () => {
const { ptyProcess, promise } = rig.runInteractive();

let output = '';

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] E2E test never terminates the PTY process

runInteractive() returns { ptyProcess, promise } but only ptyProcess is destructured. The CLI process is never stopped — rig.cleanup() only deletes the test directory. Orphaned PTY processes accumulate during test runs.

Suggested change
let output = '';
const { ptyProcess, promise } = rig.runInteractive();

Then at the end of the test, gracefully exit:

ptyProcess.write('\x03'); // Ctrl+C
await new Promise(r => setTimeout(r, 500));
ptyProcess.write('\x03'); // Ctrl+C again
await promise;

— qwen3.7-max via Qwen Code /review

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);
});
});
6 changes: 6 additions & 0 deletions packages/cli/src/config/keyBindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,11 @@ describe('keyBindings config', () => {
{ key: 'space', ctrl: false, meta: false },
]);
});

it('should bind model toggle to Ctrl+F by default', () => {
expect(defaultKeyBindings[Command.TOGGLE_MODEL]).toEqual([
{ key: 'f', ctrl: true, shift: false },
]);
});
});
});
4 changes: 4 additions & 0 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export enum Command {
// Transcript full-detail screen (Ctrl+O)
TOGGLE_TRANSCRIPT = 'toggleTranscript',

// Model toggle (Ctrl+F)
TOGGLE_MODEL = 'toggleModel',

// Scroll commands
SCROLL_UP = 'scrollUp',
SCROLL_DOWN = 'scrollDown',
Expand Down Expand Up @@ -242,6 +245,7 @@ export const defaultKeyBindings: KeyBindingConfig = {

// App level bindings
[Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }],
[Command.TOGGLE_MODEL]: [{ key: 'f', ctrl: true, shift: false }],
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }],
[Command.QUIT]: [{ key: 'c', ctrl: true, shift: false }],
[Command.EXIT]: [{ key: 'd', ctrl: true }],
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1380,6 +1380,16 @@ const SETTINGS_SCHEMA = {
description: 'The model to use for conversations.',
showInDialog: false,
},
toggleModel: {
type: 'string',
label: 'Toggle Model',
category: 'Model',
requiresRestart: false,
default: undefined as string | undefined,
description:

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] Description says "Ctrl+Shift+M hotkey" but the primary bindings are Ctrl+F / Alt+S (Ctrl+Shift+M is the third/legacy binding). Users who read this description won't know about the more accessible shortcuts. Consider: 'Alternate model ID for quick toggle via the model toggle hotkey (Ctrl+F / Alt+S). ...'

Same issue in settings.schema.json.

— qwen3.7-max via Qwen Code /review

'Alternate model ID for quick toggle via the toggleModel hotkey. When set, pressing the configured hotkey switches between the current model and this model. Use the qualified syntax "model-id(authType)" (e.g. "gpt-4(openai)") to disambiguate when multiple providers offer the same model ID; without qualification the first provider in enum order wins. Leave empty to disable the hotkey.',
showInDialog: false,
},
baseUrl: {
type: 'string',
label: 'Model Base URL',
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/i18n/locales/ca.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default {
'to paste images': 'per enganxar imatges',
'for external editor': 'per a editor extern',
'to view transcript': 'per veure la transcripció',
'to toggle model': 'alternar model',
'Jump through words in the input': "Salta entre paraules a l'entrada",
'Close dialogs, cancel requests, or quit application':
"Tanca els diàlegs, cancel·la les peticions o surt de l'aplicació",
Expand Down Expand Up @@ -2761,4 +2762,11 @@ export default {
'Executant aquesta tasca programada en una sessió nova: {{link}}',
'This scheduled run could not be started: {{error}}':
"Aquesta execució programada no s'ha pogut iniciar: {{error}}",
// Model toggle hotkey messages
'⚠ Cannot toggle: auth type not available':
"⚠ No es pot alternar: tipus d'autenticació no disponible",
'Already on {{model}}': 'Ja en {{model}}',
'Switched to {{model}}': 'Alternat a {{model}}',
'⚠ Failed to switch to {{model}}: {{error}}':
'⚠ Error en alternar a {{model}}: {{error}}',
};
8 changes: 8 additions & 0 deletions packages/cli/src/i18n/locales/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -2050,6 +2050,7 @@ export default {
'to search history': 'zum Durchsuchen des Verlaufs',
'to paste images': 'zum Einfügen von Bildern',
'for external editor': 'für externen Editor',
'to toggle model': 'zum Umschalten des Modells',
'Updating...': 'Wird aktualisiert...',
Unknown: 'Unbekannt',
Error: 'Fehler',
Expand Down Expand Up @@ -2241,4 +2242,11 @@ export default {
'Die Sitzungsaufzeichnung wurde nach einem Schreibfehler beendet. Neue Nachrichten der betroffenen Sitzung werden nicht gespeichert. Prüfen Sie Speicherplatz und Berechtigungen und starten Sie anschließend eine neue Sitzung, um die Aufzeichnung fortzusetzen. Weitere Details finden Sie im Debug-Protokoll.',
'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.':
'Die Sitzungsaufzeichnung wurde nach einem Schreibfehler beendet. Neue Nachrichten der betroffenen Sitzung werden nicht gespeichert. Prüfen Sie Speicherplatz und Berechtigungen und führen Sie anschließend `/clear` aus, um eine neue aufgezeichnete Sitzung zu starten. Weitere Details finden Sie im Debug-Protokoll.',
// Model toggle hotkey messages
'⚠ Cannot toggle: auth type not available':
'⚠ Umschalten nicht möglich: Authentifizierungstyp nicht verfügbar',
'Already on {{model}}': 'Bereits auf {{model}}',
'Switched to {{model}}': 'Gewechselt zu {{model}}',
'⚠ Failed to switch to {{model}}: {{error}}':
'⚠ Wechsel zu {{model}} fehlgeschlagen: {{error}}',
};
8 changes: 8 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export default {
'to paste images': 'to paste images',
'for external editor': 'for external editor',
'to view transcript': 'to view transcript',
'to toggle model': 'to toggle model',
'Jump through words in the input': 'Jump through words in the input',
'Close dialogs, cancel requests, or quit application':
'Close dialogs, cancel requests, or quit application',
Expand Down Expand Up @@ -2739,4 +2740,11 @@ export default {
'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then start a new session to resume recording. See the debug log for details.',
'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.':
'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.',
// Model toggle hotkey messages
'⚠ Cannot toggle: auth type not available':
'⚠ Cannot toggle: auth type not available',
'Already on {{model}}': 'Already on {{model}}',
'Switched to {{model}}': 'Switched to {{model}}',
'⚠ Failed to switch to {{model}}: {{error}}':
'⚠ Failed to switch to {{model}}: {{error}}',
};
8 changes: 8 additions & 0 deletions packages/cli/src/i18n/locales/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export default {
'to search history': "pour rechercher dans l'historique",
'to paste images': 'pour coller des images',
'for external editor': 'pour un éditeur externe',
'to toggle model': 'pour basculer de modèle',
'Jump through words in the input': "Sauter de mot en mot dans l'entrée",
'Close dialogs, cancel requests, or quit application':
"Fermer les boîtes de dialogue, annuler les requêtes ou quitter l'application",
Expand Down Expand Up @@ -2244,4 +2245,11 @@ export default {
"L'enregistrement de la session s'est arrêté après un échec d'écriture. Les nouveaux messages de la session concernée ne seront pas enregistrés. Vérifiez l'espace disque et les autorisations, puis démarrez une nouvelle session pour reprendre l'enregistrement. Consultez le journal de débogage pour plus de détails.",
'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.':
"L'enregistrement de la session s'est arrêté après un échec d'écriture. Les nouveaux messages de la session concernée ne seront pas enregistrés. Vérifiez l'espace disque et les autorisations, puis exécutez `/clear` pour démarrer une nouvelle session enregistrée. Consultez le journal de débogage pour plus de détails.",
// Model toggle hotkey messages
'⚠ Cannot toggle: auth type not available':
"⚠ Impossible de basculer : type d'authentification indisponible",
'Already on {{model}}': 'Déjà sur {{model}}',
'Switched to {{model}}': 'Basculé vers {{model}}',
'⚠ Failed to switch to {{model}}: {{error}}':
'⚠ Échec du basculement vers {{model}} : {{error}}',
};
8 changes: 8 additions & 0 deletions packages/cli/src/i18n/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -1492,6 +1492,7 @@ export default {
'to search history': '履歴を検索',
'to paste images': '画像を貼り付け',
'for external editor': '外部エディタ用',
'to toggle model': 'モデルを切り替え',
'? for shortcuts': '? でショートカット表示',
'Pasting…': '貼り付け中…',
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
Expand Down Expand Up @@ -2008,4 +2009,11 @@ export default {
'書き込みに失敗したため、セッションの記録を停止しました。影響を受けたセッションの新しいメッセージは保存されません。ディスク容量と権限を確認してから、新しいセッションを開始して記録を再開してください。詳細はデバッグログを確認してください。',
'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.':
'書き込みに失敗したため、セッションの記録を停止しました。影響を受けたセッションの新しいメッセージは保存されません。ディスク容量と権限を確認してから、`/clear` を実行して記録可能な新しいセッションを開始してください。詳細はデバッグログを確認してください。',
// Model toggle hotkey messages
'⚠ Cannot toggle: auth type not available':
'⚠ 切り替えできません:認証タイプが利用できません',
'Already on {{model}}': '既に {{model}} を使用中',
'Switched to {{model}}': '{{model}} に切り替えました',
'⚠ Failed to switch to {{model}}: {{error}}':
'⚠ {{model}} への切り替えに失敗:{{error}}',
};
8 changes: 8 additions & 0 deletions packages/cli/src/i18n/locales/pt.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export default {
'to paste images': 'para colar imagens',
'for external editor': 'para editor externo',
'to view transcript': 'para ver a transcrição',
'to toggle model': 'alternar modelo',
'Jump through words in the input': 'Pular palavras na entrada',
'Close dialogs, cancel requests, or quit application':
'Fechar diálogos, cancelar solicitações ou sair do aplicativo',
Expand Down Expand Up @@ -2227,4 +2228,11 @@ export default {
'A gravação da sessão foi interrompida após uma falha de escrita. As novas mensagens da sessão afetada não serão salvas. Verifique o espaço em disco e as permissões e inicie uma nova sessão para retomar a gravação. Consulte o log de depuração para obter detalhes.',
'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.':
'A gravação da sessão foi interrompida após uma falha de escrita. As novas mensagens da sessão afetada não serão salvas. Verifique o espaço em disco e as permissões e execute `/clear` para iniciar uma nova sessão gravada. Consulte o log de depuração para obter detalhes.',
// Model toggle hotkey messages
'⚠ Cannot toggle: auth type not available':
'⚠ Não é possível alternar: tipo de autenticação indisponível',
'Already on {{model}}': 'Já em {{model}}',
'Switched to {{model}}': 'Alternado para {{model}}',
'⚠ Failed to switch to {{model}}: {{error}}':
'⚠ Falha ao alternar para {{model}}: {{error}}',
};
8 changes: 8 additions & 0 deletions packages/cli/src/i18n/locales/ru.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export default {
'to paste images': 'вставить изображения',
'for external editor': 'внешний редактор',
'to view transcript': 'показать транскрипт',
'to toggle model': 'переключить модель',

// ============================================================================
// Поля системной информации
Expand Down Expand Up @@ -2215,4 +2216,11 @@ export default {
'Запись сеанса остановлена после ошибки записи. Новые сообщения затронутого сеанса не будут сохранены. Проверьте свободное место и разрешения, затем начните новый сеанс, чтобы возобновить запись. Подробности см. в журнале отладки.',
'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.':
'Запись сеанса остановлена после ошибки записи. Новые сообщения затронутого сеанса не будут сохранены. Проверьте свободное место и разрешения, затем выполните `/clear`, чтобы начать новый записываемый сеанс. Подробности см. в журнале отладки.',
// Model toggle hotkey messages
'⚠ Cannot toggle: auth type not available':
'⚠ Не удалось переключить: тип авторизации недоступен',
'Already on {{model}}': 'Уже используется {{model}}',
'Switched to {{model}}': 'Переключено на {{model}}',
'⚠ Failed to switch to {{model}}: {{error}}':
'⚠ Не удалось переключиться на {{model}}: {{error}}',
};
Loading
Loading