From 9371149bba27edd79e351d3b5e981ec5192efdad Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 22:14:17 -0700 Subject: [PATCH 1/2] 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. --- README.md | 2 +- src/config/index.ts | 3 ++- src/permission/gate.ts | 5 +++-- src/provider/reasoning-effort.test.ts | 22 ++++++++++++++++++++ src/provider/reasoning-effort.ts | 19 ++++++++++++++++++ src/tui/keybindings.test.ts | 19 +++++++++++++++++- src/tui/keybindings.ts | 1 + src/tui/runner.ts | 29 ++++++++++++++++++++++++++- src/tui/shell.ts | 27 +++++++++++++++++++++++-- 9 files changed, 119 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 90a181cd0..972114801 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The chat director adds context management on top of the reactor: ## Permissions and auto mode -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. +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. ### What auto allows diff --git a/src/config/index.ts b/src/config/index.ts index 4d05a3b81..37f295742 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -468,7 +468,8 @@ export async function loadConfig( // writes/edits and unconstrained shell) run without prompting, while shell // file-mutation stays denied and installs / recursive rm / worktree / // sensitive-path / opaque-wrapper shell still ask. Pass --no-auto to revert - // to ask-on-every-write, or toggle live in the TUI with SHIFT+TAB. + // to ask-on-every-write. There is currently no in-session key to toggle auto; + // Shift+Tab in the TUI cycles reasoning effort instead. let auto = true; let configPath: string | undefined; let provider: string | undefined; diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 738bdb469..501e3a164 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -253,8 +253,9 @@ export type PermissionGate = { // Whether auto mode is currently on. Auto mode auto-approves non-destructive // consequential actions (file writes/edits, unconstrained shell) without prompting. getAuto: () => boolean; - // Turn auto mode on or off for the rest of the session. SHIFT+TAB in the TUI - // wires the toggle here so a switch takes effect on the next tool call. + // Turn auto mode on or off for the rest of the session. Live callers (slash + // commands, settings) wire the toggle here so a switch takes effect on the + // next tool call. There is currently no in-session key chord for this. setAuto: (value: boolean) => void; // Whether --dangerously-skip-permissions is active for this session. Immutable // after gate construction; pre-gate sandboxes (path-escape, shell cwd bounds) diff --git a/src/provider/reasoning-effort.test.ts b/src/provider/reasoning-effort.test.ts index 34724ee92..297fc871f 100644 --- a/src/provider/reasoning-effort.test.ts +++ b/src/provider/reasoning-effort.test.ts @@ -5,6 +5,7 @@ import { isReasoningEffort, supportedEfforts, validateEffort, + cycleReasoningEffort, setModelReasoningCapabilities, modelReasoningCapability, clampEffort, @@ -98,6 +99,27 @@ describe("validateEffort", () => { }); }); +describe("cycleReasoningEffort", () => { + afterEach(() => setModelReasoningCapabilities({})); + + test("walks the gpt-5 ladder and wraps", () => { + expect(cycleReasoningEffort("gpt-5", undefined)).toBe("minimal"); + expect(cycleReasoningEffort("gpt-5", "minimal")).toBe("low"); + expect(cycleReasoningEffort("gpt-5", "low")).toBe("medium"); + expect(cycleReasoningEffort("gpt-5", "medium")).toBe("high"); + expect(cycleReasoningEffort("gpt-5", "high")).toBe("minimal"); + }); + + test("starts at the first supported level when current is unsupported", () => { + expect(cycleReasoningEffort("gpt-5", "xhigh")).toBe("minimal"); + }); + + test("returns undefined for a non-reasoning model", () => { + setModelReasoningCapabilities({ "chat-only-model": false }); + expect(cycleReasoningEffort("chat-only-model", "medium")).toBeUndefined(); + }); +}); + describe("reasoning capability gate", () => { afterEach(() => setModelReasoningCapabilities({})); diff --git a/src/provider/reasoning-effort.ts b/src/provider/reasoning-effort.ts index 38425d303..bf742530e 100644 --- a/src/provider/reasoning-effort.ts +++ b/src/provider/reasoning-effort.ts @@ -96,6 +96,25 @@ export function validateEffort( }; } +/** + * Next effort on the model's supported ladder (wraps around). Returns undefined + * when the model supports no reasoning effort — callers flash a status and leave + * the session config alone. + */ +export function cycleReasoningEffort( + model: string, + current: ReasoningEffort | undefined, + isCodex = false, +): ReasoningEffort | undefined { + const supported = supportedEfforts(model, undefined, isCodex); + if (supported.length === 0) return undefined; + if (current === undefined || !supported.includes(current)) { + return supported[0]; + } + const idx = supported.indexOf(current); + return supported[(idx + 1) % supported.length]; +} + // --------------------------------------------------------------------------- // Role-based product defaults (CL-5162) // diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index 9e18937ba..5a68e64a2 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -37,6 +37,7 @@ import { setSentMessageHistory, setShellBridgeHooks, setShellExitHandler, + setEffortCycleHandler, clearShellBridgeHooks, setShellRunState, shellFocusPrompt, @@ -98,7 +99,7 @@ function chordsOf(keys: string): readonly (string | null)[] { const bytes = chordBytes(token.trim()) // A token nothing can encode and that is not the known kitty-only chord is // a typo in the catalog, not an untestable chord. - if (bytes === null && token.trim() !== "Ctrl+Enter") { + if (bytes === null && token.trim() !== "Ctrl+Enter" && token.trim() !== "Shift+Tab") { throw new Error(`catalog row "${keys}" has unreadable chord "${token.trim()}"`) } return bytes @@ -371,6 +372,22 @@ const PROBES: Readonly { + let cycles = 0 + setEffortCycleHandler(shell, () => { + cycles++ + }) + shellFocusPrompt(shell) + const before = focusOwner(shell.focus) + // Classic terminals often emit CSI Z for Shift+Tab; the harness can also + // inject name:"tab" with shift:true, which is what the shell handler reads. + h.pressKey("Tab", { shift: true }) + expect(cycles).toBe(1) + expect(focusOwner(shell.focus)).toBe(before) + }, + }, Esc: { group: "surfaces", probe: async ({ h, shell, chords }) => { diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index 97ac0adae..6037d1350 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -30,6 +30,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Alt+T", description: "show or hide the task list above the prompt" }, { keys: "Alt+O", description: "observe a live subagent session; a system row says so when there is none" }, { keys: "Tab", description: "move focus between the prompt and the transcript" }, + { keys: "Shift+Tab", description: "cycle reasoning effort for the current model" }, { keys: "Esc", description: "close the open overlay, or leave subagent observe" }, { keys: "Ctrl+B / Ctrl+F", description: "move the cursor back / forward one character" }, { keys: "Ctrl+D", description: "delete the character under the cursor" }, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 5145af14b..38c22caf9 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -48,11 +48,12 @@ import { modelOptionId } from "./model-catalog.js"; import type { SessionModeScope } from "./command-surfaces.js"; import { resolveWaitForApproval, type ToolWatchdogConfig } from "./tool-execution-watchdog.js"; import { attachApprovalBudget, createGateRequestApproval } from "./request-approval.js"; -import { codexProfileFromProviderName } from "../config/codex-providers.js"; +import { codexProfileFromProviderName, isCodexProviderName } from "../config/codex-providers.js"; import { xaiProfileFromProviderName } from "../config/xai-providers.js"; import type { PluginsAdmin, PluginDescriptor } from "../plugins/admin.js"; import type { PluginManifest } from "../plugins/manifest.js"; import { createInferenceDependencies } from "../provider/inference-dependencies.js"; +import { cycleReasoningEffort } from "../provider/reasoning-effort.js"; import { getValidCodexToken } from "../auth/codex/session.js"; import { getValidXaiToken } from "../auth/xai/session.js"; import { refreshCodexInstructions } from "../auth/codex/instructions.js"; @@ -164,11 +165,14 @@ import { mountRunnerHost } from "./runner-host.js"; import { applyFocus, attachClipboardImage, + setEffortCycleHandler, setMentionSuggestionSource, + setPromptModelLabel, setPromptRecognitionSource, setSentMessageHistory, setShellInputSuspended, setShellRunState, + setStatusFlash, surfaceSystemNotice, } from "./shell.js"; import { @@ -2409,6 +2413,29 @@ export async function runTUI(initialConfig: Config): Promise { agentNames: liveAgentProfiles.map((profile) => profile.id), })); + // Shift+Tab: cycle reasoning effort for the live model and rebuild sources so + // the next inference turn picks up the new providerOptions.reasoning_effort. + setEffortCycleHandler(host.shell, () => { + const next = cycleReasoningEffort( + config.model, + config.reasoningEffort, + isCodexProviderName(config.providerName), + ); + if (next === undefined) { + setStatusFlash(host.shell, "this model has no reasoning effort levels"); + return; + } + config = { ...config, reasoningEffort: next }; + const bundle = buildSessionSources(); + agentProxy.setSources(bundle.sources, bundle.defaultSource); + setPromptModelLabel(host.shell, { + profile: config.providerName, + model: config.model, + effort: next, + }); + setStatusFlash(host.shell, `reasoning effort: ${next}`); + }); + // Recall spans the whole session, including what was sent before a resume. void loadSentMessages(config.cwd, sessionId) .then((sent) => setSentMessageHistory(host.shell, sent)) diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 024072720..b160de37f 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -223,6 +223,17 @@ export function clearShellExitHandler(shell: AppShell): void { shellExitHandlers.delete(shell) } +const effortCycleHandlers = new WeakMap void>() + +/** Shift+Tab host callback: cycle reasoning effort for the live session. */ +export function setEffortCycleHandler(shell: AppShell, onCycle: () => void): void { + effortCycleHandlers.set(shell, onCycle) +} + +export function clearEffortCycleHandler(shell: AppShell): void { + effortCycleHandlers.delete(shell) +} + /** Optional Wave-4 bridge hooks (runtime-bridge attaches exclusively). */ export type ShellBridgeHooks = { onSubmit: ( @@ -3874,6 +3885,7 @@ export function handleOverlayAnswerKey( if ( key.name === "tab" && + !key.shift && !key.ctrl && !key.meta && !key.option && @@ -5093,7 +5105,7 @@ export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean { const active = shell.paletteCommands[shell.overlayList.activeIndex] - if (key.name === "tab" && !key.ctrl && !key.meta && !key.option) { + if (key.name === "tab" && !key.shift && !key.ctrl && !key.meta && !key.option) { if (active) setPromptText(shell, `/${active.id} `) closeSlashPopup(shell) return true @@ -5820,7 +5832,18 @@ export function createAppShell( shell.sentHistory = sentHistoryOnEdit(shell.sentHistory) } - if (key.name === "tab" && !key.ctrl && !key.meta && !key.option) { + if ( + ((key.name === "tab" && key.shift) || key.name === "backtab") && + !key.ctrl && + !key.meta && + !key.option + ) { + key.preventDefault() + effortCycleHandlers.get(shell)?.() + return + } + + if (key.name === "tab" && !key.ctrl && !key.meta && !key.option && !key.shift) { key.preventDefault() toggleShellFocus(shell) return From 56c9e3b5d45f60d6714e93ae3efa325638590164 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 00:07:21 -0700 Subject: [PATCH 2/2] Document the Shift+Tab reasoning-effort cycle PRODUCT and TUI specs now name the binding; implementation notes how the runner rebuilds sources and the prompt-border label. --- docs/IMPLEMENTATION.md | 4 ++++ docs/PRODUCT.md | 2 +- docs/TUI.md | 7 ++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index a5b7f4452..8180e248b 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -154,6 +154,10 @@ When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOL Unmatched shell auto-allows. Writes under the session state root (`~/.corbits/projects//…`, and legacy in-repo `.agent-state` during dual-read), mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode. +### Reasoning Effort + +**Shift+Tab** in the TUI cycles reasoning effort for the live model (`cycleReasoningEffort` in `src/provider/reasoning-effort.ts`); the runner rebuilds inference sources and the prompt-border `profile · model · effort` label so the next turn picks it up. Plain Tab still toggles focus. + ### Interrupt and Queue Steering `ChatInputProps` carries `isProcessing?: boolean` and `onInterrupt?: (message: string) => void`. When `isProcessing` is true: diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 06502643c..77d58cdf0 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -48,7 +48,7 @@ The evidence is in how the product fails today: the personas already produce exc $ corbits "Add JWT auth to the API" ``` -A full-screen terminal interface: a pinned header (session title and workflow progress), a scrollable event log, modals for permission prompts and operator questions, and a chat input for follow-up turns. +A full-screen terminal interface: a pinned header (session title and workflow progress), a scrollable event log, modals for permission prompts and operator questions, and a chat input for follow-up turns. Press **Shift+Tab** to cycle reasoning effort for the current model; the prompt border shows the active level. Plain **Tab** still toggles focus between the prompt and the transcript. **Behavior spec** (OpenTUI is the shipping shell): `docs/TUI.md` — layout, chrome budget, overlays, selectors, the `/` command list, prompt box, and diff --git a/docs/TUI.md b/docs/TUI.md index cea6b44c9..79eb5afe5 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -61,7 +61,8 @@ the pad is part of the bubble itself, not an extra turn-boundary gap, and assistant/tool rows are unchanged. The prompt box's border carries the metadata that would otherwise cost a -titlebar row: the model label sits right-aligned in the top rule; the brand +titlebar row: the model label sits right-aligned in the top rule as +`profile · model · effort` (empty segments omitted); the brand lockup sits at the left of the bottom rule with the working directory and git branch at its right (`AppShell.promptTopRule` / `promptBottomRule`, `src/tui/shell.ts`). Both rules cost zero transcript rows because they @@ -601,6 +602,10 @@ holding the current scroll lease responds to them. `Ctrl+G` (the Emacs/readline "abort" chord) cancels the most recently queued mid-run message. `Tab` toggles focus between the prompt and the transcript. +`Shift+Tab` cycles reasoning effort for the current model (wrapping the +supported ladder) and flashes the new level; the prompt-border effort +segment updates immediately. A model with no effort levels flashes instead +of mutating the session. Unshifted `Tab` still toggles focus. `e` (with Alt/Option) expands a collapsed row — a collapsed permission payload while an overlay owns focus, or a collapsed transcript row (tool output, a long diff) while the transcript does — one expand idiom shared