feat: shell command explanation option (#173)
# Shell Command Explanation Option ## Description This PR adds an option to explain shell commands when the user is prompted to approve them (Fixes #110). When reviewing a shell command, users can now select "Explain this command" to get a detailed explanation of what the command does before deciding whether to approve or reject it. ## Changes - Added a new "EXPLAIN" option to the `ReviewDecision` enum - Updated the command review UI to include an "Explain this command (x)" option - Implemented the logic to send the command to the LLM for explanation using the same model as the agent - Added a display for the explanation in the command review UI - Updated all relevant components to pass the explanation through the component tree ## Benefits - Improves user understanding of shell commands before approving them - Reduces the risk of approving potentially harmful commands - Enhances the educational aspect of the tool, helping users learn about shell commands - Maintains the same workflow with minimal UI changes ## Testing - Manually tested the explanation feature with various shell commands - Verified that the explanation is displayed correctly in the UI - Confirmed that the user can still approve or reject the command after viewing the explanation ## Screenshots  ## Additional Notes The explanation is generated using the same model as the agent, ensuring consistency in the quality and style of explanations. --------- Signed-off-by: crazywolf132 <crazywolf132@gmail.com>
This commit is contained in:
163
codex-cli/package-lock.json
generated
163
codex-cli/package-lock.json
generated
@@ -14,8 +14,10 @@
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^16.1.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"figures": "^6.1.0",
|
||||
"file-type": "^20.1.0",
|
||||
"ink": "^5.2.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"marked": "^15.0.7",
|
||||
"marked-terminal": "^7.3.0",
|
||||
"meow": "^13.2.0",
|
||||
@@ -23,8 +25,10 @@
|
||||
"openai": "^4.89.0",
|
||||
"react": "^18.2.0",
|
||||
"shell-quote": "^1.8.2",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"to-rotated": "^1.0.0",
|
||||
"use-interval": "1.4.0"
|
||||
"use-interval": "1.4.0",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"bin": {
|
||||
"codex": "dist/cli.js"
|
||||
@@ -1082,7 +1086,8 @@
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json5": {
|
||||
"version": "0.0.29",
|
||||
@@ -1574,9 +1579,7 @@
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"node_modules/array-buffer-byte-length": {
|
||||
"version": "1.0.2",
|
||||
@@ -2035,6 +2038,15 @@
|
||||
"@colors/colors": "1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -2061,6 +2073,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
|
||||
@@ -2101,6 +2125,15 @@
|
||||
"wrap-ansi": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -2141,6 +2174,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
@@ -3075,6 +3120,17 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -3132,6 +3188,20 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||
@@ -4579,8 +4649,7 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -6223,20 +6292,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/strip-ansi": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.matchall": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
|
||||
@@ -6331,22 +6386,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
@@ -7161,20 +7212,6 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/strip-ansi": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -7248,6 +7285,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -7274,6 +7320,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
@@ -7302,11 +7360,10 @@
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.24.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"version": "3.24.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
|
||||
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -37,8 +37,10 @@
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^16.1.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"figures": "^6.1.0",
|
||||
"file-type": "^20.1.0",
|
||||
"ink": "^5.2.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"marked": "^15.0.7",
|
||||
"marked-terminal": "^7.3.0",
|
||||
"meow": "^13.2.0",
|
||||
@@ -46,8 +48,10 @@
|
||||
"openai": "^4.89.0",
|
||||
"react": "^18.2.0",
|
||||
"shell-quote": "^1.8.2",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"to-rotated": "^1.0.0",
|
||||
"use-interval": "1.4.0"
|
||||
"use-interval": "1.4.0",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
|
||||
@@ -15,11 +15,24 @@ const DEFAULT_DENY_MESSAGE =
|
||||
export function TerminalChatCommandReview({
|
||||
confirmationPrompt,
|
||||
onReviewCommand,
|
||||
explanation: propExplanation,
|
||||
}: {
|
||||
confirmationPrompt: React.ReactNode;
|
||||
onReviewCommand: (decision: ReviewDecision, customMessage?: string) => void;
|
||||
explanation?: string;
|
||||
}): React.ReactElement {
|
||||
const [mode, setMode] = React.useState<"select" | "input">("select");
|
||||
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>("");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -72,6 +85,10 @@ export function TerminalChatCommandReview({
|
||||
}
|
||||
|
||||
opts.push(
|
||||
{
|
||||
label: "Explain this command (x)",
|
||||
value: ReviewDecision.EXPLAIN,
|
||||
},
|
||||
{
|
||||
label: "Edit or give feedback (e)",
|
||||
value: "edit",
|
||||
@@ -93,6 +110,8 @@ export function TerminalChatCommandReview({
|
||||
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") {
|
||||
@@ -105,6 +124,11 @@ export function TerminalChatCommandReview({
|
||||
} 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) {
|
||||
@@ -125,7 +149,44 @@ export function TerminalChatCommandReview({
|
||||
<Box flexDirection="column" gap={1} borderStyle="round" marginTop={1}>
|
||||
{confirmationPrompt}
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{mode === "select" ? (
|
||||
{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}>
|
||||
@@ -141,7 +202,7 @@ export function TerminalChatCommandReview({
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
) : mode === "input" ? (
|
||||
<>
|
||||
<Text>Give the model feedback (↵ to submit):</Text>
|
||||
<Box borderStyle="round">
|
||||
@@ -165,7 +226,7 @@ export function TerminalChatCommandReview({
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function TerminalChatInput({
|
||||
loading,
|
||||
submitInput,
|
||||
confirmationPrompt,
|
||||
explanation,
|
||||
submitConfirmation,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
@@ -48,6 +49,7 @@ export default function TerminalChatInput({
|
||||
loading: boolean;
|
||||
submitInput: (input: Array<ResponseInputItem>) => void;
|
||||
confirmationPrompt: React.ReactNode | null;
|
||||
explanation?: string;
|
||||
submitConfirmation: (
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
@@ -280,6 +282,7 @@ export default function TerminalChatInput({
|
||||
<TerminalChatCommandReview
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
onReviewCommand={submitConfirmation}
|
||||
explanation={explanation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function TerminalChatInput({
|
||||
loading,
|
||||
submitInput,
|
||||
confirmationPrompt,
|
||||
explanation,
|
||||
submitConfirmation,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
@@ -91,6 +92,7 @@ export default function TerminalChatInput({
|
||||
loading: boolean;
|
||||
submitInput: (input: Array<ResponseInputItem>) => void;
|
||||
confirmationPrompt: React.ReactNode | null;
|
||||
explanation?: string;
|
||||
submitConfirmation: (
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
@@ -375,6 +377,7 @@ export default function TerminalChatInput({
|
||||
<TerminalChatCommandReview
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
onReviewCommand={submitConfirmation}
|
||||
explanation={explanation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import React from "react";
|
||||
|
||||
export function TerminalChatToolCallCommand({
|
||||
commandForDisplay,
|
||||
explanation,
|
||||
}: {
|
||||
commandForDisplay: string;
|
||||
explanation?: string;
|
||||
}): React.ReactElement {
|
||||
// -------------------------------------------------------------------------
|
||||
// Colorize diff output inside the command preview: we detect individual
|
||||
@@ -31,10 +33,45 @@ export function TerminalChatToolCallCommand({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text bold>Shell Command</Text>
|
||||
<Text bold color="green">
|
||||
Shell Command
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>$</Text> {colorizedCommand}
|
||||
</Text>
|
||||
{explanation && (
|
||||
<>
|
||||
<Text bold color="yellow">
|
||||
Explanation
|
||||
</Text>
|
||||
{explanation.split("\n").map((line, i) => {
|
||||
// Apply different styling to headings (numbered items)
|
||||
if (line.match(/^\d+\.\s+/)) {
|
||||
return (
|
||||
<Text key={i} bold color="cyan">
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
} else if (line.match(/^\s*\*\s+/)) {
|
||||
// Style bullet points
|
||||
return (
|
||||
<Text key={i} color="magenta">
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
} else if (line.match(/^(WARNING|CAUTION|NOTE):/i)) {
|
||||
// Style warnings
|
||||
return (
|
||||
<Text key={i} bold color="red">
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
return <Text key={i}>{line}</Text>;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -17,6 +16,8 @@ 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 { ReviewDecision } from "../../utils/agent/review.js";
|
||||
import { OPENAI_BASE_URL } from "../../utils/config.js";
|
||||
import { createInputItem } from "../../utils/input-utils.js";
|
||||
import { getAvailableModels } from "../../utils/model-utils.js";
|
||||
import { CLI_VERSION } from "../../utils/session.js";
|
||||
@@ -27,6 +28,7 @@ import HelpOverlay from "../help-overlay.js";
|
||||
import HistoryOverlay from "../history-overlay.js";
|
||||
import ModelOverlay from "../model-overlay.js";
|
||||
import { Box, Text } from "ink";
|
||||
import OpenAI from "openai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { inspect } from "util";
|
||||
|
||||
@@ -44,6 +46,77 @@ const colorsByPolicy: Record<ApprovalPolicy, ColorName | undefined> = {
|
||||
"full-auto": "green",
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates an explanation for a shell command using the OpenAI API.
|
||||
*
|
||||
* @param command The command to explain
|
||||
* @param model The model to use for generating the explanation
|
||||
* @returns A human-readable explanation of what the command does
|
||||
*/
|
||||
async function generateCommandExplanation(
|
||||
command: Array<string>,
|
||||
model: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Create a temporary OpenAI client
|
||||
const oai = new OpenAI({
|
||||
apiKey: process.env["OPENAI_API_KEY"],
|
||||
baseURL: OPENAI_BASE_URL,
|
||||
});
|
||||
|
||||
// Format the command for display
|
||||
const commandForDisplay = formatCommandForDisplay(command);
|
||||
|
||||
// Create a prompt that asks for an explanation with a more detailed system prompt
|
||||
const response = await oai.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are an expert in shell commands and terminal operations. Your task is to provide detailed, accurate explanations of shell commands that users are considering executing. Break down each part of the command, explain what it does, identify any potential risks or side effects, and explain why someone might want to run it. Be specific about what files or systems will be affected. If the command could potentially be harmful, make sure to clearly highlight those risks.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Please explain this shell command in detail: \`${commandForDisplay}\`\n\nProvide a structured explanation that includes:\n1. A brief overview of what the command does\n2. A breakdown of each part of the command (flags, arguments, etc.)\n3. What files, directories, or systems will be affected\n4. Any potential risks or side effects\n5. Why someone might want to run this command\n\nBe specific and technical - this explanation will help the user decide whether to approve or reject the command.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Extract the explanation from the response
|
||||
const explanation =
|
||||
response.choices[0]?.message.content || "Unable to generate explanation.";
|
||||
return explanation;
|
||||
} catch (error) {
|
||||
log(`Error generating command explanation: ${error}`);
|
||||
|
||||
// Improved error handling with more specific error information
|
||||
let errorMessage = "Unable to generate explanation due to an error.";
|
||||
|
||||
if (error instanceof Error) {
|
||||
// Include specific error message for better debugging
|
||||
errorMessage = `Unable to generate explanation: ${error.message}`;
|
||||
|
||||
// If it's an API error, check for more specific information
|
||||
if ("status" in error && typeof error.status === "number") {
|
||||
// Handle API-specific errors
|
||||
if (error.status === 401) {
|
||||
errorMessage =
|
||||
"Unable to generate explanation: API key is invalid or expired.";
|
||||
} else if (error.status === 429) {
|
||||
errorMessage =
|
||||
"Unable to generate explanation: Rate limit exceeded. Please try again later.";
|
||||
} else if (error.status >= 500) {
|
||||
errorMessage =
|
||||
"Unable to generate explanation: OpenAI service is currently unavailable. Please try again later.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
export default function TerminalChat({
|
||||
config,
|
||||
prompt: _initialPrompt,
|
||||
@@ -60,8 +133,12 @@ export default function TerminalChat({
|
||||
initialApprovalPolicy,
|
||||
);
|
||||
const [thinkingSeconds, setThinkingSeconds] = useState(0);
|
||||
const { requestConfirmation, confirmationPrompt, submitConfirmation } =
|
||||
useConfirmation();
|
||||
const {
|
||||
requestConfirmation,
|
||||
confirmationPrompt,
|
||||
explanation,
|
||||
submitConfirmation,
|
||||
} = useConfirmation();
|
||||
const [overlayMode, setOverlayMode] = useState<
|
||||
"none" | "history" | "model" | "approval" | "help"
|
||||
>("none");
|
||||
@@ -122,12 +199,36 @@ export default function TerminalChat({
|
||||
): Promise<CommandConfirmation> => {
|
||||
log(`getCommandConfirmation: ${command}`);
|
||||
const commandForDisplay = formatCommandForDisplay(command);
|
||||
const { decision: review, customDenyMessage } =
|
||||
await requestConfirmation(
|
||||
|
||||
// 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}`);
|
||||
|
||||
// Generate an explanation using the same model
|
||||
const explanation = await generateCommandExplanation(command, model);
|
||||
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 };
|
||||
},
|
||||
});
|
||||
@@ -282,6 +383,7 @@ export default function TerminalChat({
|
||||
isNew={Boolean(items.length === 0)}
|
||||
setLastResponseId={setLastResponseId}
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
explanation={explanation}
|
||||
submitConfirmation={(
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
|
||||
@@ -12,12 +12,17 @@ type ConfirmationResult = {
|
||||
type ConfirmationItem = {
|
||||
prompt: React.ReactNode;
|
||||
resolve: (result: ConfirmationResult) => void;
|
||||
explanation?: string;
|
||||
};
|
||||
|
||||
export function useConfirmation(): {
|
||||
submitConfirmation: (result: ConfirmationResult) => void;
|
||||
requestConfirmation: (prompt: React.ReactNode) => Promise<ConfirmationResult>;
|
||||
requestConfirmation: (
|
||||
prompt: React.ReactNode,
|
||||
explanation?: string,
|
||||
) => Promise<ConfirmationResult>;
|
||||
confirmationPrompt: React.ReactNode | null;
|
||||
explanation?: string;
|
||||
} {
|
||||
// The current prompt is just the head of the queue
|
||||
const [current, setCurrent] = useState<ConfirmationItem | null>(null);
|
||||
@@ -32,10 +37,10 @@ export function useConfirmation(): {
|
||||
|
||||
// Called whenever someone wants a confirmation
|
||||
const requestConfirmation = useCallback(
|
||||
(prompt: React.ReactNode) => {
|
||||
(prompt: React.ReactNode, explanation?: string) => {
|
||||
return new Promise<ConfirmationResult>((resolve) => {
|
||||
const wasEmpty = queueRef.current.length === 0;
|
||||
queueRef.current.push({ prompt, resolve });
|
||||
queueRef.current.push({ prompt, resolve, explanation });
|
||||
|
||||
// If the queue was empty, we need to kick off the first prompt
|
||||
if (wasEmpty) {
|
||||
@@ -56,6 +61,7 @@ export function useConfirmation(): {
|
||||
|
||||
return {
|
||||
confirmationPrompt: current?.prompt, // the prompt to render now
|
||||
explanation: current?.explanation, // the explanation to render if available
|
||||
requestConfirmation,
|
||||
submitConfirmation,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ export type CommandConfirmation = {
|
||||
review: ReviewDecision;
|
||||
applyPatch?: ApplyPatchCommand | undefined;
|
||||
customDenyMessage?: string;
|
||||
explanation?: string;
|
||||
};
|
||||
|
||||
const alreadyProcessedResponses = new Set();
|
||||
|
||||
@@ -309,7 +309,13 @@ async function askUserPermission(
|
||||
alwaysApprovedCommands.add(key);
|
||||
}
|
||||
|
||||
// Any decision other than an affirmative (YES / ALWAYS) aborts execution.
|
||||
// Handle EXPLAIN decision by returning null to continue with the normal flow
|
||||
// but with a flag to indicate that an explanation was requested
|
||||
if (decision === ReviewDecision.EXPLAIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Any decision other than an affirmative (YES / ALWAYS) or EXPLAIN aborts execution.
|
||||
if (decision !== ReviewDecision.YES && decision !== ReviewDecision.ALWAYS) {
|
||||
const note =
|
||||
decision === ReviewDecision.NO_CONTINUE
|
||||
|
||||
@@ -7,4 +7,8 @@ export enum ReviewDecision {
|
||||
* future identical instances for the remainder of the session.
|
||||
*/
|
||||
ALWAYS = "always",
|
||||
/**
|
||||
* User wants an explanation of what the command does before deciding.
|
||||
*/
|
||||
EXPLAIN = "explain",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user