@hienlh/ppm 0.11.9 → 0.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/web/assets/{audio-preview-DklYMLn9.js → audio-preview-DBoohjr_.js} +1 -1
  3. package/dist/web/assets/chat-tab-Bw-y-XyO.js +12 -0
  4. package/dist/web/assets/{code-editor-b_XM6ZCA.js → code-editor-CsgU4_v0.js} +2 -2
  5. package/dist/web/assets/{conflict-editor-B0doehdT.js → conflict-editor-77mY0l5p.js} +1 -1
  6. package/dist/web/assets/{database-viewer-BOy1odBI.js → database-viewer-BHlWYrEW.js} +1 -1
  7. package/dist/web/assets/{diff-viewer-BIpHr-Qa.js → diff-viewer-BLBnf1k7.js} +1 -1
  8. package/dist/web/assets/{extension-webview-BISaL10b.js → extension-webview-BBiDp-Dm.js} +1 -1
  9. package/dist/web/assets/{image-preview-BK7gTzfW.js → image-preview-o3dt-W4e.js} +1 -1
  10. package/dist/web/assets/index-FKwHNxD8.css +2 -0
  11. package/dist/web/assets/index-Jmzyq_sm.js +23 -0
  12. package/dist/web/assets/{markdown-renderer-B2-O-_b9.js → markdown-renderer-DauR_bTH.js} +1 -1
  13. package/dist/web/assets/{pdf-preview-CAWtJtyh.js → pdf-preview-DWk-mv_p.js} +1 -1
  14. package/dist/web/assets/{port-forwarding-tab-CgO2JZy8.js → port-forwarding-tab-CnNHiV1J.js} +1 -1
  15. package/dist/web/assets/{postgres-viewer-ByUqIYuz.js → postgres-viewer-CYP0QhAp.js} +1 -1
  16. package/dist/web/assets/{settings-tab-CB1Zns46.js → settings-tab-BIVMWGLW.js} +1 -1
  17. package/dist/web/assets/{sqlite-viewer-OUxK-Ssz.js → sqlite-viewer-BfhYqGGK.js} +1 -1
  18. package/dist/web/assets/{terminal-tab-D5zcIjmH.js → terminal-tab-CsUkjfGm.js} +1 -1
  19. package/dist/web/assets/{video-preview-K0XoP6N-.js → video-preview-M0cH5kLT.js} +1 -1
  20. package/dist/web/index.html +2 -2
  21. package/dist/web/sw.js +1 -1
  22. package/docs/project-changelog.md +31 -1
  23. package/docs/system-architecture.md +2 -1
  24. package/package.json +1 -1
  25. package/src/server/index.ts +17 -11
  26. package/src/server/ws/chat.ts +23 -0
  27. package/src/services/bash-output-spy.ts +213 -0
  28. package/src/services/supervisor.ts +21 -3
  29. package/src/types/api.ts +1 -0
  30. package/src/web/components/chat/chat-tab.tsx +2 -0
  31. package/src/web/components/chat/message-list.tsx +11 -5
  32. package/src/web/components/chat/tool-cards.tsx +53 -2
  33. package/src/web/components/explorer/file-tree.tsx +57 -24
  34. package/src/web/hooks/use-chat.ts +38 -0
  35. package/dist/web/assets/chat-tab-D8jTquKg.js +0 -10
  36. package/dist/web/assets/index-CaIM2BmJ.js +0 -23
  37. package/dist/web/assets/index-DYi59ytw.css +0 -2
@@ -6,6 +6,7 @@ import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk"
6
6
  import { getSessionTitle } from "../../services/db.service.ts";
7
7
  import type { ChatWsClientMessage, SessionPhase } from "../../types/api.ts";
8
8
  import { startWatching, stopWatching, onFileChange } from "../../services/file-watcher.service.ts";
9
+ import { bashOutputSpy } from "../../services/bash-output-spy.ts";
9
10
 
10
11
  // Broadcast file changes to all WS clients for real-time editor reload
11
12
  onFileChange((projectName, path) => {
@@ -301,9 +302,27 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
301
302
  entry.pendingTeamCreate = ev.toolUseId;
302
303
  console.log(`[chat] session=${sessionId} TeamCreate tool_use detected, toolUseId=${ev.toolUseId}`);
303
304
  }
305
+ // Start bash output spy for real-time streaming
306
+ if (ev.tool === "Bash" && ev.toolUseId) {
307
+ const command = typeof ev.input === "object" && ev.input
308
+ ? String((ev.input as any).command ?? "")
309
+ : "";
310
+ if (command) {
311
+ bashOutputSpy.startSpy(ev.toolUseId, command, sessionId, (output) => {
312
+ broadcast(sessionId, {
313
+ type: "bash_output",
314
+ toolUseId: output.toolUseId,
315
+ content: output.newContent,
316
+ lineCount: output.totalLineCount,
317
+ });
318
+ });
319
+ }
320
+ }
304
321
  } else if (evType === "tool_result") {
305
322
  logSessionEvent(sessionId, "TOOL_RESULT", `error=${ev.isError ?? false} ${(ev.output ?? "").slice(0, 300)}`);
306
323
  console.log(`[chat] session=${sessionId} tool_result: toolUseId=${ev.toolUseId} pendingTeamCreate=${entry.pendingTeamCreate} output=${(ev.output ?? "").slice(0, 200)}`);
324
+ // Stop bash output spy for this tool
325
+ if (ev.toolUseId) bashOutputSpy.stopSpy(ev.toolUseId);
307
326
  // Detect team creation from TeamCreate tool_result
308
327
  if (entry.pendingTeamCreate && entry.pendingTeamCreate === ev.toolUseId) {
309
328
  const { extractTeamName, startTeamInboxWatcher } = await import("./team-inbox-watcher.ts");
@@ -376,6 +395,8 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
376
395
  const newId = ev.newSessionId as string;
377
396
  if (newId && newId !== sessionId) {
378
397
  console.log(`[chat] session_migrated: ${sessionId} → ${newId}`);
398
+ // Stop spies tagged with old session ID before re-keying
399
+ bashOutputSpy.stopAllForSession(sessionId);
379
400
  const oldEntry = activeSessions.get(sessionId);
380
401
  if (oldEntry) {
381
402
  activeSessions.delete(sessionId);
@@ -413,6 +434,8 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
413
434
  entry.turnEvents = [];
414
435
  setPhase(sessionId, "idle");
415
436
  entry.pendingApprovalEvent = undefined;
437
+ // Cleanup bash output spies
438
+ bashOutputSpy.stopAllForSession(sessionId);
416
439
  // Cleanup team watchers
417
440
  for (const w of entry.teamWatchers.values()) w.cleanup();
418
441
  entry.teamWatchers.clear();
@@ -0,0 +1,213 @@
1
+ /**
2
+ * BashOutputSpy — monitors SDK Bash tool execution by tailing output files.
3
+ *
4
+ * SDK redirects bash stdout to /tmp/claude-{uid}/-tmp/{uuid}/tasks/{task-id}.output
5
+ * We find the bash PID via pgrep, resolve its stdout fd to the output file,
6
+ * then poll every 100ms for new content, emitting line-buffered deltas.
7
+ *
8
+ * Cross-platform: Linux (/proc/PID/fd/1), macOS (lsof), Windows native = no-op.
9
+ * Graceful degradation: spy failure = same UX as today (tool_result shows full output).
10
+ */
11
+
12
+ interface SpyEntry {
13
+ sessionId: string;
14
+ toolUseId: string;
15
+ filePath: string;
16
+ bytesRead: number;
17
+ lineBuffer: string;
18
+ polling: boolean;
19
+ intervalId: ReturnType<typeof setInterval>;
20
+ }
21
+
22
+ export interface SpyOutput {
23
+ sessionId: string;
24
+ toolUseId: string;
25
+ newContent: string;
26
+ totalLineCount: number;
27
+ }
28
+
29
+ type OutputCallback = (output: SpyOutput) => void;
30
+
31
+ const activeSpies = new Map<string, SpyEntry>();
32
+ /** Track total lines per spy for lineCount reporting */
33
+ const lineCounters = new Map<string, number>();
34
+
35
+ /** Escape special regex chars for pgrep -f */
36
+ function escapeForPgrep(cmd: string): string {
37
+ // pgrep uses extended regex — escape metacharacters
38
+ return cmd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
+ }
40
+
41
+ /** Find the newest bash PID matching a command substring (current user only) */
42
+ async function findBashPid(commandSubstring: string): Promise<number | null> {
43
+ try {
44
+ // Use a short unique substring of the command for matching
45
+ const searchStr = escapeForPgrep(commandSubstring.slice(0, 80));
46
+ const uid = String(process.getuid?.() ?? "");
47
+ const args = uid ? ["pgrep", "-fn", "-u", uid, searchStr] : ["pgrep", "-fn", searchStr];
48
+ const proc = Bun.spawn(args, {
49
+ stdout: "pipe",
50
+ stderr: "pipe",
51
+ });
52
+ const text = await new Response(proc.stdout).text();
53
+ const exitCode = await proc.exited;
54
+ if (exitCode !== 0) return null;
55
+ const pid = parseInt(text.trim(), 10);
56
+ return Number.isFinite(pid) ? pid : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** Resolve the stdout output file for a PID (cross-platform) */
63
+ async function resolveOutputFile(pid: number): Promise<string | null> {
64
+ try {
65
+ if (process.platform === "linux") {
66
+ // readlink /proc/PID/fd/1 → output file path
67
+ const proc = Bun.spawn(["readlink", `/proc/${pid}/fd/1`], {
68
+ stdout: "pipe",
69
+ stderr: "pipe",
70
+ });
71
+ const target = (await new Response(proc.stdout).text()).trim();
72
+ const exitCode = await proc.exited;
73
+ if (exitCode !== 0 || !target) return null;
74
+ if (target.includes("/tasks/") && target.endsWith(".output")) return target;
75
+ return null;
76
+ }
77
+
78
+ if (process.platform === "darwin") {
79
+ // lsof -p PID → parse for .output file in /tasks/
80
+ const proc = Bun.spawn(["lsof", "-p", String(pid)], {
81
+ stdout: "pipe",
82
+ stderr: "pipe",
83
+ });
84
+ const text = await new Response(proc.stdout).text();
85
+ await proc.exited;
86
+ for (const line of text.split("\n")) {
87
+ const match = line.match(/\s(\/\S+\/tasks\/\S+\.output)\s*$/);
88
+ if (match) return match[1]!;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ // Windows native: no-op
94
+ return null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** Poll the output file for new content, emit complete lines */
101
+ async function pollFile(entry: SpyEntry, onOutput: OutputCallback): Promise<void> {
102
+ try {
103
+ const file = Bun.file(entry.filePath);
104
+ const size = file.size;
105
+ if (size <= entry.bytesRead) return;
106
+
107
+ const newBytes = file.slice(entry.bytesRead, size);
108
+ const chunk = await newBytes.text();
109
+ entry.bytesRead = size;
110
+
111
+ // Buffer partial lines — only emit on complete newlines
112
+ const combined = entry.lineBuffer + chunk;
113
+ const lastNewline = combined.lastIndexOf("\n");
114
+
115
+ if (lastNewline === -1) {
116
+ // No complete line yet — buffer everything
117
+ entry.lineBuffer = combined;
118
+ return;
119
+ }
120
+
121
+ // Emit complete lines, keep remainder in buffer
122
+ const toEmit = combined.slice(0, lastNewline + 1);
123
+ entry.lineBuffer = combined.slice(lastNewline + 1);
124
+
125
+ const lineCount = (lineCounters.get(entry.toolUseId) ?? 0) + toEmit.split("\n").length - 1;
126
+ lineCounters.set(entry.toolUseId, lineCount);
127
+
128
+ onOutput({
129
+ sessionId: entry.sessionId,
130
+ toolUseId: entry.toolUseId,
131
+ newContent: toEmit,
132
+ totalLineCount: lineCount,
133
+ });
134
+ } catch {
135
+ // File may have been deleted — stop spying
136
+ stopSpy(entry.toolUseId);
137
+ }
138
+ }
139
+
140
+ /** Start monitoring a Bash tool's output */
141
+ async function startSpy(
142
+ toolUseId: string,
143
+ command: string,
144
+ sessionId: string,
145
+ onOutput: OutputCallback,
146
+ ): Promise<void> {
147
+ // Platform guard: skip on native Windows (not WSL)
148
+ if (process.platform === "win32") return;
149
+
150
+ // Already spying this tool
151
+ if (activeSpies.has(toolUseId)) return;
152
+
153
+ // Retry PID discovery up to 3 times with 100ms delay
154
+ let pid: number | null = null;
155
+ for (let attempt = 0; attempt < 3; attempt++) {
156
+ pid = await findBashPid(command);
157
+ if (pid) break;
158
+ await new Promise((r) => setTimeout(r, 100));
159
+ }
160
+
161
+ if (!pid) {
162
+ console.log(`[bash-spy] toolUseId=${toolUseId} PID not found — skipping`);
163
+ return;
164
+ }
165
+
166
+ const filePath = await resolveOutputFile(pid);
167
+ if (!filePath) {
168
+ console.log(`[bash-spy] toolUseId=${toolUseId} output file not resolved — skipping`);
169
+ return;
170
+ }
171
+
172
+ const entry: SpyEntry = {
173
+ sessionId,
174
+ toolUseId,
175
+ filePath,
176
+ bytesRead: 0,
177
+ lineBuffer: "",
178
+ polling: false,
179
+ intervalId: setInterval(async () => {
180
+ if (entry.polling) return;
181
+ entry.polling = true;
182
+ try { await pollFile(entry, onOutput); }
183
+ finally { entry.polling = false; }
184
+ }, 100),
185
+ };
186
+
187
+ activeSpies.set(toolUseId, entry);
188
+ lineCounters.set(toolUseId, 0);
189
+ console.log(`[bash-spy] started toolUseId=${toolUseId} pid=${pid} file=${filePath}`);
190
+ }
191
+
192
+ /** Stop monitoring a specific Bash tool */
193
+ function stopSpy(toolUseId: string): void {
194
+ const entry = activeSpies.get(toolUseId);
195
+ if (!entry) return;
196
+ clearInterval(entry.intervalId);
197
+ activeSpies.delete(toolUseId);
198
+ lineCounters.delete(toolUseId);
199
+ console.log(`[bash-spy] stopped toolUseId=${toolUseId}`);
200
+ }
201
+
202
+ /** Stop all active spies for a session (cleanup on disconnect) */
203
+ function stopAllForSession(sessionId: string): void {
204
+ for (const [id, entry] of activeSpies) {
205
+ if (entry.sessionId === sessionId) {
206
+ clearInterval(entry.intervalId);
207
+ activeSpies.delete(id);
208
+ lineCounters.delete(id);
209
+ }
210
+ }
211
+ }
212
+
213
+ export const bashOutputSpy = { startSpy, stopSpy, stopAllForSession };
@@ -440,11 +440,29 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
440
440
  }
441
441
 
442
442
  // Kill server child to free the port; keep tunnel alive for domain continuity
443
+ // Use SIGKILL + process group kill to ensure grandchildren (SDK subprocesses) die too
443
444
  log("INFO", "Stopping server before spawning new supervisor (tunnel kept alive)");
444
- if (serverChild) { try { serverChild.kill(); } catch {} serverChild = null; }
445
+ if (serverChild) {
446
+ const pid = serverChild.pid;
447
+ try { process.kill(-pid, "SIGKILL"); } catch {} // kill process group
448
+ try { serverChild.kill("SIGKILL"); } catch {} // fallback: kill direct child
449
+ serverChild = null;
450
+ }
445
451
  if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
446
- // Brief wait for port release
447
- await Bun.sleep(500);
452
+ // Poll until port is actually free (max 10s) — never guess with fixed sleep
453
+ const portFreeStart = Date.now();
454
+ while (Date.now() - portFreeStart < 10_000) {
455
+ const inUse = await new Promise<boolean>((resolve) => {
456
+ const net = require("node:net") as typeof import("node:net");
457
+ const tester = net.createServer()
458
+ .once("error", (e: NodeJS.ErrnoException) => resolve(e.code === "EADDRINUSE"))
459
+ .once("listening", () => tester.close(() => resolve(false)))
460
+ .listen(_opts.port, _opts.host);
461
+ });
462
+ if (!inUse) break;
463
+ log("DEBUG", `Port ${_opts.port} still in use, waiting...`);
464
+ await Bun.sleep(200);
465
+ }
448
466
 
449
467
  // Spawn new supervisor using saved argv
450
468
  const cmd = originalArgv.slice();
package/src/types/api.ts CHANGED
@@ -36,6 +36,7 @@ export type ChatWsServerMessage =
36
36
  | { type: "thinking"; content: string; parentToolUseId?: string }
37
37
  | { type: "tool_use"; tool: string; input: unknown; toolUseId?: string; parentToolUseId?: string }
38
38
  | { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
39
+ | { type: "bash_output"; toolUseId: string; content: string; lineCount: number }
39
40
  | { type: "approval_request"; requestId: string; tool: string; input: unknown }
40
41
  | { type: "done"; sessionId: string; contextWindowPct?: number }
41
42
  | { type: "error"; message: string }
@@ -108,6 +108,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
108
108
  teamActivity,
109
109
  teamMessages,
110
110
  markTeamRead,
111
+ bashPartialOutput,
111
112
  } = useChat(sessionId, providerId, projectName);
112
113
 
113
114
  // Flush pending message once WS connects (replaces unreliable setTimeout)
@@ -398,6 +399,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
398
399
  projectName={projectName}
399
400
  onFork={!isStreaming ? handleFork : undefined}
400
401
  onSelectSession={handleSelectSession}
402
+ bashPartialOutput={bashPartialOutput}
401
403
  />
402
404
 
403
405
  {/* Bottom toolbar */}
@@ -3,6 +3,7 @@ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
3
3
  import { getAuthToken } from "@/lib/api-client";
4
4
  import type { ChatMessage, ChatEvent } from "../../../types/chat";
5
5
  import type { SessionPhase } from "../../../types/api";
6
+ import type { BashPartialEntry } from "../../hooks/use-chat";
6
7
  import { ToolCard } from "./tool-cards";
7
8
  const MarkdownRenderer = lazy(() =>
8
9
  import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
@@ -51,6 +52,8 @@ interface MessageListProps {
51
52
  onFork?: (userMessage: string, messageId?: string) => void;
52
53
  /** Called when user selects a recent session from the welcome screen */
53
54
  onSelectSession?: (session: import("../../../types/chat").SessionInfo) => void;
55
+ /** Partial bash output ref from useChat for real-time streaming */
56
+ bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
54
57
  }
55
58
 
56
59
  export function MessageList({
@@ -66,6 +69,7 @@ export function MessageList({
66
69
  compactStatus,
67
70
  projectName,
68
71
  onFork,
72
+ bashPartialOutput,
69
73
  }: MessageListProps) {
70
74
  // Scroll handled by StickToBottom wrapper — no manual scroll logic needed
71
75
 
@@ -136,6 +140,7 @@ export function MessageList({
136
140
  projectName={projectName}
137
141
  onFork={msg.role === "user" && onFork ? handleFork : undefined}
138
142
  prevMsgId={prevMsg?.sdkUuid ?? prevMsg?.id}
143
+ bashPartialOutput={bashPartialOutput}
139
144
  />
140
145
  );
141
146
  })}
@@ -170,10 +175,11 @@ function ScrollToBottomButton() {
170
175
  );
171
176
  }
172
177
 
173
- const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId }: {
178
+ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput }: {
174
179
  message: ChatMessage; isStreaming: boolean; projectName?: string;
175
180
  onFork?: (content: string, messageId: string | undefined) => void;
176
- prevMsgId?: string
181
+ prevMsgId?: string;
182
+ bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
177
183
  }) {
178
184
  if (message.role === "user") {
179
185
  const handleFork = onFork ? () => onFork(message.content, prevMsgId) : undefined;
@@ -195,7 +201,7 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
195
201
  return (
196
202
  <div className="flex flex-col gap-2">
197
203
  {message.events && message.events.length > 0
198
- ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} />
204
+ ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} />
199
205
  : message.content && (
200
206
  <div className="text-sm text-text-primary select-text">
201
207
  <MarkdownContent content={message.content} projectName={projectName} />
@@ -636,7 +642,7 @@ type EventGroup =
636
642
  | { kind: "thinking"; content: string }
637
643
  | { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
638
644
 
639
- function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatEvent[]; isStreaming: boolean; projectName?: string }) {
645
+ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput }: { events: ChatEvent[]; isStreaming: boolean; projectName?: string; bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>> }) {
640
646
  // Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
641
647
  const groups: EventGroup[] = [];
642
648
  let textBuffer = "";
@@ -757,7 +763,7 @@ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatE
757
763
  </div>
758
764
  );
759
765
  }
760
- return <ToolCard key={`tool-${i}`} tool={group.tool} result={group.result} completed={group.completed} projectName={projectName} />;
766
+ return <ToolCard key={`tool-${i}`} tool={group.tool} result={group.result} completed={group.completed} projectName={projectName} bashPartialOutput={bashPartialOutput} />;
761
767
  })}
762
768
  </>
763
769
  );
@@ -2,7 +2,8 @@
2
2
  * Tool card components for chat message rendering.
3
3
  * Handles summary + details for all SDK tool types.
4
4
  */
5
- import { useState, useMemo, lazy, Suspense } from "react";
5
+ import { useState, useEffect, useRef, useMemo, lazy, Suspense } from "react";
6
+ import type { BashPartialEntry } from "../../hooks/use-chat";
6
7
  const MarkdownRenderer = lazy(() =>
7
8
  import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
8
9
  );
@@ -48,11 +49,13 @@ export function ToolCard({
48
49
  result,
49
50
  completed,
50
51
  projectName,
52
+ bashPartialOutput,
51
53
  }: {
52
54
  tool: ChatEvent;
53
55
  result?: ChatEvent;
54
56
  completed?: boolean;
55
57
  projectName?: string;
58
+ bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
56
59
  }) {
57
60
  const [expanded, setExpanded] = useState(false);
58
61
 
@@ -75,6 +78,18 @@ export function ToolCard({
75
78
  const hasChildren = children && children.length > 0;
76
79
  const isDone = hasResult || hasAnswers || wasApproved || completed;
77
80
 
81
+ // Read partial bash output for streaming Bash tools
82
+ const toolUseId = tool.type === "tool_use" ? (tool as any).toolUseId as string | undefined : undefined;
83
+ const partial = toolName === "Bash" && !hasResult && toolUseId
84
+ ? bashPartialOutput?.current?.get(toolUseId)
85
+ : undefined;
86
+ const isStreamingBash = !!partial;
87
+
88
+ // Auto-expand ToolCard when streaming bash output
89
+ useEffect(() => {
90
+ if (isStreamingBash && !expanded) setExpanded(true);
91
+ }, [isStreamingBash]); // eslint-disable-line react-hooks/exhaustive-deps
92
+
78
93
  return (
79
94
  <div className={`rounded border text-xs ${isSubagent ? "border-accent/30 bg-accent/5" : "border-border bg-background"}`}>
80
95
  <button
@@ -90,7 +105,10 @@ export function ToolCard({
90
105
  <span className="truncate text-text-primary">
91
106
  <ToolSummary name={toolName} input={input} />
92
107
  </span>
93
- {hasChildren && (
108
+ {isStreamingBash && (
109
+ <span className="ml-auto text-[10px] text-yellow-400 shrink-0">{partial!.lineCount} line{partial!.lineCount !== 1 ? "s" : ""} streaming...</span>
110
+ )}
111
+ {hasChildren && !isStreamingBash && (
94
112
  <span className="ml-auto text-[10px] text-text-subtle shrink-0">{children!.length} steps</span>
95
113
  )}
96
114
  </button>
@@ -99,6 +117,8 @@ export function ToolCard({
99
117
  {(tool.type === "tool_use" || tool.type === "approval_request") && (
100
118
  <ToolDetails name={toolName} input={input} projectName={projectName} />
101
119
  )}
120
+ {/* Streaming bash output */}
121
+ {partial && <StreamingBashOutput content={partial.content} lineCount={partial.lineCount} />}
102
122
  {/* Subagent children: render nested tool events */}
103
123
  {hasChildren && (
104
124
  <SubagentChildren events={children!} projectName={projectName} />
@@ -463,6 +483,37 @@ function MiniMarkdown({ content, maxHeight = "max-h-48" }: { content: string; ma
463
483
  }
464
484
 
465
485
 
486
+ /** Real-time streaming bash output with auto-scroll */
487
+ function StreamingBashOutput({ content, lineCount }: { content: string; lineCount: number }) {
488
+ const preRef = useRef<HTMLPreElement>(null);
489
+ const userScrolledRef = useRef(false);
490
+
491
+ useEffect(() => {
492
+ if (preRef.current && !userScrolledRef.current) {
493
+ preRef.current.scrollTop = preRef.current.scrollHeight;
494
+ }
495
+ }, [content]);
496
+
497
+ return (
498
+ <div className="border-t border-border pt-1.5">
499
+ <div className="flex items-center gap-1 text-[10px] text-yellow-400 mb-1">
500
+ <Loader2 className="size-3 animate-spin" />
501
+ <span>Output ({lineCount} line{lineCount !== 1 ? "s" : ""}, streaming...)</span>
502
+ </div>
503
+ <pre
504
+ ref={preRef}
505
+ onScroll={(e) => {
506
+ const el = e.currentTarget;
507
+ userScrolledRef.current = el.scrollTop + el.clientHeight < el.scrollHeight - 20;
508
+ }}
509
+ className="overflow-x-auto overflow-y-auto max-h-60 text-text-subtle font-mono whitespace-pre-wrap break-all text-[11px]"
510
+ >
511
+ {content.split("\n").slice(-200).join("\n")}
512
+ </pre>
513
+ </div>
514
+ );
515
+ }
516
+
466
517
  function truncate(str?: string, max = 50): string {
467
518
  if (!str) return "";
468
519
  return str.length > max ? str.slice(0, max) + "…" : str;
@@ -7,6 +7,12 @@ import {
7
7
  FileJson,
8
8
  FileText,
9
9
  FileType,
10
+ FileImage,
11
+ FileVideo,
12
+ FileAudio,
13
+ FileSpreadsheet,
14
+ FileArchive,
15
+ Database,
10
16
  ChevronRight,
11
17
  ChevronDown,
12
18
  Download,
@@ -40,27 +46,56 @@ function isExternalFileDrag(e: React.DragEvent): boolean {
40
46
  /** Synthetic root node for creating files/folders at project root */
41
47
  const ROOT_NODE: FileNode = { name: "", path: "", type: "directory" };
42
48
 
43
- const FILE_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
44
- ts: FileCode,
45
- tsx: FileCode,
46
- js: FileCode,
47
- jsx: FileCode,
48
- py: FileCode,
49
- rs: FileCode,
50
- go: FileCode,
51
- json: FileJson,
52
- md: FileText,
53
- txt: FileText,
54
- yaml: FileType,
55
- yml: FileType,
56
- html: FileCode,
57
- css: FileCode,
58
- scss: FileCode,
49
+ type FileIconInfo = { icon: React.ComponentType<{ className?: string }>; color?: string };
50
+
51
+ const FILE_ICON_MAP: Record<string, FileIconInfo> = {
52
+ // Code
53
+ ts: { icon: FileCode, color: "text-blue-400" }, tsx: { icon: FileCode, color: "text-blue-400" },
54
+ js: { icon: FileCode, color: "text-yellow-400" }, jsx: { icon: FileCode, color: "text-yellow-400" },
55
+ py: { icon: FileCode, color: "text-green-400" }, rs: { icon: FileCode, color: "text-orange-400" },
56
+ go: { icon: FileCode, color: "text-cyan-400" }, c: { icon: FileCode, color: "text-blue-300" },
57
+ cpp: { icon: FileCode, color: "text-blue-300" }, java: { icon: FileCode, color: "text-red-400" },
58
+ rb: { icon: FileCode, color: "text-red-400" }, php: { icon: FileCode, color: "text-purple-400" },
59
+ swift: { icon: FileCode, color: "text-orange-400" }, kt: { icon: FileCode, color: "text-purple-400" },
60
+ dart: { icon: FileCode, color: "text-cyan-400" }, sh: { icon: FileCode, color: "text-green-300" },
61
+ html: { icon: FileCode, color: "text-orange-400" }, css: { icon: FileCode, color: "text-blue-400" },
62
+ scss: { icon: FileCode, color: "text-pink-400" },
63
+ // Data
64
+ json: { icon: FileJson, color: "text-yellow-400" },
65
+ yaml: { icon: FileType, color: "text-orange-300" }, yml: { icon: FileType, color: "text-orange-300" },
66
+ toml: { icon: FileType, color: "text-orange-300" }, ini: { icon: FileType, color: "text-orange-300" },
67
+ env: { icon: FileType, color: "text-yellow-300" },
68
+ csv: { icon: FileSpreadsheet, color: "text-green-400" },
69
+ xls: { icon: FileSpreadsheet, color: "text-green-400" }, xlsx: { icon: FileSpreadsheet, color: "text-green-400" },
70
+ // Text/Docs
71
+ md: { icon: FileText, color: "text-text-secondary" }, txt: { icon: FileText, color: "text-text-secondary" },
72
+ log: { icon: FileText, color: "text-text-subtle" }, pdf: { icon: FileText, color: "text-red-400" },
73
+ // Images
74
+ png: { icon: FileImage, color: "text-green-400" }, jpg: { icon: FileImage, color: "text-green-400" },
75
+ jpeg: { icon: FileImage, color: "text-green-400" }, gif: { icon: FileImage, color: "text-green-400" },
76
+ svg: { icon: FileImage, color: "text-yellow-400" }, webp: { icon: FileImage, color: "text-green-400" },
77
+ ico: { icon: FileImage, color: "text-green-400" }, bmp: { icon: FileImage, color: "text-green-400" },
78
+ // Video
79
+ mp4: { icon: FileVideo, color: "text-purple-400" }, webm: { icon: FileVideo, color: "text-purple-400" },
80
+ mov: { icon: FileVideo, color: "text-purple-400" }, avi: { icon: FileVideo, color: "text-purple-400" },
81
+ mkv: { icon: FileVideo, color: "text-purple-400" },
82
+ // Audio
83
+ mp3: { icon: FileAudio, color: "text-pink-400" }, wav: { icon: FileAudio, color: "text-pink-400" },
84
+ ogg: { icon: FileAudio, color: "text-pink-400" }, flac: { icon: FileAudio, color: "text-pink-400" },
85
+ // Database
86
+ db: { icon: Database, color: "text-amber-400" }, sqlite: { icon: Database, color: "text-amber-400" },
87
+ sqlite3: { icon: Database, color: "text-amber-400" }, sql: { icon: Database, color: "text-amber-400" },
88
+ // Archives
89
+ zip: { icon: FileArchive, color: "text-amber-300" }, tar: { icon: FileArchive, color: "text-amber-300" },
90
+ gz: { icon: FileArchive, color: "text-amber-300" }, rar: { icon: FileArchive, color: "text-amber-300" },
91
+ "7z": { icon: FileArchive, color: "text-amber-300" },
59
92
  };
60
93
 
61
- function getFileIcon(name: string): React.ComponentType<{ className?: string }> {
94
+ const DEFAULT_FILE_ICON: FileIconInfo = { icon: File };
95
+
96
+ function getFileIcon(name: string): FileIconInfo {
62
97
  const ext = name.split(".").pop()?.toLowerCase() ?? "";
63
- return FILE_ICON_MAP[ext] ?? File;
98
+ return FILE_ICON_MAP[ext] ?? DEFAULT_FILE_ICON;
64
99
  }
65
100
 
66
101
  interface TreeNodeProps {
@@ -141,10 +176,8 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
141
176
  }
142
177
  }
143
178
 
144
- const Icon = isDir
145
- ? isExpanded
146
- ? FolderOpen
147
- : Folder
179
+ const { icon: FileIcon, color: fileIconColor } = isDir
180
+ ? { icon: isExpanded ? FolderOpen : Folder, color: "text-primary" }
148
181
  : getFileIcon(node.name);
149
182
 
150
183
  const sortedChildren = node.children
@@ -186,10 +219,10 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
186
219
  ) : (
187
220
  <span className="w-3.5 shrink-0" />
188
221
  )}
189
- <Icon
222
+ <FileIcon
190
223
  className={cn(
191
224
  "size-4 shrink-0",
192
- isDir ? "text-primary" : "text-text-secondary",
225
+ fileIconColor ?? "text-text-secondary",
193
226
  )}
194
227
  />
195
228
  <span className="truncate">{node.name}</span>