diff --git a/src/cli/App.tsx b/src/cli/App.tsx index a9f3f7b..6602693 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -231,6 +231,7 @@ import { buildQueuedUserText, getQueuedNotificationSummaries, } from "./helpers/queuedMessageParts"; +import { resolveReasoningTabToggleCommand } from "./helpers/reasoningTabToggle"; import { safeJsonParseOr } from "./helpers/safeJsonParse"; import { getDeviceType, getLocalTime } from "./helpers/sessionContext"; import { @@ -479,6 +480,7 @@ const NON_STATE_COMMANDS = new Set([ "/export", "/download", "/statusline", + "/reasoning-tab", ]); // Check if a command is interactive (opens overlay, should not be queued) @@ -925,6 +927,7 @@ export default function App({ messageHistory = [], resumedExistingConversation = false, tokenStreaming = false, + reasoningTabCycleEnabled: initialReasoningTabCycleEnabled = false, showCompactions = false, agentProvenance = null, releaseNotes = null, @@ -945,6 +948,7 @@ export default function App({ messageHistory?: Message[]; resumedExistingConversation?: boolean; // True if we explicitly resumed via --resume tokenStreaming?: boolean; + reasoningTabCycleEnabled?: boolean; showCompactions?: boolean; agentProvenance?: AgentProvenance | null; releaseNotes?: string | null; // Markdown release notes to display above header @@ -1484,6 +1488,11 @@ export default function App({ const [tokenStreamingEnabled, setTokenStreamingEnabled] = useState(tokenStreaming); + // Reasoning tier Tab cycling preference (opt-in only, persisted globally) + const [reasoningTabCycleEnabled, setReasoningTabCycleEnabled] = useState( + initialReasoningTabCycleEnabled, + ); + // Show compaction messages preference (can be toggled at runtime) const [showCompactionsEnabled, _setShowCompactionsEnabled] = useState(showCompactions); @@ -7348,6 +7357,52 @@ export default function App({ return { submitted: true }; } + // Special handling for /reasoning-tab command - opt-in toggle for Tab tier cycling + if ( + trimmed === "/reasoning-tab" || + trimmed.startsWith("/reasoning-tab ") + ) { + const resolution = resolveReasoningTabToggleCommand( + trimmed, + reasoningTabCycleEnabled, + ); + if (!resolution) { + return { submitted: false }; + } + const cmd = commandRunner.start( + trimmed, + "Updating reasoning Tab shortcut...", + ); + + setCommandRunning(true); + + try { + if (resolution.kind === "status") { + cmd.finish(resolution.message, true); + return { submitted: true }; + } + + if (resolution.kind === "invalid") { + cmd.fail(resolution.message); + return { submitted: true }; + } + + setReasoningTabCycleEnabled(resolution.enabled); + settingsManager.updateSettings({ + reasoningTabCycleEnabled: resolution.enabled, + }); + + cmd.finish(resolution.message, true); + } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); + cmd.fail(`Failed: ${errorDetails}`); + } finally { + setCommandRunning(false); + } + + return { submitted: true }; + } + // Special handling for /new command - start new conversation if (msg.trim() === "/new") { const cmd = commandRunner.start( @@ -12406,7 +12461,11 @@ Plan file path: ${planFilePath}`; } permissionMode={uiPermissionMode} onPermissionModeChange={handlePermissionModeChange} - onCycleReasoningEffort={handleCycleReasoningEffort} + onCycleReasoningEffort={ + reasoningTabCycleEnabled + ? handleCycleReasoningEffort + : undefined + } onExit={handleExit} onInterrupt={handleInterrupt} interruptRequested={interruptRequested} diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 0f2c98f..75ee943 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -288,6 +288,15 @@ export const commands: Record = { return "Managing status line..."; }, }, + "/reasoning-tab": { + desc: "Toggle Tab shortcut for reasoning tiers (/reasoning-tab on|off|status)", + args: "[on|off|status]", + order: 36.6, + handler: () => { + // Handled specially in App.tsx + return "Managing reasoning Tab shortcut..."; + }, + }, "/terminal": { desc: "Setup terminal shortcuts [--revert]", order: 37, diff --git a/src/cli/helpers/reasoningTabToggle.ts b/src/cli/helpers/reasoningTabToggle.ts new file mode 100644 index 0000000..b9f79b1 --- /dev/null +++ b/src/cli/helpers/reasoningTabToggle.ts @@ -0,0 +1,47 @@ +export type ReasoningTabToggleResolution = + | { kind: "status"; message: string } + | { kind: "set"; enabled: boolean; message: string } + | { kind: "invalid"; message: string }; + +const ENABLE_ARGS = new Set(["on", "enable", "enabled", "true", "1"]); +const DISABLE_ARGS = new Set(["off", "disable", "disabled", "false", "0"]); + +const USAGE = "Usage: /reasoning-tab [on|off|status] (default is off)"; + +export function resolveReasoningTabToggleCommand( + trimmedInput: string, + currentlyEnabled: boolean, +): ReasoningTabToggleResolution | null { + const trimmed = trimmedInput.trim(); + if (trimmed !== "/reasoning-tab" && !trimmed.startsWith("/reasoning-tab ")) { + return null; + } + + const rawArg = trimmed.slice("/reasoning-tab".length).trim().toLowerCase(); + if (!rawArg || rawArg === "status") { + return { + kind: "status", + message: currentlyEnabled + ? "Reasoning Tab shortcut is enabled. Tab now cycles reasoning tiers." + : "Reasoning Tab shortcut is disabled. Use /reasoning-tab on to enable it.", + }; + } + + if (ENABLE_ARGS.has(rawArg)) { + return { + kind: "set", + enabled: true, + message: "Reasoning Tab shortcut enabled.", + }; + } + + if (DISABLE_ARGS.has(rawArg)) { + return { + kind: "set", + enabled: false, + message: "Reasoning Tab shortcut disabled.", + }; + } + + return { kind: "invalid", message: USAGE }; +} diff --git a/src/index.ts b/src/index.ts index e2b7f10..a8a0d7a 100755 --- a/src/index.ts +++ b/src/index.ts @@ -2044,6 +2044,7 @@ async function main(): Promise { messageHistory: resumeData?.messageHistory ?? EMPTY_MESSAGE_ARRAY, resumedExistingConversation, tokenStreaming: settings.tokenStreaming, + reasoningTabCycleEnabled: settings.reasoningTabCycleEnabled === true, showCompactions: settings.showCompactions, agentProvenance, releaseNotes, @@ -2062,6 +2063,7 @@ async function main(): Promise { messageHistory: resumeData?.messageHistory ?? EMPTY_MESSAGE_ARRAY, resumedExistingConversation, tokenStreaming: settings.tokenStreaming, + reasoningTabCycleEnabled: settings.reasoningTabCycleEnabled === true, showCompactions: settings.showCompactions, agentProvenance, releaseNotes, diff --git a/src/settings-manager.ts b/src/settings-manager.ts index c91dcf9..a2338b6 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -62,6 +62,7 @@ export interface Settings { lastAgent: string | null; // DEPRECATED: kept for migration to lastSession lastSession?: SessionRef; // DEPRECATED: kept for backwards compat, use sessionsByServer tokenStreaming: boolean; + reasoningTabCycleEnabled: boolean; // Tab cycles reasoning tiers only when explicitly enabled showCompactions?: boolean; enableSleeptime: boolean; sessionContextEnabled: boolean; // Send device/agent context on first message of each session @@ -126,6 +127,7 @@ export interface LocalProjectSettings { const DEFAULT_SETTINGS: Settings = { lastAgent: null, tokenStreaming: false, + reasoningTabCycleEnabled: false, showCompactions: false, enableSleeptime: false, conversationSwitchAlertEnabled: false, diff --git a/src/tests/cli/reasoning-cycle-wiring.test.ts b/src/tests/cli/reasoning-cycle-wiring.test.ts index eb49628..fbef40d 100644 --- a/src/tests/cli/reasoning-cycle-wiring.test.ts +++ b/src/tests/cli/reasoning-cycle-wiring.test.ts @@ -45,4 +45,30 @@ describe("reasoning tier cycle wiring", () => { expect(callbackBlocks.length).toBeGreaterThanOrEqual(2); }); + + test("tab-based reasoning cycling is opt-in only", () => { + const appPath = fileURLToPath( + new URL("../../cli/App.tsx", import.meta.url), + ); + const indexPath = fileURLToPath(new URL("../../index.ts", import.meta.url)); + const settingsPath = fileURLToPath( + new URL("../../settings-manager.ts", import.meta.url), + ); + + const appSource = readFileSync(appPath, "utf-8"); + const indexSource = readFileSync(indexPath, "utf-8"); + const settingsSource = readFileSync(settingsPath, "utf-8"); + + expect(settingsSource).toContain("reasoningTabCycleEnabled: boolean;"); + expect(settingsSource).toContain("reasoningTabCycleEnabled: false,"); + expect(indexSource).toContain( + "reasoningTabCycleEnabled: settings.reasoningTabCycleEnabled === true,", + ); + expect(appSource).toMatch( + /if\s*\(\s*trimmed\s*===\s*"\/reasoning-tab"\s*\|\|\s*trimmed\.startsWith\("\/reasoning-tab "\)\s*\)\s*\{/, + ); + expect(appSource).toMatch( + /onCycleReasoningEffort=\{\s*reasoningTabCycleEnabled\s*\?\s*handleCycleReasoningEffort\s*:\s*undefined\s*\}/, + ); + }); }); diff --git a/src/tests/cli/reasoning-tab-toggle.test.ts b/src/tests/cli/reasoning-tab-toggle.test.ts new file mode 100644 index 0000000..5a22d35 --- /dev/null +++ b/src/tests/cli/reasoning-tab-toggle.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { resolveReasoningTabToggleCommand } from "../../cli/helpers/reasoningTabToggle"; + +describe("reasoning tab toggle command parsing", () => { + test("returns null for non-matching commands", () => { + expect(resolveReasoningTabToggleCommand("/model", false)).toBeNull(); + }); + + test("status/default reports current state", () => { + expect(resolveReasoningTabToggleCommand("/reasoning-tab", false)).toEqual({ + kind: "status", + message: + "Reasoning Tab shortcut is disabled. Use /reasoning-tab on to enable it.", + }); + + expect( + resolveReasoningTabToggleCommand("/reasoning-tab status", true), + ).toEqual({ + kind: "status", + message: + "Reasoning Tab shortcut is enabled. Tab now cycles reasoning tiers.", + }); + }); + + test("accepts enable aliases", () => { + for (const arg of ["on", "enable", "enabled", "true", "1"]) { + expect( + resolveReasoningTabToggleCommand(`/reasoning-tab ${arg}`, false), + ).toEqual({ + kind: "set", + enabled: true, + message: "Reasoning Tab shortcut enabled.", + }); + } + }); + + test("accepts disable aliases", () => { + for (const arg of ["off", "disable", "disabled", "false", "0"]) { + expect( + resolveReasoningTabToggleCommand(`/reasoning-tab ${arg}`, true), + ).toEqual({ + kind: "set", + enabled: false, + message: "Reasoning Tab shortcut disabled.", + }); + } + }); + + test("returns usage for invalid arg", () => { + expect( + resolveReasoningTabToggleCommand("/reasoning-tab maybe", true), + ).toEqual({ + kind: "invalid", + message: "Usage: /reasoning-tab [on|off|status] (default is off)", + }); + }); +});