claude-plan-viewer 1.1.1 → 1.3.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/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  <script src="./prism.bundle.js"></script>
9
9
  </head>
10
10
  <body>
11
- <div id="app"></div>
12
- <script type="module" src="./frontend.ts"></script>
11
+ <div id="root"></div>
12
+ <script type="module" src="./src/client/index.tsx"></script>
13
13
  </body>
14
14
  </html>
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { readdir, stat } from "node:fs/promises";
2
+ import { readdir, stat, watch } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import index from "./index.html";
@@ -8,16 +8,54 @@ import prismBundlePath from "./prism.bundle.js" with { type: "file" };
8
8
  const PLANS_DIR = join(homedir(), ".claude", "plans");
9
9
  const PROJECTS_DIR = join(homedir(), ".claude", "projects");
10
10
 
11
- // Parse --port from command line arguments (undefined = auto-assign)
12
- function getRequestedPort(): number | undefined {
13
- const args = process.argv;
14
- const portIndex = args.indexOf("--port");
15
- const portArg = args[portIndex + 1];
16
- if (portIndex !== -1 && portArg) {
17
- const port = parseInt(portArg, 10);
18
- if (!isNaN(port)) return port;
11
+ interface CliArgs {
12
+ port?: number;
13
+ json?: boolean;
14
+ output?: string;
15
+ }
16
+
17
+ function parseCliArgs(): CliArgs {
18
+ const args: CliArgs = {};
19
+ const argv = process.argv.slice(2);
20
+
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const arg = argv[i];
23
+ const nextArg = argv[i + 1];
24
+
25
+ if (arg === "--port" || arg === "-p") {
26
+ if (nextArg && !nextArg.startsWith("-")) {
27
+ args.port = parseInt(nextArg, 10);
28
+ i++;
29
+ }
30
+ } else if (arg === "--json" || arg === "-j") {
31
+ args.json = true;
32
+ } else if (arg === "--output" || arg === "-o") {
33
+ if (nextArg && !nextArg.startsWith("-")) {
34
+ args.output = nextArg;
35
+ i++;
36
+ }
37
+ }
38
+ }
39
+
40
+ return args;
41
+ }
42
+
43
+ async function exportPlansAsJson(outputPath?: string): Promise<void> {
44
+ const plans = await loadPlans();
45
+
46
+ const plansWithContent = plans.map((plan) => ({
47
+ ...plan,
48
+ content: contentCache.get(plan.filename) || "",
49
+ }));
50
+
51
+ const jsonOutput = JSON.stringify(plansWithContent, null, 2);
52
+
53
+ if (outputPath) {
54
+ await Bun.write(outputPath, jsonOutput);
55
+ console.log(`Exported ${plans.length} plans to ${outputPath}`);
56
+ } else {
57
+ console.log(jsonOutput);
19
58
  }
20
- return undefined;
21
59
  }
22
60
 
23
61
  // Find an available port starting from the requested port
@@ -52,17 +90,21 @@ async function openInEditor(filepath: string): Promise<void> {
52
90
  }
53
91
  }
54
92
 
55
- interface Plan {
93
+ interface PlanMetadata {
56
94
  filename: string;
57
95
  filepath: string;
58
96
  title: string;
59
- content: string;
60
97
  size: number;
61
98
  modified: string;
62
99
  created: string;
63
100
  lineCount: number;
64
101
  wordCount: number;
65
102
  project: string | null;
103
+ sessionId: string | null;
104
+ }
105
+
106
+ interface Plan extends PlanMetadata {
107
+ content: string;
66
108
  }
67
109
 
68
110
  // Extract project name from a full path (cross-platform)
@@ -83,27 +125,56 @@ function extractProjectName(cwd: string): string {
83
125
  function extractCwdFromJsonl(content: string): string | null {
84
126
  if (!content) return null;
85
127
  const match = content.match(/"cwd":"([^"]+)"/);
86
- if (!match) return null;
128
+ if (!match || !match[1]) return null;
87
129
  // Unescape JSON string (convert \\\\ to \\)
88
130
  return match[1].replace(/\\\\/g, "\\");
89
131
  }
90
132
 
91
- // Extract unique slugs from JSONL content
133
+ // Extract slug -> sessionId mapping from JSONL content
134
+ // Each line in JSONL may contain both "slug" and "sessionId" fields
135
+ function extractSlugSessionMap(content: string): Map<string, string> {
136
+ if (!content) return new Map();
137
+ const slugSessionMap = new Map<string, string>();
138
+
139
+ // Process each line to find slug and sessionId pairs
140
+ const lines = content.split("\n");
141
+ for (const line of lines) {
142
+ if (!line.trim()) continue;
143
+
144
+ const slugMatch = line.match(/"slug":"([\w-]+)"/);
145
+ const sessionMatch = line.match(/"sessionId":"([^"]+)"/);
146
+
147
+ if (slugMatch && slugMatch[1] && sessionMatch && sessionMatch[1]) {
148
+ slugSessionMap.set(slugMatch[1], sessionMatch[1]);
149
+ }
150
+ }
151
+
152
+ return slugSessionMap;
153
+ }
154
+
155
+ // Extract unique slugs from JSONL content (for backwards compatibility)
92
156
  function extractSlugsFromJsonl(content: string): string[] {
93
157
  if (!content) return [];
94
158
  const slugs = new Set<string>();
95
159
  const matches = content.matchAll(/"slug":"([\w-]+)"/g);
96
160
  for (const match of matches) {
97
- slugs.add(match[1]);
161
+ if (match[1]) {
162
+ slugs.add(match[1]);
163
+ }
98
164
  }
99
165
  return Array.from(slugs);
100
166
  }
101
167
 
168
+ interface SlugMetadata {
169
+ project: string;
170
+ sessionId: string | null;
171
+ }
172
+
102
173
  interface ProjectMapping {
103
- [slug: string]: string; // plan slug -> project name
174
+ [slug: string]: SlugMetadata;
104
175
  }
105
176
 
106
- // Build a mapping of plan slugs to project names by scanning Claude Code's project metadata
177
+ // Build a mapping of plan slugs to project names and session IDs by scanning Claude Code's project metadata
107
178
  async function buildProjectMapping(): Promise<ProjectMapping> {
108
179
  const mapping: ProjectMapping = {};
109
180
 
@@ -115,12 +186,12 @@ async function buildProjectMapping(): Promise<ProjectMapping> {
115
186
  const dirStats = await stat(dirPath);
116
187
  if (!dirStats.isDirectory()) continue;
117
188
 
118
- // Find JSONL files and extract cwd + slugs
189
+ // Find JSONL files and extract cwd + slugs + sessionIds
119
190
  const files = await readdir(dirPath);
120
191
  const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
121
192
 
122
193
  let projectName: string | null = null;
123
- const allSlugs: string[] = [];
194
+ const slugSessionMap = new Map<string, string>();
124
195
 
125
196
  for (const file of jsonlFiles) {
126
197
  try {
@@ -134,18 +205,23 @@ async function buildProjectMapping(): Promise<ProjectMapping> {
134
205
  }
135
206
  }
136
207
 
137
- // Collect all slugs
138
- const slugs = extractSlugsFromJsonl(content);
139
- allSlugs.push(...slugs);
208
+ // Collect slug -> sessionId mappings
209
+ const fileSlugSessions = extractSlugSessionMap(content);
210
+ for (const [slug, sessionId] of fileSlugSessions) {
211
+ slugSessionMap.set(slug, sessionId);
212
+ }
140
213
  } catch {
141
214
  // Skip files that can't be read
142
215
  }
143
216
  }
144
217
 
145
- // Map all slugs to this project
218
+ // Map all slugs to this project with their session IDs
146
219
  if (projectName) {
147
- for (const slug of allSlugs) {
148
- mapping[slug] = projectName;
220
+ for (const [slug, sessionId] of slugSessionMap) {
221
+ mapping[slug] = {
222
+ project: projectName,
223
+ sessionId: sessionId,
224
+ };
149
225
  }
150
226
  }
151
227
  }
@@ -156,9 +232,19 @@ async function buildProjectMapping(): Promise<ProjectMapping> {
156
232
  return mapping;
157
233
  }
158
234
 
159
- async function loadPlans(): Promise<Plan[]> {
160
- // Build project mapping from Claude Code metadata
161
- const projectMapping = await buildProjectMapping();
235
+ let cachedPlans: PlanMetadata[] | null = null;
236
+ let cachedProjectMapping: ProjectMapping | null = null;
237
+ const contentCache = new Map<string, string>();
238
+
239
+ async function loadPlans(): Promise<PlanMetadata[]> {
240
+ // Build or use cached project mapping
241
+ let projectMapping: ProjectMapping;
242
+ if (!cachedProjectMapping) {
243
+ projectMapping = await buildProjectMapping();
244
+ cachedProjectMapping = projectMapping;
245
+ } else {
246
+ projectMapping = cachedProjectMapping;
247
+ }
162
248
 
163
249
  const files = await readdir(PLANS_DIR);
164
250
  const mdFiles = files.filter((f) => f.endsWith(".md"));
@@ -167,6 +253,7 @@ async function loadPlans(): Promise<Plan[]> {
167
253
  mdFiles.map(async (filename) => {
168
254
  const filepath = join(PLANS_DIR, filename);
169
255
  const file = Bun.file(filepath);
256
+
170
257
  const [content, stats] = await Promise.all([
171
258
  file.text(),
172
259
  stat(filepath),
@@ -179,42 +266,120 @@ async function loadPlans(): Promise<Plan[]> {
179
266
 
180
267
  // Look up project from metadata using plan slug (filename without .md)
181
268
  const slug = filename.replace(".md", "");
269
+ const lineCount = content.split("\n").length;
270
+ const wordCount = content.split(/\s+/).filter(Boolean).length;
271
+
272
+ const metadata = projectMapping[slug];
273
+ // Cache content separately for search and lazy loading
274
+ contentCache.set(filename, content);
182
275
 
183
276
  return {
184
277
  filename,
185
278
  filepath,
186
279
  title,
187
- content,
188
280
  size: stats.size,
189
281
  modified: stats.mtime.toISOString(),
190
282
  created: stats.birthtime.toISOString(),
191
- lineCount: content.split("\n").length,
192
- wordCount: content.split(/\s+/).filter(Boolean).length,
193
- project: projectMapping[slug] || null,
283
+ lineCount,
284
+ wordCount,
285
+ project: metadata?.project || null,
286
+ sessionId: metadata?.sessionId || null,
194
287
  };
195
288
  })
196
289
  );
197
290
 
198
- return plans.sort(
199
- (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
200
- );
291
+ cachedPlans = plans;
292
+ return plans;
293
+ }
294
+
295
+ function invalidateCache() {
296
+ cachedPlans = null;
297
+ cachedProjectMapping = null;
298
+ contentCache.clear();
299
+ }
300
+
301
+ // Watch plans directory for changes and invalidate cache
302
+ async function watchPlansDirectory() {
303
+ try {
304
+ const watcher = watch(PLANS_DIR);
305
+ for await (const event of watcher) {
306
+ if (event.filename?.endsWith(".md")) {
307
+ invalidateCache();
308
+ }
309
+ }
310
+ } catch {
311
+ // Directory may not exist or watching may not be supported
312
+ }
201
313
  }
202
314
 
203
315
  // Main server startup
204
316
  async function startServer() {
205
- const requestedPort = getRequestedPort();
206
- const port = await findAvailablePort(requestedPort ?? 3000);
317
+ const args = parseCliArgs();
318
+ const port = await findAvailablePort(args.port ?? 3000);
207
319
 
208
320
  const server = Bun.serve({
209
321
  port,
210
- static: {
211
- "/prism.bundle.js": Bun.file(prismBundlePath),
212
- },
213
322
  routes: {
214
323
  "/": index,
215
- "/api/plans": async () => {
216
- const plans = await loadPlans();
217
- return Response.json(plans);
324
+ "/api/projects": async () => {
325
+ // Lazy load cache on first request
326
+ if (!cachedPlans) {
327
+ await loadPlans();
328
+ }
329
+
330
+ const plans = cachedPlans || [];
331
+ const projects = [...new Set(plans.map(p => p.project).filter(Boolean))] as string[];
332
+ projects.sort((a, b) => a.localeCompare(b));
333
+
334
+ return Response.json({ projects });
335
+ },
336
+ "/api/plans": async (req) => {
337
+ // Lazy load cache on first request
338
+ if (!cachedPlans) {
339
+ await loadPlans();
340
+ }
341
+
342
+ const plans = cachedPlans || [];
343
+
344
+ // Strip content from response - will be fetched separately via /api/plans/{id}/content
345
+ const plansWithoutContent = plans.map(p => ({
346
+ filename: p.filename,
347
+ filepath: p.filepath,
348
+ title: p.title,
349
+ size: p.size,
350
+ modified: p.modified,
351
+ created: p.created,
352
+ lineCount: p.lineCount,
353
+ wordCount: p.wordCount,
354
+ project: p.project,
355
+ sessionId: p.sessionId,
356
+ }));
357
+
358
+ return Response.json({
359
+ plans: plansWithoutContent,
360
+ });
361
+ },
362
+ "/api/plans/:filename/content": async (req) => {
363
+ // Lazy load cache on first request
364
+ if (!cachedPlans) {
365
+ await loadPlans();
366
+ }
367
+
368
+ const filename = req.params.filename as string;
369
+ const content = contentCache.get(filename);
370
+
371
+ if (content === undefined) {
372
+ return new Response("Plan not found", { status: 404 });
373
+ }
374
+
375
+ return Response.json({ content });
376
+ },
377
+ "/api/refresh": {
378
+ POST: async () => {
379
+ invalidateCache();
380
+ await loadPlans();
381
+ return Response.json({ success: true });
382
+ },
218
383
  },
219
384
  "/api/open": {
220
385
  POST: async (req) => {
@@ -253,12 +418,24 @@ const c = {
253
418
 
254
419
  // Main entry point
255
420
  (async () => {
421
+ const args = parseCliArgs();
422
+
423
+ if (args.json) {
424
+ await exportPlansAsJson(args.output);
425
+ process.exit(0);
426
+ }
427
+
256
428
  const server = await startServer();
257
429
  const planCount = (await readdir(PLANS_DIR)).filter(f => f.endsWith('.md')).length;
430
+
431
+ // Start watching for file changes (runs in background)
432
+ watchPlansDirectory();
433
+
258
434
  console.log();
259
435
  console.log(`${c.bold}${c.magenta} 📋 Plans Viewer${c.reset}`);
260
436
  console.log(`${c.dim} ─────────────────────────────${c.reset}`);
261
437
  console.log(`${c.green} ✓${c.reset} Server running`);
438
+ console.log(`${c.green} ✓${c.reset} Watching for file changes`);
262
439
  console.log();
263
440
  console.log(`${c.dim} Local:${c.reset} ${c.cyan}${c.bold}http://localhost:${server.port}${c.reset}`);
264
441
  console.log(`${c.dim} Plans:${c.reset} ${c.yellow}${planCount} plans${c.reset} in ${c.dim}${PLANS_DIR}${c.reset}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plan-viewer",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "A web-based viewer for Claude Code plan files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,13 +9,15 @@
9
9
  "files": [
10
10
  "index.ts",
11
11
  "index.html",
12
- "frontend.ts",
12
+ "src",
13
13
  "styles.css",
14
14
  "prism.bundle.js"
15
15
  ],
16
16
  "scripts": {
17
17
  "start": "bun index.ts",
18
18
  "dev": "bun --hot index.ts",
19
+ "test": "bun test index.test.ts frontend.test.ts",
20
+ "test:e2e": "bunx playwright test",
19
21
  "build": "bun build --compile --minify --bytecode ./index.ts --outfile ./dist/plans-viewer",
20
22
  "build:macos-arm64": "bun build --compile --target=bun-darwin-arm64 --minify --bytecode ./index.ts --outfile ./dist/plans-viewer-macos-arm64",
21
23
  "build:macos-x64": "bun build --compile --target=bun-darwin-x64 --minify --bytecode ./index.ts --outfile ./dist/plans-viewer-macos-x64",
@@ -45,9 +47,17 @@
45
47
  "bun": ">=1.0.0"
46
48
  },
47
49
  "devDependencies": {
48
- "@types/bun": "latest"
50
+ "@playwright/test": "^1.57.0",
51
+ "@types/bun": "latest",
52
+ "@types/react": "^19.2.7",
53
+ "@types/react-dom": "^19.2.3"
49
54
  },
50
55
  "peerDependencies": {
51
56
  "typescript": "^5.9.3"
57
+ },
58
+ "dependencies": {
59
+ "react": "^19.2.3",
60
+ "react-dom": "^19.2.3",
61
+ "react-select": "^5.10.2"
52
62
  }
53
63
  }
@@ -0,0 +1,173 @@
1
+ import { useState, useCallback, useEffect, useMemo } from "react";
2
+ import type { Plan } from "./types.ts";
3
+ import { usePlans } from "./hooks/usePlans.ts";
4
+ import { useProjects } from "./hooks/useProjects.ts";
5
+ import { useFilters } from "./hooks/useFilters.ts";
6
+ import { useDebounce } from "./hooks/useDebounce.ts";
7
+ import { useKeyboard } from "./hooks/useKeyboard.ts";
8
+ import { openInEditor } from "./utils/api.ts";
9
+ import { Header } from "./components/Header.tsx";
10
+ import { PlansTable } from "./components/PlansTable.tsx";
11
+ import { DetailPanel } from "./components/DetailPanel.tsx";
12
+ import { DetailOverlay } from "./components/DetailOverlay.tsx";
13
+ import { HelpModal } from "./components/HelpModal.tsx";
14
+
15
+ export function App() {
16
+ const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
17
+ const [showOverlay, setShowOverlay] = useState(false);
18
+ const [showHelp, setShowHelp] = useState(false);
19
+
20
+ // Filter state
21
+ const {
22
+ searchQuery,
23
+ setSearchQuery,
24
+ sortKey,
25
+ sortDir,
26
+ setSort,
27
+ selectedProjects,
28
+ toggleProject,
29
+ clearProjects,
30
+ } = useFilters();
31
+
32
+ // Debounce search to avoid hitting API on every keystroke
33
+ const debouncedSearch = useDebounce(searchQuery, 300);
34
+
35
+ // Convert Set to array for API
36
+ const projectsArray = useMemo(
37
+ () => Array.from(selectedProjects),
38
+ [selectedProjects]
39
+ );
40
+
41
+ // Fetch plans with server-side filtering
42
+ const { plans, loading, refresh, ensureContent } = usePlans({
43
+ q: debouncedSearch,
44
+ sort: sortKey,
45
+ dir: sortDir,
46
+ projects: projectsArray,
47
+ });
48
+
49
+ // Fetch projects list
50
+ const { projects: allProjects } = useProjects();
51
+
52
+ // Load content when plan is selected
53
+ const handleSelectPlan = useCallback(
54
+ async (plan: Plan | null) => {
55
+ if (plan) {
56
+ const withContent = await ensureContent(plan);
57
+ setSelectedPlan(withContent);
58
+ } else {
59
+ setSelectedPlan(null);
60
+ }
61
+ },
62
+ [ensureContent]
63
+ );
64
+
65
+ // Open in editor
66
+ const handleOpenEditor = useCallback(async () => {
67
+ if (selectedPlan) {
68
+ await openInEditor(selectedPlan.filepath);
69
+ }
70
+ }, [selectedPlan]);
71
+
72
+ // Copy session ID
73
+ const handleCopySession = useCallback((sessionId: string) => {
74
+ navigator.clipboard.writeText(`claude --resume ${sessionId}`);
75
+ }, []);
76
+
77
+ // Clear search
78
+ const handleClearSearch = useCallback(() => {
79
+ setSearchQuery("");
80
+ const searchEl = document.getElementById("search") as HTMLInputElement;
81
+ searchEl?.blur();
82
+ }, [setSearchQuery]);
83
+
84
+ // Keyboard shortcuts
85
+ useKeyboard({
86
+ plans,
87
+ selectedPlan,
88
+ onSelectPlan: handleSelectPlan,
89
+ onOpenEditor: handleOpenEditor,
90
+ onToggleHelp: () => setShowHelp((prev) => !prev),
91
+ onToggleOverlay: () => setShowOverlay((prev) => !prev),
92
+ onClearSearch: handleClearSearch,
93
+ });
94
+
95
+ // Auto-select first plan when plans change
96
+ useEffect(() => {
97
+ if (!selectedPlan && plans.length > 0) {
98
+ const firstPlan = plans[0];
99
+ if (firstPlan) {
100
+ handleSelectPlan(firstPlan);
101
+ }
102
+ }
103
+ }, [plans, selectedPlan, handleSelectPlan]);
104
+
105
+ // Clear selection if selected plan is no longer in results
106
+ useEffect(() => {
107
+ if (selectedPlan && !plans.find((p) => p.filename === selectedPlan.filename)) {
108
+ setSelectedPlan(null);
109
+ }
110
+ }, [plans, selectedPlan]);
111
+
112
+ if (loading && plans.length === 0) {
113
+ return (
114
+ <div className="container">
115
+ <div className="loading-container">
116
+ <div className="loading">Loading plans...</div>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <div className="container">
124
+ <div id="list-panel" className="list-panel">
125
+ <Header
126
+ searchQuery={searchQuery}
127
+ onSearchChange={setSearchQuery}
128
+ projects={allProjects}
129
+ selectedProjects={selectedProjects}
130
+ onToggleProject={toggleProject}
131
+ onClearProjects={clearProjects}
132
+ onRefresh={refresh}
133
+ />
134
+
135
+ {plans.length === 0 ? (
136
+ <div className="empty-state">
137
+ {searchQuery || selectedProjects.size > 0
138
+ ? "No plans match your filters"
139
+ : "No plans found"}
140
+ </div>
141
+ ) : (
142
+ <PlansTable
143
+ plans={plans}
144
+ selectedPlan={selectedPlan}
145
+ searchQuery={searchQuery}
146
+ sortKey={sortKey}
147
+ sortDir={sortDir}
148
+ onSelectPlan={handleSelectPlan}
149
+ onSort={setSort}
150
+ />
151
+ )}
152
+ </div>
153
+
154
+ <DetailPanel
155
+ plan={selectedPlan}
156
+ onOpenEditor={handleOpenEditor}
157
+ onToggleOverlay={() => setShowOverlay(true)}
158
+ onCopySession={handleCopySession}
159
+ />
160
+
161
+ {showOverlay && selectedPlan && (
162
+ <DetailOverlay
163
+ plan={selectedPlan}
164
+ onClose={() => setShowOverlay(false)}
165
+ onOpenEditor={handleOpenEditor}
166
+ onCopySession={handleCopySession}
167
+ />
168
+ )}
169
+
170
+ {showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
171
+ </div>
172
+ );
173
+ }