Repository navigation
feat(cli): Add model toggle hotkey (Ctrl+F) #6486
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 16 commits
ac2945a
f7179d7
8bf4189
23cf916
63d423a
934ca93
4b59e75
e85e8f9
0e43f0f
956e9bf
f2ddbb1
0a45b20
21e82a4
50e819a
c5eeee8
ec47b37
69ae108
ef729cc
a7701b1
f74c6b7
e238a00
2331c9b
ecd7488
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 = ''; | ||
| 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); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Same issue in — 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', | ||
|
|
||
There was a problem hiding this comment.
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 onlyptyProcessis destructured. The CLI process is never stopped —rig.cleanup()only deletes the test directory. Orphaned PTY processes accumulate during test runs.Then at the end of the test, gracefully exit:
— qwen3.7-max via Qwen Code /review