import type { ApplyPatchCommand, ApprovalPolicy } from "../../approvals.js"; import type { CommandConfirmation } from "../../utils/agent/agent-loop.js"; import type { AppConfig } from "../../utils/config.js"; import type { ColorName } from "chalk"; import type { ResponseItem } from "openai/resources/responses/responses.mjs"; import type { ReviewDecision } from "src/utils/agent/review.ts"; import TerminalChatInput from "./terminal-chat-input.js"; import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-item.js"; import { calculateContextPercentRemaining, uniqueById, } from "./terminal-chat-utils.js"; import TerminalMessageHistory from "./terminal-message-history.js"; import { formatCommandForDisplay } from "../../format-command.js"; import { useConfirmation } from "../../hooks/use-confirmation.js"; import { useTerminalSize } from "../../hooks/use-terminal-size.js"; import { AgentLoop } from "../../utils/agent/agent-loop.js"; import { log, isLoggingEnabled } from "../../utils/agent/log.js"; import { createInputItem } from "../../utils/input-utils.js"; import { getAvailableModels } from "../../utils/model-utils.js"; import { CLI_VERSION } from "../../utils/session.js"; import { shortCwd } from "../../utils/short-path.js"; import { saveRollout } from "../../utils/storage/save-rollout.js"; import ApprovalModeOverlay from "../approval-mode-overlay.js"; import HelpOverlay from "../help-overlay.js"; import HistoryOverlay from "../history-overlay.js"; import ModelOverlay from "../model-overlay.js"; import { Box, Text } from "ink"; import React, { useEffect, useMemo, useState } from "react"; import { inspect } from "util"; type Props = { config: AppConfig; prompt?: string; imagePaths?: Array; approvalPolicy: ApprovalPolicy; fullStdout: boolean; }; const colorsByPolicy: Record = { "suggest": undefined, "auto-edit": "greenBright", "full-auto": "green", }; export default function TerminalChat({ config, prompt: _initialPrompt, imagePaths: _initialImagePaths, approvalPolicy: initialApprovalPolicy, fullStdout, }: Props): React.ReactElement { const [model, setModel] = useState(config.model); const [lastResponseId, setLastResponseId] = useState(null); const [items, setItems] = useState>([]); const [loading, setLoading] = useState(false); // Allow switching approval modes at runtime via an overlay. const [approvalPolicy, setApprovalPolicy] = useState( initialApprovalPolicy, ); const [thinkingSeconds, setThinkingSeconds] = useState(0); const { requestConfirmation, confirmationPrompt, submitConfirmation } = useConfirmation(); const [overlayMode, setOverlayMode] = useState< "none" | "history" | "model" | "approval" | "help" >("none"); const [initialPrompt, setInitialPrompt] = useState(_initialPrompt); const [initialImagePaths, setInitialImagePaths] = useState(_initialImagePaths); const PWD = React.useMemo(() => shortCwd(), []); // Keep a single AgentLoop instance alive across renders; // recreate only when model/instructions/approvalPolicy change. const agentRef = React.useRef(); const [, forceUpdate] = React.useReducer((c) => c + 1, 0); // trigger re‑render // ──────────────────────────────────────────────────────────────── // DEBUG: log every render w/ key bits of state // ──────────────────────────────────────────────────────────────── if (isLoggingEnabled()) { log( `render – agent? ${Boolean(agentRef.current)} loading=${loading} items=${ items.length }`, ); } useEffect(() => { if (isLoggingEnabled()) { log("creating NEW AgentLoop"); log( `model=${model} instructions=${Boolean( config.instructions, )} approvalPolicy=${approvalPolicy}`, ); } // Tear down any existing loop before creating a new one agentRef.current?.terminate(); agentRef.current = new AgentLoop({ model, config, instructions: config.instructions, approvalPolicy, onLastResponseId: setLastResponseId, onItem: (item) => { log(`onItem: ${JSON.stringify(item)}`); setItems((prev) => { const updated = uniqueById([...prev, item as ResponseItem]); saveRollout(updated); return updated; }); }, onLoading: setLoading, getCommandConfirmation: async ( command: Array, applyPatch: ApplyPatchCommand | undefined, ): Promise => { log(`getCommandConfirmation: ${command}`); const commandForDisplay = formatCommandForDisplay(command); const { decision: review, customDenyMessage } = await requestConfirmation( , ); return { review, customDenyMessage, applyPatch }; }, }); // force a render so JSX below can "see" the freshly created agent forceUpdate(); if (isLoggingEnabled()) { log(`AgentLoop created: ${inspect(agentRef.current, { depth: 1 })}`); } return () => { if (isLoggingEnabled()) { log("terminating AgentLoop"); } agentRef.current?.terminate(); agentRef.current = undefined; forceUpdate(); // re‑render after teardown too }; }, [model, config, approvalPolicy, requestConfirmation]); // whenever loading starts/stops, reset or start a timer — but pause the // timer while a confirmation overlay is displayed so we don't trigger a // re‑render every second during apply_patch reviews. useEffect(() => { let handle: ReturnType | null = null; // Only tick the "thinking…" timer when the agent is actually processing // a request *and* the user is not being asked to review a command. if (loading && confirmationPrompt == null) { setThinkingSeconds(0); handle = setInterval(() => { setThinkingSeconds((s) => s + 1); }, 1000); } else { if (handle) { clearInterval(handle); } setThinkingSeconds(0); } return () => { if (handle) { clearInterval(handle); } }; }, [loading, confirmationPrompt]); // Let's also track whenever the ref becomes available const agent = agentRef.current; useEffect(() => { if (isLoggingEnabled()) { log(`agentRef.current is now ${Boolean(agent)}`); } }, [agent]); // --------------------------------------------------------------------- // Dynamic layout constraints – keep total rendered rows <= terminal rows // --------------------------------------------------------------------- const { rows: terminalRows } = useTerminalSize(); useEffect(() => { const processInitialInputItems = async () => { if ( (!initialPrompt || initialPrompt.trim() === "") && (!initialImagePaths || initialImagePaths.length === 0) ) { return; } const inputItems = [ await createInputItem(initialPrompt || "", initialImagePaths || []), ]; // Clear them to prevent subsequent runs setInitialPrompt(""); setInitialImagePaths([]); agent?.run(inputItems); }; processInitialInputItems(); }, [agent, initialPrompt, initialImagePaths]); // ──────────────────────────────────────────────────────────────── // In-app warning if CLI --model isn't in fetched list // ──────────────────────────────────────────────────────────────── useEffect(() => { (async () => { const available = await getAvailableModels(); if (model && available.length > 0 && !available.includes(model)) { setItems((prev) => [ ...prev, { id: `unknown-model-${Date.now()}`, type: "message", role: "system", content: [ { type: "input_text", text: `Warning: model "${model}" is not in the list of available models returned by OpenAI.`, }, ], }, ]); } })(); // run once on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Just render every item in order, no grouping/collapse const lastMessageBatch = items.map((item) => ({ item })); const groupCounts: Record = {}; const userMsgCount = items.filter( (i) => i.type === "message" && i.role === "user", ).length; const contextLeftPercent = useMemo( () => calculateContextPercentRemaining(items, model), [items, model], ); return ( {agent ? ( ) : ( Initializing agent… )} {agent && ( submitConfirmation({ decision, customDenyMessage, }) } contextLeftPercent={contextLeftPercent} openOverlay={() => setOverlayMode("history")} openModelOverlay={() => setOverlayMode("model")} openApprovalOverlay={() => setOverlayMode("approval")} openHelpOverlay={() => setOverlayMode("help")} active={overlayMode === "none"} interruptAgent={() => { if (!agent) { return; } if (isLoggingEnabled()) { log( "TerminalChat: interruptAgent invoked – calling agent.cancel()", ); } agent.cancel(); setLoading(false); }} submitInput={(inputs) => { agent.run(inputs, lastResponseId || ""); return {}; }} /> )} {overlayMode === "history" && ( setOverlayMode("none")} /> )} {overlayMode === "model" && ( { if (isLoggingEnabled()) { log( "TerminalChat: interruptAgent invoked – calling agent.cancel()", ); if (!agent) { log("TerminalChat: agent is not ready yet"); } } agent?.cancel(); setLoading(false); setModel(newModel); setLastResponseId((prev) => prev && newModel !== model ? null : prev, ); setItems((prev) => [ ...prev, { id: `switch-model-${Date.now()}`, type: "message", role: "system", content: [ { type: "input_text", text: `Switched model to ${newModel}`, }, ], }, ]); setOverlayMode("none"); }} onExit={() => setOverlayMode("none")} /> )} {overlayMode === "approval" && ( { agent?.cancel(); setLoading(false); if (newMode === approvalPolicy) { return; } setApprovalPolicy(newMode as ApprovalPolicy); setItems((prev) => [ ...prev, { id: `switch-approval-${Date.now()}`, type: "message", role: "system", content: [ { type: "input_text", text: `Switched approval mode to ${newMode}`, }, ], }, ]); setOverlayMode("none"); }} onExit={() => setOverlayMode("none")} /> )} {overlayMode === "help" && ( setOverlayMode("none")} /> )} ); }