diff --git a/.changeset/model-picker-keeps-effort.md b/.changeset/model-picker-keeps-effort.md new file mode 100644 index 00000000..98f30eca --- /dev/null +++ b/.changeset/model-picker-keeps-effort.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Keep the current thinking effort when switching models in the model picker instead of silently saving the new model's lowest level as the default, and repair a stale thinking mode in the config when saving an effort. diff --git a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts index 5b9cc02c..45dc5a76 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts @@ -215,16 +215,14 @@ export class ModelSelectorComponent extends Container implements Focusable { /** * Effort draft for a model: an explicit ←/→ override when set, otherwise the - * live effort level for the active model, otherwise the first non-off level - * (or 'off' when the model does not support thinking). + * live effort level coerced to what the model supports. Defaulting other + * models to their first level instead would silently persist that level as + * the new startup default on switch, clobbering the user's saved effort. */ private draftFor(choice: ModelChoice): string { const override = this.effortOverrides.get(choice.alias); if (override !== undefined) return override; - if (choice.alias === this.currentValue) { - return coerceEffortForModel(choice.model, this.opts.currentEffort); - } - return effortLevelsForModel(choice.model).find((level) => level !== 'off') ?? 'off'; + return coerceEffortForModel(choice.model, this.opts.currentEffort); } handleInput(data: string): boolean { diff --git a/apps/pythinker-code/src/tui/utils/persist-effort.ts b/apps/pythinker-code/src/tui/utils/persist-effort.ts index f746c609..b6376c7e 100644 --- a/apps/pythinker-code/src/tui/utils/persist-effort.ts +++ b/apps/pythinker-code/src/tui/utils/persist-effort.ts @@ -10,18 +10,23 @@ export async function persistDefaultModelSelection( effort: string, ): Promise { const defaultThinking = effort !== 'off'; + // setConfig deep-merges, so a stale `mode = "off"` left in config.toml would + // survive an effort-only patch and force thinking off on the next startup. + // Write mode alongside effort to keep the pair consistent. + const mode = defaultThinking ? 'on' : 'off'; const config = await harness.getConfig({ reload: true }); if ( config.defaultModel === alias && config.defaultThinking === defaultThinking && - config.thinking?.effort === effort + config.thinking?.effort === effort && + config.thinking.mode === mode ) { return false; } await harness.setConfig({ defaultModel: alias, defaultThinking, - thinking: { effort }, + thinking: { effort, mode }, }); return true; } diff --git a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts index 4bda19c8..1e3d58fb 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts @@ -217,6 +217,24 @@ describe('ModelSelectorComponent', () => { expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', effort: 'off' }); }); + it('keeps the live effort when switching to another model instead of resetting to its first level', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + current: model('Kimi K2', ['thinking'], ['low', 'high', 'max']), + other: model('Kimi K3', ['thinking'], ['low', 'high', 'max']), + }, + currentValue: 'current', + currentEffort: 'max', + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(DOWN); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'other', effort: 'max' }); + }); + it('clamps the live effort when it is not in the current model’s set', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ @@ -259,16 +277,16 @@ describe('ModelSelectorComponent', () => { onCancel: vi.fn(), }); - picker.handleInput(DOWN); // -> thinking model (defaults to first non-off level) - picker.handleInput(RIGHT); // low -> medium + picker.handleInput(DOWN); // -> thinking model (keeps the live off state) + picker.handleInput(RIGHT); // off -> low picker.handleInput(UP); // -> plain - picker.handleInput(DOWN); // -> thinking (the medium override persists) + picker.handleInput(DOWN); // -> thinking (the low override persists) picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', effort: 'medium' }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', effort: 'low' }); }); - it('defaults a capable model to its first level but keeps the current model state', () => { + it('keeps the live off state when moving to another capable model', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ models: { @@ -284,10 +302,10 @@ describe('ModelSelectorComponent', () => { // The active model reflects its live (off) state. expect(text(picker)).toContain('[ off ]'); picker.handleInput(DOWN); // -> the other thinking-capable model - // A capable, non-active model defaults to its first non-off level. - expect(text(picker)).toContain('[ med ]'); + // A capable, non-active model keeps the live effort instead of resetting. + expect(text(picker)).toContain('[ off ]'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'other', effort: 'medium' }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', effort: 'off' }); }); it('fuzzy-filters by typing and reports a match count', () => { diff --git a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index 43f81bd4..521a3e5c 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -190,6 +190,6 @@ describe('TabbedModelSelectorComponent', () => { expect(allLines).toHaveLength(1); component.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'terra/terra-13b', effort: 'low' }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'terra/terra-13b', effort: 'medium' }); }); }); diff --git a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts index a16e5c15..1f233fa4 100644 --- a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts @@ -54,7 +54,7 @@ describe('EditorKeyboardController thinking-effort cycling', () => { expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'test/model', defaultThinking: true, - thinking: { effort: 'medium' }, + thinking: { effort: 'medium', mode: 'on' }, }); }); }); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index fe787454..96e66d1b 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -5218,21 +5218,21 @@ command = "vim" expect(filteredOutput).toContain('Search: tu'); expect(filteredOutput).toContain('Kimi Turbo'); expect(filteredOutput).not.toContain('Kimi K2'); - // Turbo is a thinking-capable model that is not the active one, so it - // defaults to its first non-off effort level without any ←/→ movement. + // Turbo is not the active model, but it keeps the live effort (off here) + // instead of resetting to its first level and persisting that as default. (picker as TabbedModelSelectorComponent).handleInput('\r'); await vi.waitFor(() => { expect(session.setModel).toHaveBeenCalledWith('turbo'); - expect(session.setThinking).toHaveBeenCalledWith('low'); expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'turbo', - defaultThinking: true, - thinking: { effort: 'low' }, + defaultThinking: false, + thinking: { effort: 'off', mode: 'off' }, }); }); + expect(session.setThinking).not.toHaveBeenCalled(); expect(driver.state.appState.model).toBe('turbo'); - expect(driver.state.appState.thinkingLevel).toBe('low'); + expect(driver.state.appState.thinkingLevel).toBe('off'); }); it('persists /model selection even when runtime state is unchanged', async () => { @@ -5267,7 +5267,7 @@ command = "vim" expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'k2', defaultThinking: false, - thinking: { effort: 'off' }, + thinking: { effort: 'off', mode: 'off' }, }); }); expect(session.setModel).not.toHaveBeenCalled(); @@ -5301,7 +5301,7 @@ command = "vim" expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'k2', defaultThinking: true, - thinking: { effort: 'high' }, + thinking: { effort: 'high', mode: 'on' }, }); }); expect(driver.state.appState.thinkingLevel).toBe('high');