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

![improved_shell_explanation_demo](https://github.com/user-attachments/assets/05923481-29db-4eba-9cc6-5e92301d2be0)


## 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:
Brayden Moon
2025-04-18 06:28:58 +10:00
committed by GitHub
parent 693a6f96cf
commit f3d085aaf8
11 changed files with 352 additions and 68 deletions

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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>
);

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>;
}
})}
</>
)}
</>
);
}

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -32,6 +32,7 @@ export type CommandConfirmation = {
review: ReviewDecision;
applyPatch?: ApplyPatchCommand | undefined;
customDenyMessage?: string;
explanation?: string;
};
const alreadyProcessedResponses = new Set();

View File

@@ -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

View File

@@ -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",
}