Skip to content

Commit ad27bb4

Browse files
committed
feat(tui): cycle thinking effort with shift-tab
1 parent 3bebe9e commit ad27bb4

3 files changed

Lines changed: 217 additions & 59 deletions

File tree

apps/pythinker-code/src/tui/constant/tips.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,6 @@ export const ALL_TIPS: readonly ToolbarTip[] = [
4444
{ text: '/help: show commands' },
4545
{ text: '/compact compresses context when it gets long', priority: 2 },
4646
{ text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 },
47-
{ text: 'shift-tab to Plan mode to review the approach before Pythinker edits files.', priority: 2 },
47+
{ text: '/plan to review the approach before Pythinker edits files.', priority: 2 },
4848
{ text: '/model: switch model', priority: 2 },
4949
];

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

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { readFile } from 'node:fs/promises';
22

3-
import type { FileMeta, PythinkerHarness, Session } from '@pymodel/pythinker-code-sdk';
3+
import type {
4+
FileMeta,
5+
PythinkerHarness,
6+
Session,
7+
ThinkingEffort,
8+
} from '@pymodel/pythinker-code-sdk';
49
import { compressImageForModel } from '@pymodel/pythinker-code-sdk';
510

611
import {
@@ -11,6 +16,7 @@ import {
1116
import { parseImageMeta } from '#/utils/image/image-mime';
1217
import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor';
1318

19+
import { segmentsFor } from '../components/dialogs/model-selector';
1420
import {
1521
CTRL_C_HINT,
1622
CTRL_D_HINT,
@@ -28,7 +34,13 @@ import type {
2834
} from '../utils/image-attachment-store';
2935
import { extractMediaAttachments, imageExtensionForMime } from '../utils/image-placeholder';
3036
import { extractInlineSkillActivations } from '../utils/inline-skill-tokens';
31-
import type { PendingExit, QueuedMessage, SteerInputItem } from '../types';
37+
import { thinkingEffortToConfig } from '../utils/thinking-config';
38+
import type {
39+
AppState,
40+
PendingExit,
41+
QueuedMessage,
42+
SteerInputItem,
43+
} from '../types';
3244
import type { TUIState } from '../tui-state';
3345
import type { BtwPanelController } from './btw-panel';
3446

@@ -63,6 +75,8 @@ export interface EditorKeyboardHost {
6375
releaseStagingMedia(mediaAttachmentIds: readonly number[]): void;
6476
recallLastQueued(): QueuedMessage | undefined;
6577
showError(msg: string): void;
78+
showNotice(title: string, detail?: string): void;
79+
setAppState(patch: Partial<AppState>): void;
6680
track(event: string, props?: Record<string, unknown>): void;
6781
updateEditorBorderHighlight(text?: string): void;
6882
/** `undefined` means the input cannot be a `/goal` command (clear without measuring). */
@@ -76,7 +90,6 @@ export interface EditorKeyboardHost {
7690
openUndoSelector(): void;
7791
stop(exitCode?: number): Promise<void>;
7892
ensureSession(): Promise<Session | undefined>;
79-
handlePlanToggle(next: boolean): void;
8093
handleInputModeChange(mode: 'prompt' | 'bash'): void;
8194
clearQueuedMessages(): void;
8295
setExternalEditorRunning(running: boolean): void;
@@ -256,25 +269,7 @@ export class EditorKeyboardController {
256269
};
257270

258271
editor.onShiftTab = () => {
259-
const togglePlan = (): void => {
260-
const next = !host.state.appState.planMode;
261-
host.track('shortcut_plan_toggle', { enabled: next });
262-
host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' });
263-
host.handlePlanToggle(next);
264-
};
265-
if (host.session === undefined) {
266-
if (!host.engineV2) {
267-
host.showError(NO_ACTIVE_SESSION_MESSAGE);
268-
return;
269-
}
270-
// v2 session-less: lazy-create the session, then toggle — the same
271-
// path /plan takes.
272-
void host.ensureSession().then((session) => {
273-
if (session !== undefined) togglePlan();
274-
});
275-
return;
276-
}
277-
togglePlan();
272+
void this.cycleThinkingEffort();
278273
};
279274

280275
editor.onInputModeChange = (mode) => {
@@ -532,6 +527,73 @@ export class EditorKeyboardController {
532527
void this.host.session?.cancel();
533528
}
534529

530+
/** Shift-Tab: cycle the thinking effort to the current model's next level (wraps). */
531+
private async cycleThinkingEffort(): Promise<void> {
532+
const { host } = this;
533+
if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) {
534+
host.showError('Cannot change thinking effort while streaming — press Esc or Ctrl-C first.');
535+
return;
536+
}
537+
const alias = host.state.appState.model;
538+
if (alias.trim().length === 0) {
539+
host.showError(LLM_NOT_SET_MESSAGE);
540+
return;
541+
}
542+
const model = host.state.appState.availableModels[alias];
543+
if (model === undefined) {
544+
host.showError('No model selected. Run /model to select one first.');
545+
return;
546+
}
547+
const levels = segmentsFor(model);
548+
if (levels.length <= 1) {
549+
host.showNotice(`${alias} does not offer selectable thinking effort levels.`);
550+
return;
551+
}
552+
const prev = host.state.appState.thinkingEffort;
553+
const currentIndex = levels.indexOf(prev);
554+
// An out-of-list live effort (e.g. a provider-specific value) restarts the
555+
// cycle from the off entry when offered, else from the first level.
556+
const startIndex = currentIndex !== -1 ? currentIndex + 1 : Math.max(0, levels.indexOf('off'));
557+
const next = levels[startIndex % levels.length] ?? levels[0]!;
558+
if (host.session !== undefined) {
559+
try {
560+
await host.session.setThinking(next);
561+
} catch (error) {
562+
host.showError(`Failed to set thinking effort: ${formatErrorMessage(error)}`);
563+
return;
564+
}
565+
} else if (!host.engineV2) {
566+
host.showError(NO_ACTIVE_SESSION_MESSAGE);
567+
return;
568+
}
569+
// v2 session-less: carry the choice into the first lazy-created session,
570+
// the same way a session-only Alt+S choice is applied on creation.
571+
const patch: Partial<AppState> = { thinkingEffort: next };
572+
if (host.session === undefined) patch.lazySessionThinking = next;
573+
host.setAppState(patch);
574+
host.track('thinking_toggle', { enabled: next !== 'off', effort: next, from: prev });
575+
// No transcript notice: the footer already shows the new level live, and
576+
// rapid cycling would stack a line per keypress in the chat history.
577+
await this.persistDefaultEffort(alias, model, next);
578+
}
579+
580+
/** Best-effort persist of the cycled effort as the config default. */
581+
private async persistDefaultEffort(
582+
alias: string,
583+
model: Parameters<typeof segmentsFor>[0],
584+
effort: ThinkingEffort,
585+
): Promise<void> {
586+
const harness = this.host.harness;
587+
if (harness === undefined || alias !== this.host.state.appState.model) return;
588+
try {
589+
await harness.setConfig({ thinking: thinkingEffortToConfig(effort, model.supportEfforts) });
590+
} catch (error) {
591+
this.host.showError(
592+
`Thinking effort set to ${effort}, but failed to save default: ${formatErrorMessage(error)}`,
593+
);
594+
}
595+
}
596+
535597
private cancelCurrentCompaction(): void {
536598
const session = this.host.session;
537599
if (session === undefined) return;

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

Lines changed: 132 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -383,84 +383,180 @@ describe('EditorKeyboardController input changes', () => {
383383
});
384384
});
385385

386-
describe('EditorKeyboardController Shift-Tab plan toggle', () => {
387-
function createShiftTabHarness(options: { sessionless?: boolean; engineV2?: boolean } = {}) {
386+
describe('EditorKeyboardController Shift-Tab effort cycle', () => {
387+
function createEffortHarness(
388+
options: {
389+
supportEfforts?: string[];
390+
capabilities?: string[];
391+
thinkingEffort?: string;
392+
streamingPhase?: string;
393+
sessionless?: boolean;
394+
engineV2?: boolean;
395+
setThinkingError?: Error;
396+
} = {},
397+
) {
388398
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
389399
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
390400
};
391-
const handlePlanToggle = vi.fn();
401+
const setThinking = vi.fn(async () => {});
402+
if (options.setThinkingError !== undefined) {
403+
setThinking.mockRejectedValue(options.setThinkingError);
404+
}
405+
const setConfig = vi.fn(async () => {});
392406
const track = vi.fn();
393407
const showError = vi.fn();
394-
const ensureSession = vi.fn(async (): Promise<{ id: string } | undefined> => ({ id: 'ses-lazy' }));
408+
const showNotice = vi.fn();
409+
const appState: Record<string, unknown> = {
410+
streamingPhase: options.streamingPhase ?? 'idle',
411+
isCompacting: false,
412+
model: 'kimi-k2',
413+
thinkingEffort: options.thinkingEffort ?? 'off',
414+
availableModels: {
415+
'kimi-k2':
416+
options.supportEfforts === undefined
417+
? {
418+
provider: 'managed:pythinker-code',
419+
model: 'kimi-k2',
420+
maxContextSize: 262144,
421+
capabilities: options.capabilities ?? ['thinking'],
422+
}
423+
: {
424+
provider: 'managed:pythinker-code',
425+
model: 'kimi-k2',
426+
maxContextSize: 262144,
427+
supportEfforts: options.supportEfforts,
428+
},
429+
},
430+
};
431+
const statePatches: Array<Record<string, unknown>> = [];
395432
const host = {
396433
state: {
397434
editor,
398435
activeDialog: null,
399-
appState: { streamingPhase: 'idle', isCompacting: false, planMode: false },
436+
appState,
400437
footer: { setTransientHint: vi.fn() },
401438
ui: { requestRender: vi.fn() },
402439
},
403-
session: options.sessionless ? undefined : { cancel: vi.fn(async () => {}) },
440+
session:
441+
options.sessionless === true
442+
? undefined
443+
: { cancel: vi.fn(async () => {}), setThinking },
404444
engineV2: options.engineV2 ?? false,
405-
ensureSession,
406-
handlePlanToggle,
445+
harness: { setConfig },
446+
// Merge like the real host so successive presses read fresh effort.
447+
setAppState: (patch: Record<string, unknown>) => {
448+
Object.assign(appState, patch);
449+
statePatches.push(patch);
450+
},
407451
track,
408452
showError,
453+
showNotice,
409454
btwPanelController: { cancelRunning: vi.fn(), closeOrCancel: vi.fn() },
410455
} as unknown as EditorKeyboardHost;
411456

412457
new EditorKeyboardController(host, undefined as unknown as ImageAttachmentStore).install();
413458
const onShiftTab = editor['onShiftTab'] as unknown as () => void;
414-
return { onShiftTab, handlePlanToggle, track, showError, ensureSession };
459+
return { onShiftTab, setThinking, setConfig, track, showError, showNotice, statePatches };
415460
}
416461

417-
it('toggles plan mode directly with an active session', () => {
418-
const { onShiftTab, handlePlanToggle, ensureSession } = createShiftTabHarness();
462+
async function settle(): Promise<void> {
463+
await new Promise((resolve) => setImmediate(resolve));
464+
await new Promise((resolve) => setImmediate(resolve));
465+
}
419466

420-
onShiftTab();
467+
it('cycles off → low → high → max → off and persists the default', async () => {
468+
const h = createEffortHarness({ supportEfforts: ['low', 'high', 'max'] });
469+
const press = async (): Promise<unknown> => {
470+
h.onShiftTab();
471+
await settle();
472+
return h.statePatches.at(-1)?.['thinkingEffort'];
473+
};
421474

422-
expect(ensureSession).not.toHaveBeenCalled();
423-
expect(handlePlanToggle).toHaveBeenCalledWith(true);
475+
await expect(press()).resolves.toBe('low');
476+
expect(h.setThinking).toHaveBeenCalledWith('low');
477+
expect(h.setConfig).toHaveBeenCalledWith({ thinking: { enabled: true, effort: 'low' } });
478+
479+
await expect(press()).resolves.toBe('high');
480+
await expect(press()).resolves.toBe('max');
481+
// The top declared level never becomes the stored default effort.
482+
expect(h.setConfig).toHaveBeenLastCalledWith({ thinking: { enabled: true } });
483+
484+
await expect(press()).resolves.toBe('off');
485+
expect(h.setConfig).toHaveBeenLastCalledWith({ thinking: { enabled: false } });
486+
expect(h.track).toHaveBeenLastCalledWith('thinking_toggle', {
487+
enabled: false,
488+
effort: 'off',
489+
from: 'max',
490+
});
424491
});
425492

426-
it('reports no active session on v1 when session-less', () => {
427-
const { onShiftTab, showError, handlePlanToggle } = createShiftTabHarness({
428-
sessionless: true,
493+
it('refuses to cycle while a turn is streaming', async () => {
494+
const h = createEffortHarness({
495+
supportEfforts: ['low', 'high'],
496+
streamingPhase: 'composing',
429497
});
430498

431-
onShiftTab();
499+
h.onShiftTab();
500+
await settle();
501+
502+
expect(h.showError).toHaveBeenCalledWith(
503+
'Cannot change thinking effort while streaming — press Esc or Ctrl-C first.',
504+
);
505+
expect(h.setThinking).not.toHaveBeenCalled();
506+
});
507+
508+
it('reports no active session on v1 when session-less', async () => {
509+
const h = createEffortHarness({ supportEfforts: ['low', 'high'], sessionless: true });
510+
511+
h.onShiftTab();
512+
await settle();
432513

433-
expect(showError).toHaveBeenCalledWith(NO_ACTIVE_SESSION_MESSAGE);
434-
expect(handlePlanToggle).not.toHaveBeenCalled();
514+
expect(h.showError).toHaveBeenCalledWith(NO_ACTIVE_SESSION_MESSAGE);
515+
expect(h.statePatches).toEqual([]);
435516
});
436517

437-
it('lazy-creates the session before toggling on v2 when session-less', async () => {
438-
const { onShiftTab, ensureSession, handlePlanToggle, track } = createShiftTabHarness({
518+
it('carries the cycled effort into the lazy v2 session when session-less', async () => {
519+
const h = createEffortHarness({
520+
supportEfforts: ['low', 'high'],
439521
sessionless: true,
440522
engineV2: true,
441523
});
442524

443-
onShiftTab();
444-
expect(handlePlanToggle).not.toHaveBeenCalled();
525+
h.onShiftTab();
526+
await settle();
445527

446-
await vi.waitFor(() => {
447-
expect(handlePlanToggle).toHaveBeenCalledWith(true);
528+
expect(h.setThinking).not.toHaveBeenCalled();
529+
expect(h.statePatches.at(-1)).toMatchObject({
530+
thinkingEffort: 'low',
531+
lazySessionThinking: 'low',
448532
});
449-
expect(ensureSession).toHaveBeenCalledOnce();
450-
expect(track).toHaveBeenCalledWith('shortcut_plan_toggle', { enabled: true });
533+
expect(h.showError).not.toHaveBeenCalled();
451534
});
452535

453-
it('does not toggle when the lazy creation fails on v2', async () => {
454-
const { onShiftTab, ensureSession, handlePlanToggle } = createShiftTabHarness({
455-
sessionless: true,
456-
engineV2: true,
536+
it('notifies when the model offers no selectable levels', async () => {
537+
const h = createEffortHarness({ capabilities: ['always_thinking'] });
538+
539+
h.onShiftTab();
540+
await settle();
541+
542+
expect(h.showNotice).toHaveBeenCalledWith(
543+
'kimi-k2 does not offer selectable thinking effort levels.',
544+
);
545+
expect(h.setThinking).not.toHaveBeenCalled();
546+
});
547+
548+
it('surfaces a setThinking failure without changing state', async () => {
549+
const h = createEffortHarness({
550+
supportEfforts: ['low', 'high'],
551+
setThinkingError: new Error('boom'),
457552
});
458-
ensureSession.mockResolvedValue(undefined);
459553

460-
onShiftTab();
461-
await new Promise((resolve) => setImmediate(resolve));
554+
h.onShiftTab();
555+
await settle();
462556

463-
expect(handlePlanToggle).not.toHaveBeenCalled();
557+
expect(h.showError).toHaveBeenCalledWith('Failed to set thinking effort: boom');
558+
expect(h.statePatches).toEqual([]);
559+
expect(h.setConfig).not.toHaveBeenCalled();
464560
});
465561
});
466562

0 commit comments

Comments
 (0)