@hienlh/ppm 0.11.10 → 0.11.12
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.
- package/CHANGELOG.md +13 -0
- package/dist/web/assets/{audio-preview-1DSokoKb.js → audio-preview-D8nR9F8d.js} +1 -1
- package/dist/web/assets/chat-tab-yxo8oBYc.js +12 -0
- package/dist/web/assets/{code-editor-CcHvVH2U.js → code-editor-CKcPOXZV.js} +2 -2
- package/dist/web/assets/{conflict-editor-C7ve0DV-.js → conflict-editor-DtzPzxR5.js} +1 -1
- package/dist/web/assets/{database-viewer-DIuNqm7A.js → database-viewer-DeiJYxBj.js} +1 -1
- package/dist/web/assets/{diff-viewer-DeitZvBi.js → diff-viewer-Dve9ga_f.js} +1 -1
- package/dist/web/assets/{extension-webview-Ca4T_biw.js → extension-webview-DrL4qHNy.js} +1 -1
- package/dist/web/assets/{image-preview-DvcaLB4N.js → image-preview-D3IgZDRo.js} +1 -1
- package/dist/web/assets/index-8Mwobh7l.js +23 -0
- package/dist/web/assets/{markdown-renderer-DP-MF3bM.js → markdown-renderer-zaluanbN.js} +1 -1
- package/dist/web/assets/{pdf-preview-CSreqeop.js → pdf-preview-Yty6yXJU.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-pISwYxUi.js → port-forwarding-tab-Ddlryv9D.js} +1 -1
- package/dist/web/assets/{postgres-viewer-ZIO1deEA.js → postgres-viewer-CUFg5d8S.js} +1 -1
- package/dist/web/assets/{settings-tab-DjTka3Fq.js → settings-tab-DIJMW_ZS.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Bxpo_5Ei.js → sqlite-viewer-BA2uk_fo.js} +1 -1
- package/dist/web/assets/{terminal-tab-Df8jrXRQ.js → terminal-tab-Bm0P3LZ7.js} +1 -1
- package/dist/web/assets/{video-preview-DmeBvw9D.js → video-preview-B2VaDLhw.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/docs/project-changelog.md +31 -1
- package/docs/system-architecture.md +2 -1
- package/package.json +1 -1
- package/src/server/index.ts +17 -11
- package/src/server/ws/chat.ts +23 -0
- package/src/services/bash-output-spy.ts +213 -0
- package/src/services/supervisor.ts +21 -3
- package/src/types/api.ts +1 -0
- package/src/web/components/chat/chat-tab.tsx +2 -0
- package/src/web/components/chat/message-list.tsx +11 -5
- package/src/web/components/chat/tool-cards.tsx +53 -2
- package/src/web/components/layout/command-palette.tsx +8 -13
- package/src/web/hooks/use-chat.ts +38 -0
- package/src/web/lib/score-file-search.ts +90 -0
- package/dist/web/assets/chat-tab-DJRa8l8m.js +0 -10
- package/dist/web/assets/index-BJ5XngyN.js +0 -23
package/src/server/ws/chat.ts
CHANGED
|
@@ -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) {
|
|
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
|
-
//
|
|
447
|
-
|
|
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
|
-
{
|
|
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;
|
|
@@ -25,6 +25,7 @@ import { useFileStore, type FileNode } from "@/stores/file-store";
|
|
|
25
25
|
import { useExtensionStore } from "@/stores/extension-store";
|
|
26
26
|
import { api } from "@/lib/api-client";
|
|
27
27
|
import { basename } from "@/lib/utils";
|
|
28
|
+
import { scoreFileSearch, compareScores, type FileSearchScore } from "@/lib/score-file-search";
|
|
28
29
|
|
|
29
30
|
interface CommandItem {
|
|
30
31
|
id: string;
|
|
@@ -315,19 +316,13 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
315
316
|
|
|
316
317
|
// Normal mode
|
|
317
318
|
if (!query.trim()) return actionCommands;
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
return true;
|
|
327
|
-
};
|
|
328
|
-
const matched = allCommands.filter(
|
|
329
|
-
(c) => matchesFuzzy(c.label.toLowerCase()) || (c.keywords && matchesFuzzy(c.keywords.toLowerCase())),
|
|
330
|
-
);
|
|
319
|
+
const scored: Array<{ cmd: CommandItem; score: FileSearchScore }> = [];
|
|
320
|
+
for (const c of allCommands) {
|
|
321
|
+
const s = scoreFileSearch(query, c.label, c.keywords ?? c.label);
|
|
322
|
+
if (s) scored.push({ cmd: c, score: s });
|
|
323
|
+
}
|
|
324
|
+
scored.sort((a, b) => compareScores(a.score, b.score));
|
|
325
|
+
const matched = scored.map((s) => s.cmd);
|
|
331
326
|
// Prepend DB results (already filtered server-side) when query is 2+ chars
|
|
332
327
|
return query.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
|
|
333
328
|
}, [allCommands, actionCommands, fsCommands, dbCommands, query]);
|
|
@@ -34,6 +34,11 @@ interface TeamActivityState {
|
|
|
34
34
|
|
|
35
35
|
const EMPTY_TEAM_ACTIVITY: TeamActivityState = { hasTeams: false, teamNames: [], messageCount: 0, unreadCount: 0 };
|
|
36
36
|
|
|
37
|
+
export interface BashPartialEntry {
|
|
38
|
+
content: string;
|
|
39
|
+
lineCount: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
interface UseChatReturn {
|
|
38
43
|
messages: ChatMessage[];
|
|
39
44
|
messagesLoading: boolean;
|
|
@@ -52,6 +57,8 @@ interface UseChatReturn {
|
|
|
52
57
|
teamMessages: TeamMessageItem[];
|
|
53
58
|
/** Mark team messages as read (reset unread counter) */
|
|
54
59
|
markTeamRead: () => void;
|
|
60
|
+
/** Partial bash output keyed by toolUseId (ref-backed for perf) */
|
|
61
|
+
bashPartialOutput: React.RefObject<Map<string, BashPartialEntry>>;
|
|
55
62
|
sendMessage: (content: string, opts?: { permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }) => void;
|
|
56
63
|
respondToApproval: (requestId: string, approved: boolean, data?: unknown) => void;
|
|
57
64
|
cancelStreaming: () => void;
|
|
@@ -84,6 +91,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
84
91
|
const [isConnected, setIsConnected] = useState(false);
|
|
85
92
|
const streamingContentRef = useRef("");
|
|
86
93
|
const streamingEventsRef = useRef<ChatEvent[]>([]);
|
|
94
|
+
const bashOutputRef = useRef<Map<string, BashPartialEntry>>(new Map());
|
|
87
95
|
const streamingAccountRef = useRef<{ accountId: string; accountLabel: string } | null>(null);
|
|
88
96
|
const phaseRef = useRef<SessionPhase>("idle");
|
|
89
97
|
const pendingMessageRef = useRef<string | null>(null);
|
|
@@ -249,6 +257,10 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
249
257
|
}
|
|
250
258
|
|
|
251
259
|
case "tool_result": {
|
|
260
|
+
// Clear bash partial output for this tool
|
|
261
|
+
const trId = ev.toolUseId as string;
|
|
262
|
+
if (trId) bashOutputRef.current.delete(trId);
|
|
263
|
+
|
|
252
264
|
const pid = ev.parentToolUseId as string | undefined;
|
|
253
265
|
if (pid && routeToParent(ev as ChatEvent, pid)) {
|
|
254
266
|
syncMessages();
|
|
@@ -359,6 +371,28 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
359
371
|
break;
|
|
360
372
|
}
|
|
361
373
|
|
|
374
|
+
case "bash_output": {
|
|
375
|
+
const tuId = ev.toolUseId as string;
|
|
376
|
+
if (tuId) {
|
|
377
|
+
const existing = bashOutputRef.current.get(tuId);
|
|
378
|
+
if (existing) {
|
|
379
|
+
existing.content += ev.content;
|
|
380
|
+
// Cap at ~500KB to prevent browser OOM on long-running commands
|
|
381
|
+
if (existing.content.length > 500_000) {
|
|
382
|
+
existing.content = existing.content.slice(-500_000);
|
|
383
|
+
}
|
|
384
|
+
existing.lineCount = ev.lineCount as number;
|
|
385
|
+
} else {
|
|
386
|
+
bashOutputRef.current.set(tuId, {
|
|
387
|
+
content: ev.content as string,
|
|
388
|
+
lineCount: ev.lineCount as number,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
syncMessages();
|
|
392
|
+
}
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
|
|
362
396
|
case "done": {
|
|
363
397
|
// Idempotent: may receive duplicate done (provider + stream loop finally)
|
|
364
398
|
if (phaseRef.current === "idle") break;
|
|
@@ -405,6 +439,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
405
439
|
streamingContentRef.current = "";
|
|
406
440
|
streamingEventsRef.current = [];
|
|
407
441
|
streamingAccountRef.current = null;
|
|
442
|
+
bashOutputRef.current.clear();
|
|
408
443
|
setStatusMessage(null);
|
|
409
444
|
// Phase transition to idle comes from BE via phase_changed
|
|
410
445
|
break;
|
|
@@ -570,6 +605,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
570
605
|
setCompactStatus(null);
|
|
571
606
|
streamingContentRef.current = "";
|
|
572
607
|
streamingEventsRef.current = [];
|
|
608
|
+
bashOutputRef.current.clear();
|
|
573
609
|
if (syncRafRef.current) { cancelAnimationFrame(syncRafRef.current); syncRafRef.current = 0; }
|
|
574
610
|
setIsConnected(false);
|
|
575
611
|
// Reset team state
|
|
@@ -724,6 +760,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
724
760
|
});
|
|
725
761
|
streamingContentRef.current = "";
|
|
726
762
|
streamingEventsRef.current = [];
|
|
763
|
+
bashOutputRef.current.clear();
|
|
727
764
|
pendingMessageRef.current = null;
|
|
728
765
|
setPhase("idle");
|
|
729
766
|
phaseRef.current = "idle";
|
|
@@ -773,6 +810,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
773
810
|
teamActivity,
|
|
774
811
|
teamMessages,
|
|
775
812
|
markTeamRead,
|
|
813
|
+
bashPartialOutput: bashOutputRef,
|
|
776
814
|
sendMessage,
|
|
777
815
|
respondToApproval,
|
|
778
816
|
cancelStreaming,
|