diff --git a/docs/AGENTS.md b/docs/AGENTS.md index c24563984..7fc5330b0 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/components/Messages/ToolMessage.tsx b/src/browser/components/Messages/ToolMessage.tsx index 7d9abc13a..5f4089312 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 (
void; } +const EMPTY_LIVE_OUTPUT = { + stdout: "", + stderr: "", + combined: "", + truncated: false, +}; + export const BashToolCall: React.FC = ({ + workspaceId, + toolCallId, args, result, status = "pending", @@ -46,6 +57,27 @@ export const BashToolCall: React.FC = ({ }) => { const { expanded, toggleExpanded } = useToolExpansion(); const [elapsedTime, setElapsedTime] = useState(0); + + const liveOutput = useBashToolLiveOutput(workspaceId, toolCallId); + + const outputRef = useRef(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(startedAt ?? Date.now()); // Track elapsed time for pending/executing status @@ -74,6 +106,11 @@ 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 = + !isBackground && (status === "executing" || (Boolean(liveOutput) && !resultHasOutput)); + return ( @@ -142,6 +179,30 @@ export const BashToolCall: React.FC = ({ {args.script} + {showLiveOutput && ( + <> + {liveOutputView.truncated && ( +
+ Live output truncated (showing last ~1MB) +
+ )} + + + Output + updatePinned(e.currentTarget)} + className={cn( + "px-2 py-1.5", + combinedLiveOutput.length === 0 && "text-muted italic" + )} + > + {combinedLiveOutput.length > 0 ? combinedLiveOutput : "No output yet"} + + + + )} + {result && ( <> {result.success === false && result.error && ( @@ -171,15 +232,6 @@ export const BashToolCall: React.FC = ({ )} )} - - {status === "executing" && !result && ( - - - Waiting for result - - - - )} )}
diff --git a/src/browser/components/tools/shared/ToolPrimitives.tsx b/src/browser/components/tools/shared/ToolPrimitives.tsx index 18afd64ea..69bf155db 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 691d2f955..3855b6abf 100644
--- a/src/browser/stores/WorkspaceStore.test.ts
+++ b/src/browser/stores/WorkspaceStore.test.ts
@@ -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();
+    });
+  });
 });
diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts
index 2b1a5706d..87c0a9b52 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,11 @@ 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,
+  type LiveBashOutputInternal,
+  type LiveBashOutputView,
+} from "@/browser/utils/messages/liveBashOutputBuffer";
 import { trackStreamCompleted } from "@/common/telemetry";
 
 export interface WorkspaceState {
@@ -252,6 +259,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 +376,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);
@@ -508,6 +534,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);
@@ -564,7 +602,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);
     }
   }
@@ -613,6 +655,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 +700,15 @@ 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);
+
+    // 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;
+  }
+
   /**
    * Assert that workspace exists and return its aggregator.
    * Centralized assertion for all workspace access methods.
@@ -1114,11 +1190,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) {
@@ -1146,6 +1218,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 +1269,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 +1443,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 +1451,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 +1585,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 000000000..521f6e20d
--- /dev/null
+++ b/src/browser/utils/messages/liveBashOutputBuffer.test.ts
@@ -0,0 +1,71 @@
+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.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);
+  });
+
+  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.combined).toBe("abbccc");
+    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.combined).toBe("cccDD");
+    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.combined).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 000000000..ea3bee6c6
--- /dev/null
+++ b/src/browser/utils/messages/liveBashOutputBuffer.ts
@@ -0,0 +1,112 @@
+export interface LiveBashOutputView {
+  stdout: string;
+  stderr: string;
+  /** Combined output in emission order (stdout/stderr interleaved). */
+  combined: 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 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;
+}
+
+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: "",
+      combined: "",
+      truncated: false,
+      segments: [],
+      totalBytes: 0,
+    } satisfies LiveBashOutputInternal);
+
+  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,
+  };
+
+  const segment: LiveBashOutputSegment = {
+    isError: chunk.isError,
+    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 {
+    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;
+    next.combined = next.combined.slice(removed.text.length);
+
+    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,
+    combined: state.combined,
+    truncated: state.truncated,
+  };
+}
diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts
index 6ddc25485..d61eead7a 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 f5abf5d10..d66748f68 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 6eed26e3f..d7cb32553 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 2d11a8034..ee35d8d32 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 55b0d8f88..2e0a9f533 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 34ed42806..8314d34e3 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 630e8f02d..2e86b2495 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 c88a21337..d3b0b71e9 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 f72f0fddc..015c5e254 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) => {
@@ -470,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),
         ]);
 
@@ -482,6 +573,21 @@ ${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;
+          });
+
+          // 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;
@@ -565,6 +671,8 @@ ${script}`;
           exitCode: -1,
           wall_duration_ms: Math.round(performance.now() - startTime),
         };
+      } finally {
+        stopLiveOutput(true);
       }
 
       // Unregister foreground process on normal completion