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
2 changes: 1 addition & 1 deletion docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'
## Refactoring & Runtime Etiquette

- Use `git mv` to retain history when moving files.
- Never kill the running mux process; rely on `make test` / `make typecheck` for validation.
- Never kill the running mux process; rely on `make typecheck` + targeted `bun test path/to/file.test.ts` for validation (run `make test` only when necessary; it can be slow).

## Testing Doctrine

Expand Down
2 changes: 2 additions & 0 deletions src/browser/components/Messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
return (
<div className={className}>
<BashToolCall
workspaceId={workspaceId}
toolCallId={message.toolCallId}
args={message.args}
result={message.result as BashToolResult | undefined}
status={message.status}
Expand Down
72 changes: 62 additions & 10 deletions src/browser/components/tools/BashToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
DetailSection,
DetailLabel,
DetailContent,
LoadingDots,
ToolIcon,
ErrorBox,
ExitCodeBadge,
Expand All @@ -23,9 +22,12 @@ import {
type ToolStatus,
} from "./shared/toolUtils";
import { cn } from "@/common/lib/utils";
import { useBashToolLiveOutput } from "@/browser/stores/WorkspaceStore";
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";

interface BashToolCallProps {
workspaceId?: string;
toolCallId?: string;
args: BashToolArgs;
result?: BashToolResult;
status?: ToolStatus;
Expand All @@ -36,7 +38,16 @@ interface BashToolCallProps {
onSendToBackground?: () => void;
}

const EMPTY_LIVE_OUTPUT = {
stdout: "",
stderr: "",
combined: "",
truncated: false,
};

export const BashToolCall: React.FC<BashToolCallProps> = ({
workspaceId,
toolCallId,
args,
result,
status = "pending",
Expand All @@ -46,6 +57,27 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
}) => {
const { expanded, toggleExpanded } = useToolExpansion();
const [elapsedTime, setElapsedTime] = useState(0);

const liveOutput = useBashToolLiveOutput(workspaceId, toolCallId);

const outputRef = useRef<HTMLPreElement>(null);
const outputPinnedRef = useRef(true);

const updatePinned = (el: HTMLPreElement) => {
const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
outputPinnedRef.current = distanceToBottom < 40;
};

const liveOutputView = liveOutput ?? EMPTY_LIVE_OUTPUT;
const combinedLiveOutput = liveOutputView.combined;

useEffect(() => {
const el = outputRef.current;
if (!el) return;
if (outputPinnedRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [combinedLiveOutput]);
const startTimeRef = useRef<number>(startedAt ?? Date.now());

// Track elapsed time for pending/executing status
Expand Down Expand Up @@ -74,6 +106,11 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
const effectiveStatus: ToolStatus =
status === "completed" && result && "backgroundProcessId" in result ? "backgrounded" : status;

const resultHasOutput = typeof (result as { output?: unknown } | undefined)?.output === "string";

const showLiveOutput =
!isBackground && (status === "executing" || (Boolean(liveOutput) && !resultHasOutput));

return (
<ToolContainer expanded={expanded}>
<ToolHeader onClick={toggleExpanded}>
Expand Down Expand Up @@ -142,6 +179,30 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
<DetailContent className="px-2 py-1.5">{args.script}</DetailContent>
</DetailSection>

{showLiveOutput && (
<>
{liveOutputView.truncated && (
<div className="text-muted px-2 text-[10px] italic">
Live output truncated (showing last ~1MB)
</div>
)}

<DetailSection>
<DetailLabel>Output</DetailLabel>
<DetailContent
ref={outputRef}
onScroll={(e) => updatePinned(e.currentTarget)}
className={cn(
"px-2 py-1.5",
combinedLiveOutput.length === 0 && "text-muted italic"
)}
>
{combinedLiveOutput.length > 0 ? combinedLiveOutput : "No output yet"}
</DetailContent>
</DetailSection>
</>
)}

{result && (
<>
{result.success === false && result.error && (
Expand Down Expand Up @@ -171,15 +232,6 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
)}
</>
)}

{status === "executing" && !result && (
<DetailSection>
<DetailContent className="px-2 py-1.5">
Waiting for result
<LoadingDots />
</DetailContent>
</DetailSection>
)}
</ToolDetails>
)}
</ToolContainer>
Expand Down
24 changes: 13 additions & 11 deletions src/browser/components/tools/shared/ToolPrimitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,21 @@ export const DetailLabel: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
/>
);

export const DetailContent: React.FC<React.HTMLAttributes<HTMLPreElement>> = ({
className,
...props
}) => (
<pre
className={cn(
"m-0 bg-code-bg rounded-sm text-[11px] leading-relaxed whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto",
className
)}
{...props}
/>
export const DetailContent = React.forwardRef<HTMLPreElement, React.HTMLAttributes<HTMLPreElement>>(
({ className, ...props }, ref) => (
<pre
ref={ref}
className={cn(
"m-0 bg-code-bg rounded-sm text-[11px] leading-relaxed whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto",
className
)}
{...props}
/>
)
);

DetailContent.displayName = "DetailContent";

export const LoadingDots: React.FC<React.HTMLAttributes<HTMLSpanElement>> = ({
className,
...props
Expand Down
91 changes: 91 additions & 0 deletions src/browser/stores/WorkspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,4 +626,95 @@ describe("WorkspaceStore", () => {
expect(state2.loading).toBe(true); // Fresh workspace, not caught up
});
});

describe("bash-output events", () => {
it("retains live output when bash tool result has no output", async () => {
const workspaceId = "bash-output-workspace-1";

mockOnChat.mockImplementation(async function* (): AsyncGenerator<
WorkspaceChatMessage,
void,
unknown
> {
yield { type: "caught-up" };
await Promise.resolve();
yield {
type: "bash-output",
workspaceId,
toolCallId: "call-1",
text: "out\n",
isError: false,
timestamp: 1,
};
yield {
type: "bash-output",
workspaceId,
toolCallId: "call-1",
text: "err\n",
isError: true,
timestamp: 2,
};
// Simulate tmpfile overflow: tool result has no output field.
yield {
type: "tool-call-end",
workspaceId,
messageId: "m1",
toolCallId: "call-1",
toolName: "bash",
result: { success: false, error: "overflow", exitCode: -1, wall_duration_ms: 1 },
timestamp: 3,
};
});

createAndAddWorkspace(store, workspaceId);
await new Promise((resolve) => setTimeout(resolve, 10));

const live = store.getBashToolLiveOutput(workspaceId, "call-1");
expect(live).not.toBeNull();
if (!live) throw new Error("Expected live output");

// getSnapshot in useSyncExternalStore requires referential stability when unchanged.
const liveAgain = store.getBashToolLiveOutput(workspaceId, "call-1");
expect(liveAgain).toBe(live);

expect(live.stdout).toContain("out");
expect(live.stderr).toContain("err");
});

it("clears live output when bash tool result includes output", async () => {
const workspaceId = "bash-output-workspace-2";

mockOnChat.mockImplementation(async function* (): AsyncGenerator<
WorkspaceChatMessage,
void,
unknown
> {
yield { type: "caught-up" };
await Promise.resolve();
yield {
type: "bash-output",
workspaceId,
toolCallId: "call-2",
text: "out\n",
isError: false,
timestamp: 1,
};
yield {
type: "tool-call-end",
workspaceId,
messageId: "m2",
toolCallId: "call-2",
toolName: "bash",
result: { success: true, output: "done", exitCode: 0, wall_duration_ms: 1 },
timestamp: 2,
};
});

createAndAddWorkspace(store, workspaceId);
await new Promise((resolve) => setTimeout(resolve, 10));

const live = store.getBashToolLiveOutput(workspaceId, "call-2");
expect(live).toBeNull();
});
});
});
Loading