diff --git a/src/cli/App.tsx b/src/cli/App.tsx index c53837c..d048262 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -78,6 +78,7 @@ import { import { AgentSelector } from "./components/AgentSelector"; // ApprovalDialog removed - all approvals now render inline import { ApprovalPreview } from "./components/ApprovalPreview"; +import { ApprovalSwitch } from "./components/ApprovalSwitch"; import { AssistantMessage } from "./components/AssistantMessageRich"; import { BashCommandMessage } from "./components/BashCommandMessage"; import { CommandMessage } from "./components/CommandMessage"; @@ -87,12 +88,6 @@ import { colors } from "./components/colors"; import { ErrorMessage } from "./components/ErrorMessageRich"; import { FeedbackDialog } from "./components/FeedbackDialog"; import { HelpDialog } from "./components/HelpDialog"; -import { InlineBashApproval } from "./components/InlineBashApproval"; -import { InlineEnterPlanModeApproval } from "./components/InlineEnterPlanModeApproval"; -import { InlineFileEditApproval } from "./components/InlineFileEditApproval"; -import { InlineGenericApproval } from "./components/InlineGenericApproval"; -import { InlineQuestionApproval } from "./components/InlineQuestionApproval"; -import { InlineTaskApproval } from "./components/InlineTaskApproval"; import { Input } from "./components/InputRich"; import { McpSelector } from "./components/McpSelector"; import { MemoryViewer } from "./components/MemoryViewer"; @@ -108,7 +103,6 @@ import { ResumeSelector } from "./components/ResumeSelector"; import { formatUsageStats } from "./components/SessionStats"; // InlinePlanApproval kept for easy rollback if needed // import { InlinePlanApproval } from "./components/InlinePlanApproval"; -import { StaticPlanApproval } from "./components/StaticPlanApproval"; import { StatusMessage } from "./components/StatusMessage"; import { SubagentGroupDisplay } from "./components/SubagentGroupDisplay"; import { SubagentGroupStatic } from "./components/SubagentGroupStatic"; @@ -7138,269 +7132,32 @@ Plan file path: ${planFilePath}`; return null; } - // Check if this tool call matches the current ExitPlanMode approval - const isExitPlanModeApproval = - ln.kind === "tool_call" && - currentApproval?.toolName === "ExitPlanMode" && - ln.toolCallId === currentApproval?.toolCallId; - - // Check if this tool call matches a file edit/write/patch approval - const isFileEditApproval = + // Check if this tool call matches the current approval awaiting user input + const matchesCurrentApproval = ln.kind === "tool_call" && currentApproval && - (isFileEditTool(currentApproval.toolName) || - isFileWriteTool(currentApproval.toolName) || - isPatchTool(currentApproval.toolName)) && ln.toolCallId === currentApproval.toolCallId; - // Check if this tool call matches a bash/shell approval - const isBashApproval = - ln.kind === "tool_call" && - currentApproval && - isShellTool(currentApproval.toolName) && - ln.toolCallId === currentApproval.toolCallId; - - // Check if this tool call matches an EnterPlanMode approval - const isEnterPlanModeApproval = - ln.kind === "tool_call" && - currentApproval?.toolName === "EnterPlanMode" && - ln.toolCallId === currentApproval?.toolCallId; - - // Check if this tool call matches an AskUserQuestion approval - const isAskUserQuestionApproval = - ln.kind === "tool_call" && - currentApproval?.toolName === "AskUserQuestion" && - ln.toolCallId === currentApproval?.toolCallId; - - // Check if this tool call matches a Task tool approval - const isTaskToolApproval = - ln.kind === "tool_call" && - currentApproval && - isTaskTool(currentApproval.toolName) && - ln.toolCallId === currentApproval.toolCallId; - - // Parse file edit info from approval args - const getFileEditInfo = () => { - if (!isFileEditApproval || !currentApproval) return null; - try { - const args = JSON.parse( - currentApproval.toolArgs || "{}", - ); - - // For patch tools, use the input field - if (isPatchTool(currentApproval.toolName)) { - return { - toolName: currentApproval.toolName, - filePath: "", // Patch can have multiple files - patchInput: args.input as string | undefined, - toolCallId: ln.toolCallId, - }; - } - - // For regular file edit/write tools - return { - toolName: currentApproval.toolName, - filePath: String(args.file_path || ""), - content: args.content as string | undefined, - oldString: args.old_string as string | undefined, - newString: args.new_string as string | undefined, - replaceAll: args.replace_all as boolean | undefined, - edits: args.edits as - | Array<{ - old_string: string; - new_string: string; - replace_all?: boolean; - }> - | undefined, - toolCallId: ln.toolCallId, - }; - } catch { - return null; - } - }; - - const fileEditInfo = getFileEditInfo(); - - // Parse bash info from approval args - const getBashInfo = () => { - if (!isBashApproval || !currentApproval) return null; - try { - const args = JSON.parse( - currentApproval.toolArgs || "{}", - ); - const t = currentApproval.toolName.toLowerCase(); - - // Handle different bash tool arg formats - let command = ""; - let description = ""; - - if (t === "shell") { - // Shell tool uses command array and justification - const cmdVal = args.command; - command = Array.isArray(cmdVal) - ? cmdVal.join(" ") - : typeof cmdVal === "string" - ? cmdVal - : "(no command)"; - description = - typeof args.justification === "string" - ? args.justification - : ""; - } else { - // Bash/shell_command uses command string and description - command = - typeof args.command === "string" - ? args.command - : "(no command)"; - description = - typeof args.description === "string" - ? args.description - : ""; - } - - return { - toolName: currentApproval.toolName, - command, - description, - }; - } catch { - return null; - } - }; - - const bashInfo = getBashInfo(); - - // Parse Task tool info from approval args - const getTaskInfo = () => { - if (!isTaskToolApproval || !currentApproval) return null; - try { - const args = JSON.parse( - currentApproval.toolArgs || "{}", - ); - return { - subagentType: - typeof args.subagent_type === "string" - ? args.subagent_type - : "unknown", - description: - typeof args.description === "string" - ? args.description - : "(no description)", - prompt: - typeof args.prompt === "string" - ? args.prompt - : "(no prompt)", - model: - typeof args.model === "string" - ? args.model - : undefined, - }; - } catch { - return null; - } - }; - - const taskInfo = getTaskInfo(); - return ( - {/* For ExitPlanMode awaiting approval: render StaticPlanApproval */} - {/* Plan preview is eagerly committed to staticItems, so this only shows options */} - {isExitPlanModeApproval ? ( - handlePlanApprove(false)} - onApproveAndAcceptEdits={() => - handlePlanApprove(true) - } - onKeepPlanning={handlePlanKeepPlanning} + {matchesCurrentApproval ? ( + - ) : isFileEditApproval && fileEditInfo ? ( - handleApproveCurrent(diffs)} - onApproveAlways={(scope, diffs) => - handleApproveAlways(scope, diffs) - } - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} - isFocused={true} - approveAlwaysText={ - currentApprovalContext?.approveAlwaysText - } - allowPersistence={ - currentApprovalContext?.allowPersistence ?? true - } - /> - ) : isBashApproval && bashInfo ? ( - handleApproveCurrent()} - onApproveAlways={(scope) => - handleApproveAlways(scope) - } - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} - isFocused={true} - approveAlwaysText={ - currentApprovalContext?.approveAlwaysText - } - allowPersistence={ - currentApprovalContext?.allowPersistence ?? true - } - /> - ) : isEnterPlanModeApproval ? ( - - ) : isAskUserQuestionApproval ? ( - - ) : isTaskToolApproval && taskInfo ? ( - handleApproveCurrent()} - onApproveAlways={(scope) => - handleApproveAlways(scope) - } - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} - isFocused={true} - approveAlwaysText={ - currentApprovalContext?.approveAlwaysText - } - allowPersistence={ - currentApprovalContext?.allowPersistence ?? true - } - /> - ) : ln.kind === "tool_call" && - currentApproval && - ln.toolCallId === currentApproval.toolCallId ? ( - // Generic fallback for any other tool needing approval - handleApproveCurrent()} - onApproveAlways={(scope) => - handleApproveAlways(scope) - } - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} isFocused={true} approveAlwaysText={ currentApprovalContext?.approveAlwaysText @@ -7465,68 +7222,26 @@ Plan file path: ${planFilePath}`; {/* Fallback approval UI when backfill is disabled (no liveItems) */} {liveItems.length === 0 && currentApproval && ( - {isTaskTool(currentApproval.toolName) ? ( - { - try { - const args = JSON.parse( - currentApproval.toolArgs || "{}", - ); - return { - subagentType: - typeof args.subagent_type === "string" - ? args.subagent_type - : "unknown", - description: - typeof args.description === "string" - ? args.description - : "(no description)", - prompt: - typeof args.prompt === "string" - ? args.prompt - : "(no prompt)", - model: - typeof args.model === "string" - ? args.model - : undefined, - }; - } catch { - return { - subagentType: "unknown", - description: "(parse error)", - prompt: "(parse error)", - }; - } - })()} - onApprove={() => handleApproveCurrent()} - onApproveAlways={(scope) => handleApproveAlways(scope)} - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} - isFocused={true} - approveAlwaysText={ - currentApprovalContext?.approveAlwaysText - } - allowPersistence={ - currentApprovalContext?.allowPersistence ?? true - } - /> - ) : ( - handleApproveCurrent()} - onApproveAlways={(scope) => handleApproveAlways(scope)} - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} - isFocused={true} - approveAlwaysText={ - currentApprovalContext?.approveAlwaysText - } - allowPersistence={ - currentApprovalContext?.allowPersistence ?? true - } - /> - )} + )} diff --git a/src/cli/components/ApprovalSwitch.tsx b/src/cli/components/ApprovalSwitch.tsx new file mode 100644 index 0000000..f78c35a --- /dev/null +++ b/src/cli/components/ApprovalSwitch.tsx @@ -0,0 +1,336 @@ +import { memo } from "react"; +import type { AdvancedDiffSuccess } from "../helpers/diff"; +import type { ApprovalRequest } from "../helpers/stream"; +import { + isFileEditTool, + isFileWriteTool, + isPatchTool, + isShellTool, + isTaskTool, +} from "../helpers/toolNameMapping.js"; +import { InlineBashApproval } from "./InlineBashApproval"; +import { InlineEnterPlanModeApproval } from "./InlineEnterPlanModeApproval"; +import { InlineFileEditApproval } from "./InlineFileEditApproval"; +import { InlineGenericApproval } from "./InlineGenericApproval"; +import { InlineQuestionApproval } from "./InlineQuestionApproval"; +import { InlineTaskApproval } from "./InlineTaskApproval"; +import { StaticPlanApproval } from "./StaticPlanApproval"; + +// Types for parsed tool data +type BashInfo = { + toolName: string; + command: string; + description?: string; +}; + +type FileEditInfo = { + toolName: string; + filePath: string; + content?: string; + oldString?: string; + newString?: string; + replaceAll?: boolean; + edits?: Array<{ + old_string: string; + new_string: string; + replace_all?: boolean; + }>; + patchInput?: string; + toolCallId?: string; +}; + +type TaskInfo = { + subagentType: string; + description: string; + prompt: string; + model?: string; +}; + +type Question = { + question: string; + header: string; + options: Array<{ label: string; description: string }>; + multiSelect: boolean; +}; + +type Props = { + approval: ApprovalRequest; + + // Common handlers + onApprove: (diffs?: Map) => void; + onApproveAlways: ( + scope: "project" | "session", + diffs?: Map, + ) => void; + onDeny: (reason: string) => void; + onCancel?: () => void; + isFocused?: boolean; + approveAlwaysText?: string; + allowPersistence?: boolean; + + // Special handlers for ExitPlanMode + onPlanApprove?: (acceptEdits: boolean) => void; + onPlanKeepPlanning?: (reason: string) => void; + + // Special handlers for AskUserQuestion + onQuestionSubmit?: (answers: Record) => void; + + // Special handlers for EnterPlanMode + onEnterPlanModeApprove?: () => void; + onEnterPlanModeReject?: () => void; + + // External data for FileEdit approvals + precomputedDiff?: AdvancedDiffSuccess; + allDiffs?: Map; +}; + +// Parse bash info from approval args +function getBashInfo(approval: ApprovalRequest): BashInfo | null { + try { + const args = JSON.parse(approval.toolArgs || "{}"); + const t = approval.toolName.toLowerCase(); + + let command = ""; + let description = ""; + + if (t === "shell") { + // Shell tool uses command array and justification + const cmdVal = args.command; + command = Array.isArray(cmdVal) + ? cmdVal.join(" ") + : typeof cmdVal === "string" + ? cmdVal + : "(no command)"; + description = + typeof args.justification === "string" ? args.justification : ""; + } else { + // Bash/shell_command uses command string and description + command = + typeof args.command === "string" ? args.command : "(no command)"; + description = + typeof args.description === "string" ? args.description : ""; + } + + return { + toolName: approval.toolName, + command, + description, + }; + } catch { + return null; + } +} + +// Parse file edit info from approval args +function getFileEditInfo(approval: ApprovalRequest): FileEditInfo | null { + try { + const args = JSON.parse(approval.toolArgs || "{}"); + + // For patch tools, use the input field + if (isPatchTool(approval.toolName)) { + return { + toolName: approval.toolName, + filePath: "", // Patch can have multiple files + patchInput: args.input as string | undefined, + toolCallId: approval.toolCallId, + }; + } + + // For regular file edit/write tools + return { + toolName: approval.toolName, + filePath: String(args.file_path || ""), + content: args.content as string | undefined, + oldString: args.old_string as string | undefined, + newString: args.new_string as string | undefined, + replaceAll: args.replace_all as boolean | undefined, + edits: args.edits as FileEditInfo["edits"], + toolCallId: approval.toolCallId, + }; + } catch { + return null; + } +} + +// Parse task info from approval args +function getTaskInfo(approval: ApprovalRequest): TaskInfo | null { + try { + const args = JSON.parse(approval.toolArgs || "{}"); + return { + subagentType: + typeof args.subagent_type === "string" ? args.subagent_type : "unknown", + description: + typeof args.description === "string" + ? args.description + : "(no description)", + prompt: typeof args.prompt === "string" ? args.prompt : "(no prompt)", + model: typeof args.model === "string" ? args.model : undefined, + }; + } catch { + return { + subagentType: "unknown", + description: "(parse error)", + prompt: "(parse error)", + }; + } +} + +// Parse questions from AskUserQuestion args +function getQuestions(approval: ApprovalRequest): Question[] { + try { + const args = JSON.parse(approval.toolArgs || "{}"); + return (args.questions as Question[]) || []; + } catch { + return []; + } +} + +/** + * ApprovalSwitch - Unified approval component that renders the appropriate + * specialized approval UI based on tool type. + * + * This consolidates the approval rendering logic that was previously duplicated + * in the transcript rendering and fallback UI paths. + */ +export const ApprovalSwitch = memo( + ({ + approval, + onApprove, + onApproveAlways, + onDeny, + onCancel, + isFocused = true, + approveAlwaysText, + allowPersistence = true, + onPlanApprove, + onPlanKeepPlanning, + onQuestionSubmit, + onEnterPlanModeApprove, + onEnterPlanModeReject, + precomputedDiff, + allDiffs, + }: Props) => { + const toolName = approval.toolName; + + // 1. ExitPlanMode → StaticPlanApproval + if (toolName === "ExitPlanMode" && onPlanApprove && onPlanKeepPlanning) { + return ( + onPlanApprove(false)} + onApproveAndAcceptEdits={() => onPlanApprove(true)} + onKeepPlanning={onPlanKeepPlanning} + onCancel={onCancel ?? (() => {})} + isFocused={isFocused} + /> + ); + } + + // 2. File edit/write/patch tools → InlineFileEditApproval + if ( + isFileEditTool(toolName) || + isFileWriteTool(toolName) || + isPatchTool(toolName) + ) { + const fileEditInfo = getFileEditInfo(approval); + if (fileEditInfo) { + return ( + onApprove(diffs)} + onApproveAlways={(scope, diffs) => onApproveAlways(scope, diffs)} + onDeny={onDeny} + onCancel={onCancel} + isFocused={isFocused} + approveAlwaysText={approveAlwaysText} + allowPersistence={allowPersistence} + /> + ); + } + } + + // 3. Shell/Bash tools → InlineBashApproval + if (isShellTool(toolName)) { + const bashInfo = getBashInfo(approval); + if (bashInfo) { + return ( + onApprove()} + onApproveAlways={(scope) => onApproveAlways(scope)} + onDeny={onDeny} + onCancel={onCancel} + isFocused={isFocused} + approveAlwaysText={approveAlwaysText} + allowPersistence={allowPersistence} + /> + ); + } + } + + // 4. EnterPlanMode → InlineEnterPlanModeApproval + if ( + toolName === "EnterPlanMode" && + onEnterPlanModeApprove && + onEnterPlanModeReject + ) { + return ( + + ); + } + + // 5. AskUserQuestion → InlineQuestionApproval + if (toolName === "AskUserQuestion" && onQuestionSubmit) { + const questions = getQuestions(approval); + return ( + + ); + } + + // 6. Task tool → InlineTaskApproval + if (isTaskTool(toolName)) { + const taskInfo = getTaskInfo(approval); + if (taskInfo) { + return ( + onApprove()} + onApproveAlways={(scope) => onApproveAlways(scope)} + onDeny={onDeny} + onCancel={onCancel} + isFocused={isFocused} + approveAlwaysText={approveAlwaysText} + allowPersistence={allowPersistence} + /> + ); + } + } + + // 7. Fallback → InlineGenericApproval + return ( + onApprove()} + onApproveAlways={(scope) => onApproveAlways(scope)} + onDeny={onDeny} + onCancel={onCancel} + isFocused={isFocused} + approveAlwaysText={approveAlwaysText} + allowPersistence={allowPersistence} + /> + ); + }, +); + +ApprovalSwitch.displayName = "ApprovalSwitch";