400
codex-cli/src/components/chat/terminal-chat.tsx
Normal file
400
codex-cli/src/components/chat/terminal-chat.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import type { CommandConfirmation } from "../../utils/agent/agent-loop.js";
|
||||
import type { AppConfig } from "../../utils/config.js";
|
||||
import type { ApplyPatchCommand, ApprovalPolicy } from "@lib/approvals.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 { 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 { formatCommandForDisplay } from "@lib/format-command.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<string>;
|
||||
approvalPolicy: ApprovalPolicy;
|
||||
fullStdout: boolean;
|
||||
};
|
||||
|
||||
const colorsByPolicy: Record<ApprovalPolicy, ColorName | undefined> = {
|
||||
"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<string>(config.model);
|
||||
const [lastResponseId, setLastResponseId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<Array<ResponseItem>>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
// Allow switching approval modes at runtime via an overlay.
|
||||
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>(
|
||||
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<AgentLoop>();
|
||||
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<string>,
|
||||
applyPatch: ApplyPatchCommand | undefined,
|
||||
): Promise<CommandConfirmation> => {
|
||||
log(`getCommandConfirmation: ${command}`);
|
||||
const commandForDisplay = formatCommandForDisplay(command);
|
||||
const { decision: review, customDenyMessage } =
|
||||
await requestConfirmation(
|
||||
<TerminalChatToolCallCommand
|
||||
commandForDisplay={commandForDisplay}
|
||||
/>,
|
||||
);
|
||||
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<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]);
|
||||
|
||||
// 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<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
|
||||
batch={lastMessageBatch}
|
||||
groupCounts={groupCounts}
|
||||
items={items}
|
||||
userMsgCount={userMsgCount}
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
loading={loading}
|
||||
thinkingSeconds={thinkingSeconds}
|
||||
fullStdout={fullStdout}
|
||||
headerProps={{
|
||||
terminalRows,
|
||||
version: CLI_VERSION,
|
||||
PWD,
|
||||
model,
|
||||
approvalPolicy,
|
||||
colorsByPolicy,
|
||||
agent,
|
||||
initialImagePaths,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
<Text color="gray">Initializing agent…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{agent && (
|
||||
<TerminalChatInput
|
||||
loading={loading}
|
||||
setItems={setItems}
|
||||
isNew={Boolean(items.length === 0)}
|
||||
setLastResponseId={setLastResponseId}
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
submitConfirmation={(
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
) =>
|
||||
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" && (
|
||||
<HistoryOverlay items={items} onExit={() => setOverlayMode("none")} />
|
||||
)}
|
||||
{overlayMode === "model" && (
|
||||
<ModelOverlay
|
||||
currentModel={model}
|
||||
hasLastResponse={Boolean(lastResponseId)}
|
||||
onSelect={(newModel) => {
|
||||
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" && (
|
||||
<ApprovalModeOverlay
|
||||
currentMode={approvalPolicy}
|
||||
onSelect={(newMode) => {
|
||||
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" && (
|
||||
<HelpOverlay onExit={() => setOverlayMode("none")} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user