import React, { useState, useCallback, useMemo, useEffect, useLayoutEffect, useRef, useContext, } from "react"; import type { DivElement } from "paintcannon"; import clipboardy from "clipboardy"; import { t } from "structural"; import { Auth, AuthError, Config, Metadata, ConfigContext, ConfigPathContext, SetConfigContext, mergeEnvVar, readAuthForModel, useConfig, useSetConfig, } from "./config.ts"; import Loading from "./components/loading.tsx"; import { Header } from "./header.tsx"; import { DIMMED_SCROLLBAR_COLOR, SCROLLBAR_COLOR, SUBTLE_SCROLLBAR_COLOR, THOUGHTBOX_COLOR, UnchainedContext, useColor, useUnchained, } from "./theme.ts"; import { DiffRenderer } from "./components/diff-renderer.tsx"; import { FileRenderer } from "./components/file-renderer.tsx"; import shell from "./tools/tool-defs/bash.ts"; import read from "./tools/tool-defs/read.ts"; import partialRead from "./tools/tool-defs/partial-read.ts"; import list from "./tools/tool-defs/list.ts"; import edit from "./tools/tool-defs/edit.ts"; import rewrite from "./tools/tool-defs/rewrite.ts"; import createTool from "./tools/tool-defs/create.ts"; import mcp from "./tools/tool-defs/mcp.ts"; import fetchTool from "./tools/tool-defs/fetch.ts"; import skill from "./tools/tool-defs/skill.ts"; import webSearch from "./tools/tool-defs/web-search.ts"; import glob from "./tools/tool-defs/glob.ts"; import grep from "./tools/tool-defs/grep.ts"; import { ALWAYS_REQUEST_PERMISSION_TOOLS, SKIP_CONFIRMATION_TOOLS } from "./tools/index.ts"; import { ParsedSchema as EditParsedSchema } from "./tools/tool-defs/edit.ts"; import { useShallow } from "zustand/react/shallow"; import { KbShortcutPanel } from "./components/kb-select/kb-shortcut-panel.tsx"; import { Item, ShortcutArray } from "./components/kb-select/kb-shortcut-select.tsx"; import { useAppStore, RunArgs, useModel, InflightResponseType, nextToolAction } from "./state.ts"; import { SessionNotFoundError } from "./session-history/index.ts"; import type { HistoryNode, Session } from "./session-history/index.ts"; import { Octo } from "./components/octo.tsx"; import { Menu } from "./menu.tsx"; import SelectInput from "./components/selection/select-input.tsx"; import { IndicatorComponent } from "./components/select.tsx"; import { displayLog } from "./logger.ts"; import { CenteredBox } from "./components/centered-box.tsx"; import { Transport } from "./transports/transport-common.ts"; import { TransportContext } from "./transport-context.ts"; import { SessionContext, useSession } from "./session-context.ts"; import { markUpdatesSeen } from "./update-notifs/update-notifs.ts"; import { useCtrlC, ExitOnDoubleCtrlC, useCtrlCPressed, } from "./components/exit-on-double-ctrl-c.tsx"; import { InputHistory } from "./input-history/index.ts"; import { MultimediaInput } from "./components/multimedia-input.tsx"; import { ImageInfo } from "./utils/image-utils.ts"; import { Markdown } from "./markdown/index.tsx"; import { LINE_SPLIT_REGEX } from "./str.ts"; import { VimModeIndicator } from "./components/vim-mode.tsx"; import type { ToolCall } from "./libocto/tool-def.ts"; import type toolMap from "./tools/tool-defs/index.ts"; import type { Content, MalformedToolRequest } from "./libocto/llm-ir.ts"; import type { OctoIR } from "./ir/octo-ir.ts"; import { InputPriorityProvider, usePriorityInput, UNCHAINED_PRIORITY, } from "./hooks/use-priority-input.tsx"; import { writeFileSync } from "fs"; import os from "os"; import path from "path"; import { CwdContext, useCwd } from "./hooks/use-cwd.tsx"; import { LspToolRenderer } from "./components/lsp-tool-renderer.tsx"; import { CustomAuthFlow } from "./components/add-model-flow.tsx"; import { Span, useAnimation, useApp } from "paintcannon-react"; import { useKeyboard } from "./hooks/use-keyboard.ts"; import { TerminalFlex } from "./components/terminal-flex.tsx"; import { AppShell } from "./components/app-shell.tsx"; import { ToolCallRow } from "./components/tool-call-row.tsx"; import { useToast } from "./components/toast.tsx"; import { ReactDevelopmentBuildToast } from "./components/react-development-build-toast.tsx"; import { ScrollTranscriptToBottomContext, useScrollTranscriptToBottom, } from "./transcript-scroll.ts"; type LoadedToolFrom any> = Exclude>, null>; type ParsedToolSchemaFrom any> = { name: LoadedToolFrom["name"]; arguments: t.GetType["ParsedSchema"]>; }; type ToolCallRequest = ToolCall; type AssistantDisplayItem = { content: string; reasoningContent?: string | null; }; type Props = { config: Config; configPath: string; cwd: string; metadata: Metadata; updates: string | null; unchained: boolean; transport: Transport; session: Session; onSessionChange: (session: Session) => void; inputHistory: InputHistory; bootSkills: string[]; }; type TranscriptItem = | { type: "header"; } | { type: "version"; metadata: Metadata; } | { type: "updates"; updates: string; } | { type: "slogan"; } | { type: "history-item"; item: HistoryNode; } | { type: "boot-notification"; content: string; }; const UNCHAINED_NOTIF = "Octo runs edits and shell commands automatically"; const CHAINED_NOTIF = "Octo asks permission before running edits or shell commands"; const KEYBOARD_SCROLL_DURATION_MS = 80; function UnchainedShiftTabHandler({ setIsUnchained, setTempNotification, }: { setIsUnchained: (fn: (prev: boolean) => boolean) => void; setTempNotification: (notif: string | null) => void; }) { usePriorityInput(UNCHAINED_PRIORITY, event => { if (event.shiftKey && event.key === "Tab") { event.preventDefault(); setIsUnchained(prev => { const unchained = !prev; if (unchained) { setTempNotification(UNCHAINED_NOTIF); } else { setTempNotification(CHAINED_NOTIF); } return unchained; }); } }); return null; } export default function App({ config, configPath, cwd, metadata, unchained, transport, session: initialSession, onSessionChange, updates, inputHistory, bootSkills, }: Props) { const { paintCannon } = useApp(); const showToast = useToast(); const [hasFocus, setHasFocus] = useState(paintCannon.hasFocus); const transcriptRef = useRef(null); const followTranscriptRef = useRef(true); const keyboardScrollActiveRef = useRef(false); const keyboardScrollStartRef = useRef(0); const [isKeyboardScrollActive, setIsKeyboardScrollActive] = useState(false); const { time: keyboardScrollTime } = useAnimation({ isActive: isKeyboardScrollActive, }); useEffect(() => { const handleBlur = () => setHasFocus(false); const handleFocus = () => setHasFocus(true); const handleClipboardWrite = () => showToast("Copied to clipboard"); paintCannon.addEventListener("blur", handleBlur); paintCannon.addEventListener("focus", handleFocus); paintCannon.addEventListener("clipboardWrite", handleClipboardWrite); return () => { paintCannon.removeEventListener("blur", handleBlur); paintCannon.removeEventListener("focus", handleFocus); paintCannon.removeEventListener("clipboardWrite", handleClipboardWrite); }; }, [paintCannon]); const scrollTranscriptToBottom = useCallback(() => { if (followTranscriptRef.current) scrollToBottom(transcriptRef.current); }, []); const scrollTranscriptToBottomIfNeeded = useCallback(() => { const transcript = transcriptRef.current; if (!transcript) return false; if (keyboardScrollActiveRef.current) return true; if ( isScrolledToBottom(transcript.scrollTop, transcript.scrollHeight, transcript.clientHeight) ) { return false; } followTranscriptRef.current = false; keyboardScrollActiveRef.current = true; keyboardScrollStartRef.current = transcript.scrollTop; setIsKeyboardScrollActive(true); return true; }, []); useLayoutEffect(() => { if (!isKeyboardScrollActive) return; const transcript = transcriptRef.current; if (!transcript) { keyboardScrollActiveRef.current = false; setIsKeyboardScrollActive(false); return; } const progress = Math.min(1, keyboardScrollTime / KEYBOARD_SCROLL_DURATION_MS); const easedProgress = 1 - Math.pow(1 - progress, 3); const targetScrollTop = Math.max(0, transcript.scrollHeight - transcript.clientHeight); transcript.scrollTop = keyboardScrollStartRef.current + (targetScrollTop - keyboardScrollStartRef.current) * easedProgress; if (progress === 1) { keyboardScrollActiveRef.current = false; followTranscriptRef.current = true; transcript.scrollTop = targetScrollTop; setIsKeyboardScrollActive(false); } }, [isKeyboardScrollActive, keyboardScrollTime]); const [currConfig, setCurrConfig] = useState(config); const [session, setSession] = useState(initialSession); const handleSessionChange = useCallback( (nextSession: Session) => { if (nextSession === session) return; setSession(nextSession); onSessionChange(nextSession); }, [onSessionChange, session], ); const [isUnchained, setIsUnchained] = useState(unchained); const [tempNotification, setTempNotification] = useState( isUnchained ? UNCHAINED_NOTIF : CHAINED_NOTIF, ); const { history, modeData, setVimMode, clearNonce, cancelNotifyReadyForInput, query } = useAppStore( useShallow(state => ({ history: state.history, modeData: state.modeData, setVimMode: state.setVimMode, clearNonce: state.clearNonce, cancelNotifyReadyForInput: state.cancelNotifyReadyForInput, query: state.query, })), ); useKeyboard(() => { cancelNotifyReadyForInput(); }); useEffect(() => { if (updates != null) markUpdatesSeen(); if (currConfig.vimEmulation?.enabled) setVimMode("INSERT"); }, []); const skillNotifs: string[] = []; if (bootSkills.length > 0) { skillNotifs.push(" "); skillNotifs.push("Configured skills:"); skillNotifs.push(...bootSkills.map(s => `- ${s}`)); } const bootItems: TranscriptItem[] = useMemo(() => { let items = [ { type: "header" as const, }, { type: "version" as const, metadata, }, ...skillNotifs.map(s => ({ type: "boot-notification" as const, content: s, })), ...(updates ? [ { type: "updates" as const, updates, }, ] : []), ]; return items; }, [metadata, skillNotifs, updates]); const inflightResponse = modeData.mode === "responding" || modeData.mode === "compacting" ? modeData.inflightResponse : null; useLayoutEffect(() => { scrollTranscriptToBottom(); }, [ clearNonce, history.length, inflightResponse?.content, inflightResponse?.reasoningContent, modeData.mode, bootItems.length, query, scrollTranscriptToBottom, ]); useEffect(() => { let resizeFrame: number | undefined; const handleResize = () => { if (!followTranscriptRef.current) return; if (resizeFrame !== undefined) paintCannon.cancelAnimationFrame(resizeFrame); resizeFrame = paintCannon.requestAnimationFrame(() => { resizeFrame = undefined; scrollTranscriptToBottom(); }); }; paintCannon.addEventListener("resize", handleResize); return () => { paintCannon.removeEventListener("resize", handleResize); if (resizeFrame !== undefined) paintCannon.cancelAnimationFrame(resizeFrame); }; }, [paintCannon, scrollTranscriptToBottom]); const appScrollbarColor = hasFocus ? SCROLLBAR_COLOR : DIMMED_SCROLLBAR_COLOR; return ( { followTranscriptRef.current = isScrolledToBottom( event.scrollTop, event.scrollHeight, transcriptRef.current?.clientHeight ?? 1, ); }} style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0, minHeight: 0, overflowY: "scroll", scrollbarGutter: "stable", scrollbarColor: appScrollbarColor, }} > {bootItems.map((item, index) => ( ))} {history.map((item, index) => ( ))} {(modeData.mode === "responding" || modeData.mode === "compacting") && (modeData.inflightResponse.reasoningContent || modeData.inflightResponse.content) && ( )} {modeData.mode === "tool-call" && ( )} ); } function BottomBar({ inputHistory, metadata, tempNotification, onSessionChange, }: { inputHistory: InputHistory; metadata: Metadata; tempNotification: string | null; onSessionChange: (session: Session) => void; }) { const TEMP_NOTIFICATION_DURATION = 5000; const [versionCheck, setVersionCheck] = useState("Checking for updates..."); const [displayedTempNotification, setDisplayedTempNotification] = useState(null); const themeColor = useColor(); const ctrlCPressed = useCtrlCPressed(); const { modeData } = useAppStore( useShallow(state => ({ modeData: state.modeData, })), ); useEffect(() => { getLatestVersion().then(latestVersion => { if (latestVersion && metadata.version < latestVersion) { setVersionCheck( "New version released! Run `npm install -g --omit=dev octofriend` to update.", ); return; } setVersionCheck("Octo is up-to-date."); setTimeout(() => { setVersionCheck(""); }, 5000); }); }, [metadata]); useEffect(() => { if (tempNotification) { setDisplayedTempNotification(tempNotification); const timer = setTimeout(() => { setDisplayedTempNotification(null); }, TEMP_NOTIFICATION_DURATION); return () => clearTimeout(timer); } return undefined; }, [tempNotification]); if (modeData.mode === "menu") return ; const unchained = useUnchained(); return ( {ctrlCPressed && "Press Ctrl+C again to exit."} {!ctrlCPressed && ( {unchained ? "⚡ Unchained mode" : "Collaboration mode"}{" "} (Shift+Tab to toggle) )} {versionCheck} {displayedTempNotification && ( {displayedTempNotification} )} ); } const PackageSchema = t.subtype({ "dist-tags": t.subtype({ latest: t.str, }), }); async function getLatestVersion() { try { const response = await fetch("https://registry.npmjs.com/octofriend"); const contents = await response.json(); const packageInfo = PackageSchema.slice(contents); return packageInfo["dist-tags"].latest; } catch { return null; } } function BottomBarContent({ inputHistory }: { inputHistory: InputHistory }) { const config = useConfig(); const model = useModel(); const transport = useContext(TransportContext); const session = useSession(); const showToast = useToast(); const vimEnabled = !!config.vimEmulation?.enabled; const { modeData, input, abortResponse, openMenu, closeMenu, byteCount, setVimMode, query, setQuery, } = useAppStore( useShallow(state => ({ modeData: state.modeData, input: state.input, abortResponse: state.abortResponse, closeMenu: state.closeMenu, openMenu: state.openMenu, byteCount: state.byteCount, setVimMode: state.setVimMode, query: state.query, setQuery: state.setQuery, })), ); const vimMode = vimEnabled && vimEnabled && modeData.mode === "input" ? modeData.vimMode : "NORMAL"; useCtrlC(() => { if (vimEnabled) return; setQuery(""); }); useKeyboard(event => { if (event.key === "Escape") { // Vim INSERT mode: Esc ONLY returns to NORMAL (no menu, no abort) if (vimEnabled && vimMode === "INSERT" && modeData.mode === "input") { setVimMode("NORMAL"); return; } abortResponse(session); if (modeData.mode === "menu") closeMenu(); } if (event.ctrlKey && event.key === "p") { openMenu(); } }); const color = useColor(); const onSubmit = useCallback( async (submittedQuery?: string, images?: ImageInfo[]) => { const finalQuery = submittedQuery ?? query; setQuery(""); try { await input({ query: finalQuery, config, transport, session, images, }); } catch (error) { if (error instanceof SessionNotFoundError) { showToast( Could not send message. Session {error.sessionId} does not exist. , ); return; } throw error; } }, [query, config, transport, session, setQuery, showToast], ); if (modeData.mode === "responding" || modeData.mode === "compacting") { return ( {byteCount === 0 ? null : ( ⇩ {byteCount} bytes )} (Press ESC to interrupt) ); } if (modeData.mode === "error-recovery") return ; if (modeData.mode === "diff-apply") { return ; } if (modeData.mode === "fix-json") { return ; } if (modeData.mode === "payment-error") { return ; } if (modeData.mode === "rate-limit-error") { return ; } if (modeData.mode === "auth-error") { return ( ); } if (modeData.mode === "request-error") { return ( ); } if (modeData.mode === "compaction-error") { return ( ); } if (modeData.mode === "tool-call") { return null; } const _: "menu" | "input" = modeData.mode; return ( Model: {model.nickname} (Ctrl+p to enter the menu) ); } function AuthErrorScreen({ model, error, config, transport, session, }: { model: Config["models"][number]; error: AuthError; config: Config; transport: Transport; session: Session; }) { const setConfig = useSetConfig(); const { runAgent, clearAuthError } = useAppStore( useShallow(state => ({ runAgent: state.runAgent, clearAuthError: state.clearAuthError, })), ); const [authError, setAuthError] = useState(error); const resolveModelIndex = useCallback( (models: Config["models"]) => { return models.findIndex(candidate => { if (model.type === "codex") { return ( candidate.type === "codex" && candidate.nickname === model.nickname && candidate.model === model.model ); } if (candidate.type === "codex") return false; return ( candidate.nickname === model.nickname && candidate.baseUrl === model.baseUrl && candidate.model === model.model ); }); }, [model], ); const onComplete = useCallback( async (auth?: Auth) => { let updatedConfig = config; let updatedModel = model; const index = resolveModelIndex(config.models); if (index >= 0) { updatedModel = config.models[index]; } if (auth && index >= 0) { if (updatedModel.type === "codex") { if (auth.type !== "codex") { setAuthError({ type: "invalid", message: "Codex models can only use Codex OAuth auth.", }); return; } const updatedModels = [...config.models]; updatedModel = { ...updatedModel, auth, }; updatedModels[index] = updatedModel; updatedConfig = { ...config, models: updatedModels, }; } else { if (auth.type === "codex") { setAuthError({ type: "invalid", message: "API-key models cannot use Codex OAuth auth.", }); return; } if (auth.type === "env") { updatedConfig = mergeEnvVar(config, updatedModel, auth.name); } else { const updatedModels = [...config.models]; updatedModel = { ...updatedModel, auth, }; updatedModels[index] = updatedModel; updatedConfig = { ...config, models: updatedModels, }; } } await setConfig(updatedConfig); } const updatedIndex = resolveModelIndex(updatedConfig.models); if (updatedIndex >= 0) { updatedModel = updatedConfig.models[updatedIndex]; } const result = await readAuthForModel(updatedModel, updatedConfig); if (!result.ok) { setAuthError(result.error); return; } await runAgent({ config: updatedConfig, transport, session, }); }, [config, model, resolveModelIndex, runAgent, setConfig, transport, session], ); return ( Auth is required for {model.nickname} {authError && ( {authError.message} )} ); } function RequestErrorScreen({ mode, contextualMessage, error, curlCommand, }: { mode: "request-error" | "compaction-error"; contextualMessage: string; error: string; curlCommand: string | null; }) { const config = useConfig(); const transport = useContext(TransportContext); const themeColor = useColor(); const session = useSession(); const { retryFrom, editAndRetryFrom } = useAppStore( useShallow(state => ({ retryFrom: state.retryFrom, editAndRetryFrom: state.editAndRetryFrom, })), ); const { exit } = useApp(); const [viewError, setViewError] = useState(false); const [copiedCurl, setCopiedCurl] = useState(false); const [clipboardError, setClipboardError] = useState(null); const [wroteCurl, setWroteCurl] = useState(false); const [curlFilePath, setCurlFilePath] = useState(null); const [writeError, setWriteError] = useState(null); const mapping: Record< string, Item<"view" | "copy-curl" | "write-curl" | "retry" | "edit-retry" | "quit"> > = {}; if (!viewError) { mapping["v"] = { label: "View error", value: "view", }; } if (curlCommand) { mapping["c"] = { label: copiedCurl ? "Copied cURL!" : "Copy failed request as cURL", value: "copy-curl", }; mapping["w"] = { label: wroteCurl ? "Wrote cURL to file!" : "Write cURL to file", value: "write-curl", }; } mapping["r"] = { label: "Retry", value: "retry", }; mapping["e"] = { label: "Edit & retry", value: "edit-retry", }; mapping["q"] = { label: "Quit Octo", value: "quit", }; const shortcutItems: ShortcutArray< "view" | "copy-curl" | "write-curl" | "retry" | "edit-retry" | "quit" > = [ { type: "key" as const, mapping, }, ]; const onSelect = useCallback( (item: Item<"view" | "copy-curl" | "write-curl" | "retry" | "edit-retry" | "quit">) => { if (item.value === "view") { setViewError(true); } else if (item.value === "copy-curl") { try { clipboardy.writeSync(curlCommand || "Failed to generate cURL command"); setCopiedCurl(true); } catch (error) { setClipboardError(error instanceof Error ? error.message : "Failed to copy to clipboard"); } } else if (item.value === "write-curl") { try { const filePath = path.join(os.tmpdir(), "octo-curl-request.sh"); writeFileSync(filePath, curlCommand || "Failed to generate cURL command"); setCurlFilePath(filePath); setWroteCurl(true); } catch (error) { setWriteError(error instanceof Error ? error.message : "Failed to write cURL to file"); } } else if (item.value === "retry") { retryFrom(mode, { config, transport, session, }); } else if (item.value === "edit-retry") { editAndRetryFrom(mode, { config, transport, session, }); } else { const _: "quit" = item.value; exit(); } }, [curlCommand, mode, config, transport, session], ); return ( {contextualMessage} {viewError && ( {error} )} {copiedCurl && ( {curlCommand} )} {clipboardError && ( {clipboardError} )} {wroteCurl && curlFilePath && ( Wrote cURL to{" "} {curlFilePath} )} {writeError && ( {writeError} )} ); } function RateLimitErrorScreen({ error }: { error: string }) { const config = useConfig(); const transport = useContext(TransportContext); const session = useSession(); const { retryFrom } = useAppStore( useShallow(state => ({ retryFrom: state.retryFrom, })), ); useKeyboard(event => { retryFrom("rate-limit-error", { config, transport, session, }); }); return ( It looks like you've hit a rate limit! Here's the error from the backend: {error} Press any key when you're ready to retry. ); } function PaymentErrorScreen({ error }: { error: string }) { const config = useConfig(); const transport = useContext(TransportContext); const session = useSession(); const { retryFrom } = useAppStore( useShallow(state => ({ retryFrom: state.retryFrom, })), ); useKeyboard(event => { retryFrom("payment-error", { config, transport, session, }); }); return ( Payment error: {error} Once you've paid, press any key to continue. ); } const ToolRequestItem = ({ isSelected = false, label, whitelistAllowDescription, }: { isSelected?: boolean; label: string; whitelistAllowDescription?: React.ReactNode; }) => { const themeColor = useColor(); return ( {label} {whitelistAllowDescription} ); }; function ToolRequestsRenderer({ toolReqs, config, transport, session, onContentLayout, }: { toolReqs: ToolCallRequest[]; onContentLayout: () => void; } & RunArgs) { const runAgent = useAppStore(state => state.runAgent); const { history, runningToolCallId } = useAppStore( useShallow(state => ({ history: state.history, runningToolCallId: state.runningToolCallId, })), ); /* * Derive the current action from history rather than tracking a cursor in component state: * this component unmounts when the menu opens, and a cursor would reset to 0 on remount, * re-running tools that already executed. */ const action = nextToolAction(toolReqs, runningToolCallId, history); const actionKey = action.kind === "done" ? "done" : `${action.kind}:${action.req.toolCallId}`; useLayoutEffect(() => { onContentLayout(); }, [actionKey, onContentLayout]); if (action.kind === "done") { return ( ); } const currentToolReq = action.req; return ( ); } function FinishToolRequests({ runAgent, config, transport, session, }: { runAgent: (args: RunArgs) => Promise; } & RunArgs) { useEffect(() => { runAgent({ config, transport, session, }); }, [runAgent, config, transport, session]); return ; } function ToolRequestRenderer({ toolReq, config, transport, session, onContentLayout, }: { toolReq: ToolCallRequest; onContentLayout: () => void; } & RunArgs) { const themeColor = useColor(); const scrollTranscriptToBottomIfNeeded = useScrollTranscriptToBottom(); const { runTool, rejectTool, isWhitelisted, addToWhitelist, notifyReadyForInput } = useAppStore( useShallow(state => ({ runTool: state.runTool, rejectTool: state.rejectTool, isWhitelisted: state.isWhitelisted, addToWhitelist: state.addToWhitelist, notifyReadyForInput: state.notifyReadyForInput, })), ); const unchained = useUnchained(); const whitelistKey = (() => { const fn = parsedToolSchema(toolReq); switch (fn.name) { case "read": case "partial-read": case "list": return "read:*"; case "create": case "rewrite": case "edit": return "edits:*"; case "mcp": return `${fn.name}:${fn.arguments.server}:${fn.arguments.tool}`; case "skill": case "shell": case "fetch": case "glob": case "grep": case "web-search": case "lsp-definition": case "lsp-references": case "lsp-hover": case "lsp-diagnostics": case "lsp-document-symbol": case "lsp-implementation": case "lsp-incoming-calls": case "lsp-outgoing-calls": return `${fn.name}:*`; } return `${fn.name}:*`; })(); const prompt = (() => { const fn = parsedToolSchema(toolReq); switch (fn.name) { case "create": return ( Create file {fn.arguments.filePath} ? ); case "rewrite": case "edit": return ( Make these changes to {fn.arguments.filePath} ? ); case "skill": case "read": case "partial-read": case "shell": case "fetch": case "list": case "mcp": case "glob": case "grep": case "web-search": case "lsp-definition": case "lsp-references": case "lsp-hover": case "lsp-diagnostics": case "lsp-document-symbol": case "lsp-implementation": case "lsp-incoming-calls": case "lsp-outgoing-calls": return null; } return null; })(); const toolName = toolReq.name; const [isToolWhitelisted, setIsToolWhitelisted] = useState(null); useEffect(() => { (async () => { const whitelisted = await isWhitelisted(whitelistKey); setIsToolWhitelisted(whitelisted); })(); }, [whitelistKey, isWhitelisted]); type SelectItem = { label: string; value: string; whitelistAllowDescription?: React.ReactNode; }; const items: SelectItem[] = [ { label: "Yes", value: "yes", }, ...(!SKIP_CONFIRMATION_TOOLS.includes(toolName) && !ALWAYS_REQUEST_PERMISSION_TOOLS.includes(toolName) && !isToolWhitelisted ? [ { label: "Yes, and always allow", value: "yes-whitelist", whitelistAllowDescription: , }, ] : []), { label: "No, and tell Octo what to do differently", value: "no", }, ]; const onSelect = useCallback( async (item: (typeof items)[number]) => { if (item.value === "no") { rejectTool(toolReq, session); } else if (item.value === "yes-whitelist") { await addToWhitelist(whitelistKey); await runTool({ toolReq, config, transport, session, }); } else { await runTool({ toolReq, config, transport, session, }); } }, [toolReq, config, transport, session, addToWhitelist, runTool, rejectTool, whitelistKey], ); const runningToolCallId = useAppStore(state => state.runningToolCallId); const isRunning = runningToolCallId === toolReq.toolCallId; const noConfirmationNeeded = unchained || SKIP_CONFIRMATION_TOOLS.includes(toolReq.name) || isToolWhitelisted === true; useLayoutEffect(() => { onContentLayout(); }, [isRunning, noConfirmationNeeded, onContentLayout]); useEffect(() => { // Already in flight (e.g. remounted mid-run after the menu closed): render progress without // re-invoking the tool. if (isRunning) return; if (noConfirmationNeeded) { runTool({ toolReq, config, transport, session, }); } else { notifyReadyForInput(config); } }, [toolReq, isRunning, noConfirmationNeeded, config, transport, session]); if (noConfirmationNeeded || isRunning) { return ( ); } return ( {prompt} { // If you're scrolled offscreen during the permission prompt rendering, Enter should not // accept the permission request, and should instead scroll to the bottom if (event.key === "Enter" && scrollTranscriptToBottomIfNeeded()) { event.preventDefault(); } }} indicatorComponent={IndicatorComponent} itemComponent={ToolRequestItem} /> ); } const TranscriptItemRenderer = ({ item }: { item: TranscriptItem }) => { const themeColor = useColor(); const unchained = useUnchained(); if (item.type === "header") return
; if (item.type === "version") { return ( Version: {item.metadata.version} ); } if (item.type === "slogan") { return ( Octo is your friend. Tell Octo{" "} what you want to do. ); } if (item.type === "updates") { return ( Updates: Thanks for updating! See the full changelog by running: `octo changelog` ); } if (item.type === "boot-notification") { return ( {item.content} ); } return ; }; const MessageDisplay = ({ item }: { item: HistoryNode | InflightResponseType }) => { return ( ); }; const MessageDisplayInner = ({ item }: { item: HistoryNode | InflightResponseType }) => { const { modeData } = useAppStore( useShallow(state => ({ modeData: state.modeData, })), ); if (item.type === "inflight-response") { return renderInflightResponse(item, modeData.mode === "compacting"); } if (item.type === "notification") { return ( {item.content} ); } if (item.type === "llm-ir") { return renderLlmIR(item.ir, modeData.mode === "compacting"); } if (item.type === "request-failed") { return ( Request failed. ); } if (item.type === "compaction-failed") { return ( Compaction failed. ); } const _: never = item; return null; }; function renderInflightResponse(item: InflightResponseType, isCompacting: boolean) { if (isCompacting) { return ( ); } return ( ); } function renderLlmIR(item: OctoIR, isCompacting: boolean) { if (item.role === "assistant") { if (isCompacting) { return ( ); } return ( ); } if (item.role === "tool-parse-error") { return ( {displayLog({ verbose: `Error: ${item.malformedRequest.error}`, info: "Malformed tool call. Retrying...", })} ); } if (item.role === "tool-validation-error") { const message = (() => { if (item.aborted) return "Tool call aborted."; return "Tool call failed validation checks. Retrying..."; })(); return ( {displayLog({ verbose: `Error: ${item.error}`, info: message, })} ); } if (item.role === "tool-runtime-error") { return ( {displayLog({ verbose: `Error: ${item.error}`, info: "Tool failed...", })} ); } if (item.role === "tool-reject") { return ( Tool rejected; tell Octo what to do instead: ); } // Tool skips are tracked internally for explaining to LLMs, but are not shown to users if (item.role === "tool-skip-output") { return null; } if (item.role === "checkpoint") { return ; } if (item.role === "tool-output") { return ( ); } if (item.role === "file-read") { return ( ); } if (item.role === "file-mutate") { return ( ); } if (item.role === "trajectory") { return null; } const _: "user" = item.role; const textParts = item.content.filter((part: Content["content"][number]) => part.type === "text"); const imageParts = item.content.filter( (part: Content["content"][number]) => part.type === "image", ); const contentLines = textParts.flatMap(part => part.content.split(LINE_SPLIT_REGEX)); return ( {imageParts.length > 0 && ( ⟦ 📎 {imageParts.length} image{imageParts.length > 1 ? "s" : ""} attached ⟧ )} {contentLines.map((line, i) => ( {line} ))} ); } function CompactionSummaryRenderer({ content }: { content: Content["content"] }) { const color = useColor(); const displayContent = content.map(part => { if (part.type === "image") return part; return { ...part, content: part.content.replace(/^/, "").replace(/<\/summary>$/, ""), }; }); return ( History compacted! Summary:{" "} Summary complete! ); } function ToolMessageRenderer({ item }: { item: ToolCallRequest | MalformedToolRequest }) { if (item.type === "malformed-tool-request") { return null; } switch (item.name) { case "read": return ; case "partial-read": return ; case "list": return ; case "shell": return ; case "edit": return ; case "create": return ; case "mcp": return ; case "fetch": return ; case "rewrite": return ; case "skill": return ; case "web-search": return ; case "glob": return ; case "grep": return ; case "lsp-definition": case "lsp-references": case "lsp-hover": case "lsp-diagnostics": case "lsp-document-symbol": case "lsp-implementation": case "lsp-incoming-calls": case "lsp-outgoing-calls": return ; } } function parsedToolSchema(toolCall: ToolCallRequest): any { return { name: toolCall.name, arguments: toolCall.parsed, }; } function GlobRenderer({ item }: { item: ParsedToolSchemaFrom }) { return ( Octo searched for files using a glob pattern: ); } function GrepRenderer({ item }: { item: ParsedToolSchemaFrom }) { return ( Octo searched file contents: ); } function GlobArg({ name, arg }: { name: string; arg: string | number | boolean | undefined }) { const color = useColor(); if (arg == null) return null; return ( {name}: {" "} {arg} ); } function WebSearchToolRenderer(_: { item: ParsedToolSchemaFrom }) { return ( Octo searched the web ); } function SkillToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { return ( Octo read the {item.arguments.skillName} skill ); } function FetchToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { return {item.arguments.url}; } function ShellToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { return ( {item.arguments.cmd} timeout: {item.arguments.timeout} ); } function ReadToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { return {item.arguments.filePath}; } function PartialReadToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { return ( {item.arguments.filePath}:{item.arguments.offset}- {item.arguments.offset + item.arguments.limit - 1} ); } function ListToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { return {item?.arguments?.dirPath || process.cwd()}; } function EditToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { const themeColor = useColor(); return ( Edit: {item.arguments.filePath} ); } function RewriteToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { const { text, filePath, originalFileContents } = item.arguments; return ( Octo wants to rewrite the file: ); } function DiffEditRenderer({ item, filePath, }: { item: t.GetType; filePath: string; }) { return ( Octo wants to make the following changes: ); } function CreateToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { const themeColor = useColor(); return ( Octo wants to create {item.arguments.filePath} : ); } function McpToolRenderer({ item }: { item: ParsedToolSchemaFrom }) { return ( Server: {item.arguments.server}, Tool: {item.arguments.tool} Arguments: {JSON.stringify(item.arguments.arguments)} ); } function ToolOutputContentRenderer({ content }: { content: Content["content"] }) { const textParts = content.filter(part => part.type === "text"); const imageParts = content.filter(part => part.type === "image"); const lines = textParts.reduce( (count, part) => count + part.content.split(LINE_SPLIT_REGEX).length, 0, ); return ( Got {lines} lines of output {imageParts.map((part, i) => ( ))} ); } function ContentRenderer({ content, textColor, }: { content: Content["content"]; textColor?: string; }) { return ( {content.map((part, i) => { if (part.type === "image") { return ; } return part.content.split(LINE_SPLIT_REGEX).map((line, lineIndex) => ( {line} )); })} ); } function ImageContentRenderer({ image }: { image: ImageInfo }) { return ( ⟦ 📎 {image.filePath} ({Math.ceil(image.sizeBytes / 1024)} KB) ⟧ ); } function WhitelistAllowDescription({ toolCallRequest }: { toolCallRequest: ToolCallRequest }) { const fn = parsedToolSchema(toolCallRequest); const cwd = useCwd(); switch (fn.name) { case "glob": return local glob searches in this session.; case "grep": return local grep searches in this session.; case "shell": { return ( commands starting with {fn.arguments.cmd} ); } case "fetch": { return fetches from the web during this session.; } case "web-search": { return Web Searches during this session.; } case "list": case "read": case "partial-read": { return ( file reads in {cwd} ); } case "edit": case "create": case "rewrite": { return ( file changes in {cwd} ); } case "mcp": { return ( {" "} MCP tools with Server:{" "} {fn.arguments.server} {" "} using Tool:{" "} {fn.arguments.tool} ); } case "skill": { return {fn.arguments.skillName} skill executions; } case "lsp-definition": case "lsp-references": case "lsp-hover": case "lsp-diagnostics": case "lsp-document-symbol": case "lsp-implementation": case "lsp-incoming-calls": case "lsp-outgoing-calls": return LSP queries during this session.; } return this tool in this session.; } const OCTO_MARGIN = 1; const OCTO_PADDING = 2; function OctoMessageRenderer({ children }: { children?: React.ReactNode }) { return ( {children} ); } function CompactionRenderer({ item }: { item: AssistantDisplayItem }) { return ( {item.content} ); } function AssistantMessageRenderer({ item }: { item: AssistantDisplayItem }) { let thoughts = item.reasoningContent ? item.reasoningContent.trim() : item.reasoningContent; let content = item.content.trim(); const showThoughts = thoughts && thoughts !== ""; return ( {showThoughts && } ); } const MAX_THOUGHTBOX_HEIGHT = 8; const MAX_THOUGHTBOX_WIDTH = 80; function scrollToBottom(element: DivElement | null): void { if (!element) return; element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight); } function isScrolledToBottom( scrollTop: number, scrollHeight: number, clientHeight: number, ): boolean { return scrollTop >= Math.max(0, scrollHeight - clientHeight); } function ThoughtBox({ thoughts }: { thoughts: string }) { const viewportRef = useRef(null); const followThoughtsRef = useRef(true); useEffect(() => { if (followThoughtsRef.current) scrollToBottom(viewportRef.current); }, [thoughts]); return ( { followThoughtsRef.current = isScrolledToBottom( event.scrollTop, event.scrollHeight, viewportRef.current?.clientHeight ?? 1, ); }} style={{ flexGrow: 0, flexShrink: 1, minWidth: 0, maxWidth: MAX_THOUGHTBOX_WIDTH, maxHeight: MAX_THOUGHTBOX_HEIGHT, overflowY: "scroll", scrollbarGutter: "auto", scrollbarColor: SUBTLE_SCROLLBAR_COLOR, flexDirection: "column", borderColor: THOUGHTBOX_COLOR, border: "rounded", }} > {thoughts} ); }