Skip to content

Commit 5b4f51b

Browse files
committed
🤖 feat: live peek bash stdout/stderr
Change-Id: I9cd53599a249a4d0acf49808e870788c159d55dd Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 3cf3d07 commit 5b4f51b

File tree

16 files changed

+619
-15
lines changed

16 files changed

+619
-15
lines changed

src/browser/components/Messages/ToolMessage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
154154
return (
155155
<div className={className}>
156156
<BashToolCall
157+
workspaceId={workspaceId}
158+
toolCallId={message.toolCallId}
157159
args={message.args}
158160
result={message.result as BashToolResult | undefined}
159161
status={message.status}

src/browser/components/tools/BashToolCall.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ import {
2323
type ToolStatus,
2424
} from "./shared/toolUtils";
2525
import { cn } from "@/common/lib/utils";
26+
import { useBashToolLiveOutput } from "@/browser/stores/WorkspaceStore";
2627
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
2728

2829
interface BashToolCallProps {
30+
workspaceId?: string;
31+
toolCallId?: string;
2932
args: BashToolArgs;
3033
result?: BashToolResult;
3134
status?: ToolStatus;
@@ -37,6 +40,8 @@ interface BashToolCallProps {
3740
}
3841

3942
export const BashToolCall: React.FC<BashToolCallProps> = ({
43+
workspaceId,
44+
toolCallId,
4045
args,
4146
result,
4247
status = "pending",
@@ -46,6 +51,34 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
4651
}) => {
4752
const { expanded, toggleExpanded } = useToolExpansion();
4853
const [elapsedTime, setElapsedTime] = useState(0);
54+
55+
const liveOutput = useBashToolLiveOutput(workspaceId, toolCallId);
56+
57+
const stdoutRef = useRef<HTMLPreElement>(null);
58+
const stderrRef = useRef<HTMLPreElement>(null);
59+
const stdoutPinnedRef = useRef(true);
60+
const stderrPinnedRef = useRef(true);
61+
62+
const updatePinned = (el: HTMLPreElement, pinnedRef: React.MutableRefObject<boolean>) => {
63+
const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
64+
pinnedRef.current = distanceToBottom < 40;
65+
};
66+
67+
useEffect(() => {
68+
const el = stdoutRef.current;
69+
if (!el) return;
70+
if (stdoutPinnedRef.current) {
71+
el.scrollTop = el.scrollHeight;
72+
}
73+
}, [liveOutput?.stdout]);
74+
75+
useEffect(() => {
76+
const el = stderrRef.current;
77+
if (!el) return;
78+
if (stderrPinnedRef.current) {
79+
el.scrollTop = el.scrollHeight;
80+
}
81+
}, [liveOutput?.stderr]);
4982
const startTimeRef = useRef<number>(startedAt ?? Date.now());
5083

5184
// Track elapsed time for pending/executing status
@@ -74,6 +107,12 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
74107
const effectiveStatus: ToolStatus =
75108
status === "completed" && result && "backgroundProcessId" in result ? "backgrounded" : status;
76109

110+
const resultHasOutput = typeof (result as { output?: unknown } | undefined)?.output === "string";
111+
const showLiveOutput = Boolean(
112+
liveOutput && !isBackground && (status === "executing" || !resultHasOutput)
113+
);
114+
const liveLabelSuffix = status === "executing" ? " (live)" : " (tail)";
115+
77116
return (
78117
<ToolContainer expanded={expanded}>
79118
<ToolHeader onClick={toggleExpanded}>
@@ -137,6 +176,43 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
137176

138177
{expanded && (
139178
<ToolDetails>
179+
{showLiveOutput && liveOutput && (
180+
<>
181+
{liveOutput.truncated && (
182+
<div className="text-muted px-2 text-[10px] italic">
183+
Live output truncated (showing last ~1MB)
184+
</div>
185+
)}
186+
187+
<DetailSection>
188+
<DetailLabel>{`Stdout${liveLabelSuffix}`}</DetailLabel>
189+
<DetailContent
190+
ref={stdoutRef}
191+
onScroll={(e) => updatePinned(e.currentTarget, stdoutPinnedRef)}
192+
className={cn(
193+
"px-2 py-1.5",
194+
liveOutput.stdout.length === 0 && "text-muted italic"
195+
)}
196+
>
197+
{liveOutput.stdout.length > 0 ? liveOutput.stdout : "No output yet"}
198+
</DetailContent>
199+
</DetailSection>
200+
201+
<DetailSection>
202+
<DetailLabel>{`Stderr${liveLabelSuffix}`}</DetailLabel>
203+
<DetailContent
204+
ref={stderrRef}
205+
onScroll={(e) => updatePinned(e.currentTarget, stderrPinnedRef)}
206+
className={cn(
207+
"px-2 py-1.5",
208+
liveOutput.stderr.length === 0 && "text-muted italic"
209+
)}
210+
>
211+
{liveOutput.stderr.length > 0 ? liveOutput.stderr : "No output yet"}
212+
</DetailContent>
213+
</DetailSection>
214+
</>
215+
)}
140216
<DetailSection>
141217
<DetailLabel>Script</DetailLabel>
142218
<DetailContent className="px-2 py-1.5">{args.script}</DetailContent>

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

0 commit comments

Comments
 (0)