From fa75526c13e6ce621b97f14a3e840839d3270b20 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 17 Jan 2026 18:38:34 -0600 Subject: [PATCH 1/4] refactor: centralize tool ui-only output --- .../tools/AskUserQuestionToolCall.tsx | 10 +- src/browser/components/tools/BashToolCall.tsx | 13 +- .../components/tools/FileEditToolCall.tsx | 15 +- .../tools/shared/ToolPrimitives.tsx | 32 ++- .../components/tools/shared/toolUtils.tsx | 19 +- src/browser/stores/GitStatusStore.ts | 3 + .../messages/StreamingMessageAggregator.ts | 10 +- .../messages/applyToolOutputRedaction.ts | 6 +- .../utils/messages/toolOutputRedaction.ts | 185 ------------------ src/common/orpc/schemas/tools.ts | 83 +++++--- src/common/types/tools.ts | 42 +++- .../utils/tools/askUserQuestionSummary.ts | 9 + src/common/utils/tools/toolDefinitions.ts | 120 +++++++++--- src/common/utils/tools/toolOutputUiOnly.ts | 157 +++++++++++++++ src/node/services/mock/mockAiRouter.ts | 6 +- src/node/services/tools/ask_user_question.ts | 31 ++- src/node/services/tools/bash.ts | 3 + src/node/services/tools/file_edit_insert.ts | 13 +- .../services/tools/file_edit_operation.ts | 13 +- .../services/tools/file_edit_replace_lines.ts | 8 +- src/node/services/tools/notify.test.ts | 6 +- src/node/services/tools/notify.ts | 16 +- src/node/services/workspaceService.ts | 10 +- 23 files changed, 508 insertions(+), 302 deletions(-) delete mode 100644 src/browser/utils/messages/toolOutputRedaction.ts create mode 100644 src/common/utils/tools/askUserQuestionSummary.ts create mode 100644 src/common/utils/tools/toolOutputUiOnly.ts diff --git a/src/browser/components/tools/AskUserQuestionToolCall.tsx b/src/browser/components/tools/AskUserQuestionToolCall.tsx index 14fc7b6c25..a3187a7cd5 100644 --- a/src/browser/components/tools/AskUserQuestionToolCall.tsx +++ b/src/browser/components/tools/AskUserQuestionToolCall.tsx @@ -27,9 +27,10 @@ import type { AskUserQuestionQuestion, AskUserQuestionToolArgs, AskUserQuestionToolResult, - AskUserQuestionToolSuccessResult, + AskUserQuestionUiOnlyPayload, ToolErrorResult, } from "@/common/types/tools"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; const OTHER_VALUE = "__other__"; @@ -59,7 +60,7 @@ function unwrapJsonContainer(value: unknown): unknown { return value; } -function isAskUserQuestionToolSuccessResult(val: unknown): val is AskUserQuestionToolSuccessResult { +function isAskUserQuestionPayload(val: unknown): val is AskUserQuestionUiOnlyPayload { if (!val || typeof val !== "object") { return false; } @@ -262,8 +263,11 @@ export function AskUserQuestionToolCall(props: { return unwrapJsonContainer(props.result); }, [props.result]); + const uiOnlyPayload = getToolOutputUiOnly(resultUnwrapped)?.ask_user_question; + const successResult = - resultUnwrapped && isAskUserQuestionToolSuccessResult(resultUnwrapped) ? resultUnwrapped : null; + uiOnlyPayload ?? + (resultUnwrapped && isAskUserQuestionPayload(resultUnwrapped) ? resultUnwrapped : null); const errorResult = resultUnwrapped && isToolErrorResult(resultUnwrapped) ? resultUnwrapped : null; diff --git a/src/browser/components/tools/BashToolCall.tsx b/src/browser/components/tools/BashToolCall.tsx index 2c9c7ee544..7864d23713 100644 --- a/src/browser/components/tools/BashToolCall.tsx +++ b/src/browser/components/tools/BashToolCall.tsx @@ -19,6 +19,7 @@ import { useToolExpansion, getStatusDisplay, formatDuration, + getToolOutputSeverity, type ToolStatus, } from "./shared/toolUtils"; import { cn } from "@/common/lib/utils"; @@ -195,6 +196,7 @@ export const BashToolCall: React.FC = ({ const showLiveOutput = !isBackground && (status === "executing" || (Boolean(liveOutput) && !resultHasOutput)); + const severity = getToolOutputSeverity(result); const canSendToBackground = Boolean( toolCallId && workspaceId && foregroundBashToolCallIds.has(toolCallId) ); @@ -204,7 +206,6 @@ export const BashToolCall: React.FC = ({ sendToBackground(toolCallId); } : undefined; - const truncatedInfo = result && "truncated" in result ? result.truncated : undefined; const handleToggle = () => { @@ -254,11 +255,13 @@ export const BashToolCall: React.FC = ({ {result && ` • took ${formatDuration(result.wall_duration_ms)}`} {!result && } - {result && } + {result && ( + + )} )} - - {getStatusDisplay(effectiveStatus)} + + {getStatusDisplay(effectiveStatus, severity)} {/* Show "Background" button when bash is executing and can be sent to background. Use invisible when executing but not yet confirmed as foreground to avoid layout flash. */} @@ -334,7 +337,7 @@ export const BashToolCall: React.FC = ({ {result.success === false && result.error && ( Error - {result.error} + {result.error} )} diff --git a/src/browser/components/tools/FileEditToolCall.tsx b/src/browser/components/tools/FileEditToolCall.tsx index c96b9b53ae..dfd648aa83 100644 --- a/src/browser/components/tools/FileEditToolCall.tsx +++ b/src/browser/components/tools/FileEditToolCall.tsx @@ -9,6 +9,7 @@ import type { FileEditReplaceLinesToolArgs, FileEditReplaceLinesToolResult, } from "@/common/types/tools"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; import { ToolContainer, ToolHeader, @@ -104,6 +105,8 @@ export const FileEditToolCall: React.FC = ({ const { expanded, toggleExpanded } = useToolExpansion(initialExpanded); const [showRaw, setShowRaw] = React.useState(false); + const uiOnlyDiff = getToolOutputUiOnly(result)?.file_edit?.diff; + const diff = result && result.success ? (uiOnlyDiff ?? result.diff) : undefined; const filePath = "file_path" in args ? args.file_path : undefined; // Copy to clipboard with feedback @@ -111,11 +114,11 @@ export const FileEditToolCall: React.FC = ({ // Build kebab menu items for successful edits with diffs const kebabMenuItems: KebabMenuItem[] = - result && result.success && result.diff + result && result.success && diff ? [ { label: copied ? "Copied" : "Copy Patch", - onClick: () => void copyToClipboard(result.diff), + onClick: () => void copyToClipboard(diff), }, { label: showRaw ? "Show Parsed" : "Show Patch", @@ -139,7 +142,7 @@ export const FileEditToolCall: React.FC = ({ {filePath} - {!(result && result.success && result.diff) && ( + {!(result && result.success && diff) && ( {getStatusDisplay(status)} )} {kebabMenuItems.length > 0 && ( @@ -161,15 +164,15 @@ export const FileEditToolCall: React.FC = ({ )} {result.success && - result.diff && + diff && (showRaw ? (
-                      {result.diff}
+                      {diff}
                     
) : ( - renderDiff(result.diff, filePath, onReviewNote) + renderDiff(diff, filePath, onReviewNote) ))} )} diff --git a/src/browser/components/tools/shared/ToolPrimitives.tsx b/src/browser/components/tools/shared/ToolPrimitives.tsx index 848f0cdf37..7eeeef737a 100644 --- a/src/browser/components/tools/shared/ToolPrimitives.tsx +++ b/src/browser/components/tools/shared/ToolPrimitives.tsx @@ -1,3 +1,4 @@ +import type { ToolOutputSeverity } from "@/common/types/tools"; import React from "react"; import { cn } from "@/common/lib/utils"; import type { LucideIcon } from "lucide-react"; @@ -71,16 +72,17 @@ export const ToolName: React.FC> = ({ interface StatusIndicatorProps extends React.HTMLAttributes { status: string; + severity?: ToolOutputSeverity; } -const getStatusColor = (status: string) => { +const getStatusColor = (status: string, severity?: ToolOutputSeverity) => { switch (status) { case "executing": return "text-pending"; case "completed": return "text-success"; case "failed": - return "text-danger"; + return severity === "soft" ? "text-warning" : "text-danger"; case "interrupted": return "text-interrupted"; case "backgrounded": @@ -92,6 +94,7 @@ const getStatusColor = (status: string) => { export const StatusIndicator: React.FC = ({ status, + severity, className, children, ...props @@ -100,7 +103,7 @@ export const StatusIndicator: React.FC = ({ className={cn( "text-[10px] ml-auto opacity-80 whitespace-nowrap shrink-0", "[&_.status-text]:inline [@container(max-width:350px)]:[&_.status-text]:hidden", - getStatusColor(status), + getStatusColor(status, severity), className )} {...props} @@ -230,13 +233,17 @@ export const ToolIcon: React.FC = ({ toolName, emoji, className } /** * Error display box with danger styling */ -export const ErrorBox: React.FC> = ({ - className, - ...props -}) => ( +interface ErrorBoxProps extends React.HTMLAttributes { + severity?: ToolOutputSeverity; +} + +export const ErrorBox: React.FC = ({ className, severity, ...props }) => (
> = ({ interface ExitCodeBadgeProps { exitCode: number; className?: string; + severity?: ToolOutputSeverity; } -export const ExitCodeBadge: React.FC = ({ exitCode, className }) => ( +export const ExitCodeBadge: React.FC = ({ exitCode, className, severity }) => ( diff --git a/src/browser/components/tools/shared/toolUtils.tsx b/src/browser/components/tools/shared/toolUtils.tsx index c8cb26f9f1..9402154f39 100644 --- a/src/browser/components/tools/shared/toolUtils.tsx +++ b/src/browser/components/tools/shared/toolUtils.tsx @@ -1,7 +1,8 @@ import React, { useState } from "react"; import { AlertTriangle, Check, CircleDot, X } from "lucide-react"; +import type { ToolErrorResult, ToolOutputSeverity } from "@/common/types/tools"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; import { LoadingDots } from "./ToolPrimitives"; -import type { ToolErrorResult } from "@/common/types/tools"; /** * Shared utilities and hooks for tool components @@ -27,7 +28,10 @@ export function useToolExpansion(initialExpanded = false) { /** * Get display element for tool status */ -export function getStatusDisplay(status: ToolStatus): React.ReactNode { +export function getStatusDisplay( + status: ToolStatus, + severity?: ToolOutputSeverity +): React.ReactNode { switch (status) { case "executing": return ( @@ -43,6 +47,13 @@ export function getStatusDisplay(status: ToolStatus): React.ReactNode { ); case "failed": + if (severity === "soft") { + return ( + <> + ⚠ warning + + ); + } return ( <>