Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/browser/components/tools/AskUserQuestionToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ import type {
AskUserQuestionQuestion,
AskUserQuestionToolArgs,
AskUserQuestionToolResult,
AskUserQuestionToolSuccessResult,
AskUserQuestionUiOnlyPayload,
ToolErrorResult,
} from "@/common/types/tools";
import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly";

const OTHER_VALUE = "__other__";

Expand Down Expand Up @@ -58,7 +59,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;
}
Expand Down Expand Up @@ -261,8 +262,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;
Expand Down
12 changes: 8 additions & 4 deletions src/browser/components/tools/BashToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
useToolExpansion,
getStatusDisplay,
formatDuration,
getToolOutputSeverity,
type ToolStatus,
} from "./shared/toolUtils";
import { cn } from "@/common/lib/utils";
Expand Down Expand Up @@ -192,6 +193,7 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
const showLiveOutput =
!isBackground && (status === "executing" || (Boolean(liveOutput) && !resultHasOutput));

const severity = getToolOutputSeverity(result);
const truncatedInfo = result && "truncated" in result ? result.truncated : undefined;

const handleToggle = () => {
Expand Down Expand Up @@ -224,11 +226,13 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
{result && ` • took ${formatDuration(result.wall_duration_ms)}`}
{!result && <ElapsedTimeDisplay startedAt={startedAt} isActive={isPending} />}
</span>
{result && <ExitCodeBadge exitCode={result.exitCode} className="ml-2" />}
{result && (
<ExitCodeBadge exitCode={result.exitCode} className="ml-2" severity={severity} />
)}
</>
)}
<StatusIndicator status={effectiveStatus}>
{getStatusDisplay(effectiveStatus)}
<StatusIndicator status={effectiveStatus} severity={severity}>
{getStatusDisplay(effectiveStatus, severity)}
</StatusIndicator>
{/* 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. */}
Expand Down Expand Up @@ -295,7 +299,7 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
{result.success === false && result.error && (
<DetailSection>
<DetailLabel>Error</DetailLabel>
<ErrorBox>{result.error}</ErrorBox>
<ErrorBox severity={severity}>{result.error}</ErrorBox>
</DetailSection>
)}

Expand Down
15 changes: 9 additions & 6 deletions src/browser/components/tools/FileEditToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
FileEditReplaceLinesToolArgs,
FileEditReplaceLinesToolResult,
} from "@/common/types/tools";
import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly";
import {
ToolContainer,
ToolHeader,
Expand Down Expand Up @@ -104,18 +105,20 @@ export const FileEditToolCall: React.FC<FileEditToolCallProps> = ({
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
const { copied, copyToClipboard } = useCopyToClipboard();

// 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",
Expand All @@ -139,7 +142,7 @@ export const FileEditToolCall: React.FC<FileEditToolCallProps> = ({
<span className="font-monospace truncate">{filePath}</span>
</div>
</div>
{!(result && result.success && result.diff) && (
{!(result && result.success && diff) && (
<StatusIndicator status={status}>{getStatusDisplay(status)}</StatusIndicator>
)}
{kebabMenuItems.length > 0 && (
Expand All @@ -161,15 +164,15 @@ export const FileEditToolCall: React.FC<FileEditToolCallProps> = ({
)}

{result.success &&
result.diff &&
diff &&
(showRaw ? (
<DiffContainer>
<pre className="font-monospace m-0 text-[11px] leading-[1.4] break-words whitespace-pre-wrap">
{result.diff}
{diff}
</pre>
</DiffContainer>
) : (
renderDiff(result.diff, filePath, onReviewNote)
renderDiff(diff, filePath, onReviewNote)
))}
</>
)}
Expand Down
32 changes: 22 additions & 10 deletions src/browser/components/tools/shared/ToolPrimitives.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ToolOutputSeverity } from "@/common/types/tools";
import React from "react";
import { cn } from "@/common/lib/utils";
import { Tooltip, TooltipTrigger, TooltipContent } from "../../ui/tooltip";
Expand Down Expand Up @@ -58,16 +59,17 @@ export const ToolName: React.FC<React.HTMLAttributes<HTMLSpanElement>> = ({

interface StatusIndicatorProps extends React.HTMLAttributes<HTMLSpanElement> {
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":
Expand All @@ -79,6 +81,7 @@ const getStatusColor = (status: string) => {

export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
status,
severity,
className,
children,
...props
Expand All @@ -87,7 +90,7 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
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}
Expand Down Expand Up @@ -182,13 +185,17 @@ export const ToolIcon: React.FC<ToolIconProps> = ({ emoji, toolName }) => (
/**
* Error display box with danger styling
*/
export const ErrorBox: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
interface ErrorBoxProps extends React.HTMLAttributes<HTMLDivElement> {
severity?: ToolOutputSeverity;
}

export const ErrorBox: React.FC<ErrorBoxProps> = ({ className, severity, ...props }) => (
<div
className={cn(
"text-danger bg-danger-overlay border-danger rounded border-l-2 px-2 py-1.5 text-[11px]",
"rounded border-l-2 px-2 py-1.5 text-[11px]",
severity === "soft"
? "text-warning bg-warning-overlay border-warning"
: "text-danger bg-danger-overlay border-danger",
className
)}
{...props}
Expand All @@ -201,13 +208,18 @@ export const ErrorBox: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
interface ExitCodeBadgeProps {
exitCode: number;
className?: string;
severity?: ToolOutputSeverity;
}

export const ExitCodeBadge: React.FC<ExitCodeBadgeProps> = ({ exitCode, className }) => (
export const ExitCodeBadge: React.FC<ExitCodeBadgeProps> = ({ exitCode, className, severity }) => (
<span
className={cn(
"inline-block shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium whitespace-nowrap",
exitCode === 0 ? "bg-success text-on-success" : "bg-danger text-on-danger",
exitCode === 0
? "bg-success text-on-success"
: severity === "soft"
? "bg-warning text-on-warning"
: "bg-danger text-on-danger",
className
)}
>
Expand Down
19 changes: 17 additions & 2 deletions src/browser/components/tools/shared/toolUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from "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
Expand All @@ -26,7 +27,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 (
Expand All @@ -41,6 +45,13 @@ export function getStatusDisplay(status: ToolStatus): React.ReactNode {
</>
);
case "failed":
if (severity === "soft") {
return (
<>
âš <span className="status-text"> warning</span>
</>
);
}
return (
<>
✗<span className="status-text"> failed</span>
Expand Down Expand Up @@ -88,6 +99,10 @@ export function formatDuration(ms: number): string {
return `${Math.round(ms / 3600000)}h`;
}

export function getToolOutputSeverity(output: unknown): ToolOutputSeverity | undefined {
return getToolOutputUiOnly(output)?.severity;
}

/**
* Type guard for ToolErrorResult shape: { success: false, error: string }.
* Use this when you need type narrowing to access error.
Expand Down
3 changes: 3 additions & 0 deletions src/browser/stores/GitStatusStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { STORAGE_KEYS, WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"
import { useSyncExternalStore } from "react";
import { MapStore } from "./MapStore";
import { isSSHRuntime } from "@/common/types/runtime";
import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly";
import { RefreshController } from "@/browser/utils/RefreshController";

/**
Expand Down Expand Up @@ -324,8 +325,10 @@ export class GitStatusStore {
}

if (!result.data.success) {
const uiOnlySeverity = getToolOutputUiOnly(result.data)?.severity;
// Don't log output overflow errors at all (common in large repos, handled gracefully)
if (
uiOnlySeverity !== "soft" &&
!result.data.error?.includes("OUTPUT TRUNCATED") &&
!result.data.error?.includes("OUTPUT OVERFLOW")
) {
Expand Down
10 changes: 8 additions & 2 deletions src/browser/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
} from "@/common/types/stream";
import type { LanguageModelV2Usage } from "@ai-sdk/provider";
import type { TodoItem, StatusSetToolResult, NotifyToolResult } from "@/common/types/tools";
import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly";

import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/common/orpc/types";
import { isInitStart, isInitOutput, isInitEnd, isMuxMessage } from "@/common/orpc/types";
Expand Down Expand Up @@ -1416,8 +1417,13 @@ export class StreamingMessageAggregator {
// Handle browser notifications when Electron wasn't available
if (toolName === "notify" && hasSuccessResult(output)) {
const result = output as Extract<NotifyToolResult, { success: true }>;
if (result.notifiedVia === "browser") {
this.sendBrowserNotification(result.title, result.message, result.workspaceId);
const uiOnlyNotify = getToolOutputUiOnly(output)?.notify;
const legacyNotify = output as { notifiedVia?: string; workspaceId?: string };
const notifiedVia = uiOnlyNotify?.notifiedVia ?? legacyNotify.notifiedVia;
const workspaceId = uiOnlyNotify?.workspaceId ?? legacyNotify.workspaceId;

if (notifiedVia === "browser") {
this.sendBrowserNotification(result.title, result.message, workspaceId);
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/browser/utils/messages/applyToolOutputRedaction.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Apply centralized tool-output redaction to a list of MuxMessages.
* Strip UI-only tool output before sending to providers.
* Produces a cloned array safe for sending to providers without touching persisted history/UI.
*/
import type { MuxMessage } from "@/common/types/message";
import { redactToolOutput } from "./toolOutputRedaction";
import { stripToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly";

export function applyToolOutputRedaction(messages: MuxMessage[]): MuxMessage[] {
return messages.map((msg) => {
Expand All @@ -15,7 +15,7 @@ export function applyToolOutputRedaction(messages: MuxMessage[]): MuxMessage[] {

return {
...part,
output: redactToolOutput(part.toolName, part.output),
output: stripToolOutputUiOnly(part.output),
};
});

Expand Down
Loading
Loading