cc-transcript-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { exec } from "child_process";
5
+ import { platform } from "os";
6
+
7
+ // src/cli/server.ts
8
+ import http from "http";
9
+ import fs2 from "fs";
10
+ import path2 from "path";
11
+ import { fileURLToPath } from "url";
12
+
13
+ // src/core/session-log.ts
14
+ function parseSessionLog(jsonlContent) {
15
+ const lines = jsonlContent.split("\n").filter((line) => line.trim() !== "");
16
+ const events = [];
17
+ for (const line of lines) {
18
+ let entry;
19
+ try {
20
+ entry = JSON.parse(line);
21
+ } catch {
22
+ continue;
23
+ }
24
+ if (!entry.type || !entry.uuid) continue;
25
+ events.push({
26
+ uuid: entry.uuid,
27
+ event_type: entry.type,
28
+ payload: entry,
29
+ created_at: entry.timestamp ?? ""
30
+ });
31
+ }
32
+ return events;
33
+ }
34
+
35
+ // src/cli/session-discovery.ts
36
+ import fs from "fs";
37
+ import path from "path";
38
+ import os from "os";
39
+ function discoverSessions() {
40
+ const claudeDir = path.join(os.homedir(), ".claude", "projects");
41
+ if (!fs.existsSync(claudeDir)) return [];
42
+ const projectDirs = fs.readdirSync(claudeDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(claudeDir, d.name));
43
+ const allSessions = [];
44
+ for (const dir of projectDirs) {
45
+ const indexPath = path.join(dir, "sessions-index.json");
46
+ if (!fs.existsSync(indexPath)) continue;
47
+ try {
48
+ const raw = fs.readFileSync(indexPath, "utf-8");
49
+ const index = JSON.parse(raw);
50
+ if (index.entries) {
51
+ allSessions.push(...index.entries);
52
+ }
53
+ } catch {
54
+ }
55
+ }
56
+ allSessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
57
+ return allSessions;
58
+ }
59
+ function readSessionFile(fullPath) {
60
+ return fs.readFileSync(fullPath, "utf-8");
61
+ }
62
+
63
+ // src/cli/templates.ts
64
+ function sessionListPage(sessions, colorScheme = "light") {
65
+ const isDark = colorScheme === "dark";
66
+ const rows = sessions.map((s) => {
67
+ const modified = new Date(s.modified).toLocaleString();
68
+ const project = s.projectPath.split("/").slice(-2).join("/");
69
+ const prompt = escapeHtml(truncate(s.firstPrompt, 80));
70
+ const summary = escapeHtml(truncate(s.summary || "", 60));
71
+ return `
72
+ <tr onclick="location.href='/session/${s.sessionId}'" style="cursor:pointer">
73
+ <td>${escapeHtml(project)}</td>
74
+ <td>${prompt}</td>
75
+ <td>${summary}</td>
76
+ <td style="text-align:right">${s.messageCount}</td>
77
+ <td>${modified}</td>
78
+ </tr>`;
79
+ }).join("");
80
+ return `<!DOCTYPE html>
81
+ <html lang="en">
82
+ <head>
83
+ <meta charset="UTF-8">
84
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
85
+ <title>Claude Code Sessions</title>
86
+ <style>
87
+ * { box-sizing: border-box; margin: 0; padding: 0; }
88
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: ${isDark ? "#111827" : "#f5f5f5"}; color: ${isDark ? "#d1d5db" : "#374151"}; padding: 2rem; }
89
+ h1 { font-size: 1.5rem; margin-bottom: 1.5rem; color: ${isDark ? "#f9fafb" : "#111827"}; }
90
+ table { width: 100%; border-collapse: collapse; background: ${isDark ? "#1f2937" : "#fff"}; border-radius: 0.75rem; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,${isDark ? "0.4" : "0.1"}); }
91
+ th { text-align: left; padding: 0.75rem 1rem; background: ${isDark ? "#111827" : "#f9fafb"}; border-bottom: 2px solid ${isDark ? "#374151" : "#e5e7eb"}; font-weight: 600; font-size: 0.8rem; text-transform: uppercase; color: ${isDark ? "#9ca3af" : "#6b7280"}; }
92
+ td { padding: 0.75rem 1rem; border-bottom: 1px solid ${isDark ? "#374151" : "#e5e7eb"}; font-size: 0.9rem; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
93
+ tr:hover td { background: ${isDark ? "#1e3a8a20" : "#eff6ff"}; }
94
+ .empty { padding: 3rem; text-align: center; color: ${isDark ? "#6b7280" : "#9ca3af"}; }
95
+ </style>
96
+ </head>
97
+ <body>
98
+ <h1>Claude Code Sessions</h1>
99
+ ${sessions.length === 0 ? '<div class="empty">No sessions found.</div>' : `<table>
100
+ <thead><tr>
101
+ <th>Project</th>
102
+ <th>First Prompt</th>
103
+ <th>Summary</th>
104
+ <th style="text-align:right">Messages</th>
105
+ <th>Modified</th>
106
+ </tr></thead>
107
+ <tbody>${rows}</tbody>
108
+ </table>`}
109
+ </body>
110
+ </html>`;
111
+ }
112
+ function sessionPage(sessionId, colorScheme) {
113
+ const isDark = colorScheme === "dark";
114
+ return `<!DOCTYPE html>
115
+ <html lang="en">
116
+ <head>
117
+ <meta charset="UTF-8">
118
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
119
+ <title>Session ${sessionId}</title>
120
+ <link rel="stylesheet" href="/assets/styles.css">
121
+ <style>
122
+ * { box-sizing: border-box; }
123
+ body { margin: 0; padding: 2rem; background: ${isDark ? "#111827" : "#f5f5f5"}; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
124
+ .back { display: inline-block; margin-bottom: 1rem; color: ${isDark ? "#9ca3af" : "#6b7280"}; text-decoration: none; font-size: 0.9rem; }
125
+ .back:hover { color: ${isDark ? "#d1d5db" : "#374151"}; }
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <a class="back" href="/">&larr; Back to sessions</a>
130
+ <div id="root"></div>
131
+ <script>window.__SESSION_ID__ = "${sessionId}"; window.__COLOR_SCHEME__ = "${colorScheme}";</script>
132
+ <script src="/assets/viewer.js"></script>
133
+ </body>
134
+ </html>`;
135
+ }
136
+ function escapeHtml(str) {
137
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
138
+ }
139
+ function truncate(str, max) {
140
+ if (str.length <= max) return str;
141
+ return str.slice(0, max) + "...";
142
+ }
143
+
144
+ // src/cli/server.ts
145
+ var __dirname = path2.dirname(fileURLToPath(import.meta.url));
146
+ function startServer(options) {
147
+ const { port, colorScheme, sessionId: fixedSessionId } = options;
148
+ let sessions = [];
149
+ const sessionMap = /* @__PURE__ */ new Map();
150
+ function refreshSessions() {
151
+ sessions = discoverSessions();
152
+ sessionMap.clear();
153
+ for (const s of sessions) {
154
+ sessionMap.set(s.sessionId, s);
155
+ }
156
+ }
157
+ const distDir = path2.resolve(__dirname);
158
+ function serveAsset(res, filename, contentType) {
159
+ const filePath = path2.join(distDir, filename);
160
+ try {
161
+ const content = fs2.readFileSync(filePath, "utf-8");
162
+ res.writeHead(200, { "Content-Type": contentType });
163
+ res.end(content);
164
+ } catch {
165
+ res.writeHead(404);
166
+ res.end("Not found");
167
+ }
168
+ }
169
+ function jsonResponse(res, data) {
170
+ res.writeHead(200, { "Content-Type": "application/json" });
171
+ res.end(JSON.stringify(data));
172
+ }
173
+ function htmlResponse(res, html) {
174
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
175
+ res.end(html);
176
+ }
177
+ function notFound(res) {
178
+ res.writeHead(404, { "Content-Type": "text/plain" });
179
+ res.end("Not found");
180
+ }
181
+ const server = http.createServer((req, res) => {
182
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
183
+ const pathname = url.pathname;
184
+ if (pathname === "/assets/viewer.js") {
185
+ return serveAsset(res, "viewer.global.js", "application/javascript");
186
+ }
187
+ if (pathname === "/assets/styles.css") {
188
+ return serveAsset(res, "index.css", "text/css");
189
+ }
190
+ if (pathname === "/api/sessions") {
191
+ refreshSessions();
192
+ const list = (fixedSessionId ? sessions.filter((s) => s.sessionId === fixedSessionId) : sessions).map((s) => ({
193
+ sessionId: s.sessionId,
194
+ firstPrompt: s.firstPrompt,
195
+ summary: s.summary,
196
+ messageCount: s.messageCount,
197
+ modified: s.modified,
198
+ projectPath: s.projectPath
199
+ }));
200
+ return jsonResponse(res, list);
201
+ }
202
+ const eventsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
203
+ if (eventsMatch) {
204
+ const id = eventsMatch[1];
205
+ refreshSessions();
206
+ const session = sessionMap.get(id);
207
+ if (!session) return notFound(res);
208
+ try {
209
+ const content = readSessionFile(session.fullPath);
210
+ const events = parseSessionLog(content);
211
+ return jsonResponse(res, {
212
+ events,
213
+ projectPath: session.projectPath,
214
+ colorScheme
215
+ });
216
+ } catch {
217
+ res.writeHead(500, { "Content-Type": "text/plain" });
218
+ return res.end("Failed to read session");
219
+ }
220
+ }
221
+ const sessionMatch = pathname.match(/^\/session\/([^/]+)$/);
222
+ if (sessionMatch) {
223
+ return htmlResponse(res, sessionPage(sessionMatch[1], colorScheme));
224
+ }
225
+ if (pathname === "/") {
226
+ if (fixedSessionId) {
227
+ res.writeHead(302, { Location: `/session/${fixedSessionId}` });
228
+ return res.end();
229
+ }
230
+ refreshSessions();
231
+ return htmlResponse(res, sessionListPage(sessions, colorScheme));
232
+ }
233
+ return notFound(res);
234
+ });
235
+ return new Promise((resolve, reject) => {
236
+ server.on("error", reject);
237
+ server.listen(port, () => {
238
+ resolve(server);
239
+ });
240
+ });
241
+ }
242
+
243
+ // src/cli/index.ts
244
+ function parseArgs(argv) {
245
+ const args = {
246
+ port: 3333,
247
+ colorScheme: "light",
248
+ help: false
249
+ };
250
+ const rest = argv.slice(2);
251
+ for (let i = 0; i < rest.length; i++) {
252
+ const arg = rest[i];
253
+ if (arg === "-h" || arg === "--help") {
254
+ args.help = true;
255
+ } else if (arg === "--dark") {
256
+ args.colorScheme = "dark";
257
+ } else if (arg === "-p" || arg === "--port") {
258
+ const val = rest[++i];
259
+ if (val) args.port = parseInt(val, 10);
260
+ } else if (!arg.startsWith("-")) {
261
+ args.sessionId = arg;
262
+ }
263
+ }
264
+ return args;
265
+ }
266
+ function showHelp() {
267
+ console.log(`
268
+ cc-transcript-react - Claude Code transcript viewer
269
+
270
+ Usage:
271
+ npx cc-transcript-react [options] [session-id]
272
+
273
+ Arguments:
274
+ session-id Show only this session (opens directly)
275
+
276
+ Options:
277
+ -p, --port <number> Port number (default: 3333)
278
+ --dark Use dark theme
279
+ -h, --help Show this help
280
+ `.trim());
281
+ }
282
+ function openBrowser(url) {
283
+ const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
284
+ exec(`${cmd} ${url}`);
285
+ }
286
+ async function main() {
287
+ const args = parseArgs(process.argv);
288
+ if (args.help) {
289
+ showHelp();
290
+ process.exit(0);
291
+ }
292
+ try {
293
+ const server = await startServer({
294
+ port: args.port,
295
+ colorScheme: args.colorScheme,
296
+ sessionId: args.sessionId
297
+ });
298
+ const address = server.address();
299
+ const port = typeof address === "object" && address ? address.port : args.port;
300
+ const url = `http://localhost:${port}`;
301
+ console.log(`Claude Code transcript viewer running at ${url}`);
302
+ console.log("Press Ctrl+C to stop");
303
+ openBrowser(url);
304
+ } catch (err) {
305
+ if (err instanceof Error && "code" in err && err.code === "EADDRINUSE") {
306
+ console.error(`Port ${args.port} is already in use. Try a different port with -p <port>`);
307
+ } else {
308
+ console.error("Failed to start server:", err);
309
+ }
310
+ process.exit(1);
311
+ }
312
+ }
313
+ main();
@@ -0,0 +1,50 @@
1
+ /** A single event from a Claude Code transcript. */
2
+ interface TranscriptEvent {
3
+ /** Consumer-provided key for React rendering. Falls back to uuid if not provided. */
4
+ key?: string;
5
+ /** Claude Code's UUID for this event (unique per session). */
6
+ uuid: string;
7
+ event_type: 'user' | 'assistant' | 'tool_use' | 'tool_result' | string;
8
+ payload: Record<string, unknown>;
9
+ created_at: string;
10
+ }
11
+ /** Label structure for display blocks. */
12
+ interface BlockLabel {
13
+ /** Main label text like 'User', 'Thinking', 'Tool: Edit'. */
14
+ text: string;
15
+ /** Optional parameter to display (e.g., file path, command). */
16
+ params?: string;
17
+ }
18
+ /** An expanded block ready for display. */
19
+ interface DisplayBlock {
20
+ id: string;
21
+ eventType: 'user' | 'assistant' | 'tool_use' | 'tool_result';
22
+ blockType: string;
23
+ label: BlockLabel;
24
+ timestamp: string;
25
+ content: unknown;
26
+ originalEvent: TranscriptEvent;
27
+ /** For grouped local commands and tool groups. */
28
+ childBlocks?: DisplayBlock[];
29
+ /** For tool_group: the paired result block. */
30
+ toolResultBlock?: DisplayBlock;
31
+ /** For skill_group: the skill content block. */
32
+ skillContentBlock?: DisplayBlock;
33
+ }
34
+
35
+ /** Expand raw events into display blocks for rendering. */
36
+ declare function expandEvents(events: TranscriptEvent[], projectPath?: string): DisplayBlock[];
37
+ /** Message block info for navigation (user and assistant text messages). */
38
+ interface MessageBlockInfo {
39
+ id: string;
40
+ preview: string;
41
+ timestamp: string;
42
+ role: 'user' | 'assistant';
43
+ }
44
+ /** Extract message block info for navigation sidebar. */
45
+ declare function extractMessageBlocks(blocks: DisplayBlock[]): MessageBlockInfo[];
46
+
47
+ /** Filter out hidden event types from a list of transcript events. */
48
+ declare function filterHiddenEvents(events: TranscriptEvent[]): TranscriptEvent[];
49
+
50
+ export { type BlockLabel as B, type DisplayBlock as D, type MessageBlockInfo as M, type TranscriptEvent as T, extractMessageBlocks as a, expandEvents as e, filterHiddenEvents as f };
@@ -0,0 +1,26 @@
1
+ import { T as TranscriptEvent } from './filter-events-BLh048AI.js';
2
+ export { B as BlockLabel, D as DisplayBlock, M as MessageBlockInfo, e as expandEvents, a as extractMessageBlocks, f as filterHiddenEvents } from './filter-events-BLh048AI.js';
3
+
4
+ /**
5
+ * Extract text from a single content block.
6
+ * Handles both plain strings and { type: 'text', text: '...' } objects.
7
+ */
8
+ declare function extractTextFromContentBlock(block: unknown, fallback?: (block: unknown) => string): string;
9
+ /**
10
+ * Join text from a content value (string or array of content blocks).
11
+ */
12
+ declare function joinContentText(content: unknown, options?: {
13
+ separator?: string;
14
+ fallback?: (block: unknown) => string;
15
+ }): string;
16
+
17
+ /**
18
+ * Parse a Claude Code session JSONL string into TranscriptEvent[].
19
+ *
20
+ * Each JSONL line is parsed and the entire entry is placed into `payload`
21
+ * so that the existing expandEvents pipeline can read fields like
22
+ * `payload.message`, `payload.timestamp`, `payload.isMeta`, etc.
23
+ */
24
+ declare function parseSessionLog(jsonlContent: string): TranscriptEvent[];
25
+
26
+ export { TranscriptEvent, extractTextFromContentBlock, joinContentText, parseSessionLog };
@@ -0,0 +1,38 @@
1
+ import {
2
+ expandEvents,
3
+ extractMessageBlocks,
4
+ extractTextFromContentBlock,
5
+ filterHiddenEvents,
6
+ joinContentText
7
+ } from "./chunk-6AADMKTL.js";
8
+
9
+ // src/core/session-log.ts
10
+ function parseSessionLog(jsonlContent) {
11
+ const lines = jsonlContent.split("\n").filter((line) => line.trim() !== "");
12
+ const events = [];
13
+ for (const line of lines) {
14
+ let entry;
15
+ try {
16
+ entry = JSON.parse(line);
17
+ } catch {
18
+ continue;
19
+ }
20
+ if (!entry.type || !entry.uuid) continue;
21
+ events.push({
22
+ uuid: entry.uuid,
23
+ event_type: entry.type,
24
+ payload: entry,
25
+ created_at: entry.timestamp ?? ""
26
+ });
27
+ }
28
+ return events;
29
+ }
30
+ export {
31
+ expandEvents,
32
+ extractMessageBlocks,
33
+ extractTextFromContentBlock,
34
+ filterHiddenEvents,
35
+ joinContentText,
36
+ parseSessionLog
37
+ };
38
+ //# sourceMappingURL=headless.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/session-log.ts"],"sourcesContent":["import type { TranscriptEvent } from './types'\n\n/**\n * A raw entry from a Claude Code session JSONL file.\n * Contains the original fields as-is from the log.\n */\ninterface RawSessionEntry {\n type: string\n uuid?: string\n timestamp?: string\n [key: string]: unknown\n}\n\n/**\n * Parse a Claude Code session JSONL string into TranscriptEvent[].\n *\n * Each JSONL line is parsed and the entire entry is placed into `payload`\n * so that the existing expandEvents pipeline can read fields like\n * `payload.message`, `payload.timestamp`, `payload.isMeta`, etc.\n */\nexport function parseSessionLog(jsonlContent: string): TranscriptEvent[] {\n const lines = jsonlContent.split('\\n').filter(line => line.trim() !== '')\n const events: TranscriptEvent[] = []\n\n for (const line of lines) {\n let entry: RawSessionEntry\n try {\n entry = JSON.parse(line)\n } catch {\n continue\n }\n\n if (!entry.type || !entry.uuid) continue\n\n events.push({\n uuid: entry.uuid,\n event_type: entry.type,\n payload: entry as Record<string, unknown>,\n created_at: entry.timestamp ?? '',\n })\n }\n\n return events\n}\n"],"mappings":";;;;;;;;;AAoBO,SAAS,gBAAgB,cAAyC;AACvE,QAAM,QAAQ,aAAa,MAAM,IAAI,EAAE,OAAO,UAAQ,KAAK,KAAK,MAAM,EAAE;AACxE,QAAM,SAA4B,CAAC;AAEnC,aAAW,QAAQ,OAAO;AACxB,QAAI;AACJ,QAAI;AACF,cAAQ,KAAK,MAAM,IAAI;AAAA,IACzB,QAAQ;AACN;AAAA,IACF;AAEA,QAAI,CAAC,MAAM,QAAQ,CAAC,MAAM,KAAM;AAEhC,WAAO,KAAK;AAAA,MACV,MAAM,MAAM;AAAA,MACZ,YAAY,MAAM;AAAA,MAClB,SAAS;AAAA,MACT,YAAY,MAAM,aAAa;AAAA,IACjC,CAAC;AAAA,EACH;AAEA,SAAO;AACT;","names":[]}