@hienlh/ppm 0.1.0

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 (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. package/vite.config.ts +62 -0
@@ -0,0 +1,838 @@
1
+ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
2
+ import { marked } from "marked";
3
+ import { getAuthToken } from "@/lib/api-client";
4
+ import type { ChatMessage, ChatEvent } from "../../../types/chat";
5
+ import {
6
+ ChevronDown,
7
+ ChevronRight,
8
+ AlertCircle,
9
+ Wrench,
10
+ CheckCircle2,
11
+ XCircle,
12
+ ShieldAlert,
13
+ Bot,
14
+ FileText,
15
+ Image as ImageIcon,
16
+ } from "lucide-react";
17
+
18
+ interface MessageListProps {
19
+ messages: ChatMessage[];
20
+ messagesLoading?: boolean;
21
+ pendingApproval: { requestId: string; tool: string; input: unknown } | null;
22
+ onApprovalResponse: (requestId: string, approved: boolean, data?: unknown) => void;
23
+ isStreaming: boolean;
24
+ projectName?: string;
25
+ }
26
+
27
+ export function MessageList({
28
+ messages,
29
+ messagesLoading,
30
+ pendingApproval,
31
+ onApprovalResponse,
32
+ isStreaming,
33
+ projectName,
34
+ }: MessageListProps) {
35
+ const bottomRef = useRef<HTMLDivElement>(null);
36
+
37
+ const initialLoadRef = useRef(true);
38
+
39
+ useEffect(() => {
40
+ // First load: jump instantly. Subsequent updates: smooth scroll.
41
+ const behavior = initialLoadRef.current ? "instant" : "smooth";
42
+ bottomRef.current?.scrollIntoView({ behavior: behavior as ScrollBehavior });
43
+ if (initialLoadRef.current && messages.length > 0) {
44
+ initialLoadRef.current = false;
45
+ }
46
+ }, [messages, pendingApproval]);
47
+
48
+ if (messagesLoading) {
49
+ return (
50
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
51
+ <Bot className="size-10 text-text-subtle animate-pulse" />
52
+ <p className="text-sm">Loading messages...</p>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ if (messages.length === 0 && !isStreaming) {
58
+ return (
59
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
60
+ <Bot className="size-10 text-text-subtle" />
61
+ <p className="text-sm">Send a message to start the conversation</p>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ return (
67
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
68
+ {messages
69
+ .filter((msg) => {
70
+ // Skip empty messages: no text content AND no events
71
+ const hasContent = msg.content && msg.content.trim().length > 0;
72
+ const hasEvents = msg.events && msg.events.length > 0;
73
+ return hasContent || hasEvents;
74
+ })
75
+ .map((msg) => (
76
+ <MessageBubble
77
+ key={msg.id}
78
+ message={msg}
79
+ isStreaming={isStreaming && msg.id.startsWith("streaming-")}
80
+ projectName={projectName}
81
+ />
82
+ ))}
83
+
84
+ {pendingApproval && (
85
+ pendingApproval.tool === "AskUserQuestion"
86
+ ? <AskUserQuestionCard approval={pendingApproval} onRespond={onApprovalResponse} />
87
+ : <ApprovalCard approval={pendingApproval} onRespond={onApprovalResponse} />
88
+ )}
89
+
90
+ {isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} />}
91
+
92
+ <div ref={bottomRef} />
93
+ </div>
94
+ );
95
+ }
96
+
97
+ function MessageBubble({ message, isStreaming, projectName }: { message: ChatMessage; isStreaming: boolean; projectName?: string }) {
98
+ if (message.role === "user") {
99
+ return <UserBubble content={message.content} projectName={projectName} />;
100
+ }
101
+
102
+ if (message.role === "system") {
103
+ return (
104
+ <div className="flex items-center gap-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
105
+ <AlertCircle className="size-4 shrink-0" />
106
+ <p>{message.content}</p>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ // Assistant message — render events in order (text interleaved with tool calls)
112
+ return (
113
+ <div className="flex flex-col gap-2">
114
+ {message.events && message.events.length > 0
115
+ ? <InterleavedEvents events={message.events} isStreaming={isStreaming} />
116
+ : message.content && (
117
+ <div className="text-sm text-text-primary">
118
+ <MarkdownContent content={message.content} />
119
+ </div>
120
+ )}
121
+ </div>
122
+ );
123
+ }
124
+
125
+ /** Image extensions that can be previewed inline */
126
+ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
127
+
128
+ /** Parse user message content, extracting attached file paths and the actual text */
129
+ function parseUserAttachments(content: string): { files: string[]; text: string } {
130
+ // Match: [Attached file: /path] or [Attached files:\n/path1\n/path2\n]
131
+ const singleMatch = content.match(/^\[Attached file: (.+?)\]\n\n?/);
132
+ if (singleMatch) {
133
+ return { files: [singleMatch[1]!], text: content.slice(singleMatch[0].length) };
134
+ }
135
+
136
+ const multiMatch = content.match(/^\[Attached files:\n([\s\S]+?)\]\n\n?/);
137
+ if (multiMatch) {
138
+ const files = multiMatch[1]!.split("\n").map((l) => l.trim()).filter(Boolean);
139
+ return { files, text: content.slice(multiMatch[0].length) };
140
+ }
141
+
142
+ return { files: [], text: content };
143
+ }
144
+
145
+ /** Build a preview URL for an uploaded file (served from /chat/uploads/:filename) */
146
+ function uploadPreviewUrl(filePath: string, projectName?: string): string {
147
+ const filename = filePath.split("/").pop() ?? "";
148
+ // Use a generic project name — the upload route is project-scoped but files are global
149
+ return `/api/project/${encodeURIComponent(projectName ?? "_")}/chat/uploads/${encodeURIComponent(filename)}`;
150
+ }
151
+
152
+ /** Check if a file path is an image based on extension */
153
+ function isImagePath(path: string): boolean {
154
+ const dot = path.lastIndexOf(".");
155
+ if (dot === -1) return false;
156
+ return IMAGE_EXTS.has(path.slice(dot).toLowerCase());
157
+ }
158
+
159
+ function isPdfPath(path: string): boolean {
160
+ return path.toLowerCase().endsWith(".pdf");
161
+ }
162
+
163
+ /** User message bubble with attachment rendering */
164
+ function UserBubble({ content, projectName }: { content: string; projectName?: string }) {
165
+ const { files, text } = useMemo(() => parseUserAttachments(content), [content]);
166
+
167
+ return (
168
+ <div className="flex justify-end">
169
+ <div className="rounded-lg bg-primary/10 px-3 py-2 text-sm text-text-primary max-w-[85%] space-y-2">
170
+ {/* Attached files */}
171
+ {files.length > 0 && (
172
+ <div className="flex flex-wrap gap-2">
173
+ {files.map((filePath, i) =>
174
+ isImagePath(filePath) ? (
175
+ <AuthImage
176
+ key={i}
177
+ src={uploadPreviewUrl(filePath, projectName)}
178
+ alt={filePath.split("/").pop() ?? "image"}
179
+ />
180
+ ) : isPdfPath(filePath) ? (
181
+ <AuthFileLink
182
+ key={i}
183
+ src={uploadPreviewUrl(filePath, projectName)}
184
+ filename={filePath.split("/").pop() ?? "document.pdf"}
185
+ mimeType="application/pdf"
186
+ />
187
+ ) : (
188
+ <div
189
+ key={i}
190
+ className="flex items-center gap-1.5 rounded-md border border-border bg-background/50 px-2 py-1 text-xs text-text-secondary"
191
+ >
192
+ <FileText className="size-3.5 shrink-0" />
193
+ <span className="truncate max-w-40">{filePath.split("/").pop()}</span>
194
+ </div>
195
+ ),
196
+ )}
197
+ </div>
198
+ )}
199
+
200
+ {/* Text content */}
201
+ {text && <p className="whitespace-pre-wrap break-words">{text}</p>}
202
+ </div>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ /** Fetches image with auth header, renders as blob URL */
208
+ function AuthImage({ src, alt }: { src: string; alt: string }) {
209
+ const [blobUrl, setBlobUrl] = useState<string | null>(null);
210
+ const [error, setError] = useState(false);
211
+
212
+ useEffect(() => {
213
+ let revoke: string | undefined;
214
+ const token = getAuthToken();
215
+ fetch(src, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
216
+ .then((r) => {
217
+ if (!r.ok) throw new Error("Failed to load");
218
+ return r.blob();
219
+ })
220
+ .then((blob) => {
221
+ const url = URL.createObjectURL(blob);
222
+ revoke = url;
223
+ setBlobUrl(url);
224
+ })
225
+ .catch(() => setError(true));
226
+
227
+ return () => { if (revoke) URL.revokeObjectURL(revoke); };
228
+ }, [src]);
229
+
230
+ if (error) {
231
+ return (
232
+ <div className="flex items-center gap-1.5 rounded-md border border-border bg-background/50 px-2 py-1 text-xs text-text-secondary">
233
+ <ImageIcon className="size-3.5 shrink-0" />
234
+ <span className="truncate max-w-40">{alt}</span>
235
+ </div>
236
+ );
237
+ }
238
+
239
+ if (!blobUrl) {
240
+ return <div className="rounded-md bg-surface border border-border h-24 w-32 animate-pulse" />;
241
+ }
242
+
243
+ return (
244
+ <a href={blobUrl} target="_blank" rel="noopener noreferrer" className="block">
245
+ <img
246
+ src={blobUrl}
247
+ alt={alt}
248
+ className="rounded-md max-h-48 max-w-full object-contain border border-border"
249
+ />
250
+ </a>
251
+ );
252
+ }
253
+
254
+ /** Fetches file with auth, opens in new browser tab (for PDFs, etc.) */
255
+ function AuthFileLink({ src, filename, mimeType }: { src: string; filename: string; mimeType: string }) {
256
+ const [loading, setLoading] = useState(false);
257
+
258
+ const handleClick = useCallback(async () => {
259
+ setLoading(true);
260
+ try {
261
+ const token = getAuthToken();
262
+ const res = await fetch(src, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
263
+ if (!res.ok) throw new Error("Failed to load");
264
+ const blob = await res.blob();
265
+ const url = URL.createObjectURL(new Blob([blob], { type: mimeType }));
266
+ window.open(url, "_blank");
267
+ // Revoke after a delay to let the new tab load
268
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
269
+ } catch {
270
+ // Fallback: try direct link
271
+ window.open(src, "_blank");
272
+ } finally {
273
+ setLoading(false);
274
+ }
275
+ }, [src, mimeType]);
276
+
277
+ return (
278
+ <button
279
+ type="button"
280
+ onClick={handleClick}
281
+ disabled={loading}
282
+ className="flex items-center gap-1.5 rounded-md border border-border bg-background/50 px-2 py-1 text-xs text-text-secondary hover:bg-surface hover:text-text-primary transition-colors cursor-pointer disabled:opacity-50"
283
+ >
284
+ <FileText className="size-3.5 shrink-0 text-red-400" />
285
+ <span className="truncate max-w-40">{filename}</span>
286
+ {loading && <span className="animate-spin text-[10px]">...</span>}
287
+ </button>
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Renders events in order — consecutive text events merged into one bubble,
293
+ * tool_use/tool_result render as cards between text sections.
294
+ * Last text group shows streaming cursor when actively streaming.
295
+ */
296
+ type EventGroup =
297
+ | { kind: "text"; content: string }
298
+ | { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
299
+
300
+ function InterleavedEvents({ events, isStreaming }: { events: ChatEvent[]; isStreaming: boolean }) {
301
+ // Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
302
+ const groups: EventGroup[] = [];
303
+ let textBuffer = "";
304
+
305
+ // First pass: create groups for text and tool_use events
306
+ for (let i = 0; i < events.length; i++) {
307
+ const event = events[i]!;
308
+ if (event.type === "text") {
309
+ textBuffer += event.content;
310
+ } else if (event.type === "tool_use") {
311
+ if (textBuffer) {
312
+ groups.push({ kind: "text", content: textBuffer });
313
+ textBuffer = "";
314
+ }
315
+ groups.push({ kind: "tool", tool: event });
316
+ } else if (event.type === "tool_result") {
317
+ // Skip tool_results in first pass — matched below
318
+ } else {
319
+ if (textBuffer) {
320
+ groups.push({ kind: "text", content: textBuffer });
321
+ textBuffer = "";
322
+ }
323
+ groups.push({ kind: "tool", tool: event });
324
+ }
325
+ }
326
+ if (textBuffer) {
327
+ groups.push({ kind: "text", content: textBuffer });
328
+ }
329
+
330
+ // Second pass: match tool_result events to their tool_use by toolUseId
331
+ const toolResults = events.filter((e) => e.type === "tool_result");
332
+ for (const tr of toolResults) {
333
+ const trId = (tr as any).toolUseId;
334
+ // Match by ID if available
335
+ if (trId) {
336
+ const match = groups.find(
337
+ (g) => g.kind === "tool" && g.tool.type === "tool_use" && (g.tool as any).toolUseId === trId,
338
+ ) as (EventGroup & { kind: "tool" }) | undefined;
339
+ if (match) {
340
+ match.result = tr;
341
+ continue;
342
+ }
343
+ }
344
+ // Fallback: attach to first tool group without a result
345
+ const unmatched = groups.find(
346
+ (g) => g.kind === "tool" && !g.result,
347
+ ) as (EventGroup & { kind: "tool" }) | undefined;
348
+ if (unmatched) {
349
+ unmatched.result = tr;
350
+ }
351
+ }
352
+
353
+ // Mark tool groups as completed: if there are events after the tool group,
354
+ // or streaming is finished, the tool has completed (even without explicit tool_result).
355
+ // The SDK doesn't emit tool_result during streaming — only in REST history.
356
+ for (let gi = 0; gi < groups.length; gi++) {
357
+ const g = groups[gi]!;
358
+ if (g.kind === "tool" && !g.result) {
359
+ const hasEventsAfter = gi < groups.length - 1;
360
+ g.completed = hasEventsAfter || !isStreaming;
361
+ }
362
+ }
363
+
364
+ return (
365
+ <>
366
+ {groups.map((group, i) => {
367
+ if (group.kind === "text") {
368
+ const isLast = isStreaming && i === groups.length - 1;
369
+ return (
370
+ <div key={`text-${i}`} className="text-sm text-text-primary">
371
+ <StreamingText content={group.content} animate={isLast} />
372
+ </div>
373
+ );
374
+ }
375
+ return <ToolCard key={`tool-${i}`} tool={group.tool} result={group.result} completed={group.completed} />;
376
+ })}
377
+ </>
378
+ );
379
+ }
380
+
381
+ /**
382
+ * Text component with typewriter effect.
383
+ * When `animate=true`, reveals content progressively.
384
+ * When `animate=false` (finalized), shows full content instantly.
385
+ */
386
+ function StreamingText({ content, animate }: { content: string; animate: boolean }) {
387
+ const [displayed, setDisplayed] = useState(content);
388
+ const prevLenRef = useRef(0);
389
+ const rafRef = useRef<number>(0);
390
+
391
+ useEffect(() => {
392
+ if (!animate) {
393
+ // Not streaming — show everything immediately
394
+ setDisplayed(content);
395
+ prevLenRef.current = content.length;
396
+ return;
397
+ }
398
+
399
+ // If content grew, animate from where we left off
400
+ const prevLen = prevLenRef.current;
401
+ if (content.length <= prevLen) {
402
+ setDisplayed(content);
403
+ return;
404
+ }
405
+
406
+ let cursor = prevLen;
407
+ const target = content.length;
408
+ // Reveal ~20 chars per frame (~60fps = ~1200 chars/sec)
409
+ const charsPerFrame = Math.max(3, Math.ceil((target - cursor) / 30));
410
+
411
+ const step = () => {
412
+ cursor = Math.min(cursor + charsPerFrame, target);
413
+ setDisplayed(content.slice(0, cursor));
414
+ if (cursor < target) {
415
+ rafRef.current = requestAnimationFrame(step);
416
+ } else {
417
+ prevLenRef.current = target;
418
+ }
419
+ };
420
+
421
+ rafRef.current = requestAnimationFrame(step);
422
+ return () => cancelAnimationFrame(rafRef.current);
423
+ }, [content, animate]);
424
+
425
+ // When streaming finishes, sync to full content
426
+ useEffect(() => {
427
+ if (!animate) {
428
+ setDisplayed(content);
429
+ prevLenRef.current = content.length;
430
+ }
431
+ }, [animate, content]);
432
+
433
+ return (
434
+ <>
435
+ <MarkdownContent content={displayed} />
436
+ {animate && <StreamingCursor />}
437
+ </>
438
+ );
439
+ }
440
+
441
+ /** Blinking cursor shown at the end of streaming text */
442
+ function StreamingCursor() {
443
+ return (
444
+ <span className="inline-block w-[2px] h-[1em] bg-accent ml-0.5 align-text-bottom animate-blink" />
445
+ );
446
+ }
447
+
448
+ /**
449
+ * Shows "Thinking..." when:
450
+ * - No assistant message yet (waiting for first response)
451
+ * - Last event is tool_use/tool_result (waiting for Claude after tool execution)
452
+ */
453
+ function ThinkingIndicator({ lastMessage }: { lastMessage?: ChatMessage }) {
454
+ // No assistant message yet
455
+ if (!lastMessage || lastMessage.role !== "assistant") {
456
+ return (
457
+ <div className="flex items-center gap-2 text-text-subtle text-sm">
458
+ <span className="animate-pulse">Thinking...</span>
459
+ </div>
460
+ );
461
+ }
462
+
463
+ // Check if last event is non-text (tool_use, tool_result) → waiting for next response
464
+ const events = lastMessage.events;
465
+ if (events && events.length > 0) {
466
+ const lastEvent = events[events.length - 1]!;
467
+ if (lastEvent?.type === "tool_use" || lastEvent?.type === "tool_result") {
468
+ return (
469
+ <div className="flex items-center gap-2 text-text-subtle text-sm">
470
+ <span className="animate-pulse">Thinking...</span>
471
+ </div>
472
+ );
473
+ }
474
+ }
475
+
476
+ return null;
477
+ }
478
+
479
+ /** Configure marked for safe rendering */
480
+ marked.setOptions({
481
+ gfm: true,
482
+ breaks: true,
483
+ });
484
+
485
+ /** Renders markdown content using `marked` → HTML string */
486
+ function MarkdownContent({ content }: { content: string }) {
487
+ const html = useMemo(() => {
488
+ try {
489
+ return marked.parse(content) as string;
490
+ } catch {
491
+ return content;
492
+ }
493
+ }, [content]);
494
+
495
+ return (
496
+ <div
497
+ className="markdown-content prose-sm"
498
+ dangerouslySetInnerHTML={{ __html: html }}
499
+ />
500
+ );
501
+ }
502
+
503
+ /** Unified tool card: shows tool-specific summary + expandable details */
504
+ function ToolCard({ tool, result, completed }: { tool: ChatEvent; result?: ChatEvent; completed?: boolean }) {
505
+ const [expanded, setExpanded] = useState(false);
506
+
507
+ if (tool.type === "error") {
508
+ return (
509
+ <div className="flex items-center gap-2 rounded bg-red-500/10 border border-red-500/20 px-2 py-1.5 text-xs text-red-400">
510
+ <AlertCircle className="size-3" />
511
+ <span>{tool.message}</span>
512
+ </div>
513
+ );
514
+ }
515
+
516
+ const isApproval = tool.type === "approval_request";
517
+ const toolName = tool.type === "tool_use"
518
+ ? tool.tool
519
+ : isApproval
520
+ ? (tool as any).tool ?? "Tool"
521
+ : "Tool";
522
+ const input = tool.type === "tool_use"
523
+ ? (tool.input as Record<string, unknown>)
524
+ : isApproval
525
+ ? ((tool as any).input as Record<string, unknown>) ?? {}
526
+ : {};
527
+ const hasResult = result?.type === "tool_result";
528
+ const isError = hasResult && !!(result as any).isError;
529
+ // AskUserQuestion with answers already submitted → show as completed
530
+ const hasAnswers = toolName === "AskUserQuestion" && !!(input as any)?.answers;
531
+ // Determine icon: error (red X) > success (green check) > pending (yellow wrench)
532
+ const isDone = hasResult || hasAnswers || completed;
533
+
534
+ return (
535
+ <div className="rounded border border-border bg-background text-xs">
536
+ <button
537
+ onClick={() => setExpanded(!expanded)}
538
+ className="flex items-center gap-2 px-2 py-1.5 w-full text-left hover:bg-surface transition-colors min-w-0"
539
+ >
540
+ {expanded ? <ChevronDown className="size-3 shrink-0" /> : <ChevronRight className="size-3 shrink-0" />}
541
+ {isError
542
+ ? <XCircle className="size-3 text-red-400 shrink-0" />
543
+ : isDone
544
+ ? <CheckCircle2 className="size-3 text-green-400 shrink-0" />
545
+ : <Wrench className="size-3 text-yellow-400 shrink-0" />
546
+ }
547
+ <span className="truncate text-text-primary">
548
+ <ToolSummary name={toolName} input={input} />
549
+ </span>
550
+ </button>
551
+ {expanded && (
552
+ <div className="px-2 pb-2 space-y-1.5">
553
+ {(tool.type === "tool_use" || isApproval) && (
554
+ <ToolDetails name={toolName} input={input} />
555
+ )}
556
+ {hasResult && (
557
+ <pre className="overflow-x-auto text-text-subtle font-mono max-h-40 border-t border-border pt-1.5 whitespace-pre-wrap break-all">
558
+ {(result as any).output}
559
+ </pre>
560
+ )}
561
+ </div>
562
+ )}
563
+ </div>
564
+ );
565
+ }
566
+
567
+ /** Render one-line summary per tool type */
568
+ function ToolSummary({ name, input }: { name: string; input: Record<string, unknown> }) {
569
+ const s = (v: unknown) => String(v ?? "");
570
+ switch (name) {
571
+ case "Read":
572
+ case "Write":
573
+ case "Edit":
574
+ return <>{name} <span className="text-text-subtle">{basename(s(input.file_path))}</span></>;
575
+ case "Bash":
576
+ return <>{name} <span className="font-mono text-text-subtle">{truncate(s(input.command), 60)}</span></>;
577
+ case "Glob":
578
+ return <>{name} <span className="font-mono text-text-subtle">{s(input.pattern)}</span></>;
579
+ case "Grep":
580
+ return <>{name} <span className="font-mono text-text-subtle">{truncate(s(input.pattern), 40)}</span></>;
581
+ case "WebSearch":
582
+ return <>{name} <span className="text-text-subtle">{truncate(s(input.query), 50)}</span></>;
583
+ case "WebFetch":
584
+ return <>{name} <span className="text-text-subtle">{truncate(s(input.url), 50)}</span></>;
585
+ case "AskUserQuestion": {
586
+ const qs = (input.questions as Array<{ question: string }>) ?? [];
587
+ const hasAns = !!(input.answers);
588
+ return <>{name} <span className="text-text-subtle">{qs.length} question{qs.length !== 1 ? "s" : ""}{hasAns ? " ✓" : ""}</span></>;
589
+ }
590
+ default:
591
+ return <>{name}</>;
592
+ }
593
+ }
594
+
595
+ /** Render expanded details per tool type */
596
+ function ToolDetails({ name, input }: { name: string; input: Record<string, unknown> }) {
597
+ const s = (v: unknown) => String(v ?? "");
598
+ switch (name) {
599
+ case "Bash":
600
+ return (
601
+ <div className="space-y-1">
602
+ {!!input.description && <p className="text-text-subtle italic">{s(input.description)}</p>}
603
+ <pre className="font-mono text-text-secondary overflow-x-auto whitespace-pre-wrap break-all">{s(input.command)}</pre>
604
+ </div>
605
+ );
606
+ case "Read":
607
+ case "Write":
608
+ case "Edit":
609
+ return (
610
+ <div className="space-y-1">
611
+ <p className="font-mono text-text-secondary break-all">{s(input.file_path)}</p>
612
+ {name === "Edit" && !!input.old_string && (
613
+ <div className="border-l-2 border-red-400/40 pl-2">
614
+ <pre className="font-mono text-red-400/70 overflow-x-auto whitespace-pre-wrap">{truncate(s(input.old_string), 200)}</pre>
615
+ </div>
616
+ )}
617
+ {name === "Edit" && !!input.new_string && (
618
+ <div className="border-l-2 border-green-400/40 pl-2">
619
+ <pre className="font-mono text-green-400/70 overflow-x-auto whitespace-pre-wrap">{truncate(s(input.new_string), 200)}</pre>
620
+ </div>
621
+ )}
622
+ {name === "Write" && !!input.content && (
623
+ <pre className="font-mono text-text-subtle overflow-x-auto max-h-32 whitespace-pre-wrap">{truncate(s(input.content), 300)}</pre>
624
+ )}
625
+ </div>
626
+ );
627
+ case "Glob":
628
+ return <p className="font-mono text-text-secondary">{s(input.pattern)}{input.path ? ` in ${s(input.path)}` : ""}</p>;
629
+ case "Grep":
630
+ return (
631
+ <div className="space-y-0.5">
632
+ <p className="font-mono text-text-secondary">/{s(input.pattern)}/</p>
633
+ {!!input.path && <p className="text-text-subtle">in {s(input.path)}</p>}
634
+ </div>
635
+ );
636
+ case "AskUserQuestion": {
637
+ const qs = (input.questions as Array<{ question: string; header?: string; options: Array<{ label: string; description?: string }>; multiSelect?: boolean }>) ?? [];
638
+ const answers = (input.answers as Record<string, string>) ?? {};
639
+ return (
640
+ <div className="space-y-2">
641
+ {qs.map((q, i) => (
642
+ <div key={i} className="space-y-0.5">
643
+ <p className="text-text-primary font-medium">{q.header ? `${q.header}: ` : ""}{q.question}</p>
644
+ <div className="flex flex-wrap gap-1">
645
+ {q.options.map((opt, oi) => {
646
+ const answer = answers[q.question] ?? "";
647
+ const isSelected = answer.split(", ").includes(opt.label);
648
+ return (
649
+ <span key={oi} className={`inline-block rounded px-1.5 py-0.5 text-xs border ${
650
+ isSelected ? "border-accent bg-accent/20 text-text-primary" : "border-border text-text-subtle"
651
+ }`}>
652
+ {opt.label}
653
+ </span>
654
+ );
655
+ })}
656
+ </div>
657
+ {answers[q.question] && (
658
+ <p className="text-accent text-xs">Answer: {answers[q.question]}</p>
659
+ )}
660
+ </div>
661
+ ))}
662
+ </div>
663
+ );
664
+ }
665
+ default:
666
+ return (
667
+ <pre className="overflow-x-auto text-text-secondary font-mono whitespace-pre-wrap break-all">
668
+ {JSON.stringify(input, null, 2)}
669
+ </pre>
670
+ );
671
+ }
672
+ }
673
+
674
+ function basename(path?: string): string {
675
+ if (!path) return "";
676
+ return path.split("/").pop() ?? path;
677
+ }
678
+
679
+ function truncate(str?: string, max = 50): string {
680
+ if (!str) return "";
681
+ return str.length > max ? str.slice(0, max) + "…" : str;
682
+ }
683
+
684
+ function ApprovalCard({
685
+ approval,
686
+ onRespond,
687
+ }: {
688
+ approval: { requestId: string; tool: string; input: unknown };
689
+ onRespond: (requestId: string, approved: boolean, data?: unknown) => void;
690
+ }) {
691
+ return (
692
+ <div className="rounded-lg border-2 border-yellow-500/40 bg-yellow-500/10 p-3 space-y-2">
693
+ <div className="flex items-center gap-2 text-yellow-400 text-sm font-medium">
694
+ <ShieldAlert className="size-4" />
695
+ <span>Tool Approval Required</span>
696
+ </div>
697
+ <div className="text-xs text-text-primary">
698
+ <span className="font-medium">{approval.tool}</span>
699
+ </div>
700
+ <pre className="text-xs font-mono text-text-secondary overflow-x-auto bg-background rounded p-2 border border-border">
701
+ {JSON.stringify(approval.input, null, 2)}
702
+ </pre>
703
+ <div className="flex gap-2">
704
+ <button
705
+ onClick={() => onRespond(approval.requestId, true)}
706
+ className="px-4 py-1.5 rounded bg-green-600 text-white text-xs font-medium hover:bg-green-500 transition-colors"
707
+ >
708
+ Allow
709
+ </button>
710
+ <button
711
+ onClick={() => onRespond(approval.requestId, false)}
712
+ className="px-4 py-1.5 rounded bg-red-600 text-white text-xs font-medium hover:bg-red-500 transition-colors"
713
+ >
714
+ Deny
715
+ </button>
716
+ </div>
717
+ </div>
718
+ );
719
+ }
720
+
721
+ /** Interactive quiz form for AskUserQuestion — renders questions with selectable options + Other */
722
+ function AskUserQuestionCard({
723
+ approval,
724
+ onRespond,
725
+ }: {
726
+ approval: { requestId: string; tool: string; input: unknown };
727
+ onRespond: (requestId: string, approved: boolean, data?: unknown) => void;
728
+ }) {
729
+ const input = approval.input as {
730
+ questions?: Array<{
731
+ question: string;
732
+ header?: string;
733
+ options: Array<{ label: string; description?: string }>;
734
+ multiSelect?: boolean;
735
+ }>;
736
+ };
737
+ const questions = input.questions ?? [];
738
+
739
+ const [answers, setAnswers] = useState<Record<string, string>>({});
740
+ // Track which questions have "Other" active
741
+ const [otherActive, setOtherActive] = useState<Record<string, boolean>>({});
742
+
743
+ const handleSelect = (question: string, label: string, multiSelect?: boolean) => {
744
+ // Deactivate "Other" when selecting a predefined option
745
+ setOtherActive((prev) => ({ ...prev, [question]: false }));
746
+ setAnswers((prev) => {
747
+ if (!multiSelect) return { ...prev, [question]: label };
748
+ const current = prev[question] ?? "";
749
+ const labels = current ? current.split(", ") : [];
750
+ const idx = labels.indexOf(label);
751
+ if (idx >= 0) labels.splice(idx, 1);
752
+ else labels.push(label);
753
+ return { ...prev, [question]: labels.join(", ") };
754
+ });
755
+ };
756
+
757
+ const handleOtherToggle = (question: string) => {
758
+ setOtherActive((prev) => ({ ...prev, [question]: true }));
759
+ setAnswers((prev) => ({ ...prev, [question]: "" }));
760
+ };
761
+
762
+ const handleOtherText = (question: string, text: string) => {
763
+ setAnswers((prev) => ({ ...prev, [question]: text }));
764
+ };
765
+
766
+ const allAnswered = questions.every((q) => answers[q.question]?.trim());
767
+
768
+ return (
769
+ <div className="rounded-lg border-2 border-accent/40 bg-accent/5 p-3 space-y-3">
770
+ {questions.map((q, qi) => (
771
+ <div key={qi} className="space-y-1.5">
772
+ <p className="text-sm text-text-primary font-medium">
773
+ {q.header ? `${q.header}: ` : ""}{q.question}
774
+ </p>
775
+ {q.multiSelect && (
776
+ <p className="text-xs text-text-subtle">Select multiple</p>
777
+ )}
778
+ <div className="flex flex-col gap-1">
779
+ {q.options.map((opt, oi) => {
780
+ const isOther = otherActive[q.question];
781
+ const selected = !isOther && (answers[q.question] ?? "").split(", ").includes(opt.label);
782
+ return (
783
+ <button
784
+ key={oi}
785
+ onClick={() => handleSelect(q.question, opt.label, q.multiSelect)}
786
+ className={`text-left rounded px-2.5 py-1.5 text-xs border transition-colors ${
787
+ selected
788
+ ? "border-accent bg-accent/20 text-text-primary"
789
+ : "border-border bg-background text-text-secondary hover:bg-surface-elevated"
790
+ }`}
791
+ >
792
+ <span className="font-medium">{opt.label}</span>
793
+ {opt.description && (
794
+ <span className="text-text-subtle ml-1.5">— {opt.description}</span>
795
+ )}
796
+ </button>
797
+ );
798
+ })}
799
+ {/* Other option */}
800
+ {otherActive[q.question] ? (
801
+ <input
802
+ type="text"
803
+ autoFocus
804
+ placeholder="Type your answer..."
805
+ value={answers[q.question] ?? ""}
806
+ onChange={(e) => handleOtherText(q.question, e.target.value)}
807
+ className="rounded px-2.5 py-1.5 text-xs border border-accent bg-accent/10 text-text-primary outline-none placeholder:text-text-subtle"
808
+ />
809
+ ) : (
810
+ <button
811
+ onClick={() => handleOtherToggle(q.question)}
812
+ className="text-left rounded px-2.5 py-1.5 text-xs border border-dashed border-border text-text-subtle hover:bg-surface-elevated transition-colors"
813
+ >
814
+ Other — type your own answer
815
+ </button>
816
+ )}
817
+ </div>
818
+ </div>
819
+ ))}
820
+
821
+ <div className="flex gap-2 pt-1">
822
+ <button
823
+ onClick={() => onRespond(approval.requestId, true, answers)}
824
+ disabled={!allAnswered}
825
+ className="px-4 py-1.5 rounded bg-accent text-white text-xs font-medium hover:bg-accent/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
826
+ >
827
+ Submit
828
+ </button>
829
+ <button
830
+ onClick={() => onRespond(approval.requestId, false)}
831
+ className="px-4 py-1.5 rounded bg-surface-elevated text-text-secondary text-xs hover:bg-surface transition-colors"
832
+ >
833
+ Skip
834
+ </button>
835
+ </div>
836
+ </div>
837
+ );
838
+ }