Skip to content

Commit d329140

Browse files
authored
🤖 feat: live peek bash stdout/stderr (#1225)
Live-stream stdout/stderr from the `bash` tool into the UI while it runs (UI-only; tool contract stays final-result-only). - `bash` tool emits throttled `bash-output` chat events and stops emitting once migrated to a background process. - Renderer buffers a bounded tail per `toolCallId` (1MB cap) and clears it when the final tool result includes `output`. - `BashToolCall` shows live (or tail) stdout/stderr in the expanded view, with pinned auto-scroll. Validation: - `make static-check` --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_ --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent beaf779 commit d329140

File tree

17 files changed

+672
-33
lines changed

17 files changed

+672
-33
lines changed

docs/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'
6767
## Refactoring & Runtime Etiquette
6868

6969
- Use `git mv` to retain history when moving files.
70-
- Never kill the running mux process; rely on `make test` / `make typecheck` for validation.
70+
- 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).
7171

7272
## Testing Doctrine
7373

src/browser/components/Messages/ToolMessage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
164164
return (
165165
<div className={className}>
166166
<BashToolCall
167+
workspaceId={workspaceId}
168+
toolCallId={message.toolCallId}
167169
args={message.args}
168170
result={message.result as BashToolResult | undefined}
169171
status={message.status}

src/browser/components/tools/BashToolCall.tsx

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
DetailSection,
1212
DetailLabel,
1313
DetailContent,
14-
LoadingDots,
1514
ToolIcon,
1615
ErrorBox,
1716
ExitCodeBadge,
@@ -23,9 +22,12 @@ import {
2322
type ToolStatus,
2423
} from "./shared/toolUtils";
2524
import { cn } from "@/common/lib/utils";
25+
import { useBashToolLiveOutput } from "@/browser/stores/WorkspaceStore";
2626
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
2727

2828
interface BashToolCallProps {
29+
workspaceId?: string;
30+
toolCallId?: string;
2931
args: BashToolArgs;
3032
result?: BashToolResult;
3133
status?: ToolStatus;
@@ -36,7 +38,16 @@ interface BashToolCallProps {
3638
onSendToBackground?: () => void;
3739
}
3840

41+
const EMPTY_LIVE_OUTPUT = {
42+
stdout: "",
43+
stderr: "",
44+
combined: "",
45+
truncated: false,
46+
};
47+
3948
export const BashToolCall: React.FC<BashToolCallProps> = ({
49+
workspaceId,
50+
toolCallId,
4051
args,
4152
result,
4253
status = "pending",
@@ -46,6 +57,27 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
4657
}) => {
4758
const { expanded, toggleExpanded } = useToolExpansion();
4859
const [elapsedTime, setElapsedTime] = useState(0);
60+
61+
const liveOutput = useBashToolLiveOutput(workspaceId, toolCallId);
62+
63+
const outputRef = useRef<HTMLPreElement>(null);
64+
const outputPinnedRef = useRef(true);
65+
66+
const updatePinned = (el: HTMLPreElement) => {
67+
const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
68+
outputPinnedRef.current = distanceToBottom < 40;
69+
};
70+
71+
const liveOutputView = liveOutput ?? EMPTY_LIVE_OUTPUT;
72+
const combinedLiveOutput = liveOutputView.combined;
73+
74+
useEffect(() => {
75+
const el = outputRef.current;
76+
if (!el) return;
77+
if (outputPinnedRef.current) {
78+
el.scrollTop = el.scrollHeight;
79+
}
80+
}, [combinedLiveOutput]);
4981
const startTimeRef = useRef<number>(startedAt ?? Date.now());
5082

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

109+
const resultHasOutput = typeof (result as { output?: unknown } | undefined)?.output === "string";
110+
111+
const showLiveOutput =
112+
!isBackground && (status === "executing" || (Boolean(liveOutput) && !resultHasOutput));
113+
77114
return (
78115
<ToolContainer expanded={expanded}>
79116
<ToolHeader onClick={toggleExpanded}>
@@ -142,6 +179,30 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
142179
<DetailContent className="px-2 py-1.5">{args.script}</DetailContent>
143180
</DetailSection>
144181

182+
{showLiveOutput && (
183+
<>
184+
{liveOutputView.truncated && (
185+
<div className="text-muted px-2 text-[10px] italic">
186+
Live output truncated (showing last ~1MB)
187+
</div>
188+
)}
189+
190+
<DetailSection>
191+
<DetailLabel>Output</DetailLabel>
192+
<DetailContent
193+
ref={outputRef}
194+
onScroll={(e) => updatePinned(e.currentTarget)}
195+
className={cn(
196+
"px-2 py-1.5",
197+
combinedLiveOutput.length === 0 && "text-muted italic"
198+
)}
199+
>
200+
{combinedLiveOutput.length > 0 ? combinedLiveOutput : "No output yet"}
201+
</DetailContent>
202+
</DetailSection>
203+
</>
204+
)}
205+
145206
{result && (
146207
<>
147208
{result.success === false && result.error && (
@@ -171,15 +232,6 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
171232
)}
172233
</>
173234
)}
174-
175-
{status === "executing" && !result && (
176-
<DetailSection>
177-
<DetailContent className="px-2 py-1.5">
178-
Waiting for result
179-
<LoadingDots />
180-
</DetailContent>
181-
</DetailSection>
182-
)}
183235
</ToolDetails>
184236
)}
185237
</ToolContainer>

src/browser/components/tools/shared/ToolPrimitives.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,21 @@ export const DetailLabel: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
118118
/>
119119
);
120120

121-
export const DetailContent: React.FC<React.HTMLAttributes<HTMLPreElement>> = ({
122-
className,
123-
...props
124-
}) => (
125-
<pre
126-
className={cn(
127-
"m-0 bg-code-bg rounded-sm text-[11px] leading-relaxed whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto",
128-
className
129-
)}
130-
{...props}
131-
/>
121+
export const DetailContent = React.forwardRef<HTMLPreElement, React.HTMLAttributes<HTMLPreElement>>(
122+
({ className, ...props }, ref) => (
123+
<pre
124+
ref={ref}
125+
className={cn(
126+
"m-0 bg-code-bg rounded-sm text-[11px] leading-relaxed whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto",
127+
className
128+
)}
129+
{...props}
130+
/>
131+
)
132132
);
133133

134+
DetailContent.displayName = "DetailContent";
135+
134136
export const LoadingDots: React.FC<React.HTMLAttributes<HTMLSpanElement>> = ({
135137
className,
136138
...props

src/browser/stores/WorkspaceStore.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,4 +626,95 @@ describe("WorkspaceStore", () => {
626626
expect(state2.loading).toBe(true); // Fresh workspace, not caught up
627627
});
628628
});
629+
630+
describe("bash-output events", () => {
631+
it("retains live output when bash tool result has no output", async () => {
632+
const workspaceId = "bash-output-workspace-1";
633+
634+
mockOnChat.mockImplementation(async function* (): AsyncGenerator<
635+
WorkspaceChatMessage,
636+
void,
637+
unknown
638+
> {
639+
yield { type: "caught-up" };
640+
await Promise.resolve();
641+
yield {
642+
type: "bash-output",
643+
workspaceId,
644+
toolCallId: "call-1",
645+
text: "out\n",
646+
isError: false,
647+
timestamp: 1,
648+
};
649+
yield {
650+
type: "bash-output",
651+
workspaceId,
652+
toolCallId: "call-1",
653+
text: "err\n",
654+
isError: true,
655+
timestamp: 2,
656+
};
657+
// Simulate tmpfile overflow: tool result has no output field.
658+
yield {
659+
type: "tool-call-end",
660+
workspaceId,
661+
messageId: "m1",
662+
toolCallId: "call-1",
663+
toolName: "bash",
664+
result: { success: false, error: "overflow", exitCode: -1, wall_duration_ms: 1 },
665+
timestamp: 3,
666+
};
667+
});
668+
669+
createAndAddWorkspace(store, workspaceId);
670+
await new Promise((resolve) => setTimeout(resolve, 10));
671+
672+
const live = store.getBashToolLiveOutput(workspaceId, "call-1");
673+
expect(live).not.toBeNull();
674+
if (!live) throw new Error("Expected live output");
675+
676+
// getSnapshot in useSyncExternalStore requires referential stability when unchanged.
677+
const liveAgain = store.getBashToolLiveOutput(workspaceId, "call-1");
678+
expect(liveAgain).toBe(live);
679+
680+
expect(live.stdout).toContain("out");
681+
expect(live.stderr).toContain("err");
682+
});
683+
684+
it("clears live output when bash tool result includes output", async () => {
685+
const workspaceId = "bash-output-workspace-2";
686+
687+
mockOnChat.mockImplementation(async function* (): AsyncGenerator<
688+
WorkspaceChatMessage,
689+
void,
690+
unknown
691+
> {
692+
yield { type: "caught-up" };
693+
await Promise.resolve();
694+
yield {
695+
type: "bash-output",
696+
workspaceId,
697+
toolCallId: "call-2",
698+
text: "out\n",
699+
isError: false,
700+
timestamp: 1,
701+
};
702+
yield {
703+
type: "tool-call-end",
704+
workspaceId,
705+
messageId: "m2",
706+
toolCallId: "call-2",
707+
toolName: "bash",
708+
result: { success: true, output: "done", exitCode: 0, wall_duration_ms: 1 },
709+
timestamp: 2,
710+
};
711+
});
712+
713+
createAndAddWorkspace(store, workspaceId);
714+
await new Promise((resolve) => setTimeout(resolve, 10));
715+
716+
const live = store.getBashToolLiveOutput(workspaceId, "call-2");
717+
expect(live).toBeNull();
718+
});
719+
});
629720
});

0 commit comments

Comments
 (0)