From 1ecb2499a18e3de9b49786ce33728ca41cbc5a52 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Dec 2025 14:16:48 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A4=96=20feat:=20live=20peek=20bash?= =?UTF-8?q?=20stdout/stderr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I9cd53599a249a4d0acf49808e870788c159d55dd Signed-off-by: Thomas Kosiewski --- .../components/Messages/ToolMessage.tsx | 2 + src/browser/components/tools/BashToolCall.tsx | 76 ++++++++++++ .../tools/shared/ToolPrimitives.tsx | 24 ++-- src/browser/stores/WorkspaceStore.test.ts | 85 ++++++++++++++ src/browser/stores/WorkspaceStore.ts | 106 +++++++++++++++++ .../messages/liveBashOutputBuffer.test.ts | 54 +++++++++ .../utils/messages/liveBashOutputBuffer.ts | 95 +++++++++++++++ src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/stream.ts | 15 +++ src/common/orpc/types.ts | 4 + src/common/types/stream.ts | 3 + src/common/utils/tools/tools.ts | 6 + src/node/services/agentSession.ts | 1 + src/node/services/aiService.ts | 7 ++ src/node/services/tools/bash.test.ts | 44 +++++++ src/node/services/tools/bash.ts | 110 +++++++++++++++++- 16 files changed, 618 insertions(+), 15 deletions(-) create mode 100644 src/browser/utils/messages/liveBashOutputBuffer.test.ts create mode 100644 src/browser/utils/messages/liveBashOutputBuffer.ts diff --git a/src/browser/components/Messages/ToolMessage.tsx b/src/browser/components/Messages/ToolMessage.tsx index 7d9abc13ac..5f4089312d 100644 --- a/src/browser/components/Messages/ToolMessage.tsx +++ b/src/browser/components/Messages/ToolMessage.tsx @@ -164,6 +164,8 @@ export const ToolMessage: React.FC = ({ return (
= ({ + workspaceId, + toolCallId, args, result, status = "pending", @@ -46,6 +51,34 @@ export const BashToolCall: React.FC = ({ }) => { const { expanded, toggleExpanded } = useToolExpansion(); const [elapsedTime, setElapsedTime] = useState(0); + + const liveOutput = useBashToolLiveOutput(workspaceId, toolCallId); + + const stdoutRef = useRef(null); + const stderrRef = useRef(null); + const stdoutPinnedRef = useRef(true); + const stderrPinnedRef = useRef(true); + + const updatePinned = (el: HTMLPreElement, pinnedRef: React.MutableRefObject) => { + const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + pinnedRef.current = distanceToBottom < 40; + }; + + useEffect(() => { + const el = stdoutRef.current; + if (!el) return; + if (stdoutPinnedRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [liveOutput?.stdout]); + + useEffect(() => { + const el = stderrRef.current; + if (!el) return; + if (stderrPinnedRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [liveOutput?.stderr]); const startTimeRef = useRef(startedAt ?? Date.now()); // Track elapsed time for pending/executing status @@ -74,6 +107,12 @@ export const BashToolCall: React.FC = ({ const effectiveStatus: ToolStatus = status === "completed" && result && "backgroundProcessId" in result ? "backgrounded" : status; + const resultHasOutput = typeof (result as { output?: unknown } | undefined)?.output === "string"; + const showLiveOutput = Boolean( + liveOutput && !isBackground && (status === "executing" || !resultHasOutput) + ); + const liveLabelSuffix = status === "executing" ? " (live)" : " (tail)"; + return ( @@ -137,6 +176,43 @@ export const BashToolCall: React.FC = ({ {expanded && ( + {showLiveOutput && liveOutput && ( + <> + {liveOutput.truncated && ( +
+ Live output truncated (showing last ~1MB) +
+ )} + + + {`Stdout${liveLabelSuffix}`} + updatePinned(e.currentTarget, stdoutPinnedRef)} + className={cn( + "px-2 py-1.5", + liveOutput.stdout.length === 0 && "text-muted italic" + )} + > + {liveOutput.stdout.length > 0 ? liveOutput.stdout : "No output yet"} + + + + + {`Stderr${liveLabelSuffix}`} + updatePinned(e.currentTarget, stderrPinnedRef)} + className={cn( + "px-2 py-1.5", + liveOutput.stderr.length === 0 && "text-muted italic" + )} + > + {liveOutput.stderr.length > 0 ? liveOutput.stderr : "No output yet"} + + + + )} Script {args.script} diff --git a/src/browser/components/tools/shared/ToolPrimitives.tsx b/src/browser/components/tools/shared/ToolPrimitives.tsx index 18afd64eaa..69bf155dbf 100644 --- a/src/browser/components/tools/shared/ToolPrimitives.tsx +++ b/src/browser/components/tools/shared/ToolPrimitives.tsx @@ -118,19 +118,21 @@ export const DetailLabel: React.FC> = ({ /> ); -export const DetailContent: React.FC> = ({ - className, - ...props -}) => ( -
+export const DetailContent = React.forwardRef>(
+  ({ className, ...props }, ref) => (
+    
+  )
 );
 
+DetailContent.displayName = "DetailContent";
+
 export const LoadingDots: React.FC> = ({
   className,
   ...props
diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts
index 691d2f955f..a85dd2d3f8 100644
--- a/src/browser/stores/WorkspaceStore.test.ts
+++ b/src/browser/stores/WorkspaceStore.test.ts
@@ -626,4 +626,89 @@ 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();
+      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();
+    });
+  });
 });
diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts
index 2b1a5706dd..d058f4f3c6 100644
--- a/src/browser/stores/WorkspaceStore.ts
+++ b/src/browser/stores/WorkspaceStore.ts
@@ -8,12 +8,14 @@ import type { TodoItem } from "@/common/types/tools";
 import { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
 import { updatePersistedState } from "@/browser/hooks/usePersistedState";
 import { getRetryStateKey } from "@/common/constants/storage";
+import { BASH_TRUNCATE_MAX_TOTAL_BYTES } from "@/common/constants/toolLimits";
 import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
 import { useSyncExternalStore } from "react";
 import {
   isCaughtUpMessage,
   isStreamError,
   isDeleteMessage,
+  isBashOutputEvent,
   isMuxMessage,
   isQueuedMessageChanged,
   isRestoreToInput,
@@ -30,6 +32,12 @@ import type { z } from "zod";
 import type { SessionUsageFileSchema } from "@/common/orpc/schemas/chatStats";
 import type { LanguageModelV2Usage } from "@ai-sdk/provider";
 import { createFreshRetryState } from "@/browser/utils/messages/retryState";
+import {
+  appendLiveBashOutputChunk,
+  toLiveBashOutputView,
+  type LiveBashOutputInternal,
+  type LiveBashOutputView,
+} from "@/browser/utils/messages/liveBashOutputBuffer";
 import { trackStreamCompleted } from "@/common/telemetry";
 
 export interface WorkspaceState {
@@ -252,6 +260,10 @@ export class WorkspaceStore {
   private statsStore = new MapStore();
   private statsUnsubscribers = new Map void>();
   // Cumulative session usage (from session-usage.json)
+
+  // UI-only incremental bash output streamed via bash-output events (not persisted).
+  // Keyed by toolCallId.
+  private liveBashOutput = new Map>();
   private sessionUsage = new Map>();
 
   // Idle compaction notification callbacks (called when backend signals idle compaction needed)
@@ -365,6 +377,21 @@ export class WorkspaceStore {
       this.scheduleIdleStateBump(workspaceId);
     },
     "tool-call-end": (workspaceId, aggregator, data) => {
+      const toolCallEnd = data as Extract;
+
+      // Cleanup live bash output once the real tool result contains output.
+      // If output is missing (e.g. tmpfile overflow), keep the tail buffer so the UI still shows something.
+      if (toolCallEnd.toolName === "bash") {
+        const output = (toolCallEnd.result as { output?: unknown } | undefined)?.output;
+        if (typeof output === "string") {
+          const perWorkspace = this.liveBashOutput.get(workspaceId);
+          perWorkspace?.delete(toolCallEnd.toolCallId);
+          if (perWorkspace?.size === 0) {
+            this.liveBashOutput.delete(workspaceId);
+          }
+        }
+      }
+
       aggregator.handleToolCallEnd(data as never);
       this.states.bump(workspaceId);
       this.consumerManager.scheduleCalculation(workspaceId, aggregator);
@@ -613,6 +640,31 @@ export class WorkspaceStore {
     }
   }
 
+  private cleanupStaleLiveBashOutput(
+    workspaceId: string,
+    aggregator: StreamingMessageAggregator
+  ): void {
+    const perWorkspace = this.liveBashOutput.get(workspaceId);
+    if (!perWorkspace || perWorkspace.size === 0) return;
+
+    const activeToolCallIds = new Set();
+    for (const msg of aggregator.getDisplayedMessages()) {
+      if (msg.type === "tool" && msg.toolName === "bash") {
+        activeToolCallIds.add(msg.toolCallId);
+      }
+    }
+
+    for (const toolCallId of Array.from(perWorkspace.keys())) {
+      if (!activeToolCallIds.has(toolCallId)) {
+        perWorkspace.delete(toolCallId);
+      }
+    }
+
+    if (perWorkspace.size === 0) {
+      this.liveBashOutput.delete(workspaceId);
+    }
+  }
+
   /**
    * Subscribe to store changes (any workspace).
    * Delegates to MapStore's subscribeAny.
@@ -633,6 +685,12 @@ export class WorkspaceStore {
     return this.states.subscribeKey(workspaceId, listener);
   };
 
+  getBashToolLiveOutput(workspaceId: string, toolCallId: string): LiveBashOutputView | null {
+    const perWorkspace = this.liveBashOutput.get(workspaceId);
+    const state = perWorkspace?.get(toolCallId);
+    return state ? toLiveBashOutputView(state) : null;
+  }
+
   /**
    * Assert that workspace exists and return its aggregator.
    * Centralized assertion for all workspace access methods.
@@ -1146,6 +1204,7 @@ export class WorkspaceStore {
     this.workspaceCreatedAt.delete(workspaceId);
     this.workspaceStats.delete(workspaceId);
     this.statsStore.delete(workspaceId);
+    this.liveBashOutput.delete(workspaceId);
     this.sessionUsage.delete(workspaceId);
   }
 
@@ -1196,7 +1255,11 @@ export class WorkspaceStore {
     this.pendingStreamEvents.clear();
     this.workspaceStats.clear();
     this.statsStore.clear();
+    this.liveBashOutput.clear();
     this.sessionUsage.clear();
+    this.recencyCache.clear();
+    this.previousSidebarValues.clear();
+    this.sidebarStateCache.clear();
     this.workspaceCreatedAt.clear();
   }
 
@@ -1366,6 +1429,7 @@ export class WorkspaceStore {
 
     if (isDeleteMessage(data)) {
       aggregator.handleDeleteMessage(data);
+      this.cleanupStaleLiveBashOutput(workspaceId, aggregator);
       this.states.bump(workspaceId);
       this.checkAndBumpRecencyIfChanged();
       this.usageStore.bump(workspaceId);
@@ -1373,6 +1437,27 @@ export class WorkspaceStore {
       return;
     }
 
+    if (isBashOutputEvent(data)) {
+      if (data.text.length === 0) return;
+
+      const perWorkspace =
+        this.liveBashOutput.get(workspaceId) ?? new Map();
+
+      const prev = perWorkspace.get(data.toolCallId);
+      const next = appendLiveBashOutputChunk(
+        prev,
+        { text: data.text, isError: data.isError },
+        BASH_TRUNCATE_MAX_TOTAL_BYTES
+      );
+
+      perWorkspace.set(data.toolCallId, next);
+      this.liveBashOutput.set(workspaceId, perWorkspace);
+
+      // High-frequency: throttle UI updates like other delta-style events.
+      this.scheduleIdleStateBump(workspaceId);
+      return;
+    }
+
     // Try buffered event handlers (single source of truth)
     if ("type" in data && data.type in this.bufferedEventHandlers) {
       this.bufferedEventHandlers[data.type](workspaceId, aggregator, data);
@@ -1486,6 +1571,27 @@ export function useWorkspaceSidebarState(workspaceId: string): WorkspaceSidebarS
   );
 }
 
+/**
+ * Hook to get UI-only live stdout/stderr for a running bash tool call.
+ */
+export function useBashToolLiveOutput(
+  workspaceId: string | undefined,
+  toolCallId: string | undefined
+): LiveBashOutputView | null {
+  const store = getStoreInstance();
+
+  return useSyncExternalStore(
+    (listener) => {
+      if (!workspaceId) return () => undefined;
+      return store.subscribeKey(workspaceId, listener);
+    },
+    () => {
+      if (!workspaceId || !toolCallId) return null;
+      return store.getBashToolLiveOutput(workspaceId, toolCallId);
+    }
+  );
+}
+
 /**
  * Hook to get an aggregator for a workspace.
  */
diff --git a/src/browser/utils/messages/liveBashOutputBuffer.test.ts b/src/browser/utils/messages/liveBashOutputBuffer.test.ts
new file mode 100644
index 0000000000..9bc23ba136
--- /dev/null
+++ b/src/browser/utils/messages/liveBashOutputBuffer.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from "bun:test";
+import { appendLiveBashOutputChunk } from "./liveBashOutputBuffer";
+
+describe("appendLiveBashOutputChunk", () => {
+  it("appends stdout and stderr independently", () => {
+    const a = appendLiveBashOutputChunk(undefined, { text: "out1\n", isError: false }, 1024);
+    expect(a.stdout).toBe("out1\n");
+    expect(a.stderr).toBe("");
+    expect(a.truncated).toBe(false);
+
+    const b = appendLiveBashOutputChunk(a, { text: "err1\n", isError: true }, 1024);
+    expect(b.stdout).toBe("out1\n");
+    expect(b.stderr).toBe("err1\n");
+    expect(b.truncated).toBe(false);
+  });
+
+  it("drops the oldest segments to enforce maxBytes", () => {
+    const maxBytes = 5;
+    const a = appendLiveBashOutputChunk(undefined, { text: "1234", isError: false }, maxBytes);
+    expect(a.stdout).toBe("1234");
+    expect(a.truncated).toBe(false);
+
+    const b = appendLiveBashOutputChunk(a, { text: "abc", isError: false }, maxBytes);
+    expect(b.stdout).toBe("abc");
+    expect(b.truncated).toBe(true);
+  });
+
+  it("drops multiple segments when needed", () => {
+    const maxBytes = 6;
+    const a = appendLiveBashOutputChunk(undefined, { text: "a", isError: false }, maxBytes);
+    const b = appendLiveBashOutputChunk(a, { text: "bb", isError: true }, maxBytes);
+    const c = appendLiveBashOutputChunk(b, { text: "ccc", isError: false }, maxBytes);
+
+    // total "a" (1) + "bb" (2) + "ccc" (3) = 6 (fits)
+    expect(c.stdout).toBe("accc");
+    expect(c.stderr).toBe("bb");
+    expect(c.truncated).toBe(false);
+
+    const d = appendLiveBashOutputChunk(c, { text: "DD", isError: true }, maxBytes);
+    // total would be 8, so drop oldest segments until <= 6.
+    // Drops stdout "a" (1) then stderr "bb" (2) => remaining "ccc" (3) + "DD" (2) = 5
+    expect(d.stdout).toBe("ccc");
+    expect(d.stderr).toBe("DD");
+    expect(d.truncated).toBe(true);
+  });
+
+  it("drops a single chunk larger than the cap", () => {
+    const maxBytes = 3;
+    const a = appendLiveBashOutputChunk(undefined, { text: "hello", isError: false }, maxBytes);
+    expect(a.stdout).toBe("");
+    expect(a.stderr).toBe("");
+    expect(a.truncated).toBe(true);
+  });
+});
diff --git a/src/browser/utils/messages/liveBashOutputBuffer.ts b/src/browser/utils/messages/liveBashOutputBuffer.ts
new file mode 100644
index 0000000000..666abf0987
--- /dev/null
+++ b/src/browser/utils/messages/liveBashOutputBuffer.ts
@@ -0,0 +1,95 @@
+export interface LiveBashOutputView {
+  stdout: string;
+  stderr: string;
+  truncated: boolean;
+}
+
+interface LiveBashOutputSegment {
+  isError: boolean;
+  text: string;
+  bytes: number;
+}
+
+/**
+ * Internal representation used by WorkspaceStore.
+ *
+ * We retain per-chunk segments so we can drop the oldest output first while
+ * still rendering stdout and stderr separately.
+ */
+export interface LiveBashOutputInternal extends LiveBashOutputView {
+  segments: LiveBashOutputSegment[];
+  totalBytes: number;
+}
+
+function getUtf8ByteLength(text: string): number {
+  return new TextEncoder().encode(text).length;
+}
+
+export function appendLiveBashOutputChunk(
+  prev: LiveBashOutputInternal | undefined,
+  chunk: { text: string; isError: boolean },
+  maxBytes: number
+): LiveBashOutputInternal {
+  if (maxBytes <= 0) {
+    throw new Error(`maxBytes must be > 0 (got ${maxBytes})`);
+  }
+
+  const base: LiveBashOutputInternal =
+    prev ??
+    ({
+      stdout: "",
+      stderr: "",
+      truncated: false,
+      segments: [],
+      totalBytes: 0,
+    } satisfies LiveBashOutputInternal);
+
+  if (chunk.text.length === 0) return base;
+
+  // Clone for purity (tests + avoids hidden mutation assumptions).
+  const next: LiveBashOutputInternal = {
+    stdout: base.stdout,
+    stderr: base.stderr,
+    truncated: base.truncated,
+    segments: base.segments.slice(),
+    totalBytes: base.totalBytes,
+  };
+
+  const segment: LiveBashOutputSegment = {
+    isError: chunk.isError,
+    text: chunk.text,
+    bytes: getUtf8ByteLength(chunk.text),
+  };
+
+  next.segments.push(segment);
+  next.totalBytes += segment.bytes;
+  if (segment.isError) {
+    next.stderr += segment.text;
+  } else {
+    next.stdout += segment.text;
+  }
+
+  while (next.totalBytes > maxBytes && next.segments.length > 0) {
+    const removed = next.segments.shift();
+    if (!removed) break;
+
+    next.totalBytes -= removed.bytes;
+    next.truncated = true;
+
+    if (removed.isError) {
+      next.stderr = next.stderr.slice(removed.text.length);
+    } else {
+      next.stdout = next.stdout.slice(removed.text.length);
+    }
+  }
+
+  if (next.totalBytes < 0) {
+    throw new Error("Invariant violation: totalBytes < 0");
+  }
+
+  return next;
+}
+
+export function toLiveBashOutputView(state: LiveBashOutputInternal): LiveBashOutputView {
+  return { stdout: state.stdout, stderr: state.stderr, truncated: state.truncated };
+}
diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts
index 6ddc254855..d61eead7a2 100644
--- a/src/common/orpc/schemas.ts
+++ b/src/common/orpc/schemas.ts
@@ -104,6 +104,7 @@ export {
   ToolCallDeltaEventSchema,
   ToolCallEndEventSchema,
   ToolCallStartEventSchema,
+  BashOutputEventSchema,
   UpdateStatusSchema,
   UsageDeltaEventSchema,
   WorkspaceChatMessageSchema,
diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts
index f5abf5d10d..d66748f68c 100644
--- a/src/common/orpc/schemas/stream.ts
+++ b/src/common/orpc/schemas/stream.ts
@@ -169,6 +169,20 @@ export const ToolCallDeltaEventSchema = z.object({
   timestamp: z.number().meta({ description: "When delta was received (Date.now())" }),
 });
 
+/**
+ * UI-only incremental output from the bash tool.
+ *
+ * This is intentionally NOT part of the tool result returned to the model.
+ * It is streamed over workspace.onChat so users can "peek" while the tool is running.
+ */
+export const BashOutputEventSchema = z.object({
+  type: z.literal("bash-output"),
+  workspaceId: z.string(),
+  toolCallId: z.string(),
+  text: z.string(),
+  isError: z.boolean().meta({ description: "True if this chunk is from stderr" }),
+  timestamp: z.number().meta({ description: "When output was flushed (Date.now())" }),
+});
 export const ToolCallEndEventSchema = z.object({
   type: z.literal("tool-call-end"),
   workspaceId: z.string(),
@@ -294,6 +308,7 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [
   ToolCallStartEventSchema,
   ToolCallDeltaEventSchema,
   ToolCallEndEventSchema,
+  BashOutputEventSchema,
   // Reasoning events
   ReasoningDeltaEventSchema,
   ReasoningEndEventSchema,
diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts
index 6eed26e3ff..d7cb32553d 100644
--- a/src/common/orpc/types.ts
+++ b/src/common/orpc/types.ts
@@ -9,6 +9,7 @@ import type {
   ToolCallStartEvent,
   ToolCallDeltaEvent,
   ToolCallEndEvent,
+  BashOutputEvent,
   ReasoningDeltaEvent,
   ReasoningEndEvent,
   UsageDeltaEvent,
@@ -75,6 +76,9 @@ export function isToolCallDelta(msg: WorkspaceChatMessage): msg is ToolCallDelta
   return (msg as { type?: string }).type === "tool-call-delta";
 }
 
+export function isBashOutputEvent(msg: WorkspaceChatMessage): msg is BashOutputEvent {
+  return (msg as { type?: string }).type === "bash-output";
+}
 export function isToolCallEnd(msg: WorkspaceChatMessage): msg is ToolCallEndEvent {
   return (msg as { type?: string }).type === "tool-call-end";
 }
diff --git a/src/common/types/stream.ts b/src/common/types/stream.ts
index 2d11a8034f..ee35d8d32b 100644
--- a/src/common/types/stream.ts
+++ b/src/common/types/stream.ts
@@ -15,6 +15,7 @@ import type {
   ToolCallDeltaEventSchema,
   ToolCallEndEventSchema,
   ToolCallStartEventSchema,
+  BashOutputEventSchema,
   UsageDeltaEventSchema,
 } from "../orpc/schemas";
 
@@ -31,6 +32,7 @@ export type StreamAbortEvent = z.infer;
 
 export type ErrorEvent = z.infer;
 
+export type BashOutputEvent = z.infer;
 export type ToolCallStartEvent = z.infer;
 export type ToolCallDeltaEvent = z.infer;
 export type ToolCallEndEvent = z.infer;
@@ -53,6 +55,7 @@ export type AIServiceEvent =
   | ToolCallStartEvent
   | ToolCallDeltaEvent
   | ToolCallEndEvent
+  | BashOutputEvent
   | ReasoningDeltaEvent
   | ReasoningEndEvent
   | UsageDeltaEvent;
diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts
index 55b0d8f887..2e0a9f533e 100644
--- a/src/common/utils/tools/tools.ts
+++ b/src/common/utils/tools/tools.ts
@@ -19,6 +19,7 @@ import type { Runtime } from "@/node/runtime/Runtime";
 import type { InitStateManager } from "@/node/services/initStateManager";
 import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
 import type { UIMode } from "@/common/types/mode";
+import type { WorkspaceChatMessage } from "@/common/orpc/types";
 import type { FileState } from "@/node/services/agentSession";
 
 /**
@@ -45,6 +46,11 @@ export interface ToolConfiguration {
   mode?: UIMode;
   /** Plan file path - only this file can be edited in plan mode */
   planFilePath?: string;
+  /**
+   * Optional callback for emitting UI-only workspace chat events.
+   * Used for streaming bash stdout/stderr to the UI without sending it to the model.
+   */
+  emitChatEvent?: (event: WorkspaceChatMessage) => void;
   /** Workspace ID for tracking background processes and plan storage */
   workspaceId?: string;
   /** Callback to record file state for external edit detection (plan files) */
diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts
index 34ed42806c..8314d34e35 100644
--- a/src/node/services/agentSession.ts
+++ b/src/node/services/agentSession.ts
@@ -658,6 +658,7 @@ export class AgentSession {
     forward("stream-start", (payload) => this.emitChatEvent(payload));
     forward("stream-delta", (payload) => this.emitChatEvent(payload));
     forward("tool-call-start", (payload) => this.emitChatEvent(payload));
+    forward("bash-output", (payload) => this.emitChatEvent(payload));
     forward("tool-call-delta", (payload) => this.emitChatEvent(payload));
     forward("tool-call-end", (payload) => {
       this.emitChatEvent(payload);
diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts
index 630e8f02d2..2e86b2495c 100644
--- a/src/node/services/aiService.ts
+++ b/src/node/services/aiService.ts
@@ -1268,6 +1268,13 @@ export class AIService extends EventEmitter {
           // - read: plan file is readable in all modes (useful context)
           // - write: enforced by file_edit_* tools (plan file is read-only outside plan mode)
           mode: uiMode,
+          emitChatEvent: (event) => {
+            // Defensive: tools should only emit events for the workspace they belong to.
+            if ("workspaceId" in event && event.workspaceId !== workspaceId) {
+              return;
+            }
+            this.emit(event.type, event as never);
+          },
           planFilePath,
           workspaceId,
           // External edit detection callback
diff --git a/src/node/services/tools/bash.test.ts b/src/node/services/tools/bash.test.ts
index c88a21337f..d3b0b71e95 100644
--- a/src/node/services/tools/bash.test.ts
+++ b/src/node/services/tools/bash.test.ts
@@ -1,6 +1,7 @@
 import { describe, it, expect } from "bun:test";
 import { LocalRuntime } from "@/node/runtime/LocalRuntime";
 import { createBashTool } from "./bash";
+import type { BashOutputEvent } from "@/common/types/stream";
 import type { BashToolArgs, BashToolResult } from "@/common/types/tools";
 import { BASH_MAX_TOTAL_BYTES } from "@/common/constants/toolLimits";
 import * as fs from "fs";
@@ -53,6 +54,49 @@ describe("bash tool", () => {
     }
   });
 
+  it("should emit bash-output events when emitChatEvent is provided", async () => {
+    const tempDir = new TestTempDir("test-bash-live-output");
+    const events: BashOutputEvent[] = [];
+
+    const config = createTestToolConfig(process.cwd());
+    config.runtimeTempDir = tempDir.path;
+    config.emitChatEvent = (event) => {
+      if (event.type === "bash-output") {
+        events.push(event);
+      }
+    };
+
+    const tool = createBashTool(config);
+
+    const args: BashToolArgs = {
+      script: "echo out && echo err 1>&2",
+      timeout_secs: 5,
+      run_in_background: false,
+      display_name: "test",
+    };
+
+    const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult;
+    expect(result.success).toBe(true);
+
+    expect(events.length).toBeGreaterThan(0);
+    expect(events.every((e) => e.workspaceId === config.workspaceId)).toBe(true);
+    expect(events.every((e) => e.toolCallId === mockToolCallOptions.toolCallId)).toBe(true);
+
+    const stdoutText = events
+      .filter((e) => !e.isError)
+      .map((e) => e.text)
+      .join("");
+    const stderrText = events
+      .filter((e) => e.isError)
+      .map((e) => e.text)
+      .join("");
+
+    expect(stdoutText).toContain("out");
+    expect(stderrText).toContain("err");
+
+    tempDir[Symbol.dispose]();
+  });
+
   it("should handle multi-line output", async () => {
     using testEnv = createTestBashTool();
     const tool = testEnv.tool;
diff --git a/src/node/services/tools/bash.ts b/src/node/services/tools/bash.ts
index f72f0fddc1..d19aade8d7 100644
--- a/src/node/services/tools/bash.ts
+++ b/src/node/services/tools/bash.ts
@@ -12,6 +12,7 @@ import {
 } from "@/common/constants/toolLimits";
 import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes";
 
+import type { BashOutputEvent } from "@/common/types/stream";
 import type { BashToolResult } from "@/common/types/tools";
 import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools";
 import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions";
@@ -391,9 +392,93 @@ ${script}`;
         triggerFileTruncation
       );
 
+      // UI-only incremental output streaming over workspace.onChat (not sent to the model).
+      // We flush chunked text rather than per-line to keep overhead low.
+      let liveOutputStopped = false;
+      let liveStdoutBuffer = "";
+      let liveStderrBuffer = "";
+      let liveOutputTimer: ReturnType | null = null;
+
+      const LIVE_FLUSH_INTERVAL_MS = 75;
+      const MAX_LIVE_EVENT_CHARS = 32_768;
+
+      const emitBashOutput = (isError: boolean, text: string): void => {
+        if (!config.emitChatEvent || !config.workspaceId || !toolCallId) return;
+        if (liveOutputStopped) return;
+        if (text.length === 0) return;
+
+        config.emitChatEvent({
+          type: "bash-output",
+          workspaceId: config.workspaceId,
+          toolCallId,
+          text,
+          isError,
+          timestamp: Date.now(),
+        } satisfies BashOutputEvent);
+      };
+
+      const flushLiveOutput = (): void => {
+        if (liveOutputStopped) return;
+
+        const flush = (isError: boolean, buffer: string): void => {
+          if (buffer.length === 0) return;
+          for (let i = 0; i < buffer.length; i += MAX_LIVE_EVENT_CHARS) {
+            emitBashOutput(isError, buffer.slice(i, i + MAX_LIVE_EVENT_CHARS));
+          }
+        };
+
+        if (liveStdoutBuffer.length > 0) {
+          const buf = liveStdoutBuffer;
+          liveStdoutBuffer = "";
+          flush(false, buf);
+        }
+
+        if (liveStderrBuffer.length > 0) {
+          const buf = liveStderrBuffer;
+          liveStderrBuffer = "";
+          flush(true, buf);
+        }
+      };
+
+      const stopLiveOutput = (flush: boolean): void => {
+        if (liveOutputStopped) return;
+        if (flush) flushLiveOutput();
+
+        liveOutputStopped = true;
+
+        if (liveOutputTimer) {
+          clearInterval(liveOutputTimer);
+          liveOutputTimer = null;
+        }
+
+        liveStdoutBuffer = "";
+        liveStderrBuffer = "";
+      };
+
+      if (config.emitChatEvent && config.workspaceId && toolCallId) {
+        liveOutputTimer = setInterval(flushLiveOutput, LIVE_FLUSH_INTERVAL_MS);
+      }
+
+      const appendLiveOutput = (isError: boolean, text: string): void => {
+        if (!config.emitChatEvent || !config.workspaceId || !toolCallId) return;
+        if (liveOutputStopped) return;
+        if (text.length === 0) return;
+
+        if (isError) {
+          liveStderrBuffer += text;
+          if (liveStderrBuffer.length >= MAX_LIVE_EVENT_CHARS) flushLiveOutput();
+        } else {
+          liveStdoutBuffer += text;
+          if (liveStdoutBuffer.length >= MAX_LIVE_EVENT_CHARS) flushLiveOutput();
+        }
+      };
+
       // Consume a ReadableStream and emit lines to lineHandler.
       // Uses TextDecoder streaming to preserve multibyte boundaries.
-      const consumeStream = async (stream: ReadableStream): Promise => {
+      const consumeStream = async (
+        stream: ReadableStream,
+        isError: boolean
+      ): Promise => {
         const reader = stream.getReader();
         const decoder = new TextDecoder("utf-8");
         let carry = "";
@@ -420,6 +505,7 @@ ${script}`;
             if (done) break;
             // Decode chunk (streaming keeps partial code points)
             const text = decoder.decode(value, { stream: true });
+            appendLiveOutput(isError, text);
             carry += text;
             // Split into lines; support both \n and \r\n
             let start = 0;
@@ -449,7 +535,10 @@ ${script}`;
           // Flush decoder for any trailing bytes and emit the last line (if any)
           try {
             const tail = decoder.decode();
-            if (tail) carry += tail;
+            if (tail) {
+              appendLiveOutput(isError, tail);
+              carry += tail;
+            }
             if (carry.length > 0 && !truncationState.fileTruncated) {
               lineHandler(carry);
             }
@@ -460,8 +549,8 @@ ${script}`;
       };
 
       // Start consuming stdout and stderr concurrently (using UI branches)
-      const consumeStdout = consumeStream(stdoutForUI);
-      const consumeStderr = consumeStream(stderrForUI);
+      const consumeStdout = consumeStream(stdoutForUI, false);
+      const consumeStderr = consumeStream(stderrForUI, true);
 
       // Create a promise that resolves when user clicks "Background"
       const backgroundPromise = new Promise((resolve) => {
@@ -482,6 +571,17 @@ ${script}`;
           // Unregister foreground process
           fgRegistration?.unregister();
 
+          // Stop UI-only output streaming before migrating to background.
+          stopLiveOutput(true);
+
+          // Stop consuming UI stream branches - further output should be handled by bash_output.
+          stdoutForUI.cancel().catch(() => {
+            /* ignore */ return;
+          });
+          stderrForUI.cancel().catch(() => {
+            /* ignore */ return;
+          });
+
           // Detach from abort signal - process should continue running
           // even when the stream ends and fires abort
           abortDetached = true;
@@ -565,6 +665,8 @@ ${script}`;
           exitCode: -1,
           wall_duration_ms: Math.round(performance.now() - startTime),
         };
+      } finally {
+        stopLiveOutput(true);
       }
 
       // Unregister foreground process on normal completion

From d80a0bd8cf5a5b9020cfb971c870fa010927b5b0 Mon Sep 17 00:00:00 2001
From: Thomas Kosiewski 
Date: Fri, 19 Dec 2025 14:37:58 +0100
Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20handle=20bash=20backg?=
 =?UTF-8?q?rounding=20+=20idle=20bump=20fallback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Change-Id: I9c70f712251657b4a57c71fc9f3cab9f2a05cc4e
Signed-off-by: Thomas Kosiewski 
---
 docs/AGENTS.md                       |  2 +-
 src/browser/stores/WorkspaceStore.ts | 24 ++++++++++++++++++------
 src/node/services/tools/bash.ts      |  8 +++++++-
 3 files changed, 26 insertions(+), 8 deletions(-)

diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index c24563984e..7fc5330b0b 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -67,7 +67,7 @@ gh pr view  --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
 
diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts
index d058f4f3c6..2ee8004a5a 100644
--- a/src/browser/stores/WorkspaceStore.ts
+++ b/src/browser/stores/WorkspaceStore.ts
@@ -535,6 +535,18 @@ export class WorkspaceStore {
       return;
     }
 
+    // requestIdleCallback is not available in some environments (e.g. Node-based unit tests).
+    // Fall back to a regular timeout so we still throttle bumps.
+    if (typeof requestIdleCallback !== "function") {
+      const handle = setTimeout(() => {
+        this.deltaIdleHandles.delete(workspaceId);
+        this.states.bump(workspaceId);
+      }, 0);
+
+      this.deltaIdleHandles.set(workspaceId, handle as unknown as number);
+      return;
+    }
+
     const handle = requestIdleCallback(
       () => {
         this.deltaIdleHandles.delete(workspaceId);
@@ -591,7 +603,11 @@ export class WorkspaceStore {
   private cancelPendingIdleBump(workspaceId: string): void {
     const handle = this.deltaIdleHandles.get(workspaceId);
     if (handle) {
-      cancelIdleCallback(handle);
+      if (typeof cancelIdleCallback === "function") {
+        cancelIdleCallback(handle);
+      } else {
+        clearTimeout(handle as unknown as number);
+      }
       this.deltaIdleHandles.delete(workspaceId);
     }
   }
@@ -1172,11 +1188,7 @@ export class WorkspaceStore {
     this.consumerManager.removeWorkspace(workspaceId);
 
     // Clean up idle callback to prevent stale callbacks
-    const handle = this.deltaIdleHandles.get(workspaceId);
-    if (handle) {
-      cancelIdleCallback(handle);
-      this.deltaIdleHandles.delete(workspaceId);
-    }
+    this.cancelPendingIdleBump(workspaceId);
 
     const statsUnsubscribe = this.statsUnsubscribers.get(workspaceId);
     if (statsUnsubscribe) {
diff --git a/src/node/services/tools/bash.ts b/src/node/services/tools/bash.ts
index d19aade8d7..015c5e2549 100644
--- a/src/node/services/tools/bash.ts
+++ b/src/node/services/tools/bash.ts
@@ -559,10 +559,12 @@ ${script}`;
 
       // Wait for process exit and stream consumption concurrently
       // Also race with the background promise to detect early return request
+      const foregroundCompletion = Promise.all([execStream.exitCode, consumeStdout, consumeStderr]);
+
       let exitCode: number;
       try {
         const result = await Promise.race([
-          Promise.all([execStream.exitCode, consumeStdout, consumeStderr]),
+          foregroundCompletion,
           backgroundPromise.then(() => "backgrounded" as const),
         ]);
 
@@ -582,6 +584,10 @@ ${script}`;
             /* ignore */ return;
           });
 
+          // Avoid unhandled promise rejections if the cancelled UI readers cause
+          // the foreground consumption promise to reject after we return.
+          void foregroundCompletion.catch(() => undefined);
+
           // Detach from abort signal - process should continue running
           // even when the stream ends and fires abort
           abortDetached = true;

From 73c9559dab89feb0605f1c87dec34040f4780ffd Mon Sep 17 00:00:00 2001
From: Thomas Kosiewski 
Date: Fri, 19 Dec 2025 17:03:08 +0100
Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20infinite=20re?=
 =?UTF-8?q?render=20for=20live=20bash=20output?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Change-Id: Idb868686d632862e0ed5c9b472670fd49ecd6e8a
Signed-off-by: Thomas Kosiewski 
---
 src/browser/stores/WorkspaceStore.test.ts | 10 ++++++++--
 src/browser/stores/WorkspaceStore.ts      |  6 ++++--
 2 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts
index a85dd2d3f8..3855b6abfb 100644
--- a/src/browser/stores/WorkspaceStore.test.ts
+++ b/src/browser/stores/WorkspaceStore.test.ts
@@ -671,8 +671,14 @@ describe("WorkspaceStore", () => {
 
       const live = store.getBashToolLiveOutput(workspaceId, "call-1");
       expect(live).not.toBeNull();
-      expect(live?.stdout).toContain("out");
-      expect(live?.stderr).toContain("err");
+      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 () => {
diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts
index 2ee8004a5a..87c0a9b52c 100644
--- a/src/browser/stores/WorkspaceStore.ts
+++ b/src/browser/stores/WorkspaceStore.ts
@@ -34,7 +34,6 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider";
 import { createFreshRetryState } from "@/browser/utils/messages/retryState";
 import {
   appendLiveBashOutputChunk,
-  toLiveBashOutputView,
   type LiveBashOutputInternal,
   type LiveBashOutputView,
 } from "@/browser/utils/messages/liveBashOutputBuffer";
@@ -704,7 +703,10 @@ export class WorkspaceStore {
   getBashToolLiveOutput(workspaceId: string, toolCallId: string): LiveBashOutputView | null {
     const perWorkspace = this.liveBashOutput.get(workspaceId);
     const state = perWorkspace?.get(toolCallId);
-    return state ? toLiveBashOutputView(state) : null;
+
+    // Important: return the stored object reference so useSyncExternalStore sees a stable snapshot.
+    // (Returning a fresh object every call can trigger an infinite re-render loop.)
+    return state ?? null;
   }
 
   /**

From f559ea5efde3d99f5f3f9ccfd85cc540708e3ecb Mon Sep 17 00:00:00 2001
From: Thomas Kosiewski 
Date: Fri, 19 Dec 2025 17:16:03 +0100
Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20show=20live=20bash=20?=
 =?UTF-8?q?output=20pane=20while=20executing?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Change-Id: I8be7efa5ba17ca3b20a9cba58a435c5afcc65be6
Signed-off-by: Thomas Kosiewski 
---
 src/browser/components/tools/BashToolCall.tsx | 31 +++++++++++++------
 1 file changed, 21 insertions(+), 10 deletions(-)

diff --git a/src/browser/components/tools/BashToolCall.tsx b/src/browser/components/tools/BashToolCall.tsx
index 0de96c1844..df77386c3e 100644
--- a/src/browser/components/tools/BashToolCall.tsx
+++ b/src/browser/components/tools/BashToolCall.tsx
@@ -39,6 +39,12 @@ interface BashToolCallProps {
   onSendToBackground?: () => void;
 }
 
+const EMPTY_LIVE_OUTPUT = {
+  stdout: "",
+  stderr: "",
+  truncated: false,
+};
+
 export const BashToolCall: React.FC = ({
   workspaceId,
   toolCallId,
@@ -108,9 +114,14 @@ export const BashToolCall: React.FC = ({
     status === "completed" && result && "backgroundProcessId" in result ? "backgrounded" : status;
 
   const resultHasOutput = typeof (result as { output?: unknown } | undefined)?.output === "string";
-  const showLiveOutput = Boolean(
-    liveOutput && !isBackground && (status === "executing" || !resultHasOutput)
-  );
+
+  const hasLiveOutputSource = Boolean(workspaceId && toolCallId);
+  const showLiveOutput =
+    !isBackground &&
+    hasLiveOutputSource &&
+    (status === "executing" || (Boolean(liveOutput) && !resultHasOutput));
+
+  const liveOutputView = liveOutput ?? EMPTY_LIVE_OUTPUT;
   const liveLabelSuffix = status === "executing" ? " (live)" : " (tail)";
 
   return (
@@ -176,9 +187,9 @@ export const BashToolCall: React.FC = ({
 
       {expanded && (
         
-          {showLiveOutput && liveOutput && (
+          {showLiveOutput && (
             <>
-              {liveOutput.truncated && (
+              {liveOutputView.truncated && (
                 
Live output truncated (showing last ~1MB)
@@ -191,10 +202,10 @@ export const BashToolCall: React.FC = ({ onScroll={(e) => updatePinned(e.currentTarget, stdoutPinnedRef)} className={cn( "px-2 py-1.5", - liveOutput.stdout.length === 0 && "text-muted italic" + liveOutputView.stdout.length === 0 && "text-muted italic" )} > - {liveOutput.stdout.length > 0 ? liveOutput.stdout : "No output yet"} + {liveOutputView.stdout.length > 0 ? liveOutputView.stdout : "No output yet"} @@ -205,10 +216,10 @@ export const BashToolCall: React.FC = ({ onScroll={(e) => updatePinned(e.currentTarget, stderrPinnedRef)} className={cn( "px-2 py-1.5", - liveOutput.stderr.length === 0 && "text-muted italic" + liveOutputView.stderr.length === 0 && "text-muted italic" )} > - {liveOutput.stderr.length > 0 ? liveOutput.stderr : "No output yet"} + {liveOutputView.stderr.length > 0 ? liveOutputView.stderr : "No output yet"} @@ -248,7 +259,7 @@ export const BashToolCall: React.FC = ({ )} - {status === "executing" && !result && ( + {status === "executing" && !result && !showLiveOutput && ( Waiting for result From 791374b5574a9fdddc15bdca64a33efedd33064c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Dec 2025 17:31:09 +0100 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stream=20bash=20outpu?= =?UTF-8?q?t=20in=20output=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Iaa4abaa85fe259214f09039381233c37726df175 Signed-off-by: Thomas Kosiewski --- src/browser/components/tools/BashToolCall.tsx | 74 +++++-------------- 1 file changed, 20 insertions(+), 54 deletions(-) diff --git a/src/browser/components/tools/BashToolCall.tsx b/src/browser/components/tools/BashToolCall.tsx index df77386c3e..1cb4236691 100644 --- a/src/browser/components/tools/BashToolCall.tsx +++ b/src/browser/components/tools/BashToolCall.tsx @@ -11,7 +11,6 @@ import { DetailSection, DetailLabel, DetailContent, - LoadingDots, ToolIcon, ErrorBox, ExitCodeBadge, @@ -60,31 +59,24 @@ export const BashToolCall: React.FC = ({ const liveOutput = useBashToolLiveOutput(workspaceId, toolCallId); - const stdoutRef = useRef(null); - const stderrRef = useRef(null); - const stdoutPinnedRef = useRef(true); - const stderrPinnedRef = useRef(true); + const outputRef = useRef(null); + const outputPinnedRef = useRef(true); - const updatePinned = (el: HTMLPreElement, pinnedRef: React.MutableRefObject) => { + const updatePinned = (el: HTMLPreElement) => { const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - pinnedRef.current = distanceToBottom < 40; + outputPinnedRef.current = distanceToBottom < 40; }; - useEffect(() => { - const el = stdoutRef.current; - if (!el) return; - if (stdoutPinnedRef.current) { - el.scrollTop = el.scrollHeight; - } - }, [liveOutput?.stdout]); + const liveOutputView = liveOutput ?? EMPTY_LIVE_OUTPUT; + const combinedLiveOutput = liveOutputView.stdout + liveOutputView.stderr; useEffect(() => { - const el = stderrRef.current; + const el = outputRef.current; if (!el) return; - if (stderrPinnedRef.current) { + if (outputPinnedRef.current) { el.scrollTop = el.scrollHeight; } - }, [liveOutput?.stderr]); + }, [combinedLiveOutput]); const startTimeRef = useRef(startedAt ?? Date.now()); // Track elapsed time for pending/executing status @@ -115,13 +107,9 @@ export const BashToolCall: React.FC = ({ const resultHasOutput = typeof (result as { output?: unknown } | undefined)?.output === "string"; - const hasLiveOutputSource = Boolean(workspaceId && toolCallId); const showLiveOutput = - !isBackground && - hasLiveOutputSource && - (status === "executing" || (Boolean(liveOutput) && !resultHasOutput)); + !isBackground && (status === "executing" || (Boolean(liveOutput) && !resultHasOutput)); - const liveOutputView = liveOutput ?? EMPTY_LIVE_OUTPUT; const liveLabelSuffix = status === "executing" ? " (live)" : " (tail)"; return ( @@ -187,6 +175,11 @@ export const BashToolCall: React.FC = ({ {expanded && ( + + Script + {args.script} + + {showLiveOutput && ( <> {liveOutputView.truncated && ( @@ -196,38 +189,20 @@ export const BashToolCall: React.FC = ({ )} - {`Stdout${liveLabelSuffix}`} - updatePinned(e.currentTarget, stdoutPinnedRef)} - className={cn( - "px-2 py-1.5", - liveOutputView.stdout.length === 0 && "text-muted italic" - )} - > - {liveOutputView.stdout.length > 0 ? liveOutputView.stdout : "No output yet"} - - - - - {`Stderr${liveLabelSuffix}`} + {`Output${liveLabelSuffix}`} updatePinned(e.currentTarget, stderrPinnedRef)} + ref={outputRef} + onScroll={(e) => updatePinned(e.currentTarget)} className={cn( "px-2 py-1.5", - liveOutputView.stderr.length === 0 && "text-muted italic" + combinedLiveOutput.length === 0 && "text-muted italic" )} > - {liveOutputView.stderr.length > 0 ? liveOutputView.stderr : "No output yet"} + {combinedLiveOutput.length > 0 ? combinedLiveOutput : "No output yet"} )} - - Script - {args.script} - {result && ( <> @@ -258,15 +233,6 @@ export const BashToolCall: React.FC = ({ )} )} - - {status === "executing" && !result && !showLiveOutput && ( - - - Waiting for result - - - - )} )} From e61a021c786dbf3b61504e693c51ad01bf74e5be Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Dec 2025 17:51:12 +0100 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20normalize=20live=20ba?= =?UTF-8?q?sh=20output=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: If9a4747c2cd9cc9461d1730445e83f9cf219a61d Signed-off-by: Thomas Kosiewski --- src/browser/components/tools/BashToolCall.tsx | 7 +++--- .../messages/liveBashOutputBuffer.test.ts | 17 +++++++++++++ .../utils/messages/liveBashOutputBuffer.ts | 25 ++++++++++++++++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/browser/components/tools/BashToolCall.tsx b/src/browser/components/tools/BashToolCall.tsx index 1cb4236691..eb0df78d9f 100644 --- a/src/browser/components/tools/BashToolCall.tsx +++ b/src/browser/components/tools/BashToolCall.tsx @@ -41,6 +41,7 @@ interface BashToolCallProps { const EMPTY_LIVE_OUTPUT = { stdout: "", stderr: "", + combined: "", truncated: false, }; @@ -68,7 +69,7 @@ export const BashToolCall: React.FC = ({ }; const liveOutputView = liveOutput ?? EMPTY_LIVE_OUTPUT; - const combinedLiveOutput = liveOutputView.stdout + liveOutputView.stderr; + const combinedLiveOutput = liveOutputView.combined; useEffect(() => { const el = outputRef.current; @@ -110,8 +111,6 @@ export const BashToolCall: React.FC = ({ const showLiveOutput = !isBackground && (status === "executing" || (Boolean(liveOutput) && !resultHasOutput)); - const liveLabelSuffix = status === "executing" ? " (live)" : " (tail)"; - return ( @@ -189,7 +188,7 @@ export const BashToolCall: React.FC = ({ )} - {`Output${liveLabelSuffix}`} + Output updatePinned(e.currentTarget)} diff --git a/src/browser/utils/messages/liveBashOutputBuffer.test.ts b/src/browser/utils/messages/liveBashOutputBuffer.test.ts index 9bc23ba136..521f6e20da 100644 --- a/src/browser/utils/messages/liveBashOutputBuffer.test.ts +++ b/src/browser/utils/messages/liveBashOutputBuffer.test.ts @@ -6,22 +6,36 @@ describe("appendLiveBashOutputChunk", () => { const a = appendLiveBashOutputChunk(undefined, { text: "out1\n", isError: false }, 1024); expect(a.stdout).toBe("out1\n"); expect(a.stderr).toBe(""); + expect(a.combined).toBe("out1\n"); expect(a.truncated).toBe(false); const b = appendLiveBashOutputChunk(a, { text: "err1\n", isError: true }, 1024); expect(b.stdout).toBe("out1\n"); expect(b.stderr).toBe("err1\n"); + expect(b.combined).toBe("out1\nerr1\n"); expect(b.truncated).toBe(false); }); + it("normalizes carriage returns to newlines", () => { + const a = appendLiveBashOutputChunk(undefined, { text: "a\rb", isError: false }, 1024); + expect(a.stdout).toBe("a\nb"); + expect(a.combined).toBe("a\nb"); + + const b = appendLiveBashOutputChunk(undefined, { text: "a\r\nb", isError: false }, 1024); + expect(b.stdout).toBe("a\nb"); + expect(b.combined).toBe("a\nb"); + }); + it("drops the oldest segments to enforce maxBytes", () => { const maxBytes = 5; const a = appendLiveBashOutputChunk(undefined, { text: "1234", isError: false }, maxBytes); expect(a.stdout).toBe("1234"); + expect(a.combined).toBe("1234"); expect(a.truncated).toBe(false); const b = appendLiveBashOutputChunk(a, { text: "abc", isError: false }, maxBytes); expect(b.stdout).toBe("abc"); + expect(b.combined).toBe("abc"); expect(b.truncated).toBe(true); }); @@ -34,6 +48,7 @@ describe("appendLiveBashOutputChunk", () => { // total "a" (1) + "bb" (2) + "ccc" (3) = 6 (fits) expect(c.stdout).toBe("accc"); expect(c.stderr).toBe("bb"); + expect(c.combined).toBe("abbccc"); expect(c.truncated).toBe(false); const d = appendLiveBashOutputChunk(c, { text: "DD", isError: true }, maxBytes); @@ -41,6 +56,7 @@ describe("appendLiveBashOutputChunk", () => { // Drops stdout "a" (1) then stderr "bb" (2) => remaining "ccc" (3) + "DD" (2) = 5 expect(d.stdout).toBe("ccc"); expect(d.stderr).toBe("DD"); + expect(d.combined).toBe("cccDD"); expect(d.truncated).toBe(true); }); @@ -49,6 +65,7 @@ describe("appendLiveBashOutputChunk", () => { const a = appendLiveBashOutputChunk(undefined, { text: "hello", isError: false }, maxBytes); expect(a.stdout).toBe(""); expect(a.stderr).toBe(""); + expect(a.combined).toBe(""); expect(a.truncated).toBe(true); }); }); diff --git a/src/browser/utils/messages/liveBashOutputBuffer.ts b/src/browser/utils/messages/liveBashOutputBuffer.ts index 666abf0987..ea3bee6c6f 100644 --- a/src/browser/utils/messages/liveBashOutputBuffer.ts +++ b/src/browser/utils/messages/liveBashOutputBuffer.ts @@ -1,6 +1,8 @@ export interface LiveBashOutputView { stdout: string; stderr: string; + /** Combined output in emission order (stdout/stderr interleaved). */ + combined: string; truncated: boolean; } @@ -21,6 +23,11 @@ export interface LiveBashOutputInternal extends LiveBashOutputView { totalBytes: number; } +function normalizeNewlines(text: string): string { + // Many CLIs print "progress" output using carriage returns so they can update a single line. + // In our UI, that reads better as actual line breaks. + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} function getUtf8ByteLength(text: string): number { return new TextEncoder().encode(text).length; } @@ -39,17 +46,20 @@ export function appendLiveBashOutputChunk( ({ stdout: "", stderr: "", + combined: "", truncated: false, segments: [], totalBytes: 0, } satisfies LiveBashOutputInternal); - if (chunk.text.length === 0) return base; + const normalizedText = normalizeNewlines(chunk.text); + if (normalizedText.length === 0) return base; // Clone for purity (tests + avoids hidden mutation assumptions). const next: LiveBashOutputInternal = { stdout: base.stdout, stderr: base.stderr, + combined: base.combined, truncated: base.truncated, segments: base.segments.slice(), totalBytes: base.totalBytes, @@ -57,12 +67,13 @@ export function appendLiveBashOutputChunk( const segment: LiveBashOutputSegment = { isError: chunk.isError, - text: chunk.text, - bytes: getUtf8ByteLength(chunk.text), + text: normalizedText, + bytes: getUtf8ByteLength(normalizedText), }; next.segments.push(segment); next.totalBytes += segment.bytes; + next.combined += segment.text; if (segment.isError) { next.stderr += segment.text; } else { @@ -75,6 +86,7 @@ export function appendLiveBashOutputChunk( next.totalBytes -= removed.bytes; next.truncated = true; + next.combined = next.combined.slice(removed.text.length); if (removed.isError) { next.stderr = next.stderr.slice(removed.text.length); @@ -91,5 +103,10 @@ export function appendLiveBashOutputChunk( } export function toLiveBashOutputView(state: LiveBashOutputInternal): LiveBashOutputView { - return { stdout: state.stdout, stderr: state.stderr, truncated: state.truncated }; + return { + stdout: state.stdout, + stderr: state.stderr, + combined: state.combined, + truncated: state.truncated, + }; }