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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<project-key>/…`, 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:
Expand Down
2 changes: 1 addition & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions src/provider/reasoning-effort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isReasoningEffort,
supportedEfforts,
validateEffort,
cycleReasoningEffort,
setModelReasoningCapabilities,
modelReasoningCapability,
clampEffort,
Expand Down Expand Up @@ -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({}));

Expand Down
19 changes: 19 additions & 0 deletions src/provider/reasoning-effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
//
Expand Down
19 changes: 18 additions & 1 deletion src/tui/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
setSentMessageHistory,
setShellBridgeHooks,
setShellExitHandler,
setEffortCycleHandler,
clearShellBridgeHooks,
setShellRunState,
shellFocusPrompt,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -371,6 +372,22 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
expect(focusOwner(shell.focus)).toBe("prompt")
},
},
"Shift+Tab": {
group: "surfaces",
probe: ({ h, shell }) => {
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 }) => {
Expand Down
1 change: 1 addition & 0 deletions src/tui/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
29 changes: 28 additions & 1 deletion src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2409,6 +2413,29 @@ export async function runTUI(initialConfig: Config): Promise<number> {
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))
Expand Down
27 changes: 25 additions & 2 deletions src/tui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,17 @@ export function clearShellExitHandler(shell: AppShell): void {
shellExitHandlers.delete(shell)
}

const effortCycleHandlers = new WeakMap<AppShell, () => 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: (
Expand Down Expand Up @@ -3874,6 +3885,7 @@ export function handleOverlayAnswerKey(

if (
key.name === "tab" &&
!key.shift &&
!key.ctrl &&
!key.meta &&
!key.option &&
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading