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/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 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