@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.
- package/CLAUDE.md +45 -0
- package/bun.lock +3 -0
- package/dist/ppm +0 -0
- package/dist/web/assets/api-client-BgVufYKf.js +1 -0
- package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +1 -0
- package/dist/web/assets/button-KIZetva8.js +41 -0
- package/dist/web/assets/chat-tab-CNXjLOhI.js +6 -0
- package/dist/web/assets/code-editor-tGMPwYNs.js +2 -0
- package/dist/web/assets/copy-B-kLwqzg.js +1 -0
- package/dist/web/assets/dialog-D8ulRTfX.js +5 -0
- package/dist/web/assets/diff-viewer-B4A8pPbo.js +4 -0
- package/dist/web/assets/dist-C4W3AGh3.js +1 -0
- package/dist/web/assets/dist-PA84y4Ga.js +1 -0
- package/dist/web/assets/external-link-Dim3NH6h.js +1 -0
- package/dist/web/assets/git-graph-ODjrGZOQ.js +1 -0
- package/dist/web/assets/git-status-panel-B0Im1hrU.js +1 -0
- package/dist/web/assets/index-BePIZMuy.css +2 -0
- package/dist/web/assets/index-D2STBl88.js +10 -0
- package/dist/web/assets/{jsx-runtime-BnxRlLMJ.js → jsx-runtime-BFALxl05.js} +1 -1
- package/dist/web/assets/marked.esm-Cv8mjgnt.js +59 -0
- package/dist/web/assets/project-list-VjQQcU3X.js +1 -0
- package/dist/web/assets/{react-Uzd0zARU.js → react-BSLFEYu8.js} +1 -1
- package/dist/web/assets/refresh-cw-DJSjl6Ev.js +1 -0
- package/dist/web/assets/settings-tab-ChhdL0EG.js +1 -0
- package/dist/web/assets/terminal-tab-DDf6S-Tu.js +36 -0
- package/dist/web/assets/trash-2-CjahwKg8.js +1 -0
- package/dist/web/assets/x-BxhOxZ5p.js +1 -0
- package/dist/web/index.html +11 -10
- package/dist/web/sw.js +1 -1
- package/docs/claude-agent-sdk-reference.md +780 -0
- package/docs/codebase-summary.md +11 -13
- package/docs/lessons-learned.md +58 -0
- package/docs/system-architecture.md +78 -2
- package/package.json +2 -1
- package/schemas/ppm-config.schema.json +87 -0
- package/src/providers/claude-agent-sdk.ts +84 -3
- package/src/providers/registry.ts +0 -2
- package/src/server/index.ts +7 -1
- package/src/server/routes/settings.ts +70 -0
- package/src/server/ws/chat.ts +23 -8
- package/src/services/git.service.ts +23 -1
- package/src/types/chat.ts +8 -1
- package/src/types/config.ts +50 -3
- package/src/web/app.tsx +8 -0
- package/src/web/components/chat/message-input.tsx +1 -1
- package/src/web/components/chat/message-list.tsx +112 -251
- package/src/web/components/chat/tool-cards.tsx +411 -0
- package/src/web/components/editor/code-editor.tsx +80 -20
- package/src/web/components/editor/diff-viewer.tsx +72 -7
- package/src/web/components/git/git-graph.tsx +3 -0
- package/src/web/components/git/git-status-panel.tsx +50 -1
- package/src/web/components/layout/command-palette.tsx +215 -0
- package/src/web/components/layout/mobile-drawer.tsx +143 -42
- package/src/web/components/layout/sidebar.tsx +103 -67
- package/src/web/components/layout/tab-bar.tsx +1 -2
- package/src/web/components/settings/ai-settings-section.tsx +166 -0
- package/src/web/components/settings/settings-tab.tsx +5 -0
- package/src/web/components/terminal/terminal-tab.tsx +45 -22
- package/src/web/components/ui/input.tsx +4 -3
- package/src/web/components/ui/label.tsx +24 -0
- package/src/web/components/ui/select.tsx +188 -0
- package/src/web/hooks/use-chat.ts +3 -0
- package/src/web/hooks/use-global-keybindings.ts +56 -0
- package/src/web/hooks/use-terminal.ts +14 -1
- package/src/web/lib/api-settings.ts +24 -0
- package/src/web/stores/project-store.ts +47 -2
- package/src/web/stores/tab-store.ts +1 -1
- package/src/web/styles/globals.css +16 -3
- package/test-tool.mjs +41 -0
- package/dist/web/assets/api-client-Bnf9LAt4.js +0 -1
- package/dist/web/assets/arrow-up-from-line-BXL5dtbG.js +0 -1
- package/dist/web/assets/button-DxRZgE8F.js +0 -1
- package/dist/web/assets/chat-tab-BkCV4ZC9.js +0 -61
- package/dist/web/assets/code-editor-f77XD8lZ.js +0 -2
- package/dist/web/assets/createLucideIcon-Dy1wlrF7.js +0 -1
- package/dist/web/assets/dialog-Db6prp1p.js +0 -45
- package/dist/web/assets/diff-viewer-BF7IYlm4.js +0 -4
- package/dist/web/assets/external-link-WSiY-639.js +0 -1
- package/dist/web/assets/git-graph-Ct1-XDz2.js +0 -1
- package/dist/web/assets/git-status-panel-D1rNmbrT.js +0 -1
- package/dist/web/assets/index-DYd_2slk.css +0 -2
- package/dist/web/assets/index-iwjbGjDp.js +0 -10
- package/dist/web/assets/project-list-DB85YVTT.js +0 -1
- package/dist/web/assets/refresh-cw-DtopuYJf.js +0 -1
- package/dist/web/assets/settings-tab-Ooz2h9Hu.js +0 -1
- package/dist/web/assets/terminal-tab-DHwn2LMT.js +0 -36
- package/dist/web/assets/trash-2-CHLebaNh.js +0 -1
- package/dist/web/assets/x-BISR7bpK.js +0 -1
- package/src/providers/claude-binary-finder.ts +0 -256
- package/src/providers/claude-code-cli.ts +0 -413
- package/src/providers/claude-process-registry.ts +0 -106
- /package/dist/web/assets/{dist-CSp7ir0r.js → dist-CBiGQxfr.js} +0 -0
- /package/dist/web/assets/{utils-CiBGfeHD.js → utils-DpJF9mAi.js} +0 -0
package/src/server/ws/chat.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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,
|
package/src/types/config.ts
CHANGED
|
@@ -22,9 +22,14 @@ export interface AIConfig {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface AIProviderConfig {
|
|
25
|
-
type: "agent-sdk" | "
|
|
25
|
+
type: "agent-sdk" | "mock";
|
|
26
26
|
api_key_env?: string;
|
|
27
|
-
|
|
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: {
|
|
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-
|
|
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
|
|
383
|
-
*
|
|
384
|
-
* When `
|
|
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={
|
|
436
|
-
{
|
|
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
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
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,
|