opencode-replay 1.0.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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/dist/assets/highlight.js +300 -0
- package/dist/assets/prism.css +273 -0
- package/dist/assets/search.js +445 -0
- package/dist/assets/styles.css +3384 -0
- package/dist/assets/theme.js +111 -0
- package/dist/index.js +2569 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2569 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
import { parseArgs } from "util";
|
|
6
|
+
import { resolve as resolve2, join as join4 } from "path";
|
|
7
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
8
|
+
|
|
9
|
+
// src/storage/reader.ts
|
|
10
|
+
import { readdir } from "fs/promises";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
function getDefaultStoragePath() {
|
|
14
|
+
return join(homedir(), ".local", "share", "opencode", "storage");
|
|
15
|
+
}
|
|
16
|
+
async function readJson(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
const file = Bun.file(filePath);
|
|
19
|
+
if (!await file.exists()) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return await file.json();
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function listJsonFiles(dirPath) {
|
|
28
|
+
try {
|
|
29
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
30
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".json")).map((e) => e.name.replace(".json", ""));
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function listProjects(storagePath) {
|
|
36
|
+
const projectDir = join(storagePath, "project");
|
|
37
|
+
const projectIds = await listJsonFiles(projectDir);
|
|
38
|
+
const projects = [];
|
|
39
|
+
for (const id of projectIds) {
|
|
40
|
+
const project = await readJson(join(projectDir, `${id}.json`));
|
|
41
|
+
if (project)
|
|
42
|
+
projects.push(project);
|
|
43
|
+
}
|
|
44
|
+
return projects.sort((a, b) => b.time.updated - a.time.updated);
|
|
45
|
+
}
|
|
46
|
+
async function findProjectByPath(storagePath, workdir) {
|
|
47
|
+
const projects = await listProjects(storagePath);
|
|
48
|
+
return projects.find((p) => workdir === p.worktree || workdir.startsWith(p.worktree + "/")) ?? null;
|
|
49
|
+
}
|
|
50
|
+
async function listSessions(storagePath, projectId) {
|
|
51
|
+
const sessionDir = join(storagePath, "session", projectId);
|
|
52
|
+
const sessionIds = await listJsonFiles(sessionDir);
|
|
53
|
+
const sessions = [];
|
|
54
|
+
for (const id of sessionIds) {
|
|
55
|
+
const session = await readJson(join(sessionDir, `${id}.json`));
|
|
56
|
+
if (session)
|
|
57
|
+
sessions.push(session);
|
|
58
|
+
}
|
|
59
|
+
return sessions.sort((a, b) => b.time.updated - a.time.updated);
|
|
60
|
+
}
|
|
61
|
+
async function listMessages(storagePath, sessionId) {
|
|
62
|
+
const messageDir = join(storagePath, "message", sessionId);
|
|
63
|
+
const messageIds = await listJsonFiles(messageDir);
|
|
64
|
+
const messages = [];
|
|
65
|
+
for (const id of messageIds) {
|
|
66
|
+
const message = await readJson(join(messageDir, `${id}.json`));
|
|
67
|
+
if (message)
|
|
68
|
+
messages.push(message);
|
|
69
|
+
}
|
|
70
|
+
return messages.sort((a, b) => a.time.created - b.time.created);
|
|
71
|
+
}
|
|
72
|
+
async function listParts(storagePath, messageId) {
|
|
73
|
+
const partDir = join(storagePath, "part", messageId);
|
|
74
|
+
const partIds = await listJsonFiles(partDir);
|
|
75
|
+
const parts = [];
|
|
76
|
+
for (const id of partIds) {
|
|
77
|
+
const part = await readJson(join(partDir, `${id}.json`));
|
|
78
|
+
if (part)
|
|
79
|
+
parts.push(part);
|
|
80
|
+
}
|
|
81
|
+
return parts.sort((a, b) => a.id.localeCompare(b.id));
|
|
82
|
+
}
|
|
83
|
+
async function getMessagesWithParts(storagePath, sessionId) {
|
|
84
|
+
const messages = await listMessages(storagePath, sessionId);
|
|
85
|
+
const result = [];
|
|
86
|
+
for (const message of messages) {
|
|
87
|
+
const parts = await listParts(storagePath, message.id);
|
|
88
|
+
result.push({ message, parts });
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/render/html.ts
|
|
94
|
+
import { join as join2, dirname } from "path";
|
|
95
|
+
import { mkdir, copyFile } from "fs/promises";
|
|
96
|
+
|
|
97
|
+
// src/utils/html.ts
|
|
98
|
+
var HTML_ESCAPE_MAP = {
|
|
99
|
+
"&": "&",
|
|
100
|
+
"<": "<",
|
|
101
|
+
">": ">",
|
|
102
|
+
'"': """,
|
|
103
|
+
"'": "'"
|
|
104
|
+
};
|
|
105
|
+
function escapeHtml(str) {
|
|
106
|
+
if (str == null)
|
|
107
|
+
return "";
|
|
108
|
+
return String(str).replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char] ?? char);
|
|
109
|
+
}
|
|
110
|
+
function escapeAttr(str) {
|
|
111
|
+
return escapeHtml(str);
|
|
112
|
+
}
|
|
113
|
+
function isSafeUrl(url) {
|
|
114
|
+
if (/^(https?:\/\/|\/|#|\.\.?\/)/.test(url))
|
|
115
|
+
return true;
|
|
116
|
+
if (/^data:image\//i.test(url))
|
|
117
|
+
return true;
|
|
118
|
+
return !/^[a-z]+:/i.test(url);
|
|
119
|
+
}
|
|
120
|
+
function renderMarkdown(text) {
|
|
121
|
+
let html = escapeHtml(text);
|
|
122
|
+
html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
123
|
+
const rawLang = (lang || "").trim();
|
|
124
|
+
const safeLang = rawLang.replace(/[^a-zA-Z0-9_-]/g, "") || "text";
|
|
125
|
+
const displayLang = rawLang || "text";
|
|
126
|
+
return `<div class="code-block-wrapper">
|
|
127
|
+
<span class="code-language">${escapeHtml(displayLang)}</span>
|
|
128
|
+
<pre><code class="language-${safeLang}">${code.trim()}</code></pre>
|
|
129
|
+
</div>`;
|
|
130
|
+
});
|
|
131
|
+
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
132
|
+
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
133
|
+
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
134
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText, url) => {
|
|
135
|
+
const safeUrl = isSafeUrl(url) ? url : "#";
|
|
136
|
+
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
|
|
137
|
+
});
|
|
138
|
+
html = html.split(/\n\n+/).map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`).join(`
|
|
139
|
+
`);
|
|
140
|
+
return html;
|
|
141
|
+
}
|
|
142
|
+
function truncate(str, maxLength) {
|
|
143
|
+
if (str == null)
|
|
144
|
+
return "";
|
|
145
|
+
if (str.length <= maxLength)
|
|
146
|
+
return str;
|
|
147
|
+
return str.slice(0, maxLength - 3) + "...";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/render/templates/base.ts
|
|
151
|
+
var FOUC_PREVENTION_SCRIPT = `<script>
|
|
152
|
+
(function() {
|
|
153
|
+
var theme = localStorage.getItem('opencode-replay-theme');
|
|
154
|
+
if (!theme) {
|
|
155
|
+
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
156
|
+
}
|
|
157
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
158
|
+
})();
|
|
159
|
+
</script>`;
|
|
160
|
+
function renderBasePage(options) {
|
|
161
|
+
const {
|
|
162
|
+
title,
|
|
163
|
+
content,
|
|
164
|
+
assetsPath = "./assets",
|
|
165
|
+
headExtra = "",
|
|
166
|
+
bodyClass = "",
|
|
167
|
+
totalPages
|
|
168
|
+
} = options;
|
|
169
|
+
const bodyAttrs = [];
|
|
170
|
+
if (bodyClass)
|
|
171
|
+
bodyAttrs.push(`class="${bodyClass}"`);
|
|
172
|
+
if (totalPages !== undefined)
|
|
173
|
+
bodyAttrs.push(`data-total-pages="${totalPages}"`);
|
|
174
|
+
const bodyAttrStr = bodyAttrs.length > 0 ? ` ${bodyAttrs.join(" ")}` : "";
|
|
175
|
+
return `<!DOCTYPE html>
|
|
176
|
+
<html lang="en">
|
|
177
|
+
<head>
|
|
178
|
+
<meta charset="UTF-8">
|
|
179
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
180
|
+
<meta name="generator" content="opencode-replay">
|
|
181
|
+
<meta name="color-scheme" content="light dark">
|
|
182
|
+
<title>${escapeHtml(title)} - OpenCode Replay</title>
|
|
183
|
+
${FOUC_PREVENTION_SCRIPT}
|
|
184
|
+
<link rel="stylesheet" href="${assetsPath}/styles.css">
|
|
185
|
+
<link rel="stylesheet" href="${assetsPath}/prism.css">
|
|
186
|
+
${headExtra}
|
|
187
|
+
</head>
|
|
188
|
+
<body${bodyAttrStr}>
|
|
189
|
+
<div class="container">
|
|
190
|
+
${content}
|
|
191
|
+
</div>
|
|
192
|
+
<script src="${assetsPath}/theme.js"></script>
|
|
193
|
+
<script src="${assetsPath}/highlight.js"></script>
|
|
194
|
+
<script src="${assetsPath}/search.js"></script>
|
|
195
|
+
</body>
|
|
196
|
+
</html>`;
|
|
197
|
+
}
|
|
198
|
+
var THEME_TOGGLE_BUTTON = `<button
|
|
199
|
+
id="theme-toggle"
|
|
200
|
+
class="theme-toggle"
|
|
201
|
+
type="button"
|
|
202
|
+
aria-label="Toggle dark mode"
|
|
203
|
+
aria-pressed="false"
|
|
204
|
+
title="Toggle theme"
|
|
205
|
+
>
|
|
206
|
+
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
207
|
+
<circle cx="12" cy="12" r="5"/>
|
|
208
|
+
<line x1="12" y1="1" x2="12" y2="3"/>
|
|
209
|
+
<line x1="12" y1="21" x2="12" y2="23"/>
|
|
210
|
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
|
211
|
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
|
212
|
+
<line x1="1" y1="12" x2="3" y2="12"/>
|
|
213
|
+
<line x1="21" y1="12" x2="23" y2="12"/>
|
|
214
|
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
|
215
|
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
|
216
|
+
</svg>
|
|
217
|
+
<svg class="icon-moon" viewBox="0 0 24 24" fill="currentColor">
|
|
218
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
219
|
+
</svg>
|
|
220
|
+
</button>`;
|
|
221
|
+
function renderHeader(options) {
|
|
222
|
+
const {
|
|
223
|
+
title,
|
|
224
|
+
subtitle,
|
|
225
|
+
breadcrumbs = [],
|
|
226
|
+
showSearch = true,
|
|
227
|
+
showThemeToggle = true
|
|
228
|
+
} = options;
|
|
229
|
+
const breadcrumbHtml = breadcrumbs.length > 0 ? `<nav class="breadcrumbs">
|
|
230
|
+
${breadcrumbs.map((b, i) => {
|
|
231
|
+
const isLast = i === breadcrumbs.length - 1;
|
|
232
|
+
if (isLast || !b.href) {
|
|
233
|
+
return `<span class="breadcrumb-item current">${escapeHtml(b.label)}</span>`;
|
|
234
|
+
}
|
|
235
|
+
const safeHref = isSafeUrl(b.href) ? escapeAttr(b.href) : "#";
|
|
236
|
+
return `<a href="${safeHref}" class="breadcrumb-item">${escapeHtml(b.label)}</a>`;
|
|
237
|
+
}).join('<span class="breadcrumb-separator">/</span>')}
|
|
238
|
+
</nav>` : "";
|
|
239
|
+
const headerActions = showSearch || showThemeToggle ? `<div class="header-actions">
|
|
240
|
+
${showSearch ? `<button class="search-trigger" type="button" aria-label="Search">
|
|
241
|
+
<span class="search-icon">🔍</span>
|
|
242
|
+
<span class="search-text">Search...</span>
|
|
243
|
+
<kbd>Ctrl+K</kbd>
|
|
244
|
+
</button>` : ""}
|
|
245
|
+
${showThemeToggle ? THEME_TOGGLE_BUTTON : ""}
|
|
246
|
+
</div>` : "";
|
|
247
|
+
return `<header class="page-header">
|
|
248
|
+
<div class="header-top">
|
|
249
|
+
${breadcrumbHtml}
|
|
250
|
+
${headerActions}
|
|
251
|
+
</div>
|
|
252
|
+
<h1>${escapeHtml(title)}</h1>
|
|
253
|
+
${subtitle ? `<p class="subtitle">${escapeHtml(subtitle)}</p>` : ""}
|
|
254
|
+
</header>`;
|
|
255
|
+
}
|
|
256
|
+
function renderFooter() {
|
|
257
|
+
return `<footer class="page-footer">
|
|
258
|
+
<p>Generated by <a href="https://github.com/opencode-replay" target="_blank" rel="noopener">opencode-replay</a></p>
|
|
259
|
+
</footer>`;
|
|
260
|
+
}
|
|
261
|
+
// src/utils/format.ts
|
|
262
|
+
function formatDate(timestamp) {
|
|
263
|
+
return new Date(timestamp).toLocaleDateString("en-US", {
|
|
264
|
+
year: "numeric",
|
|
265
|
+
month: "short",
|
|
266
|
+
day: "numeric"
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function formatDateTime(timestamp) {
|
|
270
|
+
return new Date(timestamp).toLocaleString("en-US", {
|
|
271
|
+
year: "numeric",
|
|
272
|
+
month: "short",
|
|
273
|
+
day: "numeric",
|
|
274
|
+
hour: "2-digit",
|
|
275
|
+
minute: "2-digit"
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function formatTime(timestamp) {
|
|
279
|
+
return new Date(timestamp).toLocaleTimeString("en-US", {
|
|
280
|
+
hour: "2-digit",
|
|
281
|
+
minute: "2-digit"
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
function formatDuration(startMs, endMs) {
|
|
285
|
+
const diff = endMs - startMs;
|
|
286
|
+
if (diff <= 0)
|
|
287
|
+
return "0s";
|
|
288
|
+
const seconds = Math.floor(diff / 1000);
|
|
289
|
+
if (seconds < 60)
|
|
290
|
+
return `${seconds}s`;
|
|
291
|
+
if (seconds < 3600) {
|
|
292
|
+
const minutes2 = Math.floor(seconds / 60);
|
|
293
|
+
const secs = seconds % 60;
|
|
294
|
+
return secs > 0 ? `${minutes2}m ${secs}s` : `${minutes2}m`;
|
|
295
|
+
}
|
|
296
|
+
const hours = Math.floor(seconds / 3600);
|
|
297
|
+
const minutes = Math.floor(seconds % 3600 / 60);
|
|
298
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
299
|
+
}
|
|
300
|
+
function formatTokens(tokens) {
|
|
301
|
+
if (tokens < 1000)
|
|
302
|
+
return tokens.toString();
|
|
303
|
+
if (tokens < 1e6)
|
|
304
|
+
return `${(tokens / 1000).toFixed(1)}k`;
|
|
305
|
+
return `${(tokens / 1e6).toFixed(2)}M`;
|
|
306
|
+
}
|
|
307
|
+
function formatCost(cost) {
|
|
308
|
+
if (cost === 0)
|
|
309
|
+
return "\u2014";
|
|
310
|
+
if (cost < 0.01)
|
|
311
|
+
return `$${cost.toFixed(4)}`;
|
|
312
|
+
if (cost < 1)
|
|
313
|
+
return `$${cost.toFixed(3)}`;
|
|
314
|
+
return `$${cost.toFixed(2)}`;
|
|
315
|
+
}
|
|
316
|
+
function formatDiff(additions, deletions) {
|
|
317
|
+
const parts = [];
|
|
318
|
+
if (additions > 0)
|
|
319
|
+
parts.push(`+${additions}`);
|
|
320
|
+
if (deletions > 0)
|
|
321
|
+
parts.push(`-${deletions}`);
|
|
322
|
+
return parts.join(" ") || "0";
|
|
323
|
+
}
|
|
324
|
+
function formatRelativeTime(timestamp) {
|
|
325
|
+
const now = Date.now();
|
|
326
|
+
const diff = now - timestamp;
|
|
327
|
+
if (diff < 0)
|
|
328
|
+
return formatDate(timestamp);
|
|
329
|
+
const seconds = Math.floor(diff / 1000);
|
|
330
|
+
const minutes = Math.floor(seconds / 60);
|
|
331
|
+
const hours = Math.floor(minutes / 60);
|
|
332
|
+
const days = Math.floor(hours / 24);
|
|
333
|
+
if (seconds < 60)
|
|
334
|
+
return "just now";
|
|
335
|
+
if (minutes < 60)
|
|
336
|
+
return `${minutes}m ago`;
|
|
337
|
+
if (hours < 24)
|
|
338
|
+
return `${hours}h ago`;
|
|
339
|
+
if (days === 1)
|
|
340
|
+
return "yesterday";
|
|
341
|
+
if (days < 7)
|
|
342
|
+
return `${days}d ago`;
|
|
343
|
+
return formatDate(timestamp);
|
|
344
|
+
}
|
|
345
|
+
function formatBytes(bytes) {
|
|
346
|
+
if (bytes < 1024)
|
|
347
|
+
return `${bytes} B`;
|
|
348
|
+
if (bytes < 1024 * 1024)
|
|
349
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
350
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/render/templates/index-page.ts
|
|
354
|
+
function renderSessionCard(data, isAllProjects) {
|
|
355
|
+
const { session, project, messageCount, firstPrompt } = data;
|
|
356
|
+
const sessionUrl = `sessions/${session.id}/index.html`;
|
|
357
|
+
const dateStr = formatRelativeTime(session.time.updated);
|
|
358
|
+
const fullDate = formatDate(session.time.created);
|
|
359
|
+
const summary = session.summary;
|
|
360
|
+
const hasStats = summary && (summary.additions || summary.deletions || summary.files);
|
|
361
|
+
return `<a href="${sessionUrl}" class="session-card" data-session-id="${escapeHtml(session.id)}">
|
|
362
|
+
<div class="session-title">${escapeHtml(session.title)}</div>
|
|
363
|
+
<div class="session-meta">
|
|
364
|
+
<span title="${fullDate}">${dateStr}</span>
|
|
365
|
+
${messageCount !== undefined ? `<span>${messageCount} messages</span>` : ""}
|
|
366
|
+
${isAllProjects && project?.name ? `<span>${escapeHtml(project.name)}</span>` : ""}
|
|
367
|
+
</div>
|
|
368
|
+
${firstPrompt ? `<div class="session-summary">${escapeHtml(truncate(firstPrompt, 200))}</div>` : ""}
|
|
369
|
+
${hasStats ? `<div class="session-stats">
|
|
370
|
+
${summary?.additions ? `<span class="stat-additions">+${summary.additions}</span>` : ""}
|
|
371
|
+
${summary?.deletions ? `<span class="stat-deletions">-${summary.deletions}</span>` : ""}
|
|
372
|
+
${summary?.files ? `<span>${summary.files} files</span>` : ""}
|
|
373
|
+
</div>` : ""}
|
|
374
|
+
</a>`;
|
|
375
|
+
}
|
|
376
|
+
function renderSessionList(sessions, isAllProjects) {
|
|
377
|
+
if (sessions.length === 0) {
|
|
378
|
+
return `<div class="no-sessions">
|
|
379
|
+
<p>No sessions found.</p>
|
|
380
|
+
</div>`;
|
|
381
|
+
}
|
|
382
|
+
return `<div class="session-list">
|
|
383
|
+
${sessions.map((s) => renderSessionCard(s, isAllProjects)).join(`
|
|
384
|
+
`)}
|
|
385
|
+
</div>`;
|
|
386
|
+
}
|
|
387
|
+
function renderIndexStats(sessions) {
|
|
388
|
+
const totalSessions = sessions.length;
|
|
389
|
+
const totalMessages = sessions.reduce((sum, s) => sum + (s.messageCount ?? 0), 0);
|
|
390
|
+
const totalAdditions = sessions.reduce((sum, s) => sum + (s.session.summary?.additions ?? 0), 0);
|
|
391
|
+
const totalDeletions = sessions.reduce((sum, s) => sum + (s.session.summary?.deletions ?? 0), 0);
|
|
392
|
+
return `<div class="stats">
|
|
393
|
+
<div class="stat">
|
|
394
|
+
<span class="stat-label">Sessions</span>
|
|
395
|
+
<span class="stat-value">${totalSessions}</span>
|
|
396
|
+
</div>
|
|
397
|
+
${totalMessages > 0 ? `<div class="stat">
|
|
398
|
+
<span class="stat-label">Messages</span>
|
|
399
|
+
<span class="stat-value">${totalMessages}</span>
|
|
400
|
+
</div>` : ""}
|
|
401
|
+
${totalAdditions > 0 || totalDeletions > 0 ? `<div class="stat">
|
|
402
|
+
<span class="stat-label">Changes</span>
|
|
403
|
+
<span class="stat-value">${formatDiff(totalAdditions, totalDeletions)}</span>
|
|
404
|
+
</div>` : ""}
|
|
405
|
+
</div>`;
|
|
406
|
+
}
|
|
407
|
+
function renderIndexPage(data) {
|
|
408
|
+
const {
|
|
409
|
+
title,
|
|
410
|
+
subtitle,
|
|
411
|
+
sessions,
|
|
412
|
+
isAllProjects = false,
|
|
413
|
+
breadcrumbs = [],
|
|
414
|
+
assetsPath = "./assets"
|
|
415
|
+
} = data;
|
|
416
|
+
const header = renderHeader({
|
|
417
|
+
title,
|
|
418
|
+
subtitle,
|
|
419
|
+
breadcrumbs,
|
|
420
|
+
showSearch: true
|
|
421
|
+
});
|
|
422
|
+
const stats = renderIndexStats(sessions);
|
|
423
|
+
const sessionList = renderSessionList(sessions, isAllProjects);
|
|
424
|
+
const footer = renderFooter();
|
|
425
|
+
const content = `
|
|
426
|
+
${header}
|
|
427
|
+
${stats}
|
|
428
|
+
${sessionList}
|
|
429
|
+
${footer}
|
|
430
|
+
`;
|
|
431
|
+
return renderBasePage({
|
|
432
|
+
title,
|
|
433
|
+
content,
|
|
434
|
+
assetsPath,
|
|
435
|
+
bodyClass: "index-page"
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
// src/render/templates/session.ts
|
|
439
|
+
function renderCommitCard(commit) {
|
|
440
|
+
const { hash, message, branch, timestamp, url } = commit;
|
|
441
|
+
const hashDisplay = url && isSafeUrl(url) ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" class="commit-hash">${escapeHtml(hash)}</a>` : `<span class="commit-hash">${escapeHtml(hash)}</span>`;
|
|
442
|
+
const branchHtml = branch ? `<span class="commit-branch">${escapeHtml(branch)}</span>` : "";
|
|
443
|
+
const timeHtml = timestamp ? `<span class="commit-time">${formatTime(timestamp)}</span>` : "";
|
|
444
|
+
return `<div class="commit-card">
|
|
445
|
+
<div class="commit-icon">
|
|
446
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
|
447
|
+
<path d="M11.75 7.5a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0zm1.5 0a5.25 5.25 0 1 1-10.5 0 5.25 5.25 0 0 1 10.5 0zm-1.5 0a3.75 3.75 0 1 0-7.5 0 3.75 3.75 0 0 0 7.5 0z"/>
|
|
448
|
+
<path d="M8 4.5a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"/>
|
|
449
|
+
</svg>
|
|
450
|
+
</div>
|
|
451
|
+
<div class="commit-content">
|
|
452
|
+
<div class="commit-header">
|
|
453
|
+
${hashDisplay}
|
|
454
|
+
${branchHtml}
|
|
455
|
+
${timeHtml}
|
|
456
|
+
</div>
|
|
457
|
+
<div class="commit-message">${escapeHtml(message)}</div>
|
|
458
|
+
</div>
|
|
459
|
+
</div>`;
|
|
460
|
+
}
|
|
461
|
+
function renderCommitCards(commits) {
|
|
462
|
+
if (!commits || commits.length === 0)
|
|
463
|
+
return "";
|
|
464
|
+
return `<div class="commit-cards">
|
|
465
|
+
${commits.map(renderCommitCard).join(`
|
|
466
|
+
`)}
|
|
467
|
+
</div>`;
|
|
468
|
+
}
|
|
469
|
+
function renderTimelineEntry(entry) {
|
|
470
|
+
const { promptNumber, messageId, promptPreview, toolCounts, pageNumber, commits } = entry;
|
|
471
|
+
const toolStats = Object.entries(toolCounts).filter(([_, count]) => count > 0).map(([tool, count]) => `<span>${count} ${tool}</span>`).join("");
|
|
472
|
+
const commitsHtml = renderCommitCards(commits ?? []);
|
|
473
|
+
return `<div class="timeline-entry">
|
|
474
|
+
<div class="timeline-marker">${promptNumber}</div>
|
|
475
|
+
<div class="timeline-content">
|
|
476
|
+
<a href="page-${String(pageNumber).padStart(3, "0")}.html#${messageId}" class="prompt-link">
|
|
477
|
+
${escapeHtml(truncate(promptPreview, 150))}
|
|
478
|
+
</a>
|
|
479
|
+
${toolStats ? `<div class="timeline-stats">${toolStats}</div>` : ""}
|
|
480
|
+
${commitsHtml}
|
|
481
|
+
</div>
|
|
482
|
+
</div>`;
|
|
483
|
+
}
|
|
484
|
+
function renderTimeline(entries) {
|
|
485
|
+
if (entries.length === 0) {
|
|
486
|
+
return `<div class="no-timeline">
|
|
487
|
+
<p>No messages in this session.</p>
|
|
488
|
+
</div>`;
|
|
489
|
+
}
|
|
490
|
+
return `<div class="session-timeline">
|
|
491
|
+
${entries.map(renderTimelineEntry).join(`
|
|
492
|
+
`)}
|
|
493
|
+
</div>`;
|
|
494
|
+
}
|
|
495
|
+
function renderSessionStats(data) {
|
|
496
|
+
const { session, messageCount, totalTokens, totalCost, model } = data;
|
|
497
|
+
const duration = session.time.updated > session.time.created ? formatDuration(session.time.created, session.time.updated) : null;
|
|
498
|
+
const stats = [];
|
|
499
|
+
stats.push({ label: "Created", value: formatDateTime(session.time.created) });
|
|
500
|
+
if (duration) {
|
|
501
|
+
stats.push({ label: "Duration", value: duration });
|
|
502
|
+
}
|
|
503
|
+
stats.push({ label: "Messages", value: String(messageCount) });
|
|
504
|
+
if (model) {
|
|
505
|
+
stats.push({ label: "Model", value: model });
|
|
506
|
+
}
|
|
507
|
+
if (totalTokens) {
|
|
508
|
+
const tokenStr = `${formatTokens(totalTokens.input)} in / ${formatTokens(totalTokens.output)} out`;
|
|
509
|
+
stats.push({ label: "Tokens", value: tokenStr });
|
|
510
|
+
}
|
|
511
|
+
if (totalCost !== undefined && totalCost > 0) {
|
|
512
|
+
stats.push({ label: "Cost", value: formatCost(totalCost) });
|
|
513
|
+
}
|
|
514
|
+
if (session.summary) {
|
|
515
|
+
const { additions, deletions, files } = session.summary;
|
|
516
|
+
if (additions || deletions) {
|
|
517
|
+
stats.push({ label: "Changes", value: formatDiff(additions ?? 0, deletions ?? 0) });
|
|
518
|
+
}
|
|
519
|
+
if (files) {
|
|
520
|
+
stats.push({ label: "Files", value: String(files) });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return `<div class="stats">
|
|
524
|
+
${stats.map((s) => `<div class="stat"><span class="stat-label">${s.label}</span><span class="stat-value">${escapeHtml(s.value)}</span></div>`).join(`
|
|
525
|
+
`)}
|
|
526
|
+
</div>`;
|
|
527
|
+
}
|
|
528
|
+
function renderPagination(pageCount) {
|
|
529
|
+
if (pageCount <= 1)
|
|
530
|
+
return "";
|
|
531
|
+
const links = Array.from({ length: pageCount }, (_, i) => {
|
|
532
|
+
const pageNum = i + 1;
|
|
533
|
+
const pageFile = `page-${String(pageNum).padStart(3, "0")}.html`;
|
|
534
|
+
return `<a href="${pageFile}">Page ${pageNum}</a>`;
|
|
535
|
+
}).join(`
|
|
536
|
+
`);
|
|
537
|
+
return `<nav class="pagination">
|
|
538
|
+
${links}
|
|
539
|
+
</nav>`;
|
|
540
|
+
}
|
|
541
|
+
function renderSessionPage(data) {
|
|
542
|
+
const { session, projectName, timeline, pageCount, assetsPath = "../../assets" } = data;
|
|
543
|
+
const breadcrumbs = [
|
|
544
|
+
{ label: projectName ?? "Sessions", href: "../../index.html" },
|
|
545
|
+
{ label: session.title }
|
|
546
|
+
];
|
|
547
|
+
const header = renderHeader({
|
|
548
|
+
title: session.title,
|
|
549
|
+
subtitle: `Session ${session.id}`,
|
|
550
|
+
breadcrumbs,
|
|
551
|
+
showSearch: true
|
|
552
|
+
});
|
|
553
|
+
const stats = renderSessionStats(data);
|
|
554
|
+
const timelineHtml = renderTimeline(timeline);
|
|
555
|
+
const pagination = renderPagination(pageCount);
|
|
556
|
+
const footer = renderFooter();
|
|
557
|
+
const content = `
|
|
558
|
+
${header}
|
|
559
|
+
${stats}
|
|
560
|
+
<h2>Timeline</h2>
|
|
561
|
+
${timelineHtml}
|
|
562
|
+
${pagination}
|
|
563
|
+
${footer}
|
|
564
|
+
`;
|
|
565
|
+
return renderBasePage({
|
|
566
|
+
title: session.title,
|
|
567
|
+
content,
|
|
568
|
+
assetsPath,
|
|
569
|
+
bodyClass: "session-page",
|
|
570
|
+
totalPages: pageCount
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
// src/render/components/tools/bash.ts
|
|
574
|
+
function renderBashTool(part) {
|
|
575
|
+
const { state } = part;
|
|
576
|
+
const input = state.input;
|
|
577
|
+
const command = input?.command || "";
|
|
578
|
+
const description = input?.description || state.title || "";
|
|
579
|
+
const workdir = input?.workdir;
|
|
580
|
+
const output = state.output || "";
|
|
581
|
+
const error = state.error;
|
|
582
|
+
const status = state.status;
|
|
583
|
+
const workdirHtml = workdir ? `<span class="bash-workdir" title="Working directory">${escapeHtml(workdir)}</span>` : "";
|
|
584
|
+
const descriptionHtml = description ? `<span class="bash-description">${escapeHtml(description)}</span>` : "";
|
|
585
|
+
const outputLines = output.split(`
|
|
586
|
+
`).length;
|
|
587
|
+
const isLongOutput = outputLines > 20;
|
|
588
|
+
const collapsedClass = isLongOutput ? "collapsed" : "";
|
|
589
|
+
let outputHtml = "";
|
|
590
|
+
if (output) {
|
|
591
|
+
outputHtml = `<div class="bash-output">
|
|
592
|
+
<pre><code>${escapeHtml(output)}</code></pre>
|
|
593
|
+
</div>`;
|
|
594
|
+
}
|
|
595
|
+
let errorHtml = "";
|
|
596
|
+
if (error) {
|
|
597
|
+
errorHtml = `<div class="bash-error">
|
|
598
|
+
<pre><code>${escapeHtml(error)}</code></pre>
|
|
599
|
+
</div>`;
|
|
600
|
+
}
|
|
601
|
+
return `<div class="tool-call tool-bash" data-status="${escapeHtml(status)}">
|
|
602
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
603
|
+
<span class="tool-icon">$</span>
|
|
604
|
+
<span class="bash-command">${escapeHtml(command)}</span>
|
|
605
|
+
${descriptionHtml}
|
|
606
|
+
${workdirHtml}
|
|
607
|
+
<span class="tool-toggle">${isLongOutput ? "+" : "-"}</span>
|
|
608
|
+
</div>
|
|
609
|
+
<div class="tool-body ${collapsedClass}">
|
|
610
|
+
${outputHtml}
|
|
611
|
+
${errorHtml}
|
|
612
|
+
</div>
|
|
613
|
+
</div>`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// src/render/components/tools/read.ts
|
|
617
|
+
function renderReadTool(part) {
|
|
618
|
+
const { state } = part;
|
|
619
|
+
const input = state.input;
|
|
620
|
+
const filePath = input?.filePath || state.title || "Unknown file";
|
|
621
|
+
const offset = input?.offset;
|
|
622
|
+
const limit = input?.limit;
|
|
623
|
+
const output = state.output || "";
|
|
624
|
+
const error = state.error;
|
|
625
|
+
const status = state.status;
|
|
626
|
+
const fileName = filePath.split("/").pop() || filePath;
|
|
627
|
+
let rangeInfo = "";
|
|
628
|
+
if (offset !== undefined || limit !== undefined) {
|
|
629
|
+
const parts = [];
|
|
630
|
+
if (offset !== undefined)
|
|
631
|
+
parts.push(`from line ${offset}`);
|
|
632
|
+
if (limit !== undefined)
|
|
633
|
+
parts.push(`${limit} lines`);
|
|
634
|
+
rangeInfo = `<span class="read-range">(${parts.join(", ")})</span>`;
|
|
635
|
+
}
|
|
636
|
+
const lineCount = output ? output.split(`
|
|
637
|
+
`).length : 0;
|
|
638
|
+
const lineInfo = lineCount > 0 ? `<span class="read-lines">${lineCount} lines</span>` : "";
|
|
639
|
+
const isLongOutput = lineCount > 50;
|
|
640
|
+
const collapsedClass = isLongOutput ? "collapsed" : "";
|
|
641
|
+
let contentHtml = "";
|
|
642
|
+
if (output) {
|
|
643
|
+
contentHtml = `<div class="read-content">
|
|
644
|
+
<pre><code>${escapeHtml(output)}</code></pre>
|
|
645
|
+
</div>`;
|
|
646
|
+
}
|
|
647
|
+
let errorHtml = "";
|
|
648
|
+
if (error) {
|
|
649
|
+
errorHtml = `<div class="read-error">
|
|
650
|
+
<span class="error-icon">⚠</span>
|
|
651
|
+
<span class="error-message">${escapeHtml(error)}</span>
|
|
652
|
+
</div>`;
|
|
653
|
+
}
|
|
654
|
+
return `<div class="tool-call tool-read" data-status="${escapeHtml(status)}">
|
|
655
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
656
|
+
<span class="tool-icon">📄</span>
|
|
657
|
+
<span class="read-file-path" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</span>
|
|
658
|
+
<span class="read-full-path">${escapeHtml(filePath)}</span>
|
|
659
|
+
${rangeInfo}
|
|
660
|
+
${lineInfo}
|
|
661
|
+
<span class="tool-toggle">${isLongOutput ? "+" : "-"}</span>
|
|
662
|
+
</div>
|
|
663
|
+
<div class="tool-body ${collapsedClass}">
|
|
664
|
+
${contentHtml}
|
|
665
|
+
${errorHtml}
|
|
666
|
+
</div>
|
|
667
|
+
</div>`;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/render/components/tools/write.ts
|
|
671
|
+
function renderWriteTool(part) {
|
|
672
|
+
const { state } = part;
|
|
673
|
+
const input = state.input;
|
|
674
|
+
const filePath = input?.filePath || state.title || "Unknown file";
|
|
675
|
+
const content = input?.content || "";
|
|
676
|
+
const error = state.error;
|
|
677
|
+
const status = state.status;
|
|
678
|
+
const fileName = filePath.split("/").pop() || filePath;
|
|
679
|
+
const lineCount = content ? content.split(`
|
|
680
|
+
`).length : 0;
|
|
681
|
+
const lineInfo = `<span class="write-lines">${lineCount} lines</span>`;
|
|
682
|
+
const charCount = content.length;
|
|
683
|
+
const charInfo = `<span class="write-chars">${formatBytes(charCount)}</span>`;
|
|
684
|
+
const isLongContent = lineCount > 30;
|
|
685
|
+
const collapsedClass = isLongContent ? "collapsed" : "";
|
|
686
|
+
const statusBadge = status === "completed" ? `<span class="write-badge badge-success">Created</span>` : status === "error" ? `<span class="write-badge badge-error">Failed</span>` : `<span class="write-badge">Writing...</span>`;
|
|
687
|
+
let contentHtml = "";
|
|
688
|
+
if (content) {
|
|
689
|
+
contentHtml = `<div class="write-content">
|
|
690
|
+
<pre><code>${escapeHtml(content)}</code></pre>
|
|
691
|
+
</div>`;
|
|
692
|
+
}
|
|
693
|
+
let errorHtml = "";
|
|
694
|
+
if (error) {
|
|
695
|
+
errorHtml = `<div class="write-error">
|
|
696
|
+
<span class="error-icon">⚠</span>
|
|
697
|
+
<span class="error-message">${escapeHtml(error)}</span>
|
|
698
|
+
</div>`;
|
|
699
|
+
}
|
|
700
|
+
return `<div class="tool-call tool-write" data-status="${escapeHtml(status)}">
|
|
701
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
702
|
+
<span class="tool-icon">📝</span>
|
|
703
|
+
${statusBadge}
|
|
704
|
+
<span class="write-file-path" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</span>
|
|
705
|
+
<span class="write-full-path">${escapeHtml(filePath)}</span>
|
|
706
|
+
${lineInfo}
|
|
707
|
+
${charInfo}
|
|
708
|
+
<span class="tool-toggle">${isLongContent ? "+" : "-"}</span>
|
|
709
|
+
</div>
|
|
710
|
+
<div class="tool-body ${collapsedClass}">
|
|
711
|
+
${contentHtml}
|
|
712
|
+
${errorHtml}
|
|
713
|
+
</div>
|
|
714
|
+
</div>`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// src/render/components/tools/edit.ts
|
|
718
|
+
function renderEditTool(part) {
|
|
719
|
+
const { state } = part;
|
|
720
|
+
const input = state.input;
|
|
721
|
+
const filePath = input?.filePath || state.title || "Unknown file";
|
|
722
|
+
const oldString = input?.oldString || "";
|
|
723
|
+
const newString = input?.newString || "";
|
|
724
|
+
const replaceAll = input?.replaceAll || false;
|
|
725
|
+
const error = state.error;
|
|
726
|
+
const status = state.status;
|
|
727
|
+
const fileName = filePath.split("/").pop() || filePath;
|
|
728
|
+
const oldLines = oldString.split(`
|
|
729
|
+
`).length;
|
|
730
|
+
const newLines = newString.split(`
|
|
731
|
+
`).length;
|
|
732
|
+
const diffStats = `<span class="edit-stats">
|
|
733
|
+
<span class="edit-old-lines">${oldLines} lines</span>
|
|
734
|
+
<span class="edit-arrow">→</span>
|
|
735
|
+
<span class="edit-new-lines">${newLines} lines</span>
|
|
736
|
+
</span>`;
|
|
737
|
+
const replaceAllBadge = replaceAll ? `<span class="edit-replace-all">Replace All</span>` : "";
|
|
738
|
+
const diffHtml = renderDiff(oldString, newString);
|
|
739
|
+
let errorHtml = "";
|
|
740
|
+
if (error) {
|
|
741
|
+
errorHtml = `<div class="edit-error">
|
|
742
|
+
<span class="error-icon">⚠</span>
|
|
743
|
+
<span class="error-message">${escapeHtml(error)}</span>
|
|
744
|
+
</div>`;
|
|
745
|
+
}
|
|
746
|
+
return `<div class="tool-call tool-edit" data-status="${escapeHtml(status)}">
|
|
747
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
748
|
+
<span class="tool-icon">✎</span>
|
|
749
|
+
<span class="edit-file-path" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</span>
|
|
750
|
+
<span class="edit-full-path">${escapeHtml(filePath)}</span>
|
|
751
|
+
${replaceAllBadge}
|
|
752
|
+
${diffStats}
|
|
753
|
+
<span class="tool-toggle">-</span>
|
|
754
|
+
</div>
|
|
755
|
+
<div class="tool-body">
|
|
756
|
+
${diffHtml}
|
|
757
|
+
${errorHtml}
|
|
758
|
+
</div>
|
|
759
|
+
</div>`;
|
|
760
|
+
}
|
|
761
|
+
function renderDiff(oldString, newString) {
|
|
762
|
+
const oldLines = oldString.split(`
|
|
763
|
+
`);
|
|
764
|
+
const newLines = newString.split(`
|
|
765
|
+
`);
|
|
766
|
+
if (oldLines.length <= 50 && newLines.length <= 50) {
|
|
767
|
+
return `<div class="edit-diff">
|
|
768
|
+
<div class="diff-old">
|
|
769
|
+
<div class="diff-label">Old</div>
|
|
770
|
+
<pre><code class="diff-removed">${escapeHtml(oldString)}</code></pre>
|
|
771
|
+
</div>
|
|
772
|
+
<div class="diff-new">
|
|
773
|
+
<div class="diff-label">New</div>
|
|
774
|
+
<pre><code class="diff-added">${escapeHtml(newString)}</code></pre>
|
|
775
|
+
</div>
|
|
776
|
+
</div>`;
|
|
777
|
+
}
|
|
778
|
+
return `<div class="edit-diff-long">
|
|
779
|
+
<details class="diff-section diff-old-section">
|
|
780
|
+
<summary class="diff-label">Old (${oldLines.length} lines)</summary>
|
|
781
|
+
<pre><code class="diff-removed">${escapeHtml(oldString)}</code></pre>
|
|
782
|
+
</details>
|
|
783
|
+
<details class="diff-section diff-new-section" open>
|
|
784
|
+
<summary class="diff-label">New (${newLines.length} lines)</summary>
|
|
785
|
+
<pre><code class="diff-added">${escapeHtml(newString)}</code></pre>
|
|
786
|
+
</details>
|
|
787
|
+
</div>`;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// src/render/components/tools/glob.ts
|
|
791
|
+
function renderGlobTool(part) {
|
|
792
|
+
const { state } = part;
|
|
793
|
+
const input = state.input;
|
|
794
|
+
const pattern = input?.pattern || "";
|
|
795
|
+
const searchPath = input?.path || ".";
|
|
796
|
+
const output = state.output || "";
|
|
797
|
+
const error = state.error;
|
|
798
|
+
const status = state.status;
|
|
799
|
+
const files = output.trim() ? output.trim().split(`
|
|
800
|
+
`).filter(Boolean) : [];
|
|
801
|
+
const fileCount = files.length;
|
|
802
|
+
const isLongList = fileCount > 20;
|
|
803
|
+
const collapsedClass = isLongList ? "collapsed" : "";
|
|
804
|
+
const fileListHtml = files.length > 0 ? `<ul class="glob-file-list">
|
|
805
|
+
${files.map((file) => `<li class="glob-file-item">
|
|
806
|
+
<span class="glob-file-icon">${getFileIcon(file)}</span>
|
|
807
|
+
<span class="glob-file-name">${escapeHtml(file)}</span>
|
|
808
|
+
</li>`).join(`
|
|
809
|
+
`)}
|
|
810
|
+
</ul>` : `<div class="glob-no-matches">No matching files</div>`;
|
|
811
|
+
let errorHtml = "";
|
|
812
|
+
if (error) {
|
|
813
|
+
errorHtml = `<div class="glob-error">
|
|
814
|
+
<span class="error-icon">⚠</span>
|
|
815
|
+
<span class="error-message">${escapeHtml(error)}</span>
|
|
816
|
+
</div>`;
|
|
817
|
+
}
|
|
818
|
+
return `<div class="tool-call tool-glob" data-status="${escapeHtml(status)}">
|
|
819
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
820
|
+
<span class="tool-icon">🔍</span>
|
|
821
|
+
<span class="glob-pattern">${escapeHtml(pattern)}</span>
|
|
822
|
+
<span class="glob-path">${escapeHtml(searchPath)}</span>
|
|
823
|
+
<span class="glob-count">${fileCount} files</span>
|
|
824
|
+
<span class="tool-toggle">${isLongList ? "+" : "-"}</span>
|
|
825
|
+
</div>
|
|
826
|
+
<div class="tool-body ${collapsedClass}">
|
|
827
|
+
${fileListHtml}
|
|
828
|
+
${errorHtml}
|
|
829
|
+
</div>
|
|
830
|
+
</div>`;
|
|
831
|
+
}
|
|
832
|
+
function getFileIcon(filePath) {
|
|
833
|
+
const ext = filePath.split(".").pop()?.toLowerCase() || "";
|
|
834
|
+
if (filePath.endsWith("/"))
|
|
835
|
+
return "📁";
|
|
836
|
+
const icons = {
|
|
837
|
+
ts: "📜",
|
|
838
|
+
tsx: "📜",
|
|
839
|
+
js: "📜",
|
|
840
|
+
jsx: "📜",
|
|
841
|
+
json: "📋",
|
|
842
|
+
md: "📄",
|
|
843
|
+
css: "🎨",
|
|
844
|
+
html: "🌐",
|
|
845
|
+
py: "🐍",
|
|
846
|
+
rs: "⚙",
|
|
847
|
+
go: "🐧",
|
|
848
|
+
rb: "💎",
|
|
849
|
+
sh: "💻",
|
|
850
|
+
yml: "📋",
|
|
851
|
+
yaml: "📋"
|
|
852
|
+
};
|
|
853
|
+
return icons[ext] || "📄";
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/render/components/tools/grep.ts
|
|
857
|
+
function renderGrepTool(part) {
|
|
858
|
+
const { state } = part;
|
|
859
|
+
const input = state.input;
|
|
860
|
+
const pattern = input?.pattern || "";
|
|
861
|
+
const searchPath = input?.path || ".";
|
|
862
|
+
const include = input?.include;
|
|
863
|
+
const output = state.output || "";
|
|
864
|
+
const error = state.error;
|
|
865
|
+
const status = state.status;
|
|
866
|
+
const matches = parseGrepOutput(output);
|
|
867
|
+
const matchCount = matches.length;
|
|
868
|
+
const isLongList = matchCount > 20;
|
|
869
|
+
const collapsedClass = isLongList ? "collapsed" : "";
|
|
870
|
+
const includeHtml = include ? `<span class="grep-include" title="File filter">${escapeHtml(include)}</span>` : "";
|
|
871
|
+
const matchesHtml = matches.length > 0 ? `<ul class="grep-matches">
|
|
872
|
+
${matches.map((match) => `<li class="grep-match">
|
|
873
|
+
<span class="grep-match-file">${escapeHtml(match.file)}</span>
|
|
874
|
+
<span class="grep-match-line">:${match.line}</span>
|
|
875
|
+
<span class="grep-match-content">${escapeHtml(match.content)}</span>
|
|
876
|
+
</li>`).join(`
|
|
877
|
+
`)}
|
|
878
|
+
</ul>` : `<div class="grep-no-matches">No matches found</div>`;
|
|
879
|
+
let errorHtml = "";
|
|
880
|
+
if (error) {
|
|
881
|
+
errorHtml = `<div class="grep-error">
|
|
882
|
+
<span class="error-icon">⚠</span>
|
|
883
|
+
<span class="error-message">${escapeHtml(error)}</span>
|
|
884
|
+
</div>`;
|
|
885
|
+
}
|
|
886
|
+
return `<div class="tool-call tool-grep" data-status="${escapeHtml(status)}">
|
|
887
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
888
|
+
<span class="tool-icon">🔎</span>
|
|
889
|
+
<span class="grep-pattern">${escapeHtml(pattern)}</span>
|
|
890
|
+
${includeHtml}
|
|
891
|
+
<span class="grep-path">${escapeHtml(searchPath)}</span>
|
|
892
|
+
<span class="grep-count">${matchCount} matches</span>
|
|
893
|
+
<span class="tool-toggle">${isLongList ? "+" : "-"}</span>
|
|
894
|
+
</div>
|
|
895
|
+
<div class="tool-body ${collapsedClass}">
|
|
896
|
+
${matchesHtml}
|
|
897
|
+
${errorHtml}
|
|
898
|
+
</div>
|
|
899
|
+
</div>`;
|
|
900
|
+
}
|
|
901
|
+
function parseGrepOutput(output) {
|
|
902
|
+
if (!output.trim())
|
|
903
|
+
return [];
|
|
904
|
+
const lines = output.trim().split(`
|
|
905
|
+
`);
|
|
906
|
+
const matches = [];
|
|
907
|
+
for (const line of lines) {
|
|
908
|
+
const match = line.match(/^(.+?):(\d+):?(.*)$/);
|
|
909
|
+
if (match && match[1] && match[2]) {
|
|
910
|
+
matches.push({
|
|
911
|
+
file: match[1],
|
|
912
|
+
line: parseInt(match[2], 10),
|
|
913
|
+
content: match[3] || ""
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return matches;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// src/render/components/tools/task.ts
|
|
921
|
+
function renderTaskTool(part) {
|
|
922
|
+
const { state } = part;
|
|
923
|
+
const input = state.input;
|
|
924
|
+
const description = input?.description || state.title || "Task";
|
|
925
|
+
const prompt = input?.prompt || "";
|
|
926
|
+
const agentType = input?.subagent_type || "general";
|
|
927
|
+
const command = input?.command;
|
|
928
|
+
const output = state.output || "";
|
|
929
|
+
const error = state.error;
|
|
930
|
+
const status = state.status;
|
|
931
|
+
const agentBadge = getAgentBadge(agentType);
|
|
932
|
+
const commandHtml = command ? `<div class="task-command">
|
|
933
|
+
<span class="task-command-label">Command:</span>
|
|
934
|
+
<code>${escapeHtml(command)}</code>
|
|
935
|
+
</div>` : "";
|
|
936
|
+
const promptLines = prompt.split(`
|
|
937
|
+
`).length;
|
|
938
|
+
const isLongPrompt = promptLines > 5;
|
|
939
|
+
const promptHtml = prompt ? `<div class="task-prompt">
|
|
940
|
+
<details ${isLongPrompt ? "" : "open"}>
|
|
941
|
+
<summary class="task-prompt-label">Prompt (${promptLines} lines)</summary>
|
|
942
|
+
<pre><code>${escapeHtml(prompt)}</code></pre>
|
|
943
|
+
</details>
|
|
944
|
+
</div>` : "";
|
|
945
|
+
const resultLines = output.split(`
|
|
946
|
+
`).length;
|
|
947
|
+
const resultHtml = output ? `<div class="task-result">
|
|
948
|
+
<details open>
|
|
949
|
+
<summary class="task-result-label">Result (${resultLines} lines)</summary>
|
|
950
|
+
<pre><code>${escapeHtml(output)}</code></pre>
|
|
951
|
+
</details>
|
|
952
|
+
</div>` : "";
|
|
953
|
+
let errorHtml = "";
|
|
954
|
+
if (error) {
|
|
955
|
+
errorHtml = `<div class="task-error">
|
|
956
|
+
<span class="error-icon">⚠</span>
|
|
957
|
+
<span class="error-message">${escapeHtml(error)}</span>
|
|
958
|
+
</div>`;
|
|
959
|
+
}
|
|
960
|
+
return `<div class="tool-call tool-task" data-status="${escapeHtml(status)}">
|
|
961
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
962
|
+
<span class="tool-icon">👥</span>
|
|
963
|
+
${agentBadge}
|
|
964
|
+
<span class="task-description">${escapeHtml(description)}</span>
|
|
965
|
+
<span class="tool-toggle">-</span>
|
|
966
|
+
</div>
|
|
967
|
+
<div class="tool-body">
|
|
968
|
+
${commandHtml}
|
|
969
|
+
${promptHtml}
|
|
970
|
+
${resultHtml}
|
|
971
|
+
${errorHtml}
|
|
972
|
+
</div>
|
|
973
|
+
</div>`;
|
|
974
|
+
}
|
|
975
|
+
function getAgentBadge(agentType) {
|
|
976
|
+
const badges = {
|
|
977
|
+
general: { color: "#1565c0", bg: "#e3f2fd", label: "General" },
|
|
978
|
+
explore: { color: "#6a1b9a", bg: "#f3e5f5", label: "Explore" },
|
|
979
|
+
reviewer: { color: "#c62828", bg: "#ffebee", label: "Reviewer" },
|
|
980
|
+
docs: { color: "#2e7d32", bg: "#e8f5e9", label: "Docs" }
|
|
981
|
+
};
|
|
982
|
+
const badge = badges[agentType] || { color: "#757575", bg: "#f5f5f5", label: agentType };
|
|
983
|
+
return `<span class="task-agent-badge" style="color: ${badge.color}; background: ${badge.bg}">
|
|
984
|
+
${escapeHtml(badge.label)}
|
|
985
|
+
</span>`;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/render/components/tools/todowrite.ts
|
|
989
|
+
function renderTodoWriteTool(part) {
|
|
990
|
+
const { state } = part;
|
|
991
|
+
const input = state.input;
|
|
992
|
+
const todos = input?.todos || [];
|
|
993
|
+
const error = state.error;
|
|
994
|
+
const status = state.status;
|
|
995
|
+
const completed = todos.filter((t) => t.status === "completed").length;
|
|
996
|
+
const inProgress = todos.filter((t) => t.status === "in_progress").length;
|
|
997
|
+
const pending = todos.filter((t) => t.status === "pending").length;
|
|
998
|
+
const cancelled = todos.filter((t) => t.status === "cancelled").length;
|
|
999
|
+
const summary = `${completed}/${todos.length} done`;
|
|
1000
|
+
const todoListHtml = todos.length > 0 ? `<ul class="todo-list">
|
|
1001
|
+
${todos.map((todo) => renderTodoItem(todo)).join(`
|
|
1002
|
+
`)}
|
|
1003
|
+
</ul>` : `<div class="todo-empty">No todos</div>`;
|
|
1004
|
+
let errorHtml = "";
|
|
1005
|
+
if (error) {
|
|
1006
|
+
errorHtml = `<div class="todo-error">
|
|
1007
|
+
<span class="error-icon">⚠</span>
|
|
1008
|
+
<span class="error-message">${escapeHtml(error)}</span>
|
|
1009
|
+
</div>`;
|
|
1010
|
+
}
|
|
1011
|
+
return `<div class="tool-call tool-todowrite" data-status="${escapeHtml(status)}">
|
|
1012
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
1013
|
+
<span class="tool-icon">📋</span>
|
|
1014
|
+
<span class="tool-name">Todo List</span>
|
|
1015
|
+
<span class="todo-summary">${escapeHtml(summary)}</span>
|
|
1016
|
+
<span class="todo-stats">
|
|
1017
|
+
${inProgress > 0 ? `<span class="todo-stat-progress">${inProgress} in progress</span>` : ""}
|
|
1018
|
+
${pending > 0 ? `<span class="todo-stat-pending">${pending} pending</span>` : ""}
|
|
1019
|
+
${cancelled > 0 ? `<span class="todo-stat-cancelled">${cancelled} cancelled</span>` : ""}
|
|
1020
|
+
</span>
|
|
1021
|
+
<span class="tool-toggle">-</span>
|
|
1022
|
+
</div>
|
|
1023
|
+
<div class="tool-body">
|
|
1024
|
+
${todoListHtml}
|
|
1025
|
+
${errorHtml}
|
|
1026
|
+
</div>
|
|
1027
|
+
</div>`;
|
|
1028
|
+
}
|
|
1029
|
+
function renderTodoItem(todo) {
|
|
1030
|
+
const statusIcon = getStatusIcon(todo.status);
|
|
1031
|
+
const priorityBadge = getPriorityBadge(todo.priority);
|
|
1032
|
+
return `<li class="todo-item todo-${todo.status}">
|
|
1033
|
+
<span class="todo-status">${statusIcon}</span>
|
|
1034
|
+
<span class="todo-content">${escapeHtml(todo.content)}</span>
|
|
1035
|
+
${priorityBadge}
|
|
1036
|
+
</li>`;
|
|
1037
|
+
}
|
|
1038
|
+
function getStatusIcon(status) {
|
|
1039
|
+
switch (status) {
|
|
1040
|
+
case "completed":
|
|
1041
|
+
return "✓";
|
|
1042
|
+
case "in_progress":
|
|
1043
|
+
return "→";
|
|
1044
|
+
case "pending":
|
|
1045
|
+
return "○";
|
|
1046
|
+
case "cancelled":
|
|
1047
|
+
return "✗";
|
|
1048
|
+
default:
|
|
1049
|
+
return "○";
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
function getPriorityBadge(priority) {
|
|
1053
|
+
if (priority === "high") {
|
|
1054
|
+
return `<span class="todo-priority priority-high">high</span>`;
|
|
1055
|
+
}
|
|
1056
|
+
if (priority === "medium") {
|
|
1057
|
+
return `<span class="todo-priority priority-medium">medium</span>`;
|
|
1058
|
+
}
|
|
1059
|
+
return "";
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// src/render/components/tools/webfetch.ts
|
|
1063
|
+
function renderWebFetchTool(part) {
|
|
1064
|
+
const { state } = part;
|
|
1065
|
+
const input = state.input;
|
|
1066
|
+
const url = input?.url || "";
|
|
1067
|
+
const format = input?.format || "markdown";
|
|
1068
|
+
const output = state.output || "";
|
|
1069
|
+
const error = state.error;
|
|
1070
|
+
const status = state.status;
|
|
1071
|
+
const urlHtml = url && isSafeUrl(url) ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" class="webfetch-url">${escapeHtml(truncateUrl(url))}</a>` : `<span class="webfetch-url">${escapeHtml(url)}</span>`;
|
|
1072
|
+
const formatBadge = `<span class="webfetch-format">${escapeHtml(format)}</span>`;
|
|
1073
|
+
const contentLines = output.split(`
|
|
1074
|
+
`).length;
|
|
1075
|
+
const contentSize = formatBytes(output.length);
|
|
1076
|
+
const sizeInfo = output ? `<span class="webfetch-size">${contentSize}</span>` : "";
|
|
1077
|
+
const isLongContent = contentLines > 30;
|
|
1078
|
+
const collapsedClass = isLongContent ? "collapsed" : "";
|
|
1079
|
+
let contentHtml = "";
|
|
1080
|
+
if (output) {
|
|
1081
|
+
contentHtml = `<div class="webfetch-content">
|
|
1082
|
+
<pre><code>${escapeHtml(output)}</code></pre>
|
|
1083
|
+
</div>`;
|
|
1084
|
+
}
|
|
1085
|
+
let errorHtml = "";
|
|
1086
|
+
if (error) {
|
|
1087
|
+
errorHtml = `<div class="webfetch-error">
|
|
1088
|
+
<span class="error-icon">⚠</span>
|
|
1089
|
+
<span class="error-message">${escapeHtml(error)}</span>
|
|
1090
|
+
</div>`;
|
|
1091
|
+
}
|
|
1092
|
+
return `<div class="tool-call tool-webfetch" data-status="${escapeHtml(status)}">
|
|
1093
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
1094
|
+
<span class="tool-icon">🌐</span>
|
|
1095
|
+
${urlHtml}
|
|
1096
|
+
${formatBadge}
|
|
1097
|
+
${sizeInfo}
|
|
1098
|
+
<span class="tool-toggle">${isLongContent ? "+" : "-"}</span>
|
|
1099
|
+
</div>
|
|
1100
|
+
<div class="tool-body ${collapsedClass}">
|
|
1101
|
+
${contentHtml}
|
|
1102
|
+
${errorHtml}
|
|
1103
|
+
</div>
|
|
1104
|
+
</div>`;
|
|
1105
|
+
}
|
|
1106
|
+
function truncateUrl(url, maxLength = 60) {
|
|
1107
|
+
if (url.length <= maxLength)
|
|
1108
|
+
return url;
|
|
1109
|
+
try {
|
|
1110
|
+
const parsed = new URL(url);
|
|
1111
|
+
const domain = parsed.hostname;
|
|
1112
|
+
const path = parsed.pathname;
|
|
1113
|
+
if (domain.length + path.length <= maxLength) {
|
|
1114
|
+
return domain + path;
|
|
1115
|
+
}
|
|
1116
|
+
const availableForPath = maxLength - domain.length - 3;
|
|
1117
|
+
if (availableForPath > 10) {
|
|
1118
|
+
return domain + path.slice(0, availableForPath) + "...";
|
|
1119
|
+
}
|
|
1120
|
+
return url.slice(0, maxLength - 3) + "...";
|
|
1121
|
+
} catch {
|
|
1122
|
+
return url.slice(0, maxLength - 3) + "...";
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// src/render/components/tools/batch.ts
|
|
1127
|
+
function renderBatchTool(part) {
|
|
1128
|
+
const { state } = part;
|
|
1129
|
+
const input = state.input;
|
|
1130
|
+
const toolCalls = input?.tool_calls || [];
|
|
1131
|
+
const output = state.output || "";
|
|
1132
|
+
const error = state.error;
|
|
1133
|
+
const status = state.status;
|
|
1134
|
+
const toolCounts = new Map;
|
|
1135
|
+
for (const call of toolCalls) {
|
|
1136
|
+
const count = toolCounts.get(call.tool) || 0;
|
|
1137
|
+
toolCounts.set(call.tool, count + 1);
|
|
1138
|
+
}
|
|
1139
|
+
const toolSummary = Array.from(toolCounts.entries()).map(([tool, count]) => `${count} ${tool}`).join(", ");
|
|
1140
|
+
const toolListHtml = toolCalls.length > 0 ? `<ul class="batch-tool-list">
|
|
1141
|
+
${toolCalls.map((call, i) => renderNestedTool(call, i)).join(`
|
|
1142
|
+
`)}
|
|
1143
|
+
</ul>` : `<div class="batch-empty">No tool calls</div>`;
|
|
1144
|
+
let outputHtml = "";
|
|
1145
|
+
if (output) {
|
|
1146
|
+
const outputLines = output.split(`
|
|
1147
|
+
`).length;
|
|
1148
|
+
const isLongOutput = outputLines > 30;
|
|
1149
|
+
outputHtml = `<div class="batch-output">
|
|
1150
|
+
<details ${isLongOutput ? "" : "open"}>
|
|
1151
|
+
<summary class="batch-output-label">Combined Output (${outputLines} lines)</summary>
|
|
1152
|
+
<pre><code>${escapeHtml(output)}</code></pre>
|
|
1153
|
+
</details>
|
|
1154
|
+
</div>`;
|
|
1155
|
+
}
|
|
1156
|
+
let errorHtml = "";
|
|
1157
|
+
if (error) {
|
|
1158
|
+
errorHtml = `<div class="batch-error">
|
|
1159
|
+
<span class="error-icon">⚠</span>
|
|
1160
|
+
<span class="error-message">${escapeHtml(error)}</span>
|
|
1161
|
+
</div>`;
|
|
1162
|
+
}
|
|
1163
|
+
return `<div class="tool-call tool-batch" data-status="${escapeHtml(status)}">
|
|
1164
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
1165
|
+
<span class="tool-icon">📦</span>
|
|
1166
|
+
<span class="batch-title">Batch (${toolCalls.length} calls)</span>
|
|
1167
|
+
<span class="batch-summary">${escapeHtml(toolSummary)}</span>
|
|
1168
|
+
<span class="tool-toggle">-</span>
|
|
1169
|
+
</div>
|
|
1170
|
+
<div class="tool-body">
|
|
1171
|
+
${toolListHtml}
|
|
1172
|
+
${outputHtml}
|
|
1173
|
+
${errorHtml}
|
|
1174
|
+
</div>
|
|
1175
|
+
</div>`;
|
|
1176
|
+
}
|
|
1177
|
+
function renderNestedTool(call, index) {
|
|
1178
|
+
const toolIcon = getToolIcon(call.tool);
|
|
1179
|
+
const info = getToolInfo(call.tool, call.parameters);
|
|
1180
|
+
return `<li class="batch-tool-item">
|
|
1181
|
+
<span class="batch-tool-index">${index + 1}</span>
|
|
1182
|
+
<span class="batch-tool-icon">${toolIcon}</span>
|
|
1183
|
+
<span class="batch-tool-name">${escapeHtml(call.tool)}</span>
|
|
1184
|
+
<span class="batch-tool-info">${escapeHtml(info)}</span>
|
|
1185
|
+
</li>`;
|
|
1186
|
+
}
|
|
1187
|
+
function getToolIcon(tool) {
|
|
1188
|
+
const icons = {
|
|
1189
|
+
bash: "$",
|
|
1190
|
+
read: "📄",
|
|
1191
|
+
write: "📝",
|
|
1192
|
+
edit: "✎",
|
|
1193
|
+
glob: "🔍",
|
|
1194
|
+
grep: "🔎",
|
|
1195
|
+
task: "👥",
|
|
1196
|
+
todowrite: "📋",
|
|
1197
|
+
webfetch: "🌐"
|
|
1198
|
+
};
|
|
1199
|
+
return icons[tool] || "🔧";
|
|
1200
|
+
}
|
|
1201
|
+
function getToolInfo(tool, params) {
|
|
1202
|
+
switch (tool) {
|
|
1203
|
+
case "bash":
|
|
1204
|
+
return String(params.command || params.description || "");
|
|
1205
|
+
case "read":
|
|
1206
|
+
case "write":
|
|
1207
|
+
case "edit":
|
|
1208
|
+
return String(params.filePath || "");
|
|
1209
|
+
case "glob":
|
|
1210
|
+
return String(params.pattern || "");
|
|
1211
|
+
case "grep":
|
|
1212
|
+
return String(params.pattern || "");
|
|
1213
|
+
case "webfetch":
|
|
1214
|
+
return String(params.url || "");
|
|
1215
|
+
default:
|
|
1216
|
+
return "";
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// src/render/components/part.ts
|
|
1221
|
+
function renderTextPart(part) {
|
|
1222
|
+
if (part.ignored)
|
|
1223
|
+
return "";
|
|
1224
|
+
return `<div class="part part-text">
|
|
1225
|
+
${renderMarkdown(part.text)}
|
|
1226
|
+
</div>`;
|
|
1227
|
+
}
|
|
1228
|
+
function renderReasoningPart(part) {
|
|
1229
|
+
const hasTiming = part.time?.start && part.time?.end;
|
|
1230
|
+
const durationHtml = hasTiming ? `<span class="reasoning-duration">${formatDuration(part.time.start, part.time.end)}</span>` : "";
|
|
1231
|
+
const contentLines = part.text.split(`
|
|
1232
|
+
`).length;
|
|
1233
|
+
const isLong = contentLines > 20;
|
|
1234
|
+
const collapsedClass = isLong ? "collapsed" : "";
|
|
1235
|
+
const toggleIndicator = isLong ? "+" : "-";
|
|
1236
|
+
return `<div class="reasoning">
|
|
1237
|
+
<div class="reasoning-header" onclick="this.nextElementSibling.classList.toggle('collapsed'); this.querySelector('.reasoning-toggle').textContent = this.nextElementSibling.classList.contains('collapsed') ? '+' : '-';">
|
|
1238
|
+
<span class="reasoning-icon">💡</span>
|
|
1239
|
+
<span class="reasoning-label">Thinking...</span>
|
|
1240
|
+
${durationHtml}
|
|
1241
|
+
<span class="reasoning-toggle">${toggleIndicator}</span>
|
|
1242
|
+
</div>
|
|
1243
|
+
<div class="reasoning-content ${collapsedClass}">
|
|
1244
|
+
${escapeHtml(part.text)}
|
|
1245
|
+
</div>
|
|
1246
|
+
</div>`;
|
|
1247
|
+
}
|
|
1248
|
+
function renderToolPart(part) {
|
|
1249
|
+
const { tool } = part;
|
|
1250
|
+
switch (tool) {
|
|
1251
|
+
case "bash":
|
|
1252
|
+
return renderBashTool(part);
|
|
1253
|
+
case "read":
|
|
1254
|
+
return renderReadTool(part);
|
|
1255
|
+
case "write":
|
|
1256
|
+
return renderWriteTool(part);
|
|
1257
|
+
case "edit":
|
|
1258
|
+
return renderEditTool(part);
|
|
1259
|
+
case "glob":
|
|
1260
|
+
return renderGlobTool(part);
|
|
1261
|
+
case "grep":
|
|
1262
|
+
return renderGrepTool(part);
|
|
1263
|
+
case "task":
|
|
1264
|
+
return renderTaskTool(part);
|
|
1265
|
+
case "todowrite":
|
|
1266
|
+
return renderTodoWriteTool(part);
|
|
1267
|
+
case "webfetch":
|
|
1268
|
+
return renderWebFetchTool(part);
|
|
1269
|
+
case "batch":
|
|
1270
|
+
return renderBatchTool(part);
|
|
1271
|
+
default:
|
|
1272
|
+
return renderGenericToolPart(part);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
function renderGenericToolPart(part) {
|
|
1276
|
+
const { tool, state } = part;
|
|
1277
|
+
const status = state.status;
|
|
1278
|
+
const title = state.title || tool;
|
|
1279
|
+
const inputHtml = state.input ? `<div class="tool-input">
|
|
1280
|
+
<div class="tool-input-label">Input</div>
|
|
1281
|
+
<pre><code>${escapeHtml(JSON.stringify(state.input, null, 2))}</code></pre>
|
|
1282
|
+
</div>` : "";
|
|
1283
|
+
const outputHtml = state.output ? `<div class="tool-output">
|
|
1284
|
+
<div class="tool-output-label">Output</div>
|
|
1285
|
+
<pre><code>${escapeHtml(state.output)}</code></pre>
|
|
1286
|
+
</div>` : "";
|
|
1287
|
+
const errorHtml = state.error ? `<div class="tool-error">
|
|
1288
|
+
<span class="error-icon">⚠</span>
|
|
1289
|
+
<span class="error-message">${escapeHtml(state.error)}</span>
|
|
1290
|
+
</div>` : "";
|
|
1291
|
+
const outputLines = (state.output || "").split(`
|
|
1292
|
+
`).length;
|
|
1293
|
+
const isLongOutput = outputLines > 20;
|
|
1294
|
+
const collapsedClass = isLongOutput ? "collapsed" : "";
|
|
1295
|
+
return `<div class="tool-call tool-generic tool-${escapeHtml(tool)}" data-status="${escapeHtml(status)}">
|
|
1296
|
+
<div class="tool-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
|
1297
|
+
<span class="tool-icon">${getToolIcon2(tool)}</span>
|
|
1298
|
+
<span class="tool-name">${escapeHtml(tool)}</span>
|
|
1299
|
+
<span class="tool-title">${escapeHtml(title)}</span>
|
|
1300
|
+
<span class="tool-toggle">${isLongOutput ? "+" : "-"}</span>
|
|
1301
|
+
</div>
|
|
1302
|
+
<div class="tool-body ${collapsedClass}">
|
|
1303
|
+
${inputHtml}
|
|
1304
|
+
${outputHtml}
|
|
1305
|
+
${errorHtml}
|
|
1306
|
+
</div>
|
|
1307
|
+
</div>`;
|
|
1308
|
+
}
|
|
1309
|
+
function getToolIcon2(tool) {
|
|
1310
|
+
const icons = {
|
|
1311
|
+
bash: "$",
|
|
1312
|
+
read: "📄",
|
|
1313
|
+
write: "📝",
|
|
1314
|
+
edit: "✎",
|
|
1315
|
+
glob: "🔍",
|
|
1316
|
+
grep: "🔎",
|
|
1317
|
+
task: "👥",
|
|
1318
|
+
todowrite: "📋",
|
|
1319
|
+
webfetch: "🌐",
|
|
1320
|
+
batch: "📦"
|
|
1321
|
+
};
|
|
1322
|
+
return icons[tool] || "🔧";
|
|
1323
|
+
}
|
|
1324
|
+
function renderFilePart(part) {
|
|
1325
|
+
const filename = part.filename || "file";
|
|
1326
|
+
const isImage = part.mime.startsWith("image/");
|
|
1327
|
+
const isDataUrl = part.url.startsWith("data:");
|
|
1328
|
+
const icon = getFileIcon2(part.mime);
|
|
1329
|
+
let sizeHtml = "";
|
|
1330
|
+
if (isDataUrl) {
|
|
1331
|
+
const base64Part = part.url.split(",")[1];
|
|
1332
|
+
if (base64Part) {
|
|
1333
|
+
const estimatedBytes = Math.floor(base64Part.length * 0.75);
|
|
1334
|
+
sizeHtml = `<span class="file-size">${formatBytes(estimatedBytes)}</span>`;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
const sourceHtml = part.source ? `<span class="file-source">from ${escapeHtml(part.source.type)}${part.source.toolName ? `: ${escapeHtml(part.source.toolName)}` : ""}</span>` : "";
|
|
1338
|
+
let previewHtml = "";
|
|
1339
|
+
if (isImage && isDataUrl && isSafeUrl(part.url)) {
|
|
1340
|
+
previewHtml = `<div class="file-preview">
|
|
1341
|
+
<img src="${part.url}" alt="${escapeHtml(filename)}" loading="lazy">
|
|
1342
|
+
</div>`;
|
|
1343
|
+
} else if (isDataUrl) {
|
|
1344
|
+
previewHtml = `<div class="file-preview">
|
|
1345
|
+
<a href="${part.url}" download="${escapeHtml(filename)}" class="file-preview-link">
|
|
1346
|
+
<span>💾</span> Download file
|
|
1347
|
+
</a>
|
|
1348
|
+
</div>`;
|
|
1349
|
+
}
|
|
1350
|
+
return `<div class="part part-file">
|
|
1351
|
+
<div class="file-header">
|
|
1352
|
+
<span class="file-icon">${icon}</span>
|
|
1353
|
+
<div class="file-info">
|
|
1354
|
+
<span class="file-name">${escapeHtml(filename)}</span>
|
|
1355
|
+
<div class="file-meta">
|
|
1356
|
+
<span class="file-mime">${escapeHtml(part.mime)}</span>
|
|
1357
|
+
${sizeHtml}
|
|
1358
|
+
${sourceHtml}
|
|
1359
|
+
</div>
|
|
1360
|
+
</div>
|
|
1361
|
+
</div>
|
|
1362
|
+
${previewHtml}
|
|
1363
|
+
</div>`;
|
|
1364
|
+
}
|
|
1365
|
+
function getFileIcon2(mime) {
|
|
1366
|
+
if (mime.startsWith("image/"))
|
|
1367
|
+
return "🖼";
|
|
1368
|
+
if (mime.startsWith("video/"))
|
|
1369
|
+
return "🎦";
|
|
1370
|
+
if (mime.startsWith("audio/"))
|
|
1371
|
+
return "🎵";
|
|
1372
|
+
if (mime.startsWith("text/"))
|
|
1373
|
+
return "📄";
|
|
1374
|
+
if (mime.includes("pdf"))
|
|
1375
|
+
return "📋";
|
|
1376
|
+
if (mime.includes("zip") || mime.includes("compressed"))
|
|
1377
|
+
return "📦";
|
|
1378
|
+
return "📎";
|
|
1379
|
+
}
|
|
1380
|
+
function renderSnapshotPart(part) {
|
|
1381
|
+
return `<div class="part part-snapshot">
|
|
1382
|
+
<span class="snapshot-icon">📷</span>
|
|
1383
|
+
<span class="snapshot-label">Snapshot created</span>
|
|
1384
|
+
<span class="snapshot-id">${escapeHtml(part.snapshot)}</span>
|
|
1385
|
+
</div>`;
|
|
1386
|
+
}
|
|
1387
|
+
function renderPatchPart(part) {
|
|
1388
|
+
const fileCount = part.files.length;
|
|
1389
|
+
const filesHtml = part.files.length > 0 ? `<ul class="patch-files">
|
|
1390
|
+
${part.files.map((file) => `<li class="patch-file-item">
|
|
1391
|
+
<span class="patch-file-icon">📄</span>
|
|
1392
|
+
<span class="patch-file-name">${escapeHtml(file)}</span>
|
|
1393
|
+
</li>`).join("")}
|
|
1394
|
+
</ul>` : "";
|
|
1395
|
+
return `<div class="part part-patch">
|
|
1396
|
+
<div class="patch-header">
|
|
1397
|
+
<span class="patch-icon">💾</span>
|
|
1398
|
+
<span class="patch-label">File changes</span>
|
|
1399
|
+
<span class="patch-file-count">${fileCount} file${fileCount !== 1 ? "s" : ""}</span>
|
|
1400
|
+
<span class="patch-hash">${escapeHtml(part.hash.slice(0, 8))}</span>
|
|
1401
|
+
</div>
|
|
1402
|
+
${filesHtml}
|
|
1403
|
+
</div>`;
|
|
1404
|
+
}
|
|
1405
|
+
function renderAgentPart(part) {
|
|
1406
|
+
const sourceHtml = part.source?.value ? `<span class="agent-source">${escapeHtml(part.source.value.slice(0, 100))}${part.source.value.length > 100 ? "..." : ""}</span>` : "";
|
|
1407
|
+
return `<div class="part part-agent">
|
|
1408
|
+
<span class="agent-icon">🤖</span>
|
|
1409
|
+
<span class="agent-label">Agent:</span>
|
|
1410
|
+
<span class="agent-name-badge">${escapeHtml(part.name)}</span>
|
|
1411
|
+
${sourceHtml}
|
|
1412
|
+
</div>`;
|
|
1413
|
+
}
|
|
1414
|
+
function renderCompactionPart(part) {
|
|
1415
|
+
const typeLabel = part.auto ? "Auto-compacted" : "Manual compaction";
|
|
1416
|
+
const icon = part.auto ? "⚙" : "🗝";
|
|
1417
|
+
return `<div class="part part-compaction">
|
|
1418
|
+
<div class="compaction-badge">
|
|
1419
|
+
<span class="compaction-icon">${icon}</span>
|
|
1420
|
+
<span class="compaction-type">${typeLabel}</span>
|
|
1421
|
+
</div>
|
|
1422
|
+
</div>`;
|
|
1423
|
+
}
|
|
1424
|
+
function renderSubtaskPart(part) {
|
|
1425
|
+
const agentClass = `agent-${escapeHtml(part.agent.toLowerCase())}`;
|
|
1426
|
+
const commandHtml = part.command ? `<div class="subtask-command">
|
|
1427
|
+
<span class="subtask-command-label">Command:</span>
|
|
1428
|
+
<code>${escapeHtml(part.command)}</code>
|
|
1429
|
+
</div>` : "";
|
|
1430
|
+
return `<div class="part part-subtask">
|
|
1431
|
+
<div class="subtask-header">
|
|
1432
|
+
<span class="subtask-icon">👥</span>
|
|
1433
|
+
<span class="subtask-agent-badge ${agentClass}">${escapeHtml(part.agent)}</span>
|
|
1434
|
+
<span class="subtask-description">${escapeHtml(part.description)}</span>
|
|
1435
|
+
</div>
|
|
1436
|
+
<div class="subtask-body">
|
|
1437
|
+
${commandHtml}
|
|
1438
|
+
<div class="subtask-prompt">
|
|
1439
|
+
<div class="subtask-prompt-label">Prompt</div>
|
|
1440
|
+
<div class="subtask-prompt-text">${escapeHtml(part.prompt)}</div>
|
|
1441
|
+
</div>
|
|
1442
|
+
</div>
|
|
1443
|
+
</div>`;
|
|
1444
|
+
}
|
|
1445
|
+
function renderRetryPart(part) {
|
|
1446
|
+
const codeHtml = part.error.code ? `<span class="retry-error-code">Code: ${escapeHtml(part.error.code)}</span>` : "";
|
|
1447
|
+
return `<div class="part part-retry">
|
|
1448
|
+
<div class="retry-header">
|
|
1449
|
+
<span class="retry-icon">🔄</span>
|
|
1450
|
+
<span class="retry-label">Retry</span>
|
|
1451
|
+
<span class="retry-attempt">Attempt #${part.attempt}</span>
|
|
1452
|
+
</div>
|
|
1453
|
+
<div class="retry-error">
|
|
1454
|
+
<div class="retry-error-name">${escapeHtml(part.error.name)}</div>
|
|
1455
|
+
<div class="retry-error-message">${escapeHtml(part.error.message)}</div>
|
|
1456
|
+
${codeHtml}
|
|
1457
|
+
</div>
|
|
1458
|
+
</div>`;
|
|
1459
|
+
}
|
|
1460
|
+
function renderGenericPart(part) {
|
|
1461
|
+
return `<div class="part part-generic">
|
|
1462
|
+
<span class="part-type">${escapeHtml(part.type)}</span>
|
|
1463
|
+
</div>`;
|
|
1464
|
+
}
|
|
1465
|
+
function renderPart(part) {
|
|
1466
|
+
switch (part.type) {
|
|
1467
|
+
case "text":
|
|
1468
|
+
return renderTextPart(part);
|
|
1469
|
+
case "reasoning":
|
|
1470
|
+
return renderReasoningPart(part);
|
|
1471
|
+
case "tool":
|
|
1472
|
+
return renderToolPart(part);
|
|
1473
|
+
case "file":
|
|
1474
|
+
return renderFilePart(part);
|
|
1475
|
+
case "snapshot":
|
|
1476
|
+
return renderSnapshotPart(part);
|
|
1477
|
+
case "patch":
|
|
1478
|
+
return renderPatchPart(part);
|
|
1479
|
+
case "agent":
|
|
1480
|
+
return renderAgentPart(part);
|
|
1481
|
+
case "compaction":
|
|
1482
|
+
return renderCompactionPart(part);
|
|
1483
|
+
case "subtask":
|
|
1484
|
+
return renderSubtaskPart(part);
|
|
1485
|
+
case "retry":
|
|
1486
|
+
return renderRetryPart(part);
|
|
1487
|
+
default:
|
|
1488
|
+
return renderGenericPart(part);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// src/render/components/message.ts
|
|
1493
|
+
function renderUserMessage(message, parts) {
|
|
1494
|
+
const time = formatTime(message.time.created);
|
|
1495
|
+
const model = message.model ? `${message.model.providerID}/${message.model.modelID}` : "";
|
|
1496
|
+
const partsHtml = parts.map((part) => renderPart(part)).join(`
|
|
1497
|
+
`);
|
|
1498
|
+
return `<div class="message message-user" id="${escapeHtml(message.id)}">
|
|
1499
|
+
<div class="message-header">
|
|
1500
|
+
<span class="message-role">User</span>
|
|
1501
|
+
<span class="message-time">${time}</span>
|
|
1502
|
+
${model ? `<span class="message-model">${escapeHtml(model)}</span>` : ""}
|
|
1503
|
+
</div>
|
|
1504
|
+
<div class="message-content">
|
|
1505
|
+
${partsHtml}
|
|
1506
|
+
</div>
|
|
1507
|
+
</div>`;
|
|
1508
|
+
}
|
|
1509
|
+
function renderAssistantMessage(message, parts) {
|
|
1510
|
+
const time = formatTime(message.time.created);
|
|
1511
|
+
const model = message.modelID || "";
|
|
1512
|
+
const tokens = message.tokens;
|
|
1513
|
+
const cost = message.cost;
|
|
1514
|
+
const partsHtml = parts.map((part) => renderPart(part)).join(`
|
|
1515
|
+
`);
|
|
1516
|
+
const statsHtml = [];
|
|
1517
|
+
if (tokens) {
|
|
1518
|
+
statsHtml.push(`<span class="stat">Tokens: ${formatTokens(tokens.input)} in / ${formatTokens(tokens.output)} out</span>`);
|
|
1519
|
+
if (tokens.cache?.read) {
|
|
1520
|
+
statsHtml.push(`<span class="stat">Cache: ${formatTokens(tokens.cache.read)} read</span>`);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
if (cost !== undefined && cost > 0) {
|
|
1524
|
+
statsHtml.push(`<span class="stat">Cost: ${formatCost(cost)}</span>`);
|
|
1525
|
+
}
|
|
1526
|
+
if (message.finish) {
|
|
1527
|
+
statsHtml.push(`<span class="stat">Finish: ${message.finish}</span>`);
|
|
1528
|
+
}
|
|
1529
|
+
return `<div class="message message-assistant" id="${escapeHtml(message.id)}">
|
|
1530
|
+
<div class="message-header">
|
|
1531
|
+
<span class="message-role">Assistant</span>
|
|
1532
|
+
<span class="message-time">${time}</span>
|
|
1533
|
+
${model ? `<span class="message-model">${escapeHtml(model)}</span>` : ""}
|
|
1534
|
+
</div>
|
|
1535
|
+
<div class="message-content">
|
|
1536
|
+
${partsHtml}
|
|
1537
|
+
</div>
|
|
1538
|
+
${statsHtml.length > 0 ? `<div class="message-stats">${statsHtml.join(`
|
|
1539
|
+
`)}</div>` : ""}
|
|
1540
|
+
</div>`;
|
|
1541
|
+
}
|
|
1542
|
+
function renderMessage(messageWithParts) {
|
|
1543
|
+
const { message, parts } = messageWithParts;
|
|
1544
|
+
if (message.role === "user") {
|
|
1545
|
+
return renderUserMessage(message, parts);
|
|
1546
|
+
} else {
|
|
1547
|
+
return renderAssistantMessage(message, parts);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
function renderMessages(messages) {
|
|
1551
|
+
return `<div class="messages">
|
|
1552
|
+
${messages.map(renderMessage).join(`
|
|
1553
|
+
`)}
|
|
1554
|
+
</div>`;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// src/render/templates/page.ts
|
|
1558
|
+
function pageLink(pageNum, currentPage) {
|
|
1559
|
+
const pageFile = `page-${String(pageNum).padStart(3, "0")}.html`;
|
|
1560
|
+
if (pageNum === currentPage) {
|
|
1561
|
+
return `<span class="pagination-page current">${pageNum}</span>`;
|
|
1562
|
+
}
|
|
1563
|
+
return `<a href="${pageFile}" class="pagination-page">${pageNum}</a>`;
|
|
1564
|
+
}
|
|
1565
|
+
function getPageNumbers(currentPage, totalPages) {
|
|
1566
|
+
if (totalPages <= 7) {
|
|
1567
|
+
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
1568
|
+
}
|
|
1569
|
+
const pages = [];
|
|
1570
|
+
const windowSize = 1;
|
|
1571
|
+
pages.push(1);
|
|
1572
|
+
const windowStart = Math.max(2, currentPage - windowSize);
|
|
1573
|
+
const windowEnd = Math.min(totalPages - 1, currentPage + windowSize);
|
|
1574
|
+
if (windowStart > 2) {
|
|
1575
|
+
pages.push(-1);
|
|
1576
|
+
}
|
|
1577
|
+
for (let i = windowStart;i <= windowEnd; i++) {
|
|
1578
|
+
pages.push(i);
|
|
1579
|
+
}
|
|
1580
|
+
if (windowEnd < totalPages - 1) {
|
|
1581
|
+
pages.push(-1);
|
|
1582
|
+
}
|
|
1583
|
+
pages.push(totalPages);
|
|
1584
|
+
return pages;
|
|
1585
|
+
}
|
|
1586
|
+
function renderPagination2(pageNumber, totalPages) {
|
|
1587
|
+
if (totalPages <= 1)
|
|
1588
|
+
return "";
|
|
1589
|
+
const parts = [];
|
|
1590
|
+
if (pageNumber > 1) {
|
|
1591
|
+
const prevFile = `page-${String(pageNumber - 1).padStart(3, "0")}.html`;
|
|
1592
|
+
parts.push(`<a href="${prevFile}" class="pagination-prev">← Previous</a>`);
|
|
1593
|
+
} else {
|
|
1594
|
+
parts.push(`<span class="pagination-prev disabled">← Previous</span>`);
|
|
1595
|
+
}
|
|
1596
|
+
const pageNumbers = getPageNumbers(pageNumber, totalPages);
|
|
1597
|
+
for (const num of pageNumbers) {
|
|
1598
|
+
if (num === -1) {
|
|
1599
|
+
parts.push(`<span class="pagination-ellipsis">…</span>`);
|
|
1600
|
+
} else {
|
|
1601
|
+
parts.push(pageLink(num, pageNumber));
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
if (pageNumber < totalPages) {
|
|
1605
|
+
const nextFile = `page-${String(pageNumber + 1).padStart(3, "0")}.html`;
|
|
1606
|
+
parts.push(`<a href="${nextFile}" class="pagination-next">Next →</a>`);
|
|
1607
|
+
} else {
|
|
1608
|
+
parts.push(`<span class="pagination-next disabled">Next →</span>`);
|
|
1609
|
+
}
|
|
1610
|
+
return `<nav class="pagination">
|
|
1611
|
+
${parts.join(`
|
|
1612
|
+
`)}
|
|
1613
|
+
</nav>`;
|
|
1614
|
+
}
|
|
1615
|
+
function renderConversationPage(data) {
|
|
1616
|
+
const {
|
|
1617
|
+
session,
|
|
1618
|
+
projectName,
|
|
1619
|
+
messages,
|
|
1620
|
+
pageNumber,
|
|
1621
|
+
totalPages,
|
|
1622
|
+
assetsPath = "../../assets"
|
|
1623
|
+
} = data;
|
|
1624
|
+
const breadcrumbs = [
|
|
1625
|
+
{ label: projectName ?? "Sessions", href: "../../index.html" },
|
|
1626
|
+
{ label: session.title, href: "index.html" },
|
|
1627
|
+
{ label: `Page ${pageNumber}` }
|
|
1628
|
+
];
|
|
1629
|
+
const header = renderHeader({
|
|
1630
|
+
title: session.title,
|
|
1631
|
+
subtitle: `Page ${pageNumber} of ${totalPages}`,
|
|
1632
|
+
breadcrumbs,
|
|
1633
|
+
showSearch: true
|
|
1634
|
+
});
|
|
1635
|
+
const messagesHtml = renderMessages(messages);
|
|
1636
|
+
const paginationTop = renderPagination2(pageNumber, totalPages);
|
|
1637
|
+
const paginationBottom = renderPagination2(pageNumber, totalPages);
|
|
1638
|
+
const footer = renderFooter();
|
|
1639
|
+
const content = `
|
|
1640
|
+
${header}
|
|
1641
|
+
${paginationTop}
|
|
1642
|
+
${messagesHtml}
|
|
1643
|
+
${paginationBottom}
|
|
1644
|
+
${footer}
|
|
1645
|
+
`;
|
|
1646
|
+
return renderBasePage({
|
|
1647
|
+
title: `${session.title} - Page ${pageNumber}`,
|
|
1648
|
+
content,
|
|
1649
|
+
assetsPath,
|
|
1650
|
+
bodyClass: "conversation-page",
|
|
1651
|
+
totalPages
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
// src/render/git-commits.ts
|
|
1655
|
+
function parseGitRemoteUrl(url) {
|
|
1656
|
+
const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/i);
|
|
1657
|
+
if (httpsMatch) {
|
|
1658
|
+
const [, owner, name] = httpsMatch;
|
|
1659
|
+
return {
|
|
1660
|
+
owner,
|
|
1661
|
+
name,
|
|
1662
|
+
fullName: `${owner}/${name}`,
|
|
1663
|
+
baseUrl: `https://github.com/${owner}/${name}`
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/\s.]+)(?:\.git)?/i);
|
|
1667
|
+
if (sshMatch) {
|
|
1668
|
+
const [, owner, name] = sshMatch;
|
|
1669
|
+
return {
|
|
1670
|
+
owner,
|
|
1671
|
+
name,
|
|
1672
|
+
fullName: `${owner}/${name}`,
|
|
1673
|
+
baseUrl: `https://github.com/${owner}/${name}`
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
const shorthandMatch = url.match(/(?:^|[^@])github\.com[:/]([^/\s]+)\/([^/\s.]+?)(?:\.git)?(?:\s|$)/i);
|
|
1677
|
+
if (shorthandMatch) {
|
|
1678
|
+
const [, owner, name] = shorthandMatch;
|
|
1679
|
+
return {
|
|
1680
|
+
owner,
|
|
1681
|
+
name,
|
|
1682
|
+
fullName: `${owner}/${name}`,
|
|
1683
|
+
baseUrl: `https://github.com/${owner}/${name}`
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
return null;
|
|
1687
|
+
}
|
|
1688
|
+
function parseRepoString(repo) {
|
|
1689
|
+
const match = repo.match(/^([^/]+)\/([^/]+)$/);
|
|
1690
|
+
if (!match)
|
|
1691
|
+
return null;
|
|
1692
|
+
const [, owner, name] = match;
|
|
1693
|
+
return {
|
|
1694
|
+
owner,
|
|
1695
|
+
name,
|
|
1696
|
+
fullName: `${owner}/${name}`,
|
|
1697
|
+
baseUrl: `https://github.com/${owner}/${name}`
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
function parseGitCommitOutput(output) {
|
|
1701
|
+
const commitMatch = output.match(/\[([^\s\]]+)(?:\s+\([^)]+\))?\s+([a-f0-9]{7,40})\]\s+(.+)/i);
|
|
1702
|
+
if (!commitMatch)
|
|
1703
|
+
return null;
|
|
1704
|
+
const [, branch, hash, message] = commitMatch;
|
|
1705
|
+
return {
|
|
1706
|
+
hash: hash.slice(0, 7),
|
|
1707
|
+
fullHash: hash.length > 7 ? hash : undefined,
|
|
1708
|
+
message: message.trim(),
|
|
1709
|
+
branch,
|
|
1710
|
+
timestamp: 0
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
function parseGitPushOutput(output) {
|
|
1714
|
+
const result = {
|
|
1715
|
+
repo: null,
|
|
1716
|
+
commits: []
|
|
1717
|
+
};
|
|
1718
|
+
const repoMatch = output.match(/To\s+((?:[\S]*)?github\.com[^\s]+)/i);
|
|
1719
|
+
if (repoMatch) {
|
|
1720
|
+
result.repo = parseGitRemoteUrl(repoMatch[1]);
|
|
1721
|
+
}
|
|
1722
|
+
const rangeRegex = /([a-f0-9]{7,40})\.\.([a-f0-9]{7,40})\s+(\S+)\s+->\s+\S+/gi;
|
|
1723
|
+
let rangeMatch;
|
|
1724
|
+
while ((rangeMatch = rangeRegex.exec(output)) !== null) {
|
|
1725
|
+
result.commits.push({
|
|
1726
|
+
fromHash: rangeMatch[1].slice(0, 7),
|
|
1727
|
+
toHash: rangeMatch[2].slice(0, 7),
|
|
1728
|
+
branch: rangeMatch[3]
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
const newBranchRegex = /\*\s+\[new branch\]\s+(\S+)\s+->\s+\S+/gi;
|
|
1732
|
+
let newBranchMatch;
|
|
1733
|
+
while ((newBranchMatch = newBranchRegex.exec(output)) !== null) {
|
|
1734
|
+
result.commits.push({
|
|
1735
|
+
toHash: "",
|
|
1736
|
+
branch: newBranchMatch[1]
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
return result;
|
|
1740
|
+
}
|
|
1741
|
+
function isGitCommitCommand(command) {
|
|
1742
|
+
return /(?:^|&&|\|\||;|\$\()\s*(?:[A-Z_]+=\S+\s+)*git\s+commit\b/i.test(command);
|
|
1743
|
+
}
|
|
1744
|
+
function isGitPushCommand(command) {
|
|
1745
|
+
return /(?:^|&&|\|\||;|\$\()\s*(?:[A-Z_]+=\S+\s+)*git\s+push\b/i.test(command);
|
|
1746
|
+
}
|
|
1747
|
+
function extractCommitsFromMessages(messages, repoOverride) {
|
|
1748
|
+
const commits = [];
|
|
1749
|
+
let currentPromptNumber = 0;
|
|
1750
|
+
let detectedRepo = repoOverride ?? null;
|
|
1751
|
+
for (const msg of messages) {
|
|
1752
|
+
if (msg.message.role === "user") {
|
|
1753
|
+
currentPromptNumber++;
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
if (msg.message.role === "assistant") {
|
|
1757
|
+
for (const part of msg.parts) {
|
|
1758
|
+
if (part.type !== "tool" || part.tool !== "bash")
|
|
1759
|
+
continue;
|
|
1760
|
+
const toolPart = part;
|
|
1761
|
+
const input = toolPart.state.input;
|
|
1762
|
+
const output = toolPart.state.output || "";
|
|
1763
|
+
const command = input?.command || "";
|
|
1764
|
+
const timestamp = toolPart.state.time?.end ?? toolPart.state.time?.start ?? msg.message.time.created;
|
|
1765
|
+
if (isGitPushCommand(command)) {
|
|
1766
|
+
const pushResult = parseGitPushOutput(output);
|
|
1767
|
+
if (pushResult.repo && !detectedRepo) {
|
|
1768
|
+
detectedRepo = pushResult.repo;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
if (isGitCommitCommand(command)) {
|
|
1772
|
+
const commitInfo = parseGitCommitOutput(output);
|
|
1773
|
+
if (commitInfo) {
|
|
1774
|
+
commitInfo.timestamp = timestamp;
|
|
1775
|
+
if (detectedRepo || repoOverride) {
|
|
1776
|
+
const repo = repoOverride ?? detectedRepo;
|
|
1777
|
+
commitInfo.url = `${repo.baseUrl}/commit/${commitInfo.fullHash ?? commitInfo.hash}`;
|
|
1778
|
+
}
|
|
1779
|
+
commits.push({
|
|
1780
|
+
commit: commitInfo,
|
|
1781
|
+
afterPromptNumber: currentPromptNumber
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
if (detectedRepo) {
|
|
1789
|
+
for (const { commit } of commits) {
|
|
1790
|
+
if (!commit.url) {
|
|
1791
|
+
commit.url = `${detectedRepo.baseUrl}/commit/${commit.fullHash ?? commit.hash}`;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
return commits;
|
|
1796
|
+
}
|
|
1797
|
+
function detectRepoFromMessages(messages) {
|
|
1798
|
+
for (const msg of messages) {
|
|
1799
|
+
if (msg.message.role !== "assistant")
|
|
1800
|
+
continue;
|
|
1801
|
+
for (const part of msg.parts) {
|
|
1802
|
+
if (part.type !== "tool" || part.tool !== "bash")
|
|
1803
|
+
continue;
|
|
1804
|
+
const toolPart = part;
|
|
1805
|
+
const input = toolPart.state.input;
|
|
1806
|
+
const output = toolPart.state.output || "";
|
|
1807
|
+
const command = input?.command || "";
|
|
1808
|
+
if (isGitPushCommand(command)) {
|
|
1809
|
+
const pushResult = parseGitPushOutput(output);
|
|
1810
|
+
if (pushResult.repo) {
|
|
1811
|
+
return pushResult.repo;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
if (/\bgit\s+remote\b/i.test(command) && output.includes("github.com")) {
|
|
1815
|
+
const originMatch = output.match(/origin\s+([\S]+)\s+\((?:fetch|push)\)/i);
|
|
1816
|
+
if (originMatch) {
|
|
1817
|
+
const repo = parseGitRemoteUrl(originMatch[1]);
|
|
1818
|
+
if (repo)
|
|
1819
|
+
return repo;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return null;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// src/render/html.ts
|
|
1828
|
+
var PROMPTS_PER_PAGE = 5;
|
|
1829
|
+
async function ensureDir(dir) {
|
|
1830
|
+
await mkdir(dir, { recursive: true });
|
|
1831
|
+
}
|
|
1832
|
+
async function writeHtml(filePath, content) {
|
|
1833
|
+
await ensureDir(dirname(filePath));
|
|
1834
|
+
await Bun.write(filePath, content);
|
|
1835
|
+
}
|
|
1836
|
+
function getAssetsSourceDir() {
|
|
1837
|
+
const prodAssetsDir = join2(import.meta.dir, "assets");
|
|
1838
|
+
const devAssetsDir = join2(import.meta.dir, "../assets");
|
|
1839
|
+
if (Bun.file(join2(prodAssetsDir, "styles.css")).size) {
|
|
1840
|
+
return prodAssetsDir;
|
|
1841
|
+
}
|
|
1842
|
+
return devAssetsDir;
|
|
1843
|
+
}
|
|
1844
|
+
async function copyAssets(outputDir) {
|
|
1845
|
+
const assetsDir = join2(outputDir, "assets");
|
|
1846
|
+
await ensureDir(assetsDir);
|
|
1847
|
+
const sourceDir = getAssetsSourceDir();
|
|
1848
|
+
await copyFile(join2(sourceDir, "styles.css"), join2(assetsDir, "styles.css"));
|
|
1849
|
+
await copyFile(join2(sourceDir, "prism.css"), join2(assetsDir, "prism.css"));
|
|
1850
|
+
await copyFile(join2(sourceDir, "theme.js"), join2(assetsDir, "theme.js"));
|
|
1851
|
+
await copyFile(join2(sourceDir, "highlight.js"), join2(assetsDir, "highlight.js"));
|
|
1852
|
+
await copyFile(join2(sourceDir, "search.js"), join2(assetsDir, "search.js"));
|
|
1853
|
+
}
|
|
1854
|
+
function getFirstPrompt(messages) {
|
|
1855
|
+
for (const msg of messages) {
|
|
1856
|
+
if (msg.message.role === "user") {
|
|
1857
|
+
for (const part of msg.parts) {
|
|
1858
|
+
if (part.type === "text") {
|
|
1859
|
+
return part.text;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
function countTools(parts) {
|
|
1867
|
+
const counts = {};
|
|
1868
|
+
for (const part of parts) {
|
|
1869
|
+
if (part.type === "tool") {
|
|
1870
|
+
counts[part.tool] = (counts[part.tool] ?? 0) + 1;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
return counts;
|
|
1874
|
+
}
|
|
1875
|
+
function buildTimeline(messages, repoOverride) {
|
|
1876
|
+
const timeline = [];
|
|
1877
|
+
let promptNumber = 0;
|
|
1878
|
+
const detectedRepo = repoOverride ?? detectRepoFromMessages(messages) ?? undefined;
|
|
1879
|
+
const commitsWithPrompts = extractCommitsFromMessages(messages, detectedRepo);
|
|
1880
|
+
const commitsByPrompt = new Map;
|
|
1881
|
+
for (const { commit, afterPromptNumber } of commitsWithPrompts) {
|
|
1882
|
+
const existing = commitsByPrompt.get(afterPromptNumber) ?? [];
|
|
1883
|
+
existing.push(commit);
|
|
1884
|
+
commitsByPrompt.set(afterPromptNumber, existing);
|
|
1885
|
+
}
|
|
1886
|
+
for (let i = 0;i < messages.length; i++) {
|
|
1887
|
+
const msg = messages[i];
|
|
1888
|
+
if (msg.message.role !== "user")
|
|
1889
|
+
continue;
|
|
1890
|
+
promptNumber++;
|
|
1891
|
+
let promptPreview = "";
|
|
1892
|
+
for (const part of msg.parts) {
|
|
1893
|
+
if (part.type === "text") {
|
|
1894
|
+
promptPreview = part.text;
|
|
1895
|
+
break;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
const toolCounts = {};
|
|
1899
|
+
for (let j = i + 1;j < messages.length; j++) {
|
|
1900
|
+
const nextMsg = messages[j];
|
|
1901
|
+
if (nextMsg.message.role === "user")
|
|
1902
|
+
break;
|
|
1903
|
+
const counts = countTools(nextMsg.parts);
|
|
1904
|
+
for (const [tool, count] of Object.entries(counts)) {
|
|
1905
|
+
toolCounts[tool] = (toolCounts[tool] ?? 0) + count;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
const pageNumber = Math.ceil(promptNumber / PROMPTS_PER_PAGE);
|
|
1909
|
+
const commits = commitsByPrompt.get(promptNumber);
|
|
1910
|
+
timeline.push({
|
|
1911
|
+
promptNumber,
|
|
1912
|
+
messageId: msg.message.id,
|
|
1913
|
+
promptPreview,
|
|
1914
|
+
timestamp: msg.message.time.created,
|
|
1915
|
+
toolCounts,
|
|
1916
|
+
pageNumber,
|
|
1917
|
+
commits
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
return timeline;
|
|
1921
|
+
}
|
|
1922
|
+
function paginateMessages(messages) {
|
|
1923
|
+
const pages = [];
|
|
1924
|
+
let currentPage = [];
|
|
1925
|
+
let promptCount = 0;
|
|
1926
|
+
for (const msg of messages) {
|
|
1927
|
+
if (msg.message.role === "user") {
|
|
1928
|
+
promptCount++;
|
|
1929
|
+
if (promptCount > PROMPTS_PER_PAGE) {
|
|
1930
|
+
pages.push(currentPage);
|
|
1931
|
+
currentPage = [];
|
|
1932
|
+
promptCount = 1;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
currentPage.push(msg);
|
|
1936
|
+
}
|
|
1937
|
+
if (currentPage.length > 0) {
|
|
1938
|
+
pages.push(currentPage);
|
|
1939
|
+
}
|
|
1940
|
+
return pages;
|
|
1941
|
+
}
|
|
1942
|
+
async function generateSessionHtml(storagePath, outputDir, session2, projectName, repo, includeJson) {
|
|
1943
|
+
const sessionDir = join2(outputDir, "sessions", session2.id);
|
|
1944
|
+
await ensureDir(sessionDir);
|
|
1945
|
+
const messages = await getMessagesWithParts(storagePath, session2.id);
|
|
1946
|
+
const messageCount = messages.length;
|
|
1947
|
+
const firstPrompt = getFirstPrompt(messages);
|
|
1948
|
+
const timeline = buildTimeline(messages, repo);
|
|
1949
|
+
let totalTokensInput = 0;
|
|
1950
|
+
let totalTokensOutput = 0;
|
|
1951
|
+
let totalCost = 0;
|
|
1952
|
+
let model;
|
|
1953
|
+
for (const msg of messages) {
|
|
1954
|
+
if (msg.message.role === "assistant") {
|
|
1955
|
+
const asst = msg.message;
|
|
1956
|
+
if (asst.tokens) {
|
|
1957
|
+
totalTokensInput += asst.tokens.input;
|
|
1958
|
+
totalTokensOutput += asst.tokens.output;
|
|
1959
|
+
}
|
|
1960
|
+
if (asst.cost) {
|
|
1961
|
+
totalCost += asst.cost;
|
|
1962
|
+
}
|
|
1963
|
+
if (!model && asst.modelID) {
|
|
1964
|
+
model = asst.modelID;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
const pages = paginateMessages(messages);
|
|
1969
|
+
const pageCount = pages.length;
|
|
1970
|
+
const sessionOverviewHtml = renderSessionPage({
|
|
1971
|
+
session: session2,
|
|
1972
|
+
projectName,
|
|
1973
|
+
timeline,
|
|
1974
|
+
messageCount,
|
|
1975
|
+
totalTokens: totalTokensInput > 0 || totalTokensOutput > 0 ? { input: totalTokensInput, output: totalTokensOutput } : undefined,
|
|
1976
|
+
totalCost: totalCost > 0 ? totalCost : undefined,
|
|
1977
|
+
pageCount,
|
|
1978
|
+
model,
|
|
1979
|
+
assetsPath: "../../assets"
|
|
1980
|
+
});
|
|
1981
|
+
await writeHtml(join2(sessionDir, "index.html"), sessionOverviewHtml);
|
|
1982
|
+
for (let i = 0;i < pages.length; i++) {
|
|
1983
|
+
const pageNumber = i + 1;
|
|
1984
|
+
const pageMessages = pages[i] ?? [];
|
|
1985
|
+
const pageHtml = renderConversationPage({
|
|
1986
|
+
session: session2,
|
|
1987
|
+
projectName,
|
|
1988
|
+
messages: pageMessages,
|
|
1989
|
+
pageNumber,
|
|
1990
|
+
totalPages: pageCount,
|
|
1991
|
+
assetsPath: "../../assets"
|
|
1992
|
+
});
|
|
1993
|
+
const pageFile = `page-${String(pageNumber).padStart(3, "0")}.html`;
|
|
1994
|
+
await writeHtml(join2(sessionDir, pageFile), pageHtml);
|
|
1995
|
+
}
|
|
1996
|
+
if (includeJson) {
|
|
1997
|
+
const jsonData = {
|
|
1998
|
+
session: session2,
|
|
1999
|
+
messages,
|
|
2000
|
+
timeline,
|
|
2001
|
+
stats: {
|
|
2002
|
+
messageCount,
|
|
2003
|
+
pageCount,
|
|
2004
|
+
totalTokensInput,
|
|
2005
|
+
totalTokensOutput,
|
|
2006
|
+
totalCost,
|
|
2007
|
+
model
|
|
2008
|
+
}
|
|
2009
|
+
};
|
|
2010
|
+
await Bun.write(join2(sessionDir, "session.json"), JSON.stringify(jsonData, null, 2));
|
|
2011
|
+
}
|
|
2012
|
+
return { messageCount, pageCount, firstPrompt };
|
|
2013
|
+
}
|
|
2014
|
+
async function generateHtml(options) {
|
|
2015
|
+
const { storagePath, outputDir, all = false, sessionId, includeJson = false, onProgress, repo } = options;
|
|
2016
|
+
await ensureDir(outputDir);
|
|
2017
|
+
await copyAssets(outputDir);
|
|
2018
|
+
const sessionCards = [];
|
|
2019
|
+
let title = "OpenCode Sessions";
|
|
2020
|
+
let projectName;
|
|
2021
|
+
let totalPageCount = 0;
|
|
2022
|
+
let totalMessageCount = 0;
|
|
2023
|
+
async function processSession(session2, project, index, total) {
|
|
2024
|
+
onProgress?.({
|
|
2025
|
+
current: index + 1,
|
|
2026
|
+
total,
|
|
2027
|
+
title: session2.title,
|
|
2028
|
+
phase: "generating"
|
|
2029
|
+
});
|
|
2030
|
+
const result = await generateSessionHtml(storagePath, outputDir, session2, project.name, repo, includeJson);
|
|
2031
|
+
totalPageCount += result.pageCount;
|
|
2032
|
+
totalMessageCount += result.messageCount;
|
|
2033
|
+
sessionCards.push({
|
|
2034
|
+
session: session2,
|
|
2035
|
+
project,
|
|
2036
|
+
messageCount: result.messageCount,
|
|
2037
|
+
firstPrompt: result.firstPrompt
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
if (sessionId) {
|
|
2041
|
+
onProgress?.({ current: 0, total: 1, title: "Scanning...", phase: "scanning" });
|
|
2042
|
+
const projects = await listProjects(storagePath);
|
|
2043
|
+
for (const project of projects) {
|
|
2044
|
+
const sessions = await listSessions(storagePath, project.id);
|
|
2045
|
+
const session2 = sessions.find((s) => s.id === sessionId);
|
|
2046
|
+
if (session2) {
|
|
2047
|
+
await processSession(session2, project, 0, 1);
|
|
2048
|
+
title = session2.title;
|
|
2049
|
+
projectName = project.name;
|
|
2050
|
+
break;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
} else if (all) {
|
|
2054
|
+
onProgress?.({ current: 0, total: 0, title: "Scanning projects...", phase: "scanning" });
|
|
2055
|
+
const projects = await listProjects(storagePath);
|
|
2056
|
+
const allSessions = [];
|
|
2057
|
+
for (const project of projects) {
|
|
2058
|
+
const sessions = await listSessions(storagePath, project.id);
|
|
2059
|
+
for (const session2 of sessions) {
|
|
2060
|
+
allSessions.push({ session: session2, project });
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
for (let i = 0;i < allSessions.length; i++) {
|
|
2064
|
+
const { session: session2, project } = allSessions[i];
|
|
2065
|
+
await processSession(session2, project, i, allSessions.length);
|
|
2066
|
+
}
|
|
2067
|
+
title = "All OpenCode Sessions";
|
|
2068
|
+
} else {
|
|
2069
|
+
const cwd = process.cwd();
|
|
2070
|
+
onProgress?.({ current: 0, total: 0, title: "Finding project...", phase: "scanning" });
|
|
2071
|
+
const project = await findProjectByPath(storagePath, cwd);
|
|
2072
|
+
if (!project) {
|
|
2073
|
+
throw new Error(`No OpenCode project found for directory: ${cwd}
|
|
2074
|
+
` + `Run this command from a directory where you have used OpenCode, ` + `or use --all to generate for all projects.`);
|
|
2075
|
+
}
|
|
2076
|
+
projectName = project.name ?? project.worktree.split("/").pop();
|
|
2077
|
+
title = projectName ?? "OpenCode Sessions";
|
|
2078
|
+
const sessions = await listSessions(storagePath, project.id);
|
|
2079
|
+
for (let i = 0;i < sessions.length; i++) {
|
|
2080
|
+
const session2 = sessions[i];
|
|
2081
|
+
await processSession(session2, project, i, sessions.length);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
const indexHtml = renderIndexPage({
|
|
2085
|
+
title,
|
|
2086
|
+
subtitle: projectName,
|
|
2087
|
+
sessions: sessionCards,
|
|
2088
|
+
isAllProjects: all,
|
|
2089
|
+
assetsPath: "./assets"
|
|
2090
|
+
});
|
|
2091
|
+
await writeHtml(join2(outputDir, "index.html"), indexHtml);
|
|
2092
|
+
onProgress?.({
|
|
2093
|
+
current: sessionCards.length,
|
|
2094
|
+
total: sessionCards.length,
|
|
2095
|
+
title: "Complete",
|
|
2096
|
+
phase: "complete"
|
|
2097
|
+
});
|
|
2098
|
+
return {
|
|
2099
|
+
sessionCount: sessionCards.length,
|
|
2100
|
+
pageCount: totalPageCount,
|
|
2101
|
+
messageCount: totalMessageCount,
|
|
2102
|
+
projectName
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// src/server.ts
|
|
2107
|
+
import { resolve, join as join3, sep } from "path";
|
|
2108
|
+
function isPathSafe(rootDir, targetPath) {
|
|
2109
|
+
const resolvedRoot = resolve(rootDir);
|
|
2110
|
+
const resolvedTarget = resolve(targetPath);
|
|
2111
|
+
return resolvedTarget.startsWith(resolvedRoot + sep) || resolvedTarget === resolvedRoot;
|
|
2112
|
+
}
|
|
2113
|
+
function createRequestHandler(rootDir) {
|
|
2114
|
+
const ROOT_DIR = resolve(rootDir);
|
|
2115
|
+
return async function handleRequest(req) {
|
|
2116
|
+
const url = new URL(req.url);
|
|
2117
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2118
|
+
return new Response("Method Not Allowed", {
|
|
2119
|
+
status: 405,
|
|
2120
|
+
headers: { Allow: "GET, HEAD" }
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
let pathname;
|
|
2124
|
+
try {
|
|
2125
|
+
pathname = decodeURIComponent(url.pathname);
|
|
2126
|
+
} catch {
|
|
2127
|
+
return new Response("Bad Request", { status: 400 });
|
|
2128
|
+
}
|
|
2129
|
+
pathname = pathname.replace(/\0/g, "");
|
|
2130
|
+
const targetPath = join3(ROOT_DIR, pathname);
|
|
2131
|
+
if (!isPathSafe(ROOT_DIR, targetPath)) {
|
|
2132
|
+
return new Response("Forbidden", { status: 403 });
|
|
2133
|
+
}
|
|
2134
|
+
let file = Bun.file(targetPath);
|
|
2135
|
+
let fileExists = await file.exists();
|
|
2136
|
+
if (pathname.endsWith("/") || !fileExists) {
|
|
2137
|
+
const indexPath = join3(targetPath, "index.html");
|
|
2138
|
+
const indexFile = Bun.file(indexPath);
|
|
2139
|
+
if (await indexFile.exists()) {
|
|
2140
|
+
file = indexFile;
|
|
2141
|
+
fileExists = true;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
if (!fileExists) {
|
|
2145
|
+
return new Response("Not Found", {
|
|
2146
|
+
status: 404,
|
|
2147
|
+
headers: { "Content-Type": "text/plain" }
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
const content = await file.arrayBuffer();
|
|
2151
|
+
const etag = `W/"${Bun.hash(new Uint8Array(content)).toString(16)}"`;
|
|
2152
|
+
const ifNoneMatch = req.headers.get("If-None-Match");
|
|
2153
|
+
if (ifNoneMatch === etag) {
|
|
2154
|
+
return new Response(null, {
|
|
2155
|
+
status: 304,
|
|
2156
|
+
headers: { ETag: etag }
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
const isHashed = /\.[a-f0-9]{8,}\.(js|css|png|jpg|jpeg|gif|svg|woff2?)$/i.test(targetPath);
|
|
2160
|
+
const responseHeaders = {
|
|
2161
|
+
"Content-Type": file.type,
|
|
2162
|
+
"Content-Length": String(content.byteLength),
|
|
2163
|
+
ETag: etag,
|
|
2164
|
+
"Cache-Control": isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600"
|
|
2165
|
+
};
|
|
2166
|
+
if (req.method === "HEAD") {
|
|
2167
|
+
return new Response(null, { headers: responseHeaders });
|
|
2168
|
+
}
|
|
2169
|
+
return new Response(content, { headers: responseHeaders });
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
async function serve(options) {
|
|
2173
|
+
const { directory, port, open = true } = options;
|
|
2174
|
+
const handleRequest = createRequestHandler(directory);
|
|
2175
|
+
let server;
|
|
2176
|
+
try {
|
|
2177
|
+
server = Bun.serve({
|
|
2178
|
+
port,
|
|
2179
|
+
fetch: handleRequest,
|
|
2180
|
+
error(error) {
|
|
2181
|
+
console.error("Server error:", error);
|
|
2182
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
} catch (err) {
|
|
2186
|
+
const error = err;
|
|
2187
|
+
if (error.code === "EADDRINUSE") {
|
|
2188
|
+
console.error(`Error: Port ${port} is already in use`);
|
|
2189
|
+
process.exit(1);
|
|
2190
|
+
}
|
|
2191
|
+
throw err;
|
|
2192
|
+
}
|
|
2193
|
+
const serverUrl = `http://localhost:${port}`;
|
|
2194
|
+
console.log(`
|
|
2195
|
+
Server running at ${serverUrl}`);
|
|
2196
|
+
console.log(`Press Ctrl+C to stop
|
|
2197
|
+
`);
|
|
2198
|
+
if (open) {
|
|
2199
|
+
openBrowser(serverUrl);
|
|
2200
|
+
}
|
|
2201
|
+
const sigintHandler = () => shutdown("SIGINT");
|
|
2202
|
+
const sigtermHandler = () => shutdown("SIGTERM");
|
|
2203
|
+
function shutdown(signal) {
|
|
2204
|
+
console.log(`
|
|
2205
|
+
Received ${signal}, shutting down...`);
|
|
2206
|
+
process.off("SIGINT", sigintHandler);
|
|
2207
|
+
process.off("SIGTERM", sigtermHandler);
|
|
2208
|
+
server.stop();
|
|
2209
|
+
console.log("Server stopped");
|
|
2210
|
+
process.exit(0);
|
|
2211
|
+
}
|
|
2212
|
+
process.on("SIGINT", sigintHandler);
|
|
2213
|
+
process.on("SIGTERM", sigtermHandler);
|
|
2214
|
+
await new Promise(() => {});
|
|
2215
|
+
}
|
|
2216
|
+
function openBrowser(url) {
|
|
2217
|
+
const platform = process.platform;
|
|
2218
|
+
if (platform === "darwin") {
|
|
2219
|
+
Bun.spawn(["open", url]);
|
|
2220
|
+
} else if (platform === "win32") {
|
|
2221
|
+
Bun.spawn(["cmd", "/c", "start", "", url]);
|
|
2222
|
+
} else {
|
|
2223
|
+
Bun.spawn(["xdg-open", url]);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// src/index.ts
|
|
2228
|
+
var colors = {
|
|
2229
|
+
reset: "\x1B[0m",
|
|
2230
|
+
bold: "\x1B[1m",
|
|
2231
|
+
dim: "\x1B[2m",
|
|
2232
|
+
red: "\x1B[31m",
|
|
2233
|
+
green: "\x1B[32m",
|
|
2234
|
+
yellow: "\x1B[33m",
|
|
2235
|
+
blue: "\x1B[34m",
|
|
2236
|
+
magenta: "\x1B[35m",
|
|
2237
|
+
cyan: "\x1B[36m",
|
|
2238
|
+
white: "\x1B[37m",
|
|
2239
|
+
gray: "\x1B[90m"
|
|
2240
|
+
};
|
|
2241
|
+
var useColors = !process.env.NO_COLOR && process.stdout.isTTY;
|
|
2242
|
+
function color(text, ...codes) {
|
|
2243
|
+
if (!useColors)
|
|
2244
|
+
return text;
|
|
2245
|
+
return codes.join("") + text + colors.reset;
|
|
2246
|
+
}
|
|
2247
|
+
var quietMode = false;
|
|
2248
|
+
var verboseMode = false;
|
|
2249
|
+
function log(message) {
|
|
2250
|
+
if (!quietMode) {
|
|
2251
|
+
console.log(message);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
function debug(message) {
|
|
2255
|
+
if (verboseMode && !quietMode) {
|
|
2256
|
+
console.log(color("[debug]", colors.gray) + " " + message);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
function writeProgress(message) {
|
|
2260
|
+
if (!quietMode) {
|
|
2261
|
+
process.stdout.write(message);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
function formatProgress(progress) {
|
|
2265
|
+
if (progress.phase === "scanning") {
|
|
2266
|
+
return color("Scanning: ", colors.cyan) + color(progress.title, colors.dim);
|
|
2267
|
+
}
|
|
2268
|
+
if (progress.phase === "complete") {
|
|
2269
|
+
return "";
|
|
2270
|
+
}
|
|
2271
|
+
const maxTitleLength = 50;
|
|
2272
|
+
const title = progress.title.length > maxTitleLength ? progress.title.slice(0, maxTitleLength - 3) + "..." : progress.title;
|
|
2273
|
+
const counter = color(`[${progress.current}/${progress.total}]`, colors.cyan);
|
|
2274
|
+
return `${counter} ${title}`;
|
|
2275
|
+
}
|
|
2276
|
+
function formatStats(stats) {
|
|
2277
|
+
const parts = [];
|
|
2278
|
+
parts.push(`${stats.sessionCount} session${stats.sessionCount !== 1 ? "s" : ""}`);
|
|
2279
|
+
parts.push(`${stats.pageCount} page${stats.pageCount !== 1 ? "s" : ""}`);
|
|
2280
|
+
parts.push(`${stats.messageCount} message${stats.messageCount !== 1 ? "s" : ""}`);
|
|
2281
|
+
return parts.join(", ");
|
|
2282
|
+
}
|
|
2283
|
+
function slugify(text) {
|
|
2284
|
+
return text.toLowerCase().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 50);
|
|
2285
|
+
}
|
|
2286
|
+
async function getAutoOutputDir(storagePath, sessionId, all) {
|
|
2287
|
+
if (all) {
|
|
2288
|
+
const date = new Date().toISOString().split("T")[0];
|
|
2289
|
+
return `./opencode-all-${date}`;
|
|
2290
|
+
}
|
|
2291
|
+
if (sessionId) {
|
|
2292
|
+
const projects = await listProjects(storagePath);
|
|
2293
|
+
for (const project2 of projects) {
|
|
2294
|
+
const sessions = await listSessions(storagePath, project2.id);
|
|
2295
|
+
const session2 = sessions.find((s) => s.id === sessionId);
|
|
2296
|
+
if (session2) {
|
|
2297
|
+
const name = slugify(session2.title) || sessionId.slice(0, 12);
|
|
2298
|
+
return `./${name}-replay`;
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
return `./${sessionId.slice(0, 12)}-replay`;
|
|
2302
|
+
}
|
|
2303
|
+
const cwd = process.cwd();
|
|
2304
|
+
const project = await findProjectByPath(storagePath, cwd);
|
|
2305
|
+
if (project) {
|
|
2306
|
+
const name = project.name ?? project.worktree.split("/").pop() ?? "project";
|
|
2307
|
+
const slug = slugify(name) || "project";
|
|
2308
|
+
return `./${slug}-replay`;
|
|
2309
|
+
}
|
|
2310
|
+
return "./opencode-replay-output";
|
|
2311
|
+
}
|
|
2312
|
+
var { values } = parseArgs({
|
|
2313
|
+
args: Bun.argv.slice(2),
|
|
2314
|
+
options: {
|
|
2315
|
+
all: {
|
|
2316
|
+
type: "boolean",
|
|
2317
|
+
default: false,
|
|
2318
|
+
description: "Generate for all projects"
|
|
2319
|
+
},
|
|
2320
|
+
auto: {
|
|
2321
|
+
type: "boolean",
|
|
2322
|
+
short: "a",
|
|
2323
|
+
default: false,
|
|
2324
|
+
description: "Auto-name output directory from project/session"
|
|
2325
|
+
},
|
|
2326
|
+
output: {
|
|
2327
|
+
type: "string",
|
|
2328
|
+
short: "o",
|
|
2329
|
+
description: "Output directory"
|
|
2330
|
+
},
|
|
2331
|
+
session: {
|
|
2332
|
+
type: "string",
|
|
2333
|
+
short: "s",
|
|
2334
|
+
description: "Generate for specific session only"
|
|
2335
|
+
},
|
|
2336
|
+
json: {
|
|
2337
|
+
type: "boolean",
|
|
2338
|
+
default: false,
|
|
2339
|
+
description: "Include raw JSON export"
|
|
2340
|
+
},
|
|
2341
|
+
open: {
|
|
2342
|
+
type: "boolean",
|
|
2343
|
+
default: false,
|
|
2344
|
+
description: "Open in browser after generation"
|
|
2345
|
+
},
|
|
2346
|
+
storage: {
|
|
2347
|
+
type: "string",
|
|
2348
|
+
description: "Custom storage path"
|
|
2349
|
+
},
|
|
2350
|
+
serve: {
|
|
2351
|
+
type: "boolean",
|
|
2352
|
+
default: false,
|
|
2353
|
+
description: "Start HTTP server after generation"
|
|
2354
|
+
},
|
|
2355
|
+
port: {
|
|
2356
|
+
type: "string",
|
|
2357
|
+
default: "3000",
|
|
2358
|
+
description: "Server port (default: 3000)"
|
|
2359
|
+
},
|
|
2360
|
+
quiet: {
|
|
2361
|
+
type: "boolean",
|
|
2362
|
+
short: "q",
|
|
2363
|
+
default: false,
|
|
2364
|
+
description: "Suppress non-essential output"
|
|
2365
|
+
},
|
|
2366
|
+
verbose: {
|
|
2367
|
+
type: "boolean",
|
|
2368
|
+
default: false,
|
|
2369
|
+
description: "Show detailed debug output"
|
|
2370
|
+
},
|
|
2371
|
+
"no-generate": {
|
|
2372
|
+
type: "boolean",
|
|
2373
|
+
default: false,
|
|
2374
|
+
description: "Skip generation, only serve existing output"
|
|
2375
|
+
},
|
|
2376
|
+
repo: {
|
|
2377
|
+
type: "string",
|
|
2378
|
+
description: "GitHub repo (OWNER/NAME) for commit links"
|
|
2379
|
+
},
|
|
2380
|
+
help: {
|
|
2381
|
+
type: "boolean",
|
|
2382
|
+
short: "h",
|
|
2383
|
+
default: false,
|
|
2384
|
+
description: "Show help"
|
|
2385
|
+
},
|
|
2386
|
+
version: {
|
|
2387
|
+
type: "boolean",
|
|
2388
|
+
short: "v",
|
|
2389
|
+
default: false,
|
|
2390
|
+
description: "Show version"
|
|
2391
|
+
}
|
|
2392
|
+
},
|
|
2393
|
+
allowPositionals: true
|
|
2394
|
+
});
|
|
2395
|
+
if (values.help) {
|
|
2396
|
+
console.log(`
|
|
2397
|
+
opencode-replay - Generate static HTML transcripts from OpenCode sessions
|
|
2398
|
+
|
|
2399
|
+
Usage:
|
|
2400
|
+
opencode-replay [options]
|
|
2401
|
+
|
|
2402
|
+
Options:
|
|
2403
|
+
--all Generate for all projects (default: current project only)
|
|
2404
|
+
-a, --auto Auto-name output directory from project/session name
|
|
2405
|
+
-o, --output <dir> Output directory (default: ./opencode-replay-output)
|
|
2406
|
+
-s, --session <id> Generate for specific session only
|
|
2407
|
+
--json Include raw JSON export alongside HTML
|
|
2408
|
+
--open Open in browser after generation
|
|
2409
|
+
--storage <path> Custom storage path (default: ~/.local/share/opencode/storage)
|
|
2410
|
+
--serve Start HTTP server after generation
|
|
2411
|
+
--port <number> Server port (default: 3000)
|
|
2412
|
+
--no-generate Skip generation, only serve existing output
|
|
2413
|
+
--repo <owner/name> GitHub repo for commit links (e.g., sst/opencode)
|
|
2414
|
+
-q, --quiet Suppress non-essential output
|
|
2415
|
+
--verbose Show detailed debug output
|
|
2416
|
+
-h, --help Show this help message
|
|
2417
|
+
-v, --version Show version
|
|
2418
|
+
|
|
2419
|
+
Examples:
|
|
2420
|
+
opencode-replay # Current project's sessions
|
|
2421
|
+
opencode-replay --all # All projects
|
|
2422
|
+
opencode-replay -a # Auto-name output (e.g., ./my-project-replay)
|
|
2423
|
+
opencode-replay -o ./my-transcripts # Custom output directory
|
|
2424
|
+
opencode-replay --session ses_xxx # Specific session only
|
|
2425
|
+
opencode-replay --serve # Generate and serve via HTTP
|
|
2426
|
+
opencode-replay --serve --port 8080 # Serve on custom port
|
|
2427
|
+
opencode-replay --serve --no-generate -o ./existing # Serve existing output
|
|
2428
|
+
opencode-replay --repo sst/opencode # Add GitHub links to git commits
|
|
2429
|
+
`);
|
|
2430
|
+
process.exit(0);
|
|
2431
|
+
}
|
|
2432
|
+
if (values.version) {
|
|
2433
|
+
const pkg = await Bun.file(resolve2(import.meta.dir, "..", "package.json")).json();
|
|
2434
|
+
console.log(pkg.version);
|
|
2435
|
+
process.exit(0);
|
|
2436
|
+
}
|
|
2437
|
+
var storagePath = values.storage ?? getDefaultStoragePath();
|
|
2438
|
+
var port = parseInt(values.port ?? "3000", 10);
|
|
2439
|
+
quietMode = values.quiet ?? false;
|
|
2440
|
+
verboseMode = values.verbose ?? false;
|
|
2441
|
+
debug(`CLI arguments: ${JSON.stringify(values)}`);
|
|
2442
|
+
debug(`Storage path: ${storagePath}`);
|
|
2443
|
+
debug(`Working directory: ${process.cwd()}`);
|
|
2444
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
2445
|
+
console.error("Error: Invalid port number");
|
|
2446
|
+
process.exit(1);
|
|
2447
|
+
}
|
|
2448
|
+
try {
|
|
2449
|
+
await readdir2(storagePath);
|
|
2450
|
+
const projectDir = join4(storagePath, "project");
|
|
2451
|
+
try {
|
|
2452
|
+
await readdir2(projectDir);
|
|
2453
|
+
} catch {
|
|
2454
|
+
throw new Error("INVALID_STORAGE");
|
|
2455
|
+
}
|
|
2456
|
+
} catch (err) {
|
|
2457
|
+
const error = err;
|
|
2458
|
+
console.error(color("Error:", colors.red, colors.bold) + ` OpenCode storage not found at: ${storagePath}`);
|
|
2459
|
+
console.error("");
|
|
2460
|
+
if (error.code === "ENOENT") {
|
|
2461
|
+
console.error(color("The directory does not exist.", colors.yellow));
|
|
2462
|
+
} else if (error.message === "INVALID_STORAGE") {
|
|
2463
|
+
console.error(color("The directory exists but is not a valid OpenCode storage.", colors.yellow));
|
|
2464
|
+
console.error(color("Missing 'project/' subdirectory.", colors.dim));
|
|
2465
|
+
} else {
|
|
2466
|
+
console.error(color("The directory exists but is not a valid OpenCode storage.", colors.yellow));
|
|
2467
|
+
}
|
|
2468
|
+
console.error("");
|
|
2469
|
+
console.error(color("This could mean:", colors.dim));
|
|
2470
|
+
console.error(" 1. OpenCode has not been used on this machine yet");
|
|
2471
|
+
console.error(" 2. The storage path is incorrect");
|
|
2472
|
+
console.error("");
|
|
2473
|
+
console.error(color("Solutions:", colors.green));
|
|
2474
|
+
console.error(" - Run OpenCode at least once to create the storage directory");
|
|
2475
|
+
console.error(" - Use --storage <path> to specify a custom storage location");
|
|
2476
|
+
console.error("");
|
|
2477
|
+
console.error(color("Expected path:", colors.dim) + ` ${storagePath}`);
|
|
2478
|
+
process.exit(1);
|
|
2479
|
+
}
|
|
2480
|
+
var outputDir;
|
|
2481
|
+
if (values.output) {
|
|
2482
|
+
outputDir = values.output;
|
|
2483
|
+
debug(`Using explicit output directory: ${outputDir}`);
|
|
2484
|
+
} else if (values.auto) {
|
|
2485
|
+
debug("Auto-generating output directory name...");
|
|
2486
|
+
outputDir = await getAutoOutputDir(storagePath, values.session, values.all);
|
|
2487
|
+
debug(`Auto-generated output directory: ${outputDir}`);
|
|
2488
|
+
} else {
|
|
2489
|
+
outputDir = "./opencode-replay-output";
|
|
2490
|
+
debug(`Using default output directory: ${outputDir}`);
|
|
2491
|
+
}
|
|
2492
|
+
var repoInfo = values.repo ? parseRepoString(values.repo) ?? undefined : undefined;
|
|
2493
|
+
if (values.repo && !repoInfo) {
|
|
2494
|
+
console.error(color("Error:", colors.red, colors.bold) + ` Invalid repo format: ${values.repo}`);
|
|
2495
|
+
console.error("Expected format: OWNER/NAME (e.g., sst/opencode)");
|
|
2496
|
+
process.exit(1);
|
|
2497
|
+
}
|
|
2498
|
+
log(color("opencode-replay", colors.bold, colors.cyan));
|
|
2499
|
+
log(color("---------------", colors.dim));
|
|
2500
|
+
log(color("Storage:", colors.dim) + ` ${storagePath}`);
|
|
2501
|
+
log(color("Output:", colors.dim) + ` ${resolve2(outputDir)}`);
|
|
2502
|
+
if (repoInfo) {
|
|
2503
|
+
log(color("Repo:", colors.dim) + ` ${repoInfo.fullName}`);
|
|
2504
|
+
}
|
|
2505
|
+
if (values["no-generate"]) {
|
|
2506
|
+
const indexFile = Bun.file(resolve2(outputDir, "index.html"));
|
|
2507
|
+
if (!await indexFile.exists()) {
|
|
2508
|
+
console.error(color("Error:", colors.red, colors.bold) + ` Output directory not found or missing index.html: ${resolve2(outputDir)}`);
|
|
2509
|
+
console.error("Run without --no-generate to generate output first.");
|
|
2510
|
+
process.exit(1);
|
|
2511
|
+
}
|
|
2512
|
+
log(color("Skipping generation (--no-generate)", colors.yellow));
|
|
2513
|
+
log("");
|
|
2514
|
+
} else {
|
|
2515
|
+
const modeText = values.all ? "all projects" : "current project";
|
|
2516
|
+
log(color("Mode:", colors.dim) + ` ${modeText}`);
|
|
2517
|
+
if (values.session) {
|
|
2518
|
+
log(color("Session:", colors.dim) + ` ${values.session}`);
|
|
2519
|
+
}
|
|
2520
|
+
log("");
|
|
2521
|
+
try {
|
|
2522
|
+
const stats = await generateHtml({
|
|
2523
|
+
storagePath,
|
|
2524
|
+
outputDir: resolve2(outputDir),
|
|
2525
|
+
all: values.all ?? false,
|
|
2526
|
+
sessionId: values.session,
|
|
2527
|
+
includeJson: values.json ?? false,
|
|
2528
|
+
repo: repoInfo,
|
|
2529
|
+
onProgress: (progress) => {
|
|
2530
|
+
const msg = formatProgress(progress);
|
|
2531
|
+
if (msg) {
|
|
2532
|
+
writeProgress(`\r\x1B[K${msg}`);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
});
|
|
2536
|
+
writeProgress("\r\x1B[K");
|
|
2537
|
+
if (values.session && stats.sessionCount === 0) {
|
|
2538
|
+
console.error(color("Warning:", colors.yellow, colors.bold) + ` Session not found: ${values.session}`);
|
|
2539
|
+
console.error("Use --all to see all available sessions, or check the session ID.");
|
|
2540
|
+
process.exit(1);
|
|
2541
|
+
}
|
|
2542
|
+
log(color("Done!", colors.green, colors.bold) + ` Generated ${formatStats(stats)}`);
|
|
2543
|
+
console.log(resolve2(outputDir));
|
|
2544
|
+
} catch (error) {
|
|
2545
|
+
process.stdout.write("\r\x1B[K");
|
|
2546
|
+
console.error(color("Error:", colors.red, colors.bold) + " " + (error instanceof Error ? error.message : error));
|
|
2547
|
+
process.exit(1);
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
if (values.serve) {
|
|
2551
|
+
await serve({
|
|
2552
|
+
directory: resolve2(outputDir),
|
|
2553
|
+
port,
|
|
2554
|
+
open: values.open ?? true
|
|
2555
|
+
});
|
|
2556
|
+
} else if (values.open) {
|
|
2557
|
+
const indexPath = resolve2(outputDir, "index.html");
|
|
2558
|
+
openInBrowser(indexPath);
|
|
2559
|
+
}
|
|
2560
|
+
function openInBrowser(target) {
|
|
2561
|
+
const platform = process.platform;
|
|
2562
|
+
if (platform === "darwin") {
|
|
2563
|
+
Bun.spawn(["open", target]);
|
|
2564
|
+
} else if (platform === "win32") {
|
|
2565
|
+
Bun.spawn(["cmd", "/c", "start", "", target]);
|
|
2566
|
+
} else {
|
|
2567
|
+
Bun.spawn(["xdg-open", target]);
|
|
2568
|
+
}
|
|
2569
|
+
}
|