Skip to content

Commit 5d80356

Browse files
committed
fix(tui): keep live effort when switching models; persist thinking mode
1 parent a6159bc commit 5d80356

7 files changed

Lines changed: 52 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
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.

apps/pythinker-code/src/tui/components/dialogs/model-selector.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -215,16 +215,14 @@ export class ModelSelectorComponent extends Container implements Focusable {
215215

216216
/**
217217
* Effort draft for a model: an explicit ←/→ override when set, otherwise the
218-
* live effort level for the active model, otherwise the first non-off level
219-
* (or 'off' when the model does not support thinking).
218+
* live effort level coerced to what the model supports. Defaulting other
219+
* models to their first level instead would silently persist that level as
220+
* the new startup default on switch, clobbering the user's saved effort.
220221
*/
221222
private draftFor(choice: ModelChoice): string {
222223
const override = this.effortOverrides.get(choice.alias);
223224
if (override !== undefined) return override;
224-
if (choice.alias === this.currentValue) {
225-
return coerceEffortForModel(choice.model, this.opts.currentEffort);
226-
}
227-
return effortLevelsForModel(choice.model).find((level) => level !== 'off') ?? 'off';
225+
return coerceEffortForModel(choice.model, this.opts.currentEffort);
228226
}
229227

230228
handleInput(data: string): boolean {

apps/pythinker-code/src/tui/utils/persist-effort.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,23 @@ export async function persistDefaultModelSelection(
1010
effort: string,
1111
): Promise<boolean> {
1212
const defaultThinking = effort !== 'off';
13+
// setConfig deep-merges, so a stale `mode = "off"` left in config.toml would
14+
// survive an effort-only patch and force thinking off on the next startup.
15+
// Write mode alongside effort to keep the pair consistent.
16+
const mode = defaultThinking ? 'on' : 'off';
1317
const config = await harness.getConfig({ reload: true });
1418
if (
1519
config.defaultModel === alias &&
1620
config.defaultThinking === defaultThinking &&
17-
config.thinking?.effort === effort
21+
config.thinking?.effort === effort &&
22+
config.thinking.mode === mode
1823
) {
1924
return false;
2025
}
2126
await harness.setConfig({
2227
defaultModel: alias,
2328
defaultThinking,
24-
thinking: { effort },
29+
thinking: { effort, mode },
2530
});
2631
return true;
2732
}

apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,24 @@ describe('ModelSelectorComponent', () => {
217217
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', effort: 'off' });
218218
});
219219

220+
it('keeps the live effort when switching to another model instead of resetting to its first level', () => {
221+
const onSelect = vi.fn();
222+
const picker = new ModelSelectorComponent({
223+
models: {
224+
current: model('Kimi K2', ['thinking'], ['low', 'high', 'max']),
225+
other: model('Kimi K3', ['thinking'], ['low', 'high', 'max']),
226+
},
227+
currentValue: 'current',
228+
currentEffort: 'max',
229+
onSelect,
230+
onCancel: vi.fn(),
231+
});
232+
233+
picker.handleInput(DOWN);
234+
picker.handleInput('\r');
235+
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'other', effort: 'max' });
236+
});
237+
220238
it('clamps the live effort when it is not in the current model’s set', () => {
221239
const onSelect = vi.fn();
222240
const picker = new ModelSelectorComponent({
@@ -259,16 +277,16 @@ describe('ModelSelectorComponent', () => {
259277
onCancel: vi.fn(),
260278
});
261279

262-
picker.handleInput(DOWN); // -> thinking model (defaults to first non-off level)
263-
picker.handleInput(RIGHT); // low -> medium
280+
picker.handleInput(DOWN); // -> thinking model (keeps the live off state)
281+
picker.handleInput(RIGHT); // off -> low
264282
picker.handleInput(UP); // -> plain
265-
picker.handleInput(DOWN); // -> thinking (the medium override persists)
283+
picker.handleInput(DOWN); // -> thinking (the low override persists)
266284
picker.handleInput('\r');
267285

268-
expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', effort: 'medium' });
286+
expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', effort: 'low' });
269287
});
270288

271-
it('defaults a capable model to its first level but keeps the current model state', () => {
289+
it('keeps the live off state when moving to another capable model', () => {
272290
const onSelect = vi.fn();
273291
const picker = new ModelSelectorComponent({
274292
models: {
@@ -284,10 +302,10 @@ describe('ModelSelectorComponent', () => {
284302
// The active model reflects its live (off) state.
285303
expect(text(picker)).toContain('[ off ]');
286304
picker.handleInput(DOWN); // -> the other thinking-capable model
287-
// A capable, non-active model defaults to its first non-off level.
288-
expect(text(picker)).toContain('[ med ]');
305+
// A capable, non-active model keeps the live effort instead of resetting.
306+
expect(text(picker)).toContain('[ off ]');
289307
picker.handleInput('\r');
290-
expect(onSelect).toHaveBeenCalledWith({ alias: 'other', effort: 'medium' });
308+
expect(onSelect).toHaveBeenCalledWith({ alias: 'other', effort: 'off' });
291309
});
292310

293311
it('fuzzy-filters by typing and reports a match count', () => {

apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,6 @@ describe('TabbedModelSelectorComponent', () => {
190190
expect(allLines).toHaveLength(1);
191191

192192
component.handleInput('\r');
193-
expect(onSelect).toHaveBeenCalledWith({ alias: 'terra/terra-13b', effort: 'low' });
193+
expect(onSelect).toHaveBeenCalledWith({ alias: 'terra/terra-13b', effort: 'medium' });
194194
});
195195
});

apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('EditorKeyboardController thinking-effort cycling', () => {
5454
expect(setConfig).toHaveBeenCalledWith({
5555
defaultModel: 'test/model',
5656
defaultThinking: true,
57-
thinking: { effort: 'medium' },
57+
thinking: { effort: 'medium', mode: 'on' },
5858
});
5959
});
6060
});

apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5218,21 +5218,21 @@ command = "vim"
52185218
expect(filteredOutput).toContain('Search: tu');
52195219
expect(filteredOutput).toContain('Kimi Turbo');
52205220
expect(filteredOutput).not.toContain('Kimi K2');
5221-
// Turbo is a thinking-capable model that is not the active one, so it
5222-
// defaults to its first non-off effort level without any ←/→ movement.
5221+
// Turbo is not the active model, but it keeps the live effort (off here)
5222+
// instead of resetting to its first level and persisting that as default.
52235223
(picker as TabbedModelSelectorComponent).handleInput('\r');
52245224

52255225
await vi.waitFor(() => {
52265226
expect(session.setModel).toHaveBeenCalledWith('turbo');
5227-
expect(session.setThinking).toHaveBeenCalledWith('low');
52285227
expect(setConfig).toHaveBeenCalledWith({
52295228
defaultModel: 'turbo',
5230-
defaultThinking: true,
5231-
thinking: { effort: 'low' },
5229+
defaultThinking: false,
5230+
thinking: { effort: 'off', mode: 'off' },
52325231
});
52335232
});
5233+
expect(session.setThinking).not.toHaveBeenCalled();
52345234
expect(driver.state.appState.model).toBe('turbo');
5235-
expect(driver.state.appState.thinkingLevel).toBe('low');
5235+
expect(driver.state.appState.thinkingLevel).toBe('off');
52365236
});
52375237

52385238
it('persists /model selection even when runtime state is unchanged', async () => {
@@ -5267,7 +5267,7 @@ command = "vim"
52675267
expect(setConfig).toHaveBeenCalledWith({
52685268
defaultModel: 'k2',
52695269
defaultThinking: false,
5270-
thinking: { effort: 'off' },
5270+
thinking: { effort: 'off', mode: 'off' },
52715271
});
52725272
});
52735273
expect(session.setModel).not.toHaveBeenCalled();
@@ -5301,7 +5301,7 @@ command = "vim"
53015301
expect(setConfig).toHaveBeenCalledWith({
53025302
defaultModel: 'k2',
53035303
defaultThinking: true,
5304-
thinking: { effort: 'high' },
5304+
thinking: { effort: 'high', mode: 'on' },
53055305
});
53065306
});
53075307
expect(driver.state.appState.thinkingLevel).toBe('high');

0 commit comments

Comments
 (0)