Skip to content
Merged
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 @@ -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__";

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion src/browser/components/tools/BashToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
sendToBackground(toolCallId);
}
: undefined;

const truncatedInfo = result && "truncated" in result ? result.truncated : undefined;
const note = result && "note" in result ? result.note : undefined;

const handleToggle = () => {
userToggledRef.current = true;
Expand Down Expand Up @@ -331,6 +331,15 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({

{result && (
<>
{note && (
<DetailSection>
<DetailLabel>Notice</DetailLabel>
<div className="text-muted text-[11px] break-words whitespace-pre-wrap">
{note}
</div>
</DetailSection>
)}

{result.success === false && result.error && (
<DetailSection>
<DetailLabel>Error</DetailLabel>
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
9 changes: 4 additions & 5 deletions src/browser/components/tools/shared/ToolPrimitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,12 @@ export const ToolIcon: React.FC<ToolIconProps> = ({ toolName, emoji, className }
/**
* Error display box with danger styling
*/
export const ErrorBox: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
type ErrorBoxProps = React.HTMLAttributes<HTMLDivElement>;

export const ErrorBox: React.FC<ErrorBoxProps> = ({ className, ...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] text-danger bg-danger-overlay border-danger",
className
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/browser/components/tools/shared/toolUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { AlertTriangle, Check, CircleDot, X } from "lucide-react";
import { LoadingDots } from "./ToolPrimitives";
import type { ToolErrorResult } from "@/common/types/tools";
import { LoadingDots } from "./ToolPrimitives";

/**
* Shared utilities and hooks for tool components
Expand Down
4 changes: 4 additions & 0 deletions src/browser/stores/GitStatusStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,10 @@ export class GitStatusStore {
return [metadata.id, null];
}

if (result.data.note?.includes("OUTPUT OVERFLOW")) {
return [metadata.id, null];
}

// Parse the output using centralized function
const parsed = parseGitStatusScriptOutput(result.data.output);

Expand Down
58 changes: 57 additions & 1 deletion src/browser/stories/App.bash.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Bash tool stories - consolidated to 3 stories covering full UI complexity
* Bash tool stories - consolidated to a few stories covering full UI complexity
*/

import { appMeta, AppWithMocks, type AppStory } from "./meta.js";
Expand All @@ -8,6 +8,7 @@ import {
createUserMessage,
createAssistantMessage,
createBashTool,
createBashOverflowTool,
createPendingTool,
createBackgroundBashTool,
createMigratedBashTool,
Expand Down Expand Up @@ -150,6 +151,61 @@ npm test 2>&1 | head -20`,
},
};

/**
* Overflow notice: output saved to temp file with informational notice
*/
export const OverflowNotice: AppStory = {
render: () => (
<AppWithMocks
setup={() =>
setupSimpleChatStory({
workspaceId: "ws-bash-overflow",
messages: [
createUserMessage("msg-1", "Search the logs for failures", {
historySequence: 1,
timestamp: STABLE_TIMESTAMP - 140000,
}),
createAssistantMessage(
"msg-2",
"Scanning logs (output too large, saved to temp file):",
{
historySequence: 2,
timestamp: STABLE_TIMESTAMP - 135000,
toolCalls: [
createBashOverflowTool(
"call-1",
'rg "ERROR" /var/log/app/*.log',
[
"[OUTPUT OVERFLOW - Total output exceeded display limit: 18432 bytes > 16384 bytes (at line 312)]",
"",
"Full output (1250 lines) saved to /home/user/.mux/tmp/bash-1a2b3c4d.txt",
"",
"Use selective filtering tools (e.g. grep) to extract relevant information and continue your task",
"",
"File will be automatically cleaned up when stream ends.",
].join("\n"),
{
reason:
"Total output exceeded display limit: 18432 bytes > 16384 bytes (at line 312)",
totalLines: 1250,
},
5,
4200,
"Log Scan"
),
],
}
),
],
})
}
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await expandAllBashTools(canvasElement);
},
};

/**
* Background bash workflow: spawn, output states (running/exited/error/filtered/empty),
* process list, and terminate
Expand Down
31 changes: 31 additions & 0 deletions src/browser/stories/mockFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,37 @@ export function createFileEditTool(toolCallId: string, filePath: string, diff: s
};
}

export function createBashOverflowTool(
toolCallId: string,
script: string,
notice: string,
truncated: { reason: string; totalLines: number },
timeoutSecs = 3,
durationMs = 50,
displayName = "Bash"
): MuxPart {
return {
type: "dynamic-tool",
toolCallId,
toolName: "bash",
state: "output-available",
input: {
script,
run_in_background: false,
timeout_secs: timeoutSecs,
display_name: displayName,
},
output: {
success: true,
output: "",
note: notice,
exitCode: 0,
wall_duration_ms: durationMs,
truncated,
},
};
}

export function createBashTool(
toolCallId: string,
script: string,
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 @@ -23,6 +23,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 @@ -1417,8 +1418,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