import React, { useCallback } from "react"; import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; import { useAppStore, useModel } from "./state.ts"; import { useSession } from "./session-context.ts"; import { listPreviousSessions, type Session } from "./session-history/index.ts"; import { Auth, mergeEnvVar, useConfig, useSetConfig, Config } from "./config.ts"; import { ModelSetup } from "./components/auto-detect-models.tsx"; import { AutofixModelMenu } from "./components/autofix-model-menu.tsx"; import { ConfirmDialog } from "./components/confirm-dialog.tsx"; import { readAuthForModel } from "./config.ts"; import { keyFromName, SYNTHETIC_PROVIDER, providerForBaseUrl } from "./providers.ts"; import { KbShortcutPanel } from "./components/kb-select/kb-shortcut-panel.tsx"; import { Item, ShortcutArray, Keymap } from "./components/kb-select/kb-shortcut-select.tsx"; import { MenuQuotaIndicator } from "./components/menu-quota-indicator.tsx"; import { CustomAuthFlow } from "./components/add-model-flow.tsx"; import { Span, useApp } from "paintcannon-react"; import { useKeyboard } from "./hooks/use-keyboard.ts"; import { LoadSessionMenu } from "./session-history/load-session-menu.tsx"; type MenuMode = | "main-menu" | "load-session" | "settings-menu" | "model-select" | "add-model" | "diff-apply-toggle" | "fix-json-toggle" | "set-default-model" | "quit-confirm" | "remove-model" | "clear-confirm" | "notifications-menu"; type MenuState = { menuMode: MenuMode; setMenuMode: (mode: MenuMode) => void; }; const useMenuState = create((set, _) => ({ menuMode: "main-menu", setMenuMode: menuMode => { set({ menuMode, }); }, })); export function Menu({ onSessionChange }: { onSessionChange: (session: Session) => void }) { const { menuMode, setMenuMode } = useMenuState( useShallow(state => ({ menuMode: state.menuMode, setMenuMode: state.setMenuMode, })), ); if (menuMode === "main-menu") return ; if (menuMode === "load-session") { return ( setMenuMode("main-menu")} onSessionChange={onSessionChange} /> ); } if (menuMode === "settings-menu") return ; if (menuMode === "model-select") return ; if (menuMode === "set-default-model") return ; if (menuMode === "quit-confirm") return ; if (menuMode === "clear-confirm") return ; if (menuMode === "remove-model") return ; if (menuMode === "diff-apply-toggle") return ; if (menuMode === "fix-json-toggle") return ; if (menuMode === "notifications-menu") return ; const _: "add-model" = menuMode; return ; } function AutofixToggle({ configKey, modelNickname, disableNotification, enableNotification, defaultModel, children, }: { disableNotification: string; enableNotification: string; defaultModel: string; modelNickname: string; configKey: "diffApply" | "fixJson"; children: React.ReactNode; }) { const config = useConfig(); const setConfig = useSetConfig(); const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); const { toggleMenu, notify } = useAppStore( useShallow(state => ({ toggleMenu: state.toggleMenu, notify: state.notify, })), ); const session = useSession(); useKeyboard(event => { if (event.key === "Escape") setMenuMode("main-menu"); }); if (config[configKey]) { return ( { const newconf = { ...config, }; delete newconf[configKey]; await setConfig(newconf); setMenuMode("main-menu"); toggleMenu(); notify(disableNotification, session); }} onConfirm={() => { setMenuMode("main-menu"); }} /> ); } return ( { await setConfig({ ...config, defaultApiKeyOverrides: { ...(config.defaultApiKeyOverrides || {}), [keyFromName(SYNTHETIC_PROVIDER.name)]: apiEnvVar, }, }); }} onComplete={async setting => { await setConfig({ ...config, [configKey]: setting, }); setMenuMode("main-menu"); toggleMenu(); notify(enableNotification, session); }} onCancel={() => { setMenuMode("main-menu"); }} > {children} ); } function DiffApplyToggle() { return ( Even good coding models sometimes make minor mistakes generating code diffs, which can cause slow retries and can confuse them, since models often aren't trained as well to handle edit failures as they are successes. Diff-apply is a fast, small model that fixes minor code diff edit inaccuracies. It speeds up iteration and can significantly improve model performance. ); } function FixJsonToggle() { return ( Octo uses tools to work with your underlying codebase. Some model providers don't support strict constraints on how tool calls are generated, and models can make mistakes generating JSON, the format used for all of Octo's tool calls. The fix-json model can automatically fix broken JSON for Octo, helping models avoid failures more quickly and cheaply than retrying the main model. It also may help reduce the main model's confusion. ); } function SwitchModelMenu() { const { setModelOverride, toggleMenu } = useAppStore( useShallow(state => ({ setModelOverride: state.setModelOverride, toggleMenu: state.toggleMenu, })), ); const session = useSession(); const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); const config = useConfig(); const setConfig = useSetConfig(); const [pendingModel, setPendingModel] = React.useState(null); useKeyboard(event => { if (event.key === "Escape" && pendingModel == null) setMenuMode("main-menu"); }); const onSelect = useCallback( async (item: Item<`model-${string}` | "back">) => { if (item.value === "back") { setMenuMode("main-menu"); return; } const target = item.value.replace("model-", ""); const model = config.models.find(m => m.nickname === target)!; const auth = await readAuthForModel(model, config); if (!auth.ok) { setPendingModel(model); return; } setModelOverride(target, session); setMenuMode("main-menu"); toggleMenu(); }, [config, setMenuMode, setModelOverride, toggleMenu, session], ); if (pendingModel) { return ( { const index = config.models.indexOf(pendingModel); if (auth && index >= 0) { if (pendingModel.type === "codex") { if (auth.type === "codex") { const models = [...config.models]; models[index] = { ...pendingModel, auth, }; await setConfig({ ...config, models, }); } } else if (auth.type === "env") { await setConfig(mergeEnvVar(config, pendingModel, auth.name)); } else if (auth.type === "command") { const models = [...config.models]; models[index] = { ...pendingModel, auth, }; await setConfig({ ...config, models, }); } } setModelOverride(pendingModel.nickname, session); setPendingModel(null); setMenuMode("main-menu"); toggleMenu(); }} onCancel={() => { setPendingModel(null); }} /> ); } const numericItems = config.models.map(model => { return { label: model.nickname, value: `model-${model.nickname}` as const, }; }); const shortcutItems: ShortcutArray<`model-${string}` | "back"> = [ { type: "auto-list" as const, order: numericItems, }, { type: "key" as const, mapping: { b: { label: "Back to main menu", value: "back", }, }, }, ]; return ( ); } type SettingsValues = | "set-default-model" | "remove-model" | "disable-diff-apply" | "disable-fix-json"; const SETTINGS_ITEMS = { c: { label: "Change the default model", value: "set-default-model", }, r: { label: "Remove a model", value: "remove-model", }, d: { label: "Disable fast diff application", value: "disable-diff-apply", }, t: { label: "Disable auto-fixing JSON tool calls", value: "disable-fix-json", }, } satisfies Keymap; function filterSettings(config: Config) { let items: Keymap = {}; if (config.models.length > 1) { items = { ...items, c: SETTINGS_ITEMS.c, r: SETTINGS_ITEMS.r, }; } if (config.diffApply) { items = { ...items, d: SETTINGS_ITEMS.d, }; } if (config.fixJson) { items = { ...items, t: SETTINGS_ITEMS.t, }; } return items; } function MainMenu() { const { toggleMenu, notify, resetPreMenuVimMode } = useAppStore( useShallow(state => ({ toggleMenu: state.toggleMenu, notify: state.notify, resetPreMenuVimMode: state.resetPreMenuVimMode, })), ); const session = useSession(); const hasPreviousSessions = listPreviousSessions(session.metadata.cwd, session.metadata.sessionId).length > 0; const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); const config = useConfig(); const setConfig = useSetConfig(); const model = useModel(); const provider = model.type === "codex" ? null : providerForBaseUrl(model.baseUrl); const isSynthetic = provider === SYNTHETIC_PROVIDER; useKeyboard(event => { if (event.key === "Escape") toggleMenu(); }); type Value = | "model-select" | "add-model" | "load-session" | "vim-toggle" | "return" | "quit" | "fix-json-toggle" | "diff-apply-toggle" | "settings-menu" | "clear-confirm" | "notifications-menu"; let items: Keymap = { m: { label: "⤭ Switch model", value: "model-select" as const, }, a: { label: "+ Add a new model", value: "add-model" as const, }, c: { label: "⦿ New conversation", value: "clear-confirm" as const, }, }; if (hasPreviousSessions) { items = { ...items, s: { label: "⥻ Load previous session", value: "load-session" as const, }, }; } if (config.vimEmulation?.enabled) { items = { ...items, e: { label: "♺ Switch to Emacs mode", value: "vim-toggle" as const, }, }; } else { items = { ...items, v: { label: "♺ Switch to Vim mode", value: "vim-toggle" as const, }, }; } if (config.fixJson == null) { items = { ...items, f: { label: "🪄 Enable auto-fixing JSON tool calls", value: "fix-json-toggle" as const, }, }; } if (config.diffApply == null) { items = { ...items, d: { label: "💫 Enable fast diff application", value: "diff-apply-toggle" as const, }, }; } if (config.notifications?.notifyCommand) { items = { ...items, n: { label: "🕭 Notifications", value: "notifications-menu" as const, }, }; } const settings = filterSettings(config); if (Object.values(settings).length !== 0) { items = { ...items, t: { label: "⛭ Settings", value: "settings-menu" as const, }, }; } items = { ...items, b: { label: "⟵ Back to Octo", value: "return" as const, }, q: { label: "× Quit", value: "quit" as const, }, }; const onSelect = useCallback( async (item: Item) => { if (item.value === "return") toggleMenu(); else if (item.value === "quit") setMenuMode("quit-confirm"); else if (item.value === "vim-toggle") { const wasEnabled = config.vimEmulation?.["enabled"] ?? false; // Write ONLY to config - single source of truth await setConfig({ ...config, vimEmulation: { enabled: !wasEnabled, }, }); // When switching from Emacs to Vim, default to INSERT mode if (!wasEnabled) { resetPreMenuVimMode(); } // Notify user notify(`Switched to ${wasEnabled ? "Emacs" : "Vim"} mode`, session); return; } else if (item.value === "clear-confirm") setMenuMode("clear-confirm"); else setMenuMode(item.value); }, [config, setConfig, notify, session], ); return ( {isSynthetic ? : null} ); } function SettingsMenu() { const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); const config = useConfig(); useKeyboard(event => { if (event.key === "Escape") setMenuMode("main-menu"); }); const settingsItems = filterSettings(config); let items: Keymap = { ...settingsItems, b: { label: "Back", value: "back" as const, }, }; const onSelect = useCallback((item: Item) => { if (item.value === "disable-diff-apply") setMenuMode("diff-apply-toggle"); else if (item.value === "disable-fix-json") setMenuMode("fix-json-toggle"); else if (item.value === "back") setMenuMode("main-menu"); else setMenuMode(item.value); }, []); return ( ); } type NotificationValue = "always-notify" | "session-notify" | "notify-once" | "back"; function NotificationsMenu() { const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); const config = useConfig(); const setConfig = useSetConfig(); const { sessionAutoNotify, notifyOnce, toggleMenu, setNotifyOnce, setNotifySession } = useAppStore( useShallow(state => ({ sessionAutoNotify: state.sessionAutoNotify, notifyOnce: state.notifyOnce, toggleMenu: state.toggleMenu, setNotifyOnce: state.setNotifyOnce, setNotifySession: state.setNotifySession, })), ); useKeyboard(event => { if (event.key === "Escape") setMenuMode("main-menu"); }); const alwaysNotify = config.notifications?.alwaysNotify; const items: Keymap = { o: { label: notifyOnce ? "Do not notify the next time Octo needs input" : "Notify the next time Octo needs input", value: "notify-once" as const, }, s: { label: sessionAutoNotify ? "Stop auto-notifying this session" : "Auto-notify for the rest of this session", value: "session-notify" as const, }, a: { label: alwaysNotify ? "Stop always auto-notifying" : "Always auto-notify", value: "always-notify" as const, }, b: { label: "Back", value: "back" as const, }, }; const onSelect = useCallback( async (item: Item) => { if (item.value === "always-notify") { await setConfig({ ...config, notifications: { ...config.notifications, alwaysNotify: !alwaysNotify, } as Config["notifications"], }); } else if (item.value === "session-notify") { setNotifySession(!sessionAutoNotify); } else if (item.value === "notify-once") { setNotifyOnce(!notifyOnce); setMenuMode("main-menu"); toggleMenu(); } else if (item.value === "back") { setMenuMode("main-menu"); } }, [config, setConfig, alwaysNotify, sessionAutoNotify, notifyOnce, toggleMenu], ); return ( ); } function QuitConfirm() { const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); const app = useApp(); const session = useSession(); return ( { /* * Restore the stashed pre-menu state (which may be an in-flight tool batch), then * record skip markers for any un-run tool calls so the session history stays * well-formed after exit. */ const state = useAppStore.getState(); state.closeMenu(); state.abortResponse(session, { exiting: true }); app.exit(); }} onReject={() => setMenuMode("main-menu")} rejectFirst={true} /> ); } function ClearConversationConfirm({ onSessionChange, }: { onSessionChange: (session: Session) => void; }) { const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); const { startNewSession, notify } = useAppStore( useShallow(state => ({ startNewSession: state.startNewSession, notify: state.notify, })), ); const session = useSession(); return ( { const { cwd, cliArgs } = session.metadata; const oldSessionId = session.metadata.sessionId; const newSession = startNewSession(cwd, cliArgs); onSessionChange(newSession); setMenuMode("main-menu"); const resumeHint = oldSessionId != null ? ` To resume the previous session, run \`octo --resume ${oldSessionId}\`` : ""; notify(`New conversation started.${resumeHint}`, newSession); }} onReject={() => setMenuMode("main-menu")} /> ); } function SetDefaultModelMenu() { const { setModelOverride, toggleMenu } = useAppStore( useShallow(state => ({ setModelOverride: state.setModelOverride, toggleMenu: state.toggleMenu, })), ); const session = useSession(); const config = useConfig(); const setConfig = useSetConfig(); const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); useKeyboard(event => { if (event.key === "Escape") setMenuMode("main-menu"); }); const numericItems = config.models.map(model => { return { label: model.nickname, value: `model-${model.nickname}` as const, }; }); const shortcutItems: ShortcutArray<`model-${string}` | "back"> = [ { type: "auto-list" as const, order: numericItems, }, { type: "key" as const, mapping: { b: { label: "Back to main menu", value: "back", }, }, }, ]; const onSelect = useCallback( async (item: Item<`model-${string}` | "back">) => { if (item.value === "back") { setMenuMode("main-menu"); return; } const target = item.value.replace("model-", ""); const model = config.models.find(m => m.nickname === target)!; const rest = config.models.filter(m => m.nickname !== target); await setConfig({ ...config, models: [model, ...rest], }); setModelOverride(target, session); setMenuMode("main-menu"); toggleMenu(); }, [config, setModelOverride, toggleMenu, session], ); return ( ); } function RemoveModelMenu() { const { setModelOverride, toggleMenu } = useAppStore( useShallow(state => ({ setModelOverride: state.setModelOverride, toggleMenu: state.toggleMenu, })), ); const session = useSession(); const config = useConfig(); const setConfig = useSetConfig(); const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); useKeyboard(event => { if (event.key === "Escape") setMenuMode("main-menu"); }); const numericItems = config.models.map(model => { return { label: model.nickname, value: `model-${model.nickname}` as const, }; }); const shortcutItems: ShortcutArray<`model-${string}` | "back"> = [ { type: "auto-list" as const, order: numericItems, }, { type: "key" as const, mapping: { b: { label: "Back to main menu", value: "back", }, }, }, ]; const onSelect = useCallback( async (item: Item<`model-${string}` | "back">) => { if (item.value === "back") { setMenuMode("main-menu"); return; } const target = item.value.replace("model-", ""); const rest = config.models.filter(m => m.nickname !== target); await setConfig({ ...config, models: [...rest], }); const current = rest[0]; setModelOverride(current.nickname, session); setMenuMode("main-menu"); toggleMenu(); }, [config, setModelOverride, toggleMenu, session], ); return ( ); } function AddModelMenuFlow() { const { setMenuMode } = useMenuState( useShallow(state => ({ setMenuMode: state.setMenuMode, })), ); const setConfig = useSetConfig(); const config = useConfig(); const onComplete = useCallback( async (models: Config["models"]) => { await setConfig({ ...config, models: [...config.models, ...models], }); setMenuMode("model-select"); }, [config, setConfig], ); const onCancel = useCallback(() => { setMenuMode("main-menu"); }, [setMenuMode]); const onOverrideDefaultApiKey = useCallback( async (overrides: Record) => { await setConfig({ ...config, defaultApiKeyOverrides: { ...(config.defaultApiKeyOverrides || {}), ...overrides, }, }); }, [config, setConfig], ); return ( ); }