From 7c40232535b46a3113c0b2c7361e4c011a6f68fe Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 17:02:38 +0800 Subject: [PATCH] feat(tui): add Alt+S to switch model for current session only In the /model picker, Enter still switches the model and saves it as the default; Alt+S now switches only for the current session without writing to the config file. --- .changeset/session-only-model-switch.md | 5 ++ apps/kimi-code/src/tui/commands/config.ts | 42 +++++++++++------ .../tui/components/dialogs/model-selector.ts | 17 ++++++- .../dialogs/tabbed-model-selector.ts | 4 ++ .../components/dialogs/model-selector.test.ts | 46 +++++++++++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 45 ++++++++++++++++++ 6 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 .changeset/session-only-model-switch.md diff --git a/.changeset/session-only-model-switch.md b/.changeset/session-only-model-switch.md new file mode 100644 index 0000000000..9dbc0295c4 --- /dev/null +++ b/.changeset/session-only-model-switch.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add an Alt+S shortcut in the model picker to switch the model for the current session only, without saving it as the default. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9b91d4ba01..42e87b294e 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -311,7 +311,11 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = currentThinking: host.state.appState.thinking, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void performModelSwitch(host, alias, thinking); + void performModelSwitch(host, alias, thinking, true); + }, + onSessionOnlySelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking, false); }, onCancel: () => { host.restoreEditor(); @@ -320,7 +324,12 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = ); } -async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise { +async function performModelSwitch( + host: SlashCommandHost, + alias: string, + thinking: boolean, + persist: boolean, +): Promise { if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); return; @@ -360,19 +369,26 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, thinkin } let persisted = false; - try { - persisted = await persistModelSelection(host, alias, thinking); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); - return; + if (persist) { + try { + persisted = await persistModelSelection(host, alias, thinking); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); + return; + } } - const status = runtimeChanged - ? `Switched to ${alias} with thinking ${level}.` - : persisted - ? `Saved ${alias} with thinking ${level} as default.` - : `Already using ${alias} with thinking ${level}.`; + let status: string; + if (runtimeChanged) { + status = persist + ? `Switched to ${alias} with thinking ${level}.` + : `Switched to ${alias} with thinking ${level} for this session only.`; + } else if (persist && persisted) { + status = `Saved ${alias} with thinking ${level} as default.`; + } else { + status = `Already using ${alias} with thinking ${level}.`; + } host.showStatus(status, 'success'); } diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index f223ba50b5..c319c83a46 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -65,6 +65,9 @@ export interface ModelSelectorOptions { * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ readonly providerSwitchHint?: boolean; readonly onSelect: (selection: ModelSelection) => void; + /** When provided, Alt+S invokes this instead of onSelect — used to apply the + * choice to the current session only, without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } @@ -160,6 +163,16 @@ export class ModelSelectorComponent extends Container implements Focusable { alias: selected.alias, thinking: effectiveThinking(selected.model, this.draftFor(selected)), }); + return; + } + + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const selected = this.selectedChoice(); + if (selected === undefined) return; + this.opts.onSessionOnlySelect({ + alias: selected.alias, + thinking: effectiveThinking(selected.model, this.draftFor(selected)), + }); } } @@ -179,7 +192,9 @@ export class ModelSelectorComponent extends Container implements Focusable { if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider'); hintParts.push('↑↓ navigate'); if (searchable && view.query.length > 0) hintParts.push('Backspace clear'); - hintParts.push('Enter select', 'Esc cancel'); + hintParts.push('Enter select'); + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 747072a5cc..767e5ecdd0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -44,6 +44,9 @@ export interface TabbedModelSelectorOptions { * tab derived from `currentValue`. */ readonly initialTabId?: string; readonly onSelect: (selection: ModelSelection) => void; + /** Forwarded to each inner selector; when set, Alt+S applies the choice to + * the current session only without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } @@ -250,6 +253,7 @@ function makeSelector( searchable: true, providerSwitchHint: true, onSelect: opts.onSelect, + onSessionOnlySelect: opts.onSessionOnlySelect, onCancel: opts.onCancel, }; return new ModelSelectorComponent(inner); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index eec53eca41..4d0ad439bf 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -254,4 +254,50 @@ describe('ModelSelectorComponent', () => { } } }); + + it('invokes onSessionOnlySelect on Alt+S with the effective thinking state', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinking: true, + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + + // Toggle thinking Off, then Alt+S applies the choice to the session only. + picker.handleInput(RIGHT); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: false }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores Alt+S and hides its hint when onSessionOnlySelect is not provided', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinking: true, + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(`${ESC}s`); + expect(onSelect).not.toHaveBeenCalled(); + expect(text(picker)).not.toContain('Alt+S session-only'); + }); + + it('shows the Alt+S session-only hint when onSessionOnlySelect is provided', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinking: true, + onSelect: vi.fn(), + onSessionOnlySelect: vi.fn(), + onCancel: vi.fn(), + }); + expect(text(picker)).toContain('Alt+S session-only'); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index cab1bfeaed..32ff744e80 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -3411,6 +3411,51 @@ command = "vim" expect(driver.state.appState.thinking).toBe(true); }); + it('applies /model selection to the session only on Alt+S without persisting', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + displayName: 'Kimi Turbo', + capabilities: ['thinking'], + }, + }, + defaultModel: 'k2', + defaultThinking: false, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0]; + // /model turbo preselects turbo; Alt+S applies it to the current session only. + (picker as TabbedModelSelectorComponent).handleInput(`${ESC}s`); + + await vi.waitFor(() => { + expect(session.setModel).toHaveBeenCalledWith('turbo'); + expect(session.setThinking).toHaveBeenCalledWith('on'); + }); + expect(setConfig).not.toHaveBeenCalled(); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinking).toBe(true); + }); + it('persists /model selection even when runtime state is unchanged', async () => { const session = makeSession(); const setConfig = vi.fn(async () => ({ providers: {} }));