codex-cli/src/components/chat/terminal-chat-command-review.tsx (209 lines of code) (raw):
import { ReviewDecision } from "../../utils/agent/review";
// TODO: figure out why `cli-spinners` fails on Node v20.9.0
// which is why we have to do this in the first place
//
// @ts-expect-error select.js is JavaScript and has no types
import { Select } from "../vendor/ink-select/select";
import TextInput from "../vendor/ink-text-input";
import { Box, Text, useInput } from "ink";
import React from "react";
// default deny‑reason:
const DEFAULT_DENY_MESSAGE =
"Don't do that, but keep trying to fix the problem";
export function TerminalChatCommandReview({
confirmationPrompt,
onReviewCommand,
// callback to switch approval mode overlay
onSwitchApprovalMode,
explanation: propExplanation,
// whether this review Select is active (listening for keys)
isActive = true,
}: {
confirmationPrompt: React.ReactNode;
onReviewCommand: (decision: ReviewDecision, customMessage?: string) => void;
onSwitchApprovalMode: () => void;
explanation?: string;
// when false, disable the underlying Select so it won't capture input
isActive?: boolean;
}): React.ReactElement {
const [mode, setMode] = React.useState<"select" | "input" | "explanation">(
"select",
);
const [explanation, setExplanation] = React.useState<string>("");
// If the component receives an explanation prop, update the state
React.useEffect(() => {
if (propExplanation) {
setExplanation(propExplanation);
setMode("explanation");
}
}, [propExplanation]);
const [msg, setMsg] = React.useState<string>("");
// -------------------------------------------------------------------------
// Determine whether the "always approve" option should be displayed. We
// only hide it for the special `apply_patch` command since approving those
// permanently would bypass the user's review of future file modifications.
// The information is embedded in the `confirmationPrompt` React element –
// we inspect the `commandForDisplay` prop exposed by
// <TerminalChatToolCallCommand/> to extract the base command.
// -------------------------------------------------------------------------
const showAlwaysApprove = React.useMemo(() => {
if (
React.isValidElement(confirmationPrompt) &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (confirmationPrompt as any).props?.commandForDisplay === "string"
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const command: string = (confirmationPrompt as any).props
.commandForDisplay;
// Grab the first token of the first line – that corresponds to the base
// command even when the string contains embedded newlines (e.g. diffs).
const baseCmd = command.split("\n")[0]?.trim().split(/\s+/)[0] ?? "";
return baseCmd !== "apply_patch";
}
// Default to showing the option when we cannot reliably detect the base
// command.
return true;
}, [confirmationPrompt]);
// Memoize the list of selectable options to avoid recreating the array on
// every render. This keeps <Select/> stable and prevents unnecessary work
// inside Ink.
const approvalOptions = React.useMemo(() => {
const opts: Array<
| { label: string; value: ReviewDecision }
| { label: string; value: "edit" }
| { label: string; value: "switch" }
> = [
{
label: "Yes (y)",
value: ReviewDecision.YES,
},
];
if (showAlwaysApprove) {
opts.push({
label: "Yes, always approve this exact command for this session (a)",
value: ReviewDecision.ALWAYS,
});
}
opts.push(
{
label: "Explain this command (x)",
value: ReviewDecision.EXPLAIN,
},
{
label: "Edit or give feedback (e)",
value: "edit",
},
// allow switching approval mode
{
label: "Switch approval mode (s)",
value: "switch",
},
{
label: "No, and keep going (n)",
value: ReviewDecision.NO_CONTINUE,
},
{
label: "No, and stop for now (esc)",
value: ReviewDecision.NO_EXIT,
},
);
return opts;
}, [showAlwaysApprove]);
useInput(
(input, key) => {
if (mode === "select") {
if (input === "y") {
onReviewCommand(ReviewDecision.YES);
} else if (input === "x") {
onReviewCommand(ReviewDecision.EXPLAIN);
} else if (input === "e") {
setMode("input");
} else if (input === "n") {
onReviewCommand(
ReviewDecision.NO_CONTINUE,
"Don't do that, keep going though",
);
} else if (input === "a" && showAlwaysApprove) {
onReviewCommand(ReviewDecision.ALWAYS);
} else if (input === "s") {
// switch approval mode
onSwitchApprovalMode();
} else if (key.escape) {
onReviewCommand(ReviewDecision.NO_EXIT);
}
} else if (mode === "explanation") {
// When in explanation mode, any key returns to select mode
if (key.return || key.escape || input === "x") {
setMode("select");
}
} else {
// text entry mode
if (key.return) {
// if user hit enter on empty msg, fall back to DEFAULT_DENY_MESSAGE
const custom = msg.trim() === "" ? DEFAULT_DENY_MESSAGE : msg;
onReviewCommand(ReviewDecision.NO_CONTINUE, custom);
} else if (key.escape) {
// treat escape as denial with default message as well
onReviewCommand(
ReviewDecision.NO_CONTINUE,
msg.trim() === "" ? DEFAULT_DENY_MESSAGE : msg,
);
}
}
},
{ isActive },
);
return (
<Box flexDirection="column" gap={1} borderStyle="round" marginTop={1}>
{confirmationPrompt}
<Box flexDirection="column" gap={1}>
{mode === "explanation" ? (
<>
<Text bold color="yellow">
Command Explanation:
</Text>
<Box paddingX={2} flexDirection="column" gap={1}>
{explanation ? (
<>
{explanation.split("\n").map((line, i) => {
// Check if it's an error message
if (
explanation.startsWith("Unable to generate explanation")
) {
return (
<Text key={i} bold color="red">
{line}
</Text>
);
}
// Apply different styling to headings (numbered items)
else if (line.match(/^\d+\.\s+/)) {
return (
<Text key={i} bold color="cyan">
{line}
</Text>
);
} else {
return <Text key={i}>{line}</Text>;
}
})}
</>
) : (
<Text dimColor>Loading explanation...</Text>
)}
<Text dimColor>Press any key to return to options</Text>
</Box>
</>
) : mode === "select" ? (
<>
<Text>Allow command?</Text>
<Box paddingX={2} flexDirection="column" gap={1}>
<Select
isDisabled={!isActive}
visibleOptionCount={approvalOptions.length}
onChange={(value: ReviewDecision | "edit" | "switch") => {
if (value === "edit") {
setMode("input");
} else if (value === "switch") {
onSwitchApprovalMode();
} else {
onReviewCommand(value);
}
}}
options={approvalOptions}
/>
</Box>
</>
) : mode === "input" ? (
<>
<Text>Give the model feedback (↵ to submit):</Text>
<Box borderStyle="round">
<Box paddingX={1}>
<TextInput
value={msg}
onChange={setMsg}
placeholder="type a reason"
showCursor
focus
/>
</Box>
</Box>
{msg.trim() === "" && (
<Box paddingX={2} marginBottom={1}>
<Text dimColor>
default:
<Text>{DEFAULT_DENY_MESSAGE}</Text>
</Text>
</Box>
)}
</>
) : null}
</Box>
</Box>
);
}