Skip to content

Commit 9371149

Browse files
committed
Cycle reasoning effort with Shift+Tab and show it on the prompt
Shift+Tab walks the current model's supported effort ladder (wrapping), rebuilds inference sources so the next turn picks it up, and refreshes the prompt border label. Plain Tab still toggles focus. Docs no longer claim Shift+Tab toggles auto mode.
1 parent 7a189ff commit 9371149

9 files changed

Lines changed: 119 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ The chat director adds context management on top of the reactor:
7777

7878
## Permissions and auto mode
7979

80-
Corbits Code defaults to **auto mode** (`auto = true`). Workspace file writes/edits/deletes and unconstrained shell commands run without per-action prompts. Pass `--no-auto` to start in ask-on-every-consequential-action mode, or press **SHIFT+TAB** in the TUI to toggle at any time. Enabling auto prints a one-line reminder of the envelope below.
80+
Corbits Code defaults to **auto mode** (`auto = true`). Workspace file writes/edits/deletes and unconstrained shell commands run without per-action prompts. Pass `--no-auto` to start in ask-on-every-consequential-action mode (there is currently no in-session key to toggle auto). Press **Shift+Tab** in the TUI to cycle reasoning effort for the current model. Enabling auto prints a one-line reminder of the envelope below.
8181

8282
### What auto allows
8383

‎src/config/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,8 @@ export async function loadConfig(
468468
// writes/edits and unconstrained shell) run without prompting, while shell
469469
// file-mutation stays denied and installs / recursive rm / worktree /
470470
// sensitive-path / opaque-wrapper shell still ask. Pass --no-auto to revert
471-
// to ask-on-every-write, or toggle live in the TUI with SHIFT+TAB.
471+
// to ask-on-every-write. There is currently no in-session key to toggle auto;
472+
// Shift+Tab in the TUI cycles reasoning effort instead.
472473
let auto = true;
473474
let configPath: string | undefined;
474475
let provider: string | undefined;

‎src/permission/gate.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,9 @@ export type PermissionGate = {
253253
// Whether auto mode is currently on. Auto mode auto-approves non-destructive
254254
// consequential actions (file writes/edits, unconstrained shell) without prompting.
255255
getAuto: () => boolean;
256-
// Turn auto mode on or off for the rest of the session. SHIFT+TAB in the TUI
257-
// wires the toggle here so a switch takes effect on the next tool call.
256+
// Turn auto mode on or off for the rest of the session. Live callers (slash
257+
// commands, settings) wire the toggle here so a switch takes effect on the
258+
// next tool call. There is currently no in-session key chord for this.
258259
setAuto: (value: boolean) => void;
259260
// Whether --dangerously-skip-permissions is active for this session. Immutable
260261
// after gate construction; pre-gate sandboxes (path-escape, shell cwd bounds)

‎src/provider/reasoning-effort.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isReasoningEffort,
66
supportedEfforts,
77
validateEffort,
8+
cycleReasoningEffort,
89
setModelReasoningCapabilities,
910
modelReasoningCapability,
1011
clampEffort,
@@ -98,6 +99,27 @@ describe("validateEffort", () => {
9899
});
99100
});
100101

102+
describe("cycleReasoningEffort", () => {
103+
afterEach(() => setModelReasoningCapabilities({}));
104+
105+
test("walks the gpt-5 ladder and wraps", () => {
106+
expect(cycleReasoningEffort("gpt-5", undefined)).toBe("minimal");
107+
expect(cycleReasoningEffort("gpt-5", "minimal")).toBe("low");
108+
expect(cycleReasoningEffort("gpt-5", "low")).toBe("medium");
109+
expect(cycleReasoningEffort("gpt-5", "medium")).toBe("high");
110+
expect(cycleReasoningEffort("gpt-5", "high")).toBe("minimal");
111+
});
112+
113+
test("starts at the first supported level when current is unsupported", () => {
114+
expect(cycleReasoningEffort("gpt-5", "xhigh")).toBe("minimal");
115+
});
116+
117+
test("returns undefined for a non-reasoning model", () => {
118+
setModelReasoningCapabilities({ "chat-only-model": false });
119+
expect(cycleReasoningEffort("chat-only-model", "medium")).toBeUndefined();
120+
});
121+
});
122+
101123
describe("reasoning capability gate", () => {
102124
afterEach(() => setModelReasoningCapabilities({}));
103125

‎src/provider/reasoning-effort.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,25 @@ export function validateEffort(
9696
};
9797
}
9898

99+
/**
100+
* Next effort on the model's supported ladder (wraps around). Returns undefined
101+
* when the model supports no reasoning effort — callers flash a status and leave
102+
* the session config alone.
103+
*/
104+
export function cycleReasoningEffort(
105+
model: string,
106+
current: ReasoningEffort | undefined,
107+
isCodex = false,
108+
): ReasoningEffort | undefined {
109+
const supported = supportedEfforts(model, undefined, isCodex);
110+
if (supported.length === 0) return undefined;
111+
if (current === undefined || !supported.includes(current)) {
112+
return supported[0];
113+
}
114+
const idx = supported.indexOf(current);
115+
return supported[(idx + 1) % supported.length];
116+
}
117+
99118
// ---------------------------------------------------------------------------
100119
// Role-based product defaults (CL-5162)
101120
//

‎src/tui/keybindings.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
setSentMessageHistory,
3838
setShellBridgeHooks,
3939
setShellExitHandler,
40+
setEffortCycleHandler,
4041
clearShellBridgeHooks,
4142
setShellRunState,
4243
shellFocusPrompt,
@@ -98,7 +99,7 @@ function chordsOf(keys: string): readonly (string | null)[] {
9899
const bytes = chordBytes(token.trim())
99100
// A token nothing can encode and that is not the known kitty-only chord is
100101
// a typo in the catalog, not an untestable chord.
101-
if (bytes === null && token.trim() !== "Ctrl+Enter") {
102+
if (bytes === null && token.trim() !== "Ctrl+Enter" && token.trim() !== "Shift+Tab") {
102103
throw new Error(`catalog row "${keys}" has unreadable chord "${token.trim()}"`)
103104
}
104105
return bytes
@@ -371,6 +372,22 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
371372
expect(focusOwner(shell.focus)).toBe("prompt")
372373
},
373374
},
375+
"Shift+Tab": {
376+
group: "surfaces",
377+
probe: ({ h, shell }) => {
378+
let cycles = 0
379+
setEffortCycleHandler(shell, () => {
380+
cycles++
381+
})
382+
shellFocusPrompt(shell)
383+
const before = focusOwner(shell.focus)
384+
// Classic terminals often emit CSI Z for Shift+Tab; the harness can also
385+
// inject name:"tab" with shift:true, which is what the shell handler reads.
386+
h.pressKey("Tab", { shift: true })
387+
expect(cycles).toBe(1)
388+
expect(focusOwner(shell.focus)).toBe(before)
389+
},
390+
},
374391
Esc: {
375392
group: "surfaces",
376393
probe: async ({ h, shell, chords }) => {

‎src/tui/keybindings.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
3030
{ keys: "Alt+T", description: "show or hide the task list above the prompt" },
3131
{ keys: "Alt+O", description: "observe a live subagent session; a system row says so when there is none" },
3232
{ keys: "Tab", description: "move focus between the prompt and the transcript" },
33+
{ keys: "Shift+Tab", description: "cycle reasoning effort for the current model" },
3334
{ keys: "Esc", description: "close the open overlay, or leave subagent observe" },
3435
{ keys: "Ctrl+B / Ctrl+F", description: "move the cursor back / forward one character" },
3536
{ keys: "Ctrl+D", description: "delete the character under the cursor" },

‎src/tui/runner.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@ import { modelOptionId } from "./model-catalog.js";
4848
import type { SessionModeScope } from "./command-surfaces.js";
4949
import { resolveWaitForApproval, type ToolWatchdogConfig } from "./tool-execution-watchdog.js";
5050
import { attachApprovalBudget, createGateRequestApproval } from "./request-approval.js";
51-
import { codexProfileFromProviderName } from "../config/codex-providers.js";
51+
import { codexProfileFromProviderName, isCodexProviderName } from "../config/codex-providers.js";
5252
import { xaiProfileFromProviderName } from "../config/xai-providers.js";
5353
import type { PluginsAdmin, PluginDescriptor } from "../plugins/admin.js";
5454
import type { PluginManifest } from "../plugins/manifest.js";
5555
import { createInferenceDependencies } from "../provider/inference-dependencies.js";
56+
import { cycleReasoningEffort } from "../provider/reasoning-effort.js";
5657
import { getValidCodexToken } from "../auth/codex/session.js";
5758
import { getValidXaiToken } from "../auth/xai/session.js";
5859
import { refreshCodexInstructions } from "../auth/codex/instructions.js";
@@ -164,11 +165,14 @@ import { mountRunnerHost } from "./runner-host.js";
164165
import {
165166
applyFocus,
166167
attachClipboardImage,
168+
setEffortCycleHandler,
167169
setMentionSuggestionSource,
170+
setPromptModelLabel,
168171
setPromptRecognitionSource,
169172
setSentMessageHistory,
170173
setShellInputSuspended,
171174
setShellRunState,
175+
setStatusFlash,
172176
surfaceSystemNotice,
173177
} from "./shell.js";
174178
import {
@@ -2409,6 +2413,29 @@ export async function runTUI(initialConfig: Config): Promise<number> {
24092413
agentNames: liveAgentProfiles.map((profile) => profile.id),
24102414
}));
24112415

2416+
// Shift+Tab: cycle reasoning effort for the live model and rebuild sources so
2417+
// the next inference turn picks up the new providerOptions.reasoning_effort.
2418+
setEffortCycleHandler(host.shell, () => {
2419+
const next = cycleReasoningEffort(
2420+
config.model,
2421+
config.reasoningEffort,
2422+
isCodexProviderName(config.providerName),
2423+
);
2424+
if (next === undefined) {
2425+
setStatusFlash(host.shell, "this model has no reasoning effort levels");
2426+
return;
2427+
}
2428+
config = { ...config, reasoningEffort: next };
2429+
const bundle = buildSessionSources();
2430+
agentProxy.setSources(bundle.sources, bundle.defaultSource);
2431+
setPromptModelLabel(host.shell, {
2432+
profile: config.providerName,
2433+
model: config.model,
2434+
effort: next,
2435+
});
2436+
setStatusFlash(host.shell, `reasoning effort: ${next}`);
2437+
});
2438+
24122439
// Recall spans the whole session, including what was sent before a resume.
24132440
void loadSentMessages(config.cwd, sessionId)
24142441
.then((sent) => setSentMessageHistory(host.shell, sent))

‎src/tui/shell.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,17 @@ export function clearShellExitHandler(shell: AppShell): void {
223223
shellExitHandlers.delete(shell)
224224
}
225225

226+
const effortCycleHandlers = new WeakMap<AppShell, () => void>()
227+
228+
/** Shift+Tab host callback: cycle reasoning effort for the live session. */
229+
export function setEffortCycleHandler(shell: AppShell, onCycle: () => void): void {
230+
effortCycleHandlers.set(shell, onCycle)
231+
}
232+
233+
export function clearEffortCycleHandler(shell: AppShell): void {
234+
effortCycleHandlers.delete(shell)
235+
}
236+
226237
/** Optional Wave-4 bridge hooks (runtime-bridge attaches exclusively). */
227238
export type ShellBridgeHooks = {
228239
onSubmit: (
@@ -3874,6 +3885,7 @@ export function handleOverlayAnswerKey(
38743885

38753886
if (
38763887
key.name === "tab" &&
3888+
!key.shift &&
38773889
!key.ctrl &&
38783890
!key.meta &&
38793891
!key.option &&
@@ -5093,7 +5105,7 @@ export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean {
50935105

50945106
const active = shell.paletteCommands[shell.overlayList.activeIndex]
50955107

5096-
if (key.name === "tab" && !key.ctrl && !key.meta && !key.option) {
5108+
if (key.name === "tab" && !key.shift && !key.ctrl && !key.meta && !key.option) {
50975109
if (active) setPromptText(shell, `/${active.id} `)
50985110
closeSlashPopup(shell)
50995111
return true
@@ -5820,7 +5832,18 @@ export function createAppShell(
58205832
shell.sentHistory = sentHistoryOnEdit(shell.sentHistory)
58215833
}
58225834

5823-
if (key.name === "tab" && !key.ctrl && !key.meta && !key.option) {
5835+
if (
5836+
((key.name === "tab" && key.shift) || key.name === "backtab") &&
5837+
!key.ctrl &&
5838+
!key.meta &&
5839+
!key.option
5840+
) {
5841+
key.preventDefault()
5842+
effortCycleHandlers.get(shell)?.()
5843+
return
5844+
}
5845+
5846+
if (key.name === "tab" && !key.ctrl && !key.meta && !key.option && !key.shift) {
58245847
key.preventDefault()
58255848
toggleShellFocus(shell)
58265849
return

0 commit comments

Comments
 (0)