From 7d0652a192d039a8bf81dac262f8b3f51d44fe2d Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 13 Aug 2026 21:28:36 -0400 Subject: [PATCH 1/3] fix(tui): persist thinking effort cycled via Ctrl-T/Shift-Tab --- .../pythinker-code/src/tui/commands/config.ts | 17 +----- .../src/tui/controllers/editor-keyboard.ts | 10 +++ .../src/tui/utils/persist-effort.ts | 27 ++++++++ .../tui/controllers/editor-keyboard.test.ts | 61 +++++++++++++++++++ 4 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 apps/pythinker-code/src/tui/utils/persist-effort.ts create mode 100644 apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index 36078248..c637f383 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -35,6 +35,7 @@ import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; import { saveTuiConfig } from '../config'; import { generateKeybindingsTemplate } from '../keybindings'; +import { persistDefaultModelSelection } from '../utils/persist-effort'; import type { ThemeName } from '#/tui/theme'; import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; import { @@ -769,21 +770,7 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, effort: } async function persistModelSelection(host: SlashCommandHost, alias: string, effort: string): Promise { - const defaultThinking = effort !== 'off'; - const config = await host.harness.getConfig({ reload: true }); - if ( - config.defaultModel === alias && - config.defaultThinking === defaultThinking && - config.thinking?.effort === effort - ) { - return false; - } - await host.harness.setConfig({ - defaultModel: alias, - defaultThinking, - thinking: { effort }, - }); - return true; + return persistDefaultModelSelection(host.harness, alias, effort); } // --------------------------------------------------------------------------- diff --git a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts index d8c8fbfd..dbfafc7b 100644 --- a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts @@ -2,6 +2,7 @@ import { Editor, parseKey } from '@earendil-works/pi-tui'; import { coerceEffortForModel, effortLevelsForModel, + type PythinkerHarness, type Session, } from '@pythoughts/pythinker-code-sdk'; @@ -28,6 +29,7 @@ import { type ParsedKeybinding, } from '#/tui/keybindings'; import { isPrintableChar, printableChar } from '#/tui/utils/printable-key'; +import { persistDefaultModelSelection } from '#/tui/utils/persist-effort'; function effectiveContextBindings( bindings: readonly ParsedKeybinding[], @@ -45,6 +47,7 @@ function effectiveContextBindings( export interface EditorKeyboardHost { state: TUIState; session: Session | undefined; + readonly harness: PythinkerHarness; cancelInFlight: (() => void) | undefined; handleUserInput(text: string): void; @@ -323,6 +326,13 @@ export class EditorKeyboardController { host.track('thinking_toggle', { enabled: next !== 'off', effort: next }); // No transcript notice: the footer already shows the new level live, and // rapid cycling would stack a line per keypress in the chat history. + try { + await persistDefaultModelSelection(host.harness, alias, next); + } catch (error) { + host.showError( + `Thinking effort set to ${next}, but failed to save default: ${formatErrorMessage(error)}`, + ); + } } private cancelCurrentCompaction(): void { diff --git a/apps/pythinker-code/src/tui/utils/persist-effort.ts b/apps/pythinker-code/src/tui/utils/persist-effort.ts new file mode 100644 index 00000000..f746c609 --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/persist-effort.ts @@ -0,0 +1,27 @@ +import type { PythinkerHarness } from '@pythoughts/pythinker-code-sdk'; + +/** + * Save the model + thinking-effort pair as the startup default. + * Returns false when the config already holds the same selection. + */ +export async function persistDefaultModelSelection( + harness: PythinkerHarness, + alias: string, + effort: string, +): Promise { + const defaultThinking = effort !== 'off'; + const config = await harness.getConfig({ reload: true }); + if ( + config.defaultModel === alias && + config.defaultThinking === defaultThinking && + config.thinking?.effort === effort + ) { + return false; + } + await harness.setConfig({ + defaultModel: alias, + defaultThinking, + thinking: { effort }, + }); + return true; +} diff --git a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts new file mode 100644 index 00000000..a16e5c15 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + EditorKeyboardController, + type EditorKeyboardHost, +} from '#/tui/controllers/editor-keyboard'; +import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; + +function makeHost() { + const editor: Record = {}; + const setConfig = vi.fn(() => Promise.resolve()); + const getConfig = vi.fn(() => + Promise.resolve({ defaultModel: undefined, defaultThinking: undefined, thinking: undefined }), + ); + const host = { + state: { + editor, + ui: { addInputListener: vi.fn(() => () => {}), requestRender: vi.fn() }, + appState: { + model: 'test/model', + thinkingLevel: 'low', + availableModels: { + 'test/model': { + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high', 'max'], + }, + }, + }, + }, + session: { setThinking: vi.fn(() => Promise.resolve()) }, + harness: { getConfig, setConfig }, + cancelInFlight: undefined, + setAppState: vi.fn(), + track: vi.fn(), + showError: vi.fn(), + showNotice: vi.fn(), + dispatchFooter: vi.fn(), + updateEditorBorderHighlight: vi.fn(), + updateQueueDisplay: vi.fn(), + } as unknown as EditorKeyboardHost; + return { host, editor, setConfig, getConfig }; +} + +describe('EditorKeyboardController thinking-effort cycling', () => { + it('persists the cycled effort as the startup default', async () => { + const { host, editor, setConfig } = makeHost(); + const controller = new EditorKeyboardController(host, {} as unknown as ImageAttachmentStore); + controller.install(); + + const onCycleEffort = editor['onCycleEffort'] as () => void; + expect(typeof onCycleEffort).toBe('function'); + onCycleEffort(); + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'test/model', + defaultThinking: true, + thinking: { effort: 'medium' }, + }); + }); + }); +}); From a6159bcf260d73997899f672050a51c156126ee3 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 13 Aug 2026 21:29:24 -0400 Subject: [PATCH 2/3] chore: add changeset --- .changeset/persist-cycled-effort.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/persist-cycled-effort.md diff --git a/.changeset/persist-cycled-effort.md b/.changeset/persist-cycled-effort.md new file mode 100644 index 00000000..3b924c00 --- /dev/null +++ b/.changeset/persist-cycled-effort.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Keep the thinking effort chosen with Ctrl-T/Shift-Tab as the default across restarts. From 5d80356ade81e83282a06102257f517e825454e9 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 13 Aug 2026 21:39:52 -0400 Subject: [PATCH 3/3] fix(tui): keep live effort when switching models; persist thinking mode --- .changeset/model-picker-keeps-effort.md | 5 +++ .../tui/components/dialogs/model-selector.ts | 10 +++--- .../src/tui/utils/persist-effort.ts | 9 +++-- .../components/dialogs/model-selector.test.ts | 34 ++++++++++++++----- .../dialogs/tabbed-model-selector.test.ts | 2 +- .../tui/controllers/editor-keyboard.test.ts | 2 +- .../tui/pythinker-tui-message-flow.test.ts | 16 ++++----- 7 files changed, 52 insertions(+), 26 deletions(-) create mode 100644 .changeset/model-picker-keeps-effort.md 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 c6bb5ddc..375ccf64 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');