claude-plan-viewer 1.3.0 → 1.4.1

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/openapi.json ADDED
@@ -0,0 +1,237 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "Claude Plan Viewer API",
5
+ "description": "API for browsing and searching Claude Code plans",
6
+ "version": "1.4.0",
7
+ "license": {
8
+ "name": "MIT",
9
+ "url": "https://opensource.org/licenses/MIT"
10
+ }
11
+ },
12
+ "servers": [
13
+ {
14
+ "url": "/",
15
+ "description": "Current server"
16
+ }
17
+ ],
18
+ "paths": {
19
+ "/api/plans": {
20
+ "get": {
21
+ "summary": "List all plans",
22
+ "description": "Returns metadata for all plans without content. Use /api/plans/{filename}/content to get content.",
23
+ "operationId": "listPlans",
24
+ "responses": {
25
+ "200": {
26
+ "description": "List of plans",
27
+ "content": {
28
+ "application/json": {
29
+ "schema": {
30
+ "type": "object",
31
+ "properties": {
32
+ "plans": {
33
+ "type": "array",
34
+ "items": {
35
+ "$ref": "#/components/schemas/PlanMetadata"
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ },
46
+ "/api/plans/{filename}/content": {
47
+ "get": {
48
+ "summary": "Get plan content",
49
+ "description": "Returns the markdown content for a specific plan",
50
+ "operationId": "getPlanContent",
51
+ "parameters": [
52
+ {
53
+ "name": "filename",
54
+ "in": "path",
55
+ "required": true,
56
+ "description": "The plan filename (e.g., my-plan.md)",
57
+ "schema": {
58
+ "type": "string"
59
+ }
60
+ }
61
+ ],
62
+ "responses": {
63
+ "200": {
64
+ "description": "Plan content",
65
+ "content": {
66
+ "application/json": {
67
+ "schema": {
68
+ "type": "object",
69
+ "properties": {
70
+ "content": {
71
+ "type": "string",
72
+ "description": "Markdown content of the plan"
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ },
79
+ "404": {
80
+ "description": "Plan not found"
81
+ }
82
+ }
83
+ }
84
+ },
85
+ "/api/projects": {
86
+ "get": {
87
+ "summary": "List all projects",
88
+ "description": "Returns a list of unique project names associated with plans",
89
+ "operationId": "listProjects",
90
+ "responses": {
91
+ "200": {
92
+ "description": "List of project names",
93
+ "content": {
94
+ "application/json": {
95
+ "schema": {
96
+ "type": "object",
97
+ "properties": {
98
+ "projects": {
99
+ "type": "array",
100
+ "items": {
101
+ "type": "string"
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ },
112
+ "/api/refresh": {
113
+ "post": {
114
+ "summary": "Refresh cache",
115
+ "description": "Invalidates the plan cache and reloads all plans from disk",
116
+ "operationId": "refreshCache",
117
+ "responses": {
118
+ "200": {
119
+ "description": "Cache refreshed successfully",
120
+ "content": {
121
+ "application/json": {
122
+ "schema": {
123
+ "type": "object",
124
+ "properties": {
125
+ "success": {
126
+ "type": "boolean"
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ },
136
+ "/api/open": {
137
+ "post": {
138
+ "summary": "Open plan in editor",
139
+ "description": "Opens the specified plan file in the system's default editor",
140
+ "operationId": "openInEditor",
141
+ "requestBody": {
142
+ "required": true,
143
+ "content": {
144
+ "application/json": {
145
+ "schema": {
146
+ "type": "object",
147
+ "required": ["filepath"],
148
+ "properties": {
149
+ "filepath": {
150
+ "type": "string",
151
+ "description": "Absolute path to the plan file"
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ },
158
+ "responses": {
159
+ "200": {
160
+ "description": "File opened successfully",
161
+ "content": {
162
+ "application/json": {
163
+ "schema": {
164
+ "type": "object",
165
+ "properties": {
166
+ "success": {
167
+ "type": "boolean"
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+ },
174
+ "400": {
175
+ "description": "Invalid path"
176
+ },
177
+ "500": {
178
+ "description": "Failed to open file"
179
+ }
180
+ }
181
+ }
182
+ }
183
+ },
184
+ "components": {
185
+ "schemas": {
186
+ "PlanMetadata": {
187
+ "type": "object",
188
+ "properties": {
189
+ "filename": {
190
+ "type": "string",
191
+ "description": "Plan filename (e.g., my-plan.md)"
192
+ },
193
+ "filepath": {
194
+ "type": "string",
195
+ "description": "Absolute path to the plan file"
196
+ },
197
+ "title": {
198
+ "type": "string",
199
+ "description": "Plan title extracted from markdown heading"
200
+ },
201
+ "size": {
202
+ "type": "integer",
203
+ "description": "File size in bytes"
204
+ },
205
+ "modified": {
206
+ "type": "string",
207
+ "format": "date-time",
208
+ "description": "Last modification timestamp"
209
+ },
210
+ "created": {
211
+ "type": "string",
212
+ "format": "date-time",
213
+ "description": "Creation timestamp"
214
+ },
215
+ "lineCount": {
216
+ "type": "integer",
217
+ "description": "Number of lines in the plan"
218
+ },
219
+ "wordCount": {
220
+ "type": "integer",
221
+ "description": "Number of words in the plan"
222
+ },
223
+ "project": {
224
+ "type": "string",
225
+ "nullable": true,
226
+ "description": "Associated Claude Code project name"
227
+ },
228
+ "sessionId": {
229
+ "type": "string",
230
+ "nullable": true,
231
+ "description": "Associated Claude Code session ID"
232
+ }
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plan-viewer",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "A web-based viewer for Claude Code plan files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,23 +8,28 @@
8
8
  },
9
9
  "files": [
10
10
  "index.ts",
11
- "index.html",
12
- "src",
13
- "styles.css",
14
- "prism.bundle.js"
11
+ "openapi.json",
12
+ "src"
15
13
  ],
16
14
  "scripts": {
17
15
  "start": "bun index.ts",
18
16
  "dev": "bun --hot index.ts",
19
- "test": "bun test index.test.ts frontend.test.ts",
17
+ "test": "bun test test/",
18
+ "test:api": "bun test test/api.test.ts",
20
19
  "test:e2e": "bunx playwright test",
21
- "build": "bun build --compile --minify --bytecode ./index.ts --outfile ./dist/plans-viewer",
22
- "build:macos-arm64": "bun build --compile --target=bun-darwin-arm64 --minify --bytecode ./index.ts --outfile ./dist/plans-viewer-macos-arm64",
23
- "build:macos-x64": "bun build --compile --target=bun-darwin-x64 --minify --bytecode ./index.ts --outfile ./dist/plans-viewer-macos-x64",
24
- "build:linux-x64": "bun build --compile --target=bun-linux-x64 --minify --bytecode ./index.ts --outfile ./dist/plans-viewer-linux-x64",
25
- "build:linux-arm64": "bun build --compile --target=bun-linux-arm64 --minify --bytecode ./index.ts --outfile ./dist/plans-viewer-linux-arm64",
26
- "build:windows": "bun build --compile --target=bun-windows-x64 --minify --bytecode ./index.ts --outfile ./dist/plans-viewer-windows.exe",
27
- "build:all": "bun run build:macos-arm64 && bun run build:macos-x64 && bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:windows"
20
+ "build": "bun build --compile --minify --bytecode ./index.ts --outfile ./dist/claude-plan-viewer",
21
+ "build:macos-arm64": "bun build --compile --target=bun-darwin-arm64 --minify --bytecode ./index.ts --outfile ./dist/claude-plan-viewer-macos-arm64",
22
+ "build:macos-x64": "bun build --compile --target=bun-darwin-x64 --minify --bytecode ./index.ts --outfile ./dist/claude-plan-viewer-macos-x64",
23
+ "build:linux-x64": "bun build --compile --target=bun-linux-x64 --minify --bytecode ./index.ts --outfile ./dist/claude-plan-viewer-linux-x64",
24
+ "build:linux-arm64": "bun build --compile --target=bun-linux-arm64 --minify --bytecode ./index.ts --outfile ./dist/claude-plan-viewer-linux-arm64",
25
+ "build:windows": "bun build --compile --target=bun-windows-x64 --minify --bytecode ./index.ts --outfile ./dist/claude-plan-viewer-windows.exe",
26
+ "build:all": "bun run build:macos-arm64 && bun run build:macos-x64 && bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:windows",
27
+ "clean": "rm -rf ./dist",
28
+ "install:link": "bun link",
29
+ "install:local": "bun scripts/install-local.ts",
30
+ "uninstall:link": "bun unlink",
31
+ "uninstall:local": "bun scripts/uninstall-local.ts",
32
+ "format": "bunx prettier --write src"
28
33
  },
29
34
  "keywords": [
30
35
  "claude",
@@ -49,8 +54,10 @@
49
54
  "devDependencies": {
50
55
  "@playwright/test": "^1.57.0",
51
56
  "@types/bun": "latest",
57
+ "@types/prismjs": "^1.26.5",
52
58
  "@types/react": "^19.2.7",
53
- "@types/react-dom": "^19.2.3"
59
+ "@types/react-dom": "^19.2.3",
60
+ "@types/react-syntax-highlighter": "^15.5.13"
54
61
  },
55
62
  "peerDependencies": {
56
63
  "typescript": "^5.9.3"
@@ -58,6 +65,10 @@
58
65
  "dependencies": {
59
66
  "react": "^19.2.3",
60
67
  "react-dom": "^19.2.3",
61
- "react-select": "^5.10.2"
68
+ "react-markdown": "^10.1.0",
69
+ "react-select": "^5.10.2",
70
+ "react-syntax-highlighter": "^16.1.0",
71
+ "remark-gfm": "^4.0.1",
72
+ "swr": "^2.3.8"
62
73
  }
63
74
  }
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Plans Viewer API</title>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
11
+ <script>
12
+ Scalar.createApiReference("#app", {
13
+ url: "/api/openapi.json",
14
+ });
15
+ </script>
16
+ </body>
17
+ </html>
@@ -16,6 +16,7 @@ export function App() {
16
16
  const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
17
17
  const [showOverlay, setShowOverlay] = useState(false);
18
18
  const [showHelp, setShowHelp] = useState(false);
19
+ const [copied, setCopied] = useState(false);
19
20
 
20
21
  // Filter state
21
22
  const {
@@ -35,11 +36,11 @@ export function App() {
35
36
  // Convert Set to array for API
36
37
  const projectsArray = useMemo(
37
38
  () => Array.from(selectedProjects),
38
- [selectedProjects]
39
+ [selectedProjects],
39
40
  );
40
41
 
41
42
  // Fetch plans with server-side filtering
42
- const { plans, loading, refresh, ensureContent } = usePlans({
43
+ const { plans, loading, refreshing, refresh, ensureContent } = usePlans({
43
44
  q: debouncedSearch,
44
45
  sort: sortKey,
45
46
  dir: sortDir,
@@ -52,6 +53,15 @@ export function App() {
52
53
  // Load content when plan is selected
53
54
  const handleSelectPlan = useCallback(
54
55
  async (plan: Plan | null) => {
56
+ // Update URL query parameter
57
+ const url = new URL(window.location.href);
58
+ if (plan) {
59
+ url.searchParams.set("plan", plan.filename);
60
+ } else {
61
+ url.searchParams.delete("plan");
62
+ }
63
+ window.history.replaceState({}, "", url.toString());
64
+
55
65
  if (plan) {
56
66
  const withContent = await ensureContent(plan);
57
67
  setSelectedPlan(withContent);
@@ -59,7 +69,7 @@ export function App() {
59
69
  setSelectedPlan(null);
60
70
  }
61
71
  },
62
- [ensureContent]
72
+ [ensureContent],
63
73
  );
64
74
 
65
75
  // Open in editor
@@ -74,6 +84,20 @@ export function App() {
74
84
  navigator.clipboard.writeText(`claude --resume ${sessionId}`);
75
85
  }, []);
76
86
 
87
+ // Copy filepath
88
+ const handleCopyFilepath = useCallback((filepath: string) => {
89
+ navigator.clipboard.writeText(filepath);
90
+ }, []);
91
+
92
+ // Copy plan content
93
+ const handleCopyPlan = useCallback(() => {
94
+ if (selectedPlan?.content) {
95
+ navigator.clipboard.writeText(selectedPlan.content);
96
+ setCopied(true);
97
+ setTimeout(() => setCopied(false), 2000);
98
+ }
99
+ }, [selectedPlan]);
100
+
77
101
  // Clear search
78
102
  const handleClearSearch = useCallback(() => {
79
103
  setSearchQuery("");
@@ -92,19 +116,34 @@ export function App() {
92
116
  onClearSearch: handleClearSearch,
93
117
  });
94
118
 
95
- // Auto-select first plan when plans change
119
+ // Select plan from URL or auto-select first plan
96
120
  useEffect(() => {
97
- if (!selectedPlan && plans.length > 0) {
98
- const firstPlan = plans[0];
99
- if (firstPlan) {
100
- handleSelectPlan(firstPlan);
121
+ if (plans.length === 0) return;
122
+
123
+ // Check URL for plan parameter
124
+ const url = new URL(window.location.href);
125
+ const planFromUrl = url.searchParams.get("plan");
126
+
127
+ if (planFromUrl) {
128
+ const matchingPlan = plans.find((p) => p.filename === planFromUrl);
129
+ if (matchingPlan && selectedPlan?.filename !== planFromUrl) {
130
+ handleSelectPlan(matchingPlan);
131
+ return;
101
132
  }
102
133
  }
134
+
135
+ // Fall back to selecting first plan if nothing selected
136
+ if (!selectedPlan) {
137
+ handleSelectPlan(plans[0]);
138
+ }
103
139
  }, [plans, selectedPlan, handleSelectPlan]);
104
140
 
105
141
  // Clear selection if selected plan is no longer in results
106
142
  useEffect(() => {
107
- if (selectedPlan && !plans.find((p) => p.filename === selectedPlan.filename)) {
143
+ if (
144
+ selectedPlan &&
145
+ !plans.find((p) => p.filename === selectedPlan.filename)
146
+ ) {
108
147
  setSelectedPlan(null);
109
148
  }
110
149
  }, [plans, selectedPlan]);
@@ -130,6 +169,7 @@ export function App() {
130
169
  onToggleProject={toggleProject}
131
170
  onClearProjects={clearProjects}
132
171
  onRefresh={refresh}
172
+ refreshing={refreshing}
133
173
  />
134
174
 
135
175
  {plans.length === 0 ? (
@@ -156,6 +196,9 @@ export function App() {
156
196
  onOpenEditor={handleOpenEditor}
157
197
  onToggleOverlay={() => setShowOverlay(true)}
158
198
  onCopySession={handleCopySession}
199
+ onCopyFilepath={handleCopyFilepath}
200
+ onCopyPlan={handleCopyPlan}
201
+ copied={copied}
159
202
  />
160
203
 
161
204
  {showOverlay && selectedPlan && (
@@ -164,6 +207,8 @@ export function App() {
164
207
  onClose={() => setShowOverlay(false)}
165
208
  onOpenEditor={handleOpenEditor}
166
209
  onCopySession={handleCopySession}
210
+ onCopyPlan={handleCopyPlan}
211
+ copied={copied}
167
212
  />
168
213
  )}
169
214
 
@@ -1,4 +1,5 @@
1
- import { useEffect, useCallback } from "react";
1
+ import { useEffect, useCallback, useRef } from "react";
2
+ import { useFocusTrap } from "../hooks/useFocusTrap";
2
3
  import type { Plan } from "../types.ts";
3
4
  import { formatFullDate, formatSize } from "../utils/formatters.ts";
4
5
  import { Markdown } from "./Markdown.tsx";
@@ -8,6 +9,8 @@ interface DetailOverlayProps {
8
9
  onClose: () => void;
9
10
  onOpenEditor: () => void;
10
11
  onCopySession: (sessionId: string) => void;
12
+ onCopyPlan: () => void;
13
+ copied: boolean;
11
14
  }
12
15
 
13
16
  export function DetailOverlay({
@@ -15,7 +18,12 @@ export function DetailOverlay({
15
18
  onClose,
16
19
  onOpenEditor,
17
20
  onCopySession,
21
+ onCopyPlan,
22
+ copied,
18
23
  }: DetailOverlayProps) {
24
+ const panelRef = useRef<HTMLDivElement>(null);
25
+ useFocusTrap(panelRef, true);
26
+
19
27
  const handleKeyDown = useCallback(
20
28
  (e: KeyboardEvent) => {
21
29
  if (e.key === "Escape" || e.key === "f") {
@@ -23,7 +31,7 @@ export function DetailOverlay({
23
31
  onClose();
24
32
  }
25
33
  },
26
- [onClose]
34
+ [onClose],
27
35
  );
28
36
 
29
37
  useEffect(() => {
@@ -43,11 +51,20 @@ export function DetailOverlay({
43
51
  aria-hidden="false"
44
52
  onClick={onClose}
45
53
  >
46
- <div className="detail-overlay-panel" onClick={(e) => e.stopPropagation()}>
54
+ <div
55
+ ref={panelRef}
56
+ className="detail-overlay-panel"
57
+ role="dialog"
58
+ aria-modal="true"
59
+ aria-labelledby="detail-overlay-title"
60
+ onClick={(e) => e.stopPropagation()}
61
+ >
47
62
  <div className="detail-overlay-bar">
48
63
  <div className="detail-meta detail-overlay-meta">
49
- {plan.project && <span className="project-tag">{plan.project}</span>}
50
- <span>{plan.filename}</span>
64
+ {plan.project && (
65
+ <span className="project-tag">{plan.project}</span>
66
+ )}
67
+ <span id="detail-overlay-title">{plan.filename}</span>
51
68
  <span>{formatFullDate(plan.modified)}</span>
52
69
  <span>{formatSize(plan.size)}</span>
53
70
  <span>{plan.lineCount} lines</span>
@@ -61,14 +78,42 @@ export function DetailOverlay({
61
78
  </button>
62
79
  )}
63
80
  </div>
81
+ <button
82
+ className={copied ? "action-btn copied" : "action-btn"}
83
+ onClick={onCopyPlan}
84
+ title="Copy plan to clipboard"
85
+ >
86
+ <svg
87
+ xmlns="http://www.w3.org/2000/svg"
88
+ viewBox="0 0 24 24"
89
+ fill="none"
90
+ stroke="currentColor"
91
+ strokeWidth="2"
92
+ strokeLinecap="round"
93
+ strokeLinejoin="round"
94
+ className="icon"
95
+ >
96
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
97
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
98
+ </svg>
99
+ </button>
64
100
  <button
65
101
  className="action-btn"
66
102
  onClick={onOpenEditor}
67
103
  title="Open in editor (Enter)"
68
104
  >
69
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="icon">
70
- <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
71
- <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
105
+ <svg
106
+ xmlns="http://www.w3.org/2000/svg"
107
+ viewBox="0 0 24 24"
108
+ fill="none"
109
+ stroke="currentColor"
110
+ strokeWidth="2"
111
+ strokeLinecap="round"
112
+ strokeLinejoin="round"
113
+ className="icon"
114
+ >
115
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
116
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
72
117
  </svg>
73
118
  </button>
74
119
  <button
@@ -76,7 +121,19 @@ export function DetailOverlay({
76
121
  onClick={onClose}
77
122
  title="Close fullscreen (Esc or F)"
78
123
  >
79
- ×
124
+ <svg
125
+ width="14"
126
+ height="14"
127
+ viewBox="0 0 24 24"
128
+ fill="none"
129
+ stroke="currentColor"
130
+ strokeWidth="2.5"
131
+ strokeLinecap="round"
132
+ strokeLinejoin="round"
133
+ >
134
+ <line x1="18" y1="6" x2="6" y2="18" />
135
+ <line x1="6" y1="6" x2="18" y2="18" />
136
+ </svg>
80
137
  </button>
81
138
  </div>
82
139
  <div className="detail-overlay-content">