Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/model-picker-keeps-effort.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 4 additions & 6 deletions apps/pythinker-code/src/tui/components/dialogs/model-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions apps/pythinker-code/src/tui/utils/persist-effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,23 @@ export async function persistDefaultModelSelection(
effort: string,
): Promise<boolean> {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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: {
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
});
});
Expand Down
16 changes: 8 additions & 8 deletions apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
Loading