@hienlh/ppm 0.1.3 → 0.1.6

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 (93) hide show
  1. package/CLAUDE.md +45 -0
  2. package/bun.lock +3 -0
  3. package/dist/ppm +0 -0
  4. package/dist/web/assets/api-client-BgVufYKf.js +1 -0
  5. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +1 -0
  6. package/dist/web/assets/button-KIZetva8.js +41 -0
  7. package/dist/web/assets/chat-tab-CNXjLOhI.js +6 -0
  8. package/dist/web/assets/code-editor-tGMPwYNs.js +2 -0
  9. package/dist/web/assets/copy-B-kLwqzg.js +1 -0
  10. package/dist/web/assets/dialog-D8ulRTfX.js +5 -0
  11. package/dist/web/assets/diff-viewer-B4A8pPbo.js +4 -0
  12. package/dist/web/assets/dist-C4W3AGh3.js +1 -0
  13. package/dist/web/assets/dist-PA84y4Ga.js +1 -0
  14. package/dist/web/assets/external-link-Dim3NH6h.js +1 -0
  15. package/dist/web/assets/git-graph-ODjrGZOQ.js +1 -0
  16. package/dist/web/assets/git-status-panel-B0Im1hrU.js +1 -0
  17. package/dist/web/assets/index-BePIZMuy.css +2 -0
  18. package/dist/web/assets/index-D2STBl88.js +10 -0
  19. package/dist/web/assets/{jsx-runtime-BnxRlLMJ.js → jsx-runtime-BFALxl05.js} +1 -1
  20. package/dist/web/assets/marked.esm-Cv8mjgnt.js +59 -0
  21. package/dist/web/assets/project-list-VjQQcU3X.js +1 -0
  22. package/dist/web/assets/{react-Uzd0zARU.js → react-BSLFEYu8.js} +1 -1
  23. package/dist/web/assets/refresh-cw-DJSjl6Ev.js +1 -0
  24. package/dist/web/assets/settings-tab-ChhdL0EG.js +1 -0
  25. package/dist/web/assets/terminal-tab-DDf6S-Tu.js +36 -0
  26. package/dist/web/assets/trash-2-CjahwKg8.js +1 -0
  27. package/dist/web/assets/x-BxhOxZ5p.js +1 -0
  28. package/dist/web/index.html +11 -10
  29. package/dist/web/sw.js +1 -1
  30. package/docs/claude-agent-sdk-reference.md +780 -0
  31. package/docs/codebase-summary.md +11 -13
  32. package/docs/lessons-learned.md +58 -0
  33. package/docs/system-architecture.md +78 -2
  34. package/package.json +2 -1
  35. package/schemas/ppm-config.schema.json +87 -0
  36. package/src/providers/claude-agent-sdk.ts +84 -3
  37. package/src/providers/registry.ts +0 -2
  38. package/src/server/index.ts +7 -1
  39. package/src/server/routes/settings.ts +70 -0
  40. package/src/server/ws/chat.ts +23 -8
  41. package/src/services/git.service.ts +23 -1
  42. package/src/types/chat.ts +8 -1
  43. package/src/types/config.ts +50 -3
  44. package/src/web/app.tsx +8 -0
  45. package/src/web/components/chat/message-input.tsx +1 -1
  46. package/src/web/components/chat/message-list.tsx +112 -251
  47. package/src/web/components/chat/tool-cards.tsx +411 -0
  48. package/src/web/components/editor/code-editor.tsx +80 -20
  49. package/src/web/components/editor/diff-viewer.tsx +72 -7
  50. package/src/web/components/git/git-graph.tsx +3 -0
  51. package/src/web/components/git/git-status-panel.tsx +50 -1
  52. package/src/web/components/layout/command-palette.tsx +215 -0
  53. package/src/web/components/layout/mobile-drawer.tsx +143 -42
  54. package/src/web/components/layout/sidebar.tsx +103 -67
  55. package/src/web/components/layout/tab-bar.tsx +1 -2
  56. package/src/web/components/settings/ai-settings-section.tsx +166 -0
  57. package/src/web/components/settings/settings-tab.tsx +5 -0
  58. package/src/web/components/terminal/terminal-tab.tsx +45 -22
  59. package/src/web/components/ui/input.tsx +4 -3
  60. package/src/web/components/ui/label.tsx +24 -0
  61. package/src/web/components/ui/select.tsx +188 -0
  62. package/src/web/hooks/use-chat.ts +3 -0
  63. package/src/web/hooks/use-global-keybindings.ts +56 -0
  64. package/src/web/hooks/use-terminal.ts +14 -1
  65. package/src/web/lib/api-settings.ts +24 -0
  66. package/src/web/stores/project-store.ts +47 -2
  67. package/src/web/stores/tab-store.ts +1 -1
  68. package/src/web/styles/globals.css +16 -3
  69. package/test-tool.mjs +41 -0
  70. package/dist/web/assets/api-client-Bnf9LAt4.js +0 -1
  71. package/dist/web/assets/arrow-up-from-line-BXL5dtbG.js +0 -1
  72. package/dist/web/assets/button-DxRZgE8F.js +0 -1
  73. package/dist/web/assets/chat-tab-BkCV4ZC9.js +0 -61
  74. package/dist/web/assets/code-editor-f77XD8lZ.js +0 -2
  75. package/dist/web/assets/createLucideIcon-Dy1wlrF7.js +0 -1
  76. package/dist/web/assets/dialog-Db6prp1p.js +0 -45
  77. package/dist/web/assets/diff-viewer-BF7IYlm4.js +0 -4
  78. package/dist/web/assets/external-link-WSiY-639.js +0 -1
  79. package/dist/web/assets/git-graph-Ct1-XDz2.js +0 -1
  80. package/dist/web/assets/git-status-panel-D1rNmbrT.js +0 -1
  81. package/dist/web/assets/index-DYd_2slk.css +0 -2
  82. package/dist/web/assets/index-iwjbGjDp.js +0 -10
  83. package/dist/web/assets/project-list-DB85YVTT.js +0 -1
  84. package/dist/web/assets/refresh-cw-DtopuYJf.js +0 -1
  85. package/dist/web/assets/settings-tab-Ooz2h9Hu.js +0 -1
  86. package/dist/web/assets/terminal-tab-DHwn2LMT.js +0 -36
  87. package/dist/web/assets/trash-2-CHLebaNh.js +0 -1
  88. package/dist/web/assets/x-BISR7bpK.js +0 -1
  89. package/src/providers/claude-binary-finder.ts +0 -256
  90. package/src/providers/claude-code-cli.ts +0 -413
  91. package/src/providers/claude-process-registry.ts +0 -106
  92. /package/dist/web/assets/{dist-CSp7ir0r.js → dist-CBiGQxfr.js} +0 -0
  93. /package/dist/web/assets/{utils-CiBGfeHD.js → utils-DpJF9mAi.js} +0 -0
@@ -6,12 +6,15 @@ import type { ChatWsClientMessage } from "../../types/api.ts";
6
6
  /** Tracks active chat WS connections: sessionId -> ws + abort controller + project context */
7
7
  const activeSessions = new Map<
8
8
  string,
9
- { providerId: string; ws: ChatWsSocket; abort?: AbortController; projectPath?: string }
9
+ { providerId: string; ws: ChatWsSocket; abort?: AbortController; projectPath?: string; pingInterval?: ReturnType<typeof setInterval> }
10
10
  >();
11
11
 
12
+ const PING_INTERVAL_MS = 15_000; // 15s keepalive
13
+
12
14
  type ChatWsSocket = {
13
15
  data: { type: string; sessionId: string; projectName?: string };
14
16
  send: (data: string) => void;
17
+ ping?: (data?: string | ArrayBuffer) => void;
15
18
  };
16
19
 
17
20
  /**
@@ -36,7 +39,15 @@ export const chatWebSocket = {
36
39
  session.projectPath = projectPath;
37
40
  }
38
41
 
39
- activeSessions.set(sessionId, { providerId, ws, projectPath });
42
+ // Start keepalive ping to prevent proxy/firewall from dropping idle connections
43
+ const pingInterval = setInterval(() => {
44
+ try {
45
+ if (ws.ping) ws.ping();
46
+ else ws.send(JSON.stringify({ type: "ping" }));
47
+ } catch { /* ws may be closed */ }
48
+ }, PING_INTERVAL_MS);
49
+
50
+ activeSessions.set(sessionId, { providerId, ws, projectPath, pingInterval });
40
51
  ws.send(JSON.stringify({ type: "connected", sessionId }));
41
52
  },
42
53
 
@@ -57,12 +68,14 @@ export const chatWebSocket = {
57
68
  const providerId = entry?.providerId ?? "mock";
58
69
 
59
70
  if (parsed.type === "message") {
60
- // Ensure provider session has projectPath for skills/settings support
61
- if (entry?.projectPath) {
62
- const provider = providerRegistry.get(providerId);
63
- if (provider && "ensureProjectPath" in provider) {
64
- (provider as any).ensureProjectPath(sessionId, entry.projectPath);
65
- }
71
+ // Resume session in provider FIRST so it exists in activeSessions,
72
+ // then backfill projectPath — fixes tool execution when server restarted
73
+ const provider = providerRegistry.get(providerId);
74
+ if (provider && "resumeSession" in provider) {
75
+ await (provider as any).resumeSession(sessionId);
76
+ }
77
+ if (entry?.projectPath && provider && "ensureProjectPath" in provider) {
78
+ (provider as any).ensureProjectPath(sessionId, entry.projectPath);
66
79
  }
67
80
 
68
81
  const abortController = new AbortController();
@@ -114,6 +127,8 @@ export const chatWebSocket = {
114
127
  const { sessionId } = ws.data;
115
128
  const entry = activeSessions.get(sessionId);
116
129
  if (entry) {
130
+ // Stop keepalive ping
131
+ if (entry.pingInterval) clearInterval(entry.pingInterval);
117
132
  // Force-break the for-await loop — no client to receive events anymore
118
133
  if (entry.abort) {
119
134
  entry.abort.abort();
@@ -129,7 +129,29 @@ class GitService {
129
129
  const args: string[] = [];
130
130
  if (ref) args.push(ref);
131
131
  args.push("--", filePath);
132
- return git.diff(args);
132
+ const diff = await git.diff(args);
133
+
134
+ // If diff is empty, file might be untracked or newly staged.
135
+ // Try staged diff, then --no-index for untracked files.
136
+ if (!diff.trim()) {
137
+ const stagedDiff = await git.diff(["--cached", "--", filePath]);
138
+ if (stagedDiff.trim()) return stagedDiff;
139
+
140
+ // Untracked file: generate diff against /dev/null
141
+ try {
142
+ const result = await git.raw([
143
+ "diff", "--no-index", "/dev/null", filePath,
144
+ ]);
145
+ return result;
146
+ } catch (e: any) {
147
+ // git diff --no-index exits with code 1 when there are differences
148
+ if (e.message?.includes("exit code 1") || e.exitCode === 1) {
149
+ return typeof e.stdout === "string" ? e.stdout : "";
150
+ }
151
+ return "";
152
+ }
153
+ }
154
+ return diff;
133
155
  }
134
156
 
135
157
  async stage(projectPath: string, files: string[]): Promise<void> {
package/src/types/chat.ts CHANGED
@@ -69,6 +69,13 @@ export interface UsageInfo {
69
69
  weeklySonnet?: LimitBucket;
70
70
  }
71
71
 
72
+ /** Result subtype from SDK ResultMessage */
73
+ export type ResultSubtype =
74
+ | "success"
75
+ | "error_max_turns"
76
+ | "error_max_budget_usd"
77
+ | "error_during_execution";
78
+
72
79
  export type ChatEvent =
73
80
  | { type: "text"; content: string }
74
81
  | { type: "tool_use"; tool: string; input: unknown; toolUseId?: string }
@@ -76,7 +83,7 @@ export type ChatEvent =
76
83
  | { type: "approval_request"; requestId: string; tool: string; input: unknown }
77
84
  | { type: "usage"; usage: UsageInfo }
78
85
  | { type: "error"; message: string }
79
- | { type: "done"; sessionId: string };
86
+ | { type: "done"; sessionId: string; resultSubtype?: ResultSubtype; numTurns?: number };
80
87
 
81
88
  export type ToolApprovalHandler = (
82
89
  tool: string,
@@ -22,9 +22,14 @@ export interface AIConfig {
22
22
  }
23
23
 
24
24
  export interface AIProviderConfig {
25
- type: "agent-sdk" | "cli";
25
+ type: "agent-sdk" | "mock";
26
26
  api_key_env?: string;
27
- command?: string;
27
+ // Agent SDK-specific settings (ignored by mock provider)
28
+ model?: string;
29
+ effort?: "low" | "medium" | "high" | "max";
30
+ max_turns?: number;
31
+ max_budget_usd?: number;
32
+ thinking_budget_tokens?: number;
28
33
  }
29
34
 
30
35
  export const DEFAULT_CONFIG: PpmConfig = {
@@ -35,7 +40,49 @@ export const DEFAULT_CONFIG: PpmConfig = {
35
40
  ai: {
36
41
  default_provider: "claude",
37
42
  providers: {
38
- claude: { type: "agent-sdk", api_key_env: "ANTHROPIC_API_KEY" },
43
+ claude: {
44
+ type: "agent-sdk",
45
+ api_key_env: "ANTHROPIC_API_KEY",
46
+ model: "claude-sonnet-4-6",
47
+ effort: "high",
48
+ max_turns: 100,
49
+ },
39
50
  },
40
51
  },
41
52
  };
53
+
54
+ const VALID_TYPES = ["agent-sdk", "mock"] as const;
55
+ const VALID_EFFORTS = ["low", "medium", "high", "max"] as const;
56
+ const VALID_MODELS = ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5"] as const;
57
+
58
+ /** Validate AI provider config fields. Returns array of error messages (empty = valid). */
59
+ export function validateAIProviderConfig(config: Partial<AIProviderConfig>): string[] {
60
+ const errors: string[] = [];
61
+ if (config.type != null && !VALID_TYPES.includes(config.type as any)) {
62
+ errors.push(`type must be one of: ${VALID_TYPES.join(", ")}`);
63
+ }
64
+ if (config.model != null && !VALID_MODELS.includes(config.model as any)) {
65
+ errors.push(`model must be one of: ${VALID_MODELS.join(", ")}`);
66
+ }
67
+ if (config.effort && !VALID_EFFORTS.includes(config.effort as any)) {
68
+ errors.push(`effort must be one of: ${VALID_EFFORTS.join(", ")}`);
69
+ }
70
+ if (config.max_turns != null && (!Number.isInteger(config.max_turns) || config.max_turns < 1 || config.max_turns > 500)) {
71
+ errors.push("max_turns must be integer 1-500");
72
+ }
73
+ if (config.max_budget_usd != null && (config.max_budget_usd < 0.01 || config.max_budget_usd > 50)) {
74
+ errors.push("max_budget_usd must be 0.01-50.00");
75
+ }
76
+ if (config.thinking_budget_tokens != null && (!Number.isInteger(config.thinking_budget_tokens) || config.thinking_budget_tokens < 0)) {
77
+ errors.push("thinking_budget_tokens must be integer >= 0");
78
+ }
79
+ return errors;
80
+ }
81
+
82
+ /** Validate default_provider references an existing provider key */
83
+ export function validateDefaultProvider(defaultProvider: string, providers: Record<string, unknown>): string | null {
84
+ if (!providers[defaultProvider]) {
85
+ return `default_provider "${defaultProvider}" not found in providers`;
86
+ }
87
+ return null;
88
+ }
package/src/web/app.tsx CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  } from "@/stores/settings-store";
16
16
  import { getAuthToken } from "@/lib/api-client";
17
17
  import { useUrlSync, parseUrlState } from "@/hooks/use-url-sync";
18
+ import { useGlobalKeybindings } from "@/hooks/use-global-keybindings";
19
+ import { CommandPalette } from "@/components/layout/command-palette";
18
20
 
19
21
  type AuthState = "checking" | "authenticated" | "unauthenticated";
20
22
 
@@ -68,6 +70,9 @@ export function App() {
68
70
  // URL sync — keeps browser URL in sync with active project/tab
69
71
  useUrlSync();
70
72
 
73
+ // Global keyboard shortcuts (Shift+Shift → command palette, Alt+[/] → cycle tabs)
74
+ const { paletteOpen, closePalette } = useGlobalKeybindings();
75
+
71
76
  // Fetch projects after auth, then restore from URL if applicable
72
77
  useEffect(() => {
73
78
  if (authState !== "authenticated") return;
@@ -155,6 +160,9 @@ export function App() {
155
160
  onClose={() => setDrawerOpen(false)}
156
161
  />
157
162
 
163
+ {/* Command palette (Shift+Shift) */}
164
+ <CommandPalette open={paletteOpen} onClose={closePalette} />
165
+
158
166
  {/* Toast notifications */}
159
167
  <Toaster
160
168
  position="bottom-left"
@@ -395,7 +395,7 @@ export function MessageInput({
395
395
  placeholder={isStreaming ? "Send follow-up or press Stop..." : "Type / for commands, @ for files, or drop files..."}
396
396
  disabled={disabled}
397
397
  rows={1}
398
- className="flex-1 resize-none rounded-lg border border-border bg-surface px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-subtle focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50 max-h-40"
398
+ className="flex-1 resize-none rounded-lg border border-border bg-surface px-3 py-2 text-base md:text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-ring disabled:opacity-50 max-h-40"
399
399
  />
400
400
  {showCancel ? (
401
401
  <button
@@ -2,17 +2,17 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
2
2
  import { marked } from "marked";
3
3
  import { getAuthToken } from "@/lib/api-client";
4
4
  import type { ChatMessage, ChatEvent } from "../../../types/chat";
5
+ import { useTabStore } from "@/stores/tab-store";
6
+ import { ToolCard } from "./tool-cards";
5
7
  import {
6
- ChevronDown,
7
- ChevronRight,
8
8
  AlertCircle,
9
- Wrench,
10
- CheckCircle2,
11
- XCircle,
12
9
  ShieldAlert,
13
10
  Bot,
14
11
  FileText,
15
12
  Image as ImageIcon,
13
+ Copy,
14
+ Check,
15
+ TerminalSquare,
16
16
  } from "lucide-react";
17
17
 
18
18
  interface MessageListProps {
@@ -112,10 +112,10 @@ function MessageBubble({ message, isStreaming, projectName }: { message: ChatMes
112
112
  return (
113
113
  <div className="flex flex-col gap-2">
114
114
  {message.events && message.events.length > 0
115
- ? <InterleavedEvents events={message.events} isStreaming={isStreaming} />
115
+ ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} />
116
116
  : message.content && (
117
117
  <div className="text-sm text-text-primary">
118
- <MarkdownContent content={message.content} />
118
+ <MarkdownContent content={message.content} projectName={projectName} />
119
119
  </div>
120
120
  )}
121
121
  </div>
@@ -297,7 +297,7 @@ type EventGroup =
297
297
  | { kind: "text"; content: string }
298
298
  | { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
299
299
 
300
- function InterleavedEvents({ events, isStreaming }: { events: ChatEvent[]; isStreaming: boolean }) {
300
+ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatEvent[]; isStreaming: boolean; projectName?: string }) {
301
301
  // Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
302
302
  const groups: EventGroup[] = [];
303
303
  let textBuffer = "";
@@ -368,83 +368,32 @@ function InterleavedEvents({ events, isStreaming }: { events: ChatEvent[]; isStr
368
368
  const isLast = isStreaming && i === groups.length - 1;
369
369
  return (
370
370
  <div key={`text-${i}`} className="text-sm text-text-primary">
371
- <StreamingText content={group.content} animate={isLast} />
371
+ <StreamingText content={group.content} animate={isLast} projectName={projectName} />
372
372
  </div>
373
373
  );
374
374
  }
375
- return <ToolCard key={`tool-${i}`} tool={group.tool} result={group.result} completed={group.completed} />;
375
+ return <ToolCard key={`tool-${i}`} tool={group.tool} result={group.result} completed={group.completed} projectName={projectName} />;
376
376
  })}
377
377
  </>
378
378
  );
379
379
  }
380
380
 
381
381
  /**
382
- * Text component with typewriter effect.
383
- * When `animate=true`, reveals content progressively.
384
- * When `animate=false` (finalized), shows full content instantly.
382
+ * Text component that renders streamed content directly.
383
+ * WebSocket already delivers tokens incrementally — no fake animation needed.
384
+ * When `isStreaming=true`, shows a blinking cursor at the end.
385
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
-
386
+ function StreamingText({ content, animate: isStreaming, projectName }: { content: string; animate: boolean; projectName?: string }) {
433
387
  return (
434
388
  <>
435
- <MarkdownContent content={displayed} />
436
- {animate && <StreamingCursor />}
389
+ <MarkdownContent content={content} projectName={projectName} />
390
+ {isStreaming && (
391
+ <span className="text-text-subtle text-sm animate-pulse">Thinking...</span>
392
+ )}
437
393
  </>
438
394
  );
439
395
  }
440
396
 
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
397
  /**
449
398
  * Shows "Thinking..." when:
450
399
  * - No assistant message yet (waiting for first response)
@@ -482,8 +431,8 @@ marked.setOptions({
482
431
  breaks: true,
483
432
  });
484
433
 
485
- /** Renders markdown content using `marked` HTML string */
486
- function MarkdownContent({ content }: { content: string }) {
434
+ /** Renders markdown content with interactive code blocks and file links */
435
+ function MarkdownContent({ content, projectName }: { content: string; projectName?: string }) {
487
436
  const html = useMemo(() => {
488
437
  try {
489
438
  return marked.parse(content) as string;
@@ -492,194 +441,106 @@ function MarkdownContent({ content }: { content: string }) {
492
441
  }
493
442
  }, [content]);
494
443
 
495
- return (
496
- <div
497
- className="markdown-content prose-sm"
498
- dangerouslySetInnerHTML={{ __html: html }}
499
- />
500
- );
501
- }
444
+ const containerRef = useRef<HTMLDivElement>(null);
445
+ const { openTab } = useTabStore();
502
446
 
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);
447
+ // After render: inject copy/run buttons into <pre> blocks, handle file link clicks
448
+ useEffect(() => {
449
+ const container = containerRef.current;
450
+ if (!container) return;
451
+
452
+ // --- Code block copy/run buttons ---
453
+ container.querySelectorAll("pre").forEach((pre) => {
454
+ if (pre.querySelector(".code-actions")) return; // already added
455
+ const code = pre.querySelector("code");
456
+ const text = code?.textContent ?? pre.textContent ?? "";
457
+ // Detect language from class (e.g. "language-bash")
458
+ const langClass = code?.className ?? "";
459
+ const isBash = /language-(bash|sh|shell|zsh)/.test(langClass)
460
+ || (!langClass.includes("language-") && text.startsWith("$"));
461
+
462
+ // Wrapper for relative positioning
463
+ pre.style.position = "relative";
464
+
465
+ const actions = document.createElement("div");
466
+ actions.className = "code-actions absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity";
467
+ // Always visible on touch devices
468
+ pre.classList.add("group");
469
+
470
+ // Copy button
471
+ const copyBtn = document.createElement("button");
472
+ copyBtn.className = "flex items-center justify-center size-6 rounded bg-surface-elevated/80 hover:bg-surface-elevated text-text-secondary hover:text-text-primary transition-colors border border-border/50";
473
+ copyBtn.title = "Copy";
474
+ copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
475
+ copyBtn.addEventListener("click", () => {
476
+ navigator.clipboard.writeText(text);
477
+ copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
478
+ setTimeout(() => {
479
+ copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
480
+ }, 2000);
481
+ });
482
+ actions.appendChild(copyBtn);
483
+
484
+ // Run in terminal button (bash only)
485
+ if (isBash) {
486
+ const runBtn = document.createElement("button");
487
+ runBtn.className = "flex items-center justify-center size-6 rounded bg-surface-elevated/80 hover:bg-surface-elevated text-text-secondary hover:text-text-primary transition-colors border border-border/50";
488
+ runBtn.title = "Run in terminal";
489
+ runBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`;
490
+ runBtn.addEventListener("click", () => {
491
+ // Copy to clipboard and open terminal
492
+ navigator.clipboard.writeText(text.replace(/^\$\s*/gm, ""));
493
+ if (projectName) {
494
+ openTab({
495
+ type: "terminal",
496
+ title: "Terminal",
497
+ metadata: { projectName },
498
+ projectId: projectName,
499
+ closable: true,
500
+ });
501
+ }
502
+ });
503
+ actions.appendChild(runBtn);
504
+ }
506
505
 
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
- }
506
+ pre.appendChild(actions);
507
+ });
515
508
 
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;
509
+ // --- File link click handling: open in editor tab ---
510
+ const handleClick = (e: MouseEvent) => {
511
+ const target = e.target as HTMLElement;
512
+ const link = target.closest("a");
513
+ if (!link || !container.contains(link)) return;
514
+
515
+ const href = link.getAttribute("href") ?? "";
516
+ // Detect file paths: starts with / or ./ or contains common extensions
517
+ const isFilePath = /^(\/|\.\/|\.\.\/)/.test(href)
518
+ || /\.(ts|tsx|js|jsx|py|json|md|yaml|yml|toml|css|html|sh|go|rs|sql)$/i.test(href);
519
+ if (isFilePath && projectName) {
520
+ e.preventDefault();
521
+ openTab({
522
+ type: "editor",
523
+ title: href.split("/").pop() ?? href,
524
+ metadata: { filePath: href, projectName },
525
+ projectId: projectName,
526
+ closable: true,
527
+ });
528
+ }
529
+ };
530
+ container.addEventListener("click", handleClick);
531
+ return () => container.removeEventListener("click", handleClick);
532
+ }, [html, projectName, openTab]);
533
533
 
534
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>
535
+ <div
536
+ ref={containerRef}
537
+ className="markdown-content prose-sm"
538
+ dangerouslySetInnerHTML={{ __html: html }}
539
+ />
564
540
  );
565
541
  }
566
542
 
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
- }
543
+ /* ToolCard, ToolSummary, ToolDetails extracted to ./tool-cards.tsx */
683
544
 
684
545
  function ApprovalCard({
685
546
  approval,