in codex-cli/src/components/chat/terminal-chat.tsx [137:572]
export default function TerminalChat({
config,
prompt: _initialPrompt,
imagePaths: _initialImagePaths,
approvalPolicy: initialApprovalPolicy,
additionalWritableRoots,
fullStdout,
}: Props): React.ReactElement {
const notify = Boolean(config.notify);
const [model, setModel] = useState<string>(config.model);
const [provider, setProvider] = useState<string>(config.provider || "openai");
const [lastResponseId, setLastResponseId] = useState<string | null>(null);
const [items, setItems] = useState<Array<ResponseItem>>([]);
const [loading, setLoading] = useState<boolean>(false);
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>(
initialApprovalPolicy,
);
const [thinkingSeconds, setThinkingSeconds] = useState(0);
const handleCompact = async () => {
setLoading(true);
try {
const summary = await generateCompactSummary(
items,
model,
Boolean(config.flexMode),
config,
);
setItems([
{
id: `compact-${Date.now()}`,
type: "message",
role: "assistant",
content: [{ type: "output_text", text: summary }],
} as ResponseItem,
]);
} catch (err) {
setItems((prev) => [
...prev,
{
id: `compact-error-${Date.now()}`,
type: "message",
role: "system",
content: [
{ type: "input_text", text: `Failed to compact context: ${err}` },
],
} as ResponseItem,
]);
} finally {
setLoading(false);
}
};
const {
requestConfirmation,
confirmationPrompt,
explanation,
submitConfirmation,
} = useConfirmation();
const [overlayMode, setOverlayMode] = useState<OverlayModeType>("none");
// Store the diff text when opening the diff overlay so the view isn’t
// recomputed on every re‑render while it is open.
// diffText is passed down to the DiffOverlay component. The setter is
// currently unused but retained for potential future updates. Prefix with
// an underscore so eslint ignores the unused variable.
const [diffText, _setDiffText] = useState<string>("");
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<AgentLoop>();
const [, forceUpdate] = React.useReducer((c) => c + 1, 0); // trigger re‑render
// ────────────────────────────────────────────────────────────────
// DEBUG: log every render w/ key bits of state
// ────────────────────────────────────────────────────────────────
log(
`render - agent? ${Boolean(agentRef.current)} loading=${loading} items=${
items.length
}`,
);
useEffect(() => {
// Skip recreating the agent if awaiting a decision on a pending confirmation.
if (confirmationPrompt != null) {
log("skip AgentLoop recreation due to pending confirmationPrompt");
return;
}
log("creating NEW AgentLoop");
log(
`model=${model} provider=${provider} instructions=${Boolean(
config.instructions,
)} approvalPolicy=${approvalPolicy}`,
);
// Tear down any existing loop before creating a new one.
agentRef.current?.terminate();
const sessionId = crypto.randomUUID();
agentRef.current = new AgentLoop({
model,
provider,
config,
instructions: config.instructions,
approvalPolicy,
disableResponseStorage: config.disableResponseStorage,
additionalWritableRoots,
onLastResponseId: setLastResponseId,
onItem: (item) => {
log(`onItem: ${JSON.stringify(item)}`);
setItems((prev) => {
const updated = uniqueById([...prev, item as ResponseItem]);
saveRollout(sessionId, updated);
return updated;
});
},
onLoading: setLoading,
getCommandConfirmation: async (
command: Array<string>,
applyPatch: ApplyPatchCommand | undefined,
): Promise<CommandConfirmation> => {
log(`getCommandConfirmation: ${command}`);
const commandForDisplay = formatCommandForDisplay(command);
// First request for confirmation
let { decision: review, customDenyMessage } = await requestConfirmation(
<TerminalChatToolCallCommand commandForDisplay={commandForDisplay} />,
);
// If the user wants an explanation, generate one and ask again.
if (review === ReviewDecision.EXPLAIN) {
log(`Generating explanation for command: ${commandForDisplay}`);
const explanation = await generateCommandExplanation(
command,
model,
Boolean(config.flexMode),
config,
);
log(`Generated explanation: ${explanation}`);
// Ask for confirmation again, but with the explanation.
const confirmResult = await requestConfirmation(
<TerminalChatToolCallCommand
commandForDisplay={commandForDisplay}
explanation={explanation}
/>,
);
// Update the decision based on the second confirmation.
review = confirmResult.decision;
customDenyMessage = confirmResult.customDenyMessage;
// Return the final decision with the explanation.
return { review, customDenyMessage, applyPatch, explanation };
}
return { review, customDenyMessage, applyPatch };
},
});
// Force a render so JSX below can "see" the freshly created agent.
forceUpdate();
log(`AgentLoop created: ${inspect(agentRef.current, { depth: 1 })}`);
return () => {
log("terminating AgentLoop");
agentRef.current?.terminate();
agentRef.current = undefined;
forceUpdate(); // re‑render after teardown too
};
// We intentionally omit 'approvalPolicy' and 'confirmationPrompt' from the deps
// so switching modes or showing confirmation dialogs doesn’t tear down the loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model, provider, config, requestConfirmation, additionalWritableRoots]);
// 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<typeof setInterval> | 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]);
// Notify desktop with a preview when an assistant response arrives.
const prevLoadingRef = useRef<boolean>(false);
useEffect(() => {
// Only notify when notifications are enabled.
if (!notify) {
prevLoadingRef.current = loading;
return;
}
if (
prevLoadingRef.current &&
!loading &&
confirmationPrompt == null &&
items.length > 0
) {
if (process.platform === "darwin") {
// find the last assistant message
const assistantMessages = items.filter(
(i) => i.type === "message" && i.role === "assistant",
);
const last = assistantMessages[assistantMessages.length - 1];
if (last) {
const text = last.content
.map((c) => {
if (c.type === "output_text") {
return c.text;
}
return "";
})
.join("")
.trim();
const preview = text.replace(/\n/g, " ").slice(0, 100);
const safePreview = preview.replace(/"/g, '\\"');
const title = "Codex CLI";
const cwd = PWD;
spawn("osascript", [
"-e",
`display notification "${safePreview}" with title "${title}" subtitle "${cwd}" sound name "Ping"`,
]);
}
}
}
prevLoadingRef.current = loading;
}, [notify, loading, confirmationPrompt, items, PWD]);
// Let's also track whenever the ref becomes available.
const agent = agentRef.current;
useEffect(() => {
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(provider);
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 for provider "${provider}".`,
},
],
},
]);
}
})();
// 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<string, number> = {};
const userMsgCount = items.filter(
(i) => i.type === "message" && i.role === "user",
).length;
const contextLeftPercent = useMemo(
() => calculateContextPercentRemaining(items, model),
[items, model],
);
return (
<Box flexDirection="column">
<Box flexDirection="column">
{agent ? (
<TerminalMessageHistory
setOverlayMode={setOverlayMode}
batch={lastMessageBatch}
groupCounts={groupCounts}
items={items}
userMsgCount={userMsgCount}
confirmationPrompt={confirmationPrompt}
loading={loading}
thinkingSeconds={thinkingSeconds}
fullStdout={fullStdout}
headerProps={{
terminalRows,
version: CLI_VERSION,
PWD,
model,
provider,
approvalPolicy,
colorsByPolicy,
agent,
initialImagePaths,
flexModeEnabled: Boolean(config.flexMode),
}}
/>
) : (
<Box>
<Text color="gray">Initializing agent…</Text>
</Box>
)}
{overlayMode === "none" && agent && (
<TerminalChatInput
loading={loading}
setItems={setItems}
isNew={Boolean(items.length === 0)}
setLastResponseId={setLastResponseId}
confirmationPrompt={confirmationPrompt}
explanation={explanation}
submitConfirmation={(
decision: ReviewDecision,
customDenyMessage?: string,
) =>
submitConfirmation({
decision,
customDenyMessage,
})
}
contextLeftPercent={contextLeftPercent}
openOverlay={() => setOverlayMode("history")}
openModelOverlay={() => setOverlayMode("model")}
openApprovalOverlay={() => setOverlayMode("approval")}
openHelpOverlay={() => setOverlayMode("help")}
openDiffOverlay={() => {
const { isGitRepo, diff } = getGitDiff();
let text: string;
if (isGitRepo) {
text = diff;
} else {
text = "`/diff` — _not inside a git repository_";
}
setItems((prev) => [
...prev,
{
id: `diff-${Date.now()}`,
type: "message",
role: "system",
content: [{ type: "input_text", text }],
},
]);
// Ensure no overlay is shown.
setOverlayMode("none");
}}
onCompact={handleCompact}
active={overlayMode === "none"}
interruptAgent={() => {
if (!agent) {
return;
}
log(
"TerminalChat: interruptAgent invoked – calling agent.cancel()",
);
agent.cancel();
setLoading(false);
// Add a system message to indicate the interruption
setItems((prev) => [
...prev,
{
id: `interrupt-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: "⏹️ Execution interrupted by user. You can continue typing.",
},
],
},
]);
}}
submitInput={(inputs) => {
agent.run(inputs, lastResponseId || "");
return {};
}}
items={items}
thinkingSeconds={thinkingSeconds}
/>
)}
{overlayMode === "history" && (
<HistoryOverlay items={items} onExit={() => setOverlayMode("none")} />
)}