claude-plan-viewer 1.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/frontend.ts ADDED
@@ -0,0 +1,843 @@
1
+ // HMR support
2
+ if (import.meta.hot) {
3
+ import.meta.hot.accept();
4
+ }
5
+
6
+ // Prism.js is loaded from CDN
7
+ declare const Prism: {
8
+ highlight: (code: string, grammar: unknown, language: string) => string;
9
+ languages: Record<string, unknown>;
10
+ };
11
+
12
+ interface Plan {
13
+ filename: string;
14
+ filepath: string;
15
+ title: string;
16
+ content: string;
17
+ size: number;
18
+ modified: string;
19
+ created: string;
20
+ lineCount: number;
21
+ wordCount: number;
22
+ project: string | null;
23
+ }
24
+
25
+ let plans: Plan[] = [];
26
+ let filteredPlans: Plan[] = [];
27
+ let selectedPlan: Plan | null = null;
28
+ let sortKey = "modified";
29
+ let sortDir: "asc" | "desc" = "desc";
30
+ let searchQuery = "";
31
+ let showHelpModal = false;
32
+ let selectedProjects: Set<string> = new Set();
33
+ const scrollPositions = new Map<string, number>();
34
+
35
+ const app = document.getElementById("app")!;
36
+
37
+ function selectPlan(plan: Plan | null): void {
38
+ // Save scroll position of current plan
39
+ if (selectedPlan) {
40
+ const detailContent = document.querySelector(".detail-content");
41
+ if (detailContent) {
42
+ scrollPositions.set(selectedPlan.filename, detailContent.scrollTop);
43
+ }
44
+ }
45
+
46
+ selectedPlan = plan;
47
+
48
+ // Update URL
49
+ if (plan) {
50
+ const url = new URL(window.location.href);
51
+ url.searchParams.set("plan", plan.filename);
52
+ history.pushState(null, "", url.toString());
53
+ } else {
54
+ const url = new URL(window.location.href);
55
+ url.searchParams.delete("plan");
56
+ history.pushState(null, "", url.toString());
57
+ }
58
+
59
+ render();
60
+
61
+ // Restore scroll position
62
+ if (plan) {
63
+ const savedScroll = scrollPositions.get(plan.filename);
64
+ if (savedScroll !== undefined) {
65
+ requestAnimationFrame(() => {
66
+ const detailContent = document.querySelector(".detail-content");
67
+ if (detailContent) {
68
+ detailContent.scrollTop = savedScroll;
69
+ }
70
+ });
71
+ }
72
+ }
73
+ }
74
+
75
+ async function openInEditor(): Promise<void> {
76
+ if (!selectedPlan) return;
77
+ try {
78
+ await fetch("/api/open", {
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/json" },
81
+ body: JSON.stringify({ filepath: selectedPlan.filepath }),
82
+ });
83
+ } catch (err) {
84
+ console.error("Failed to open in editor:", err);
85
+ }
86
+ }
87
+
88
+ function highlightText(text: string, query: string): string {
89
+ if (!query) return escapeHtml(text);
90
+ const escaped = escapeHtml(text);
91
+ const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
92
+ return escaped.replace(regex, '<mark>$1</mark>');
93
+ }
94
+
95
+ function escapeRegex(str: string): string {
96
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
+ }
98
+
99
+ function escapeHtml(str: string): string {
100
+ return str
101
+ .replace(/&/g, "&amp;")
102
+ .replace(/</g, "&lt;")
103
+ .replace(/>/g, "&gt;")
104
+ .replace(/"/g, "&quot;");
105
+ }
106
+
107
+ function debounce<Args extends unknown[]>(fn: (...args: Args) => void, ms: number): (...args: Args) => void {
108
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
109
+ return (...args: Args) => {
110
+ if (timeoutId) clearTimeout(timeoutId);
111
+ timeoutId = setTimeout(() => fn(...args), ms);
112
+ };
113
+ }
114
+
115
+ function updateSearchQueryParam(query: string): void {
116
+ const url = new URL(window.location.href);
117
+ if (query) {
118
+ url.searchParams.set("q", query);
119
+ } else {
120
+ url.searchParams.delete("q");
121
+ }
122
+ history.replaceState(null, "", url.toString());
123
+ }
124
+
125
+ const debouncedUpdateSearchQueryParam = debounce(updateSearchQueryParam, 500);
126
+
127
+ function formatDate(iso: string): string {
128
+ const d = new Date(iso);
129
+ const now = new Date();
130
+ const diff = now.getTime() - d.getTime();
131
+
132
+ if (diff < 60000) return "just now";
133
+ if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
134
+ if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago";
135
+ if (diff < 604800000) return Math.floor(diff / 86400000) + "d ago";
136
+
137
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
138
+ }
139
+
140
+ function formatFullDate(iso: string): string {
141
+ return new Date(iso).toLocaleDateString("en-US", {
142
+ year: "numeric",
143
+ month: "short",
144
+ day: "numeric",
145
+ hour: "2-digit",
146
+ minute: "2-digit",
147
+ });
148
+ }
149
+
150
+ function formatSize(bytes: number): string {
151
+ if (bytes < 1024) return bytes + " B";
152
+ return (bytes / 1024).toFixed(1) + " KB";
153
+ }
154
+
155
+ function sortPlans(): void {
156
+ filteredPlans.sort((a, b) => {
157
+ let cmp = 0;
158
+ switch (sortKey) {
159
+ case "title":
160
+ cmp = a.title.localeCompare(b.title);
161
+ break;
162
+ case "project":
163
+ cmp = (a.project || "zzz").localeCompare(b.project || "zzz");
164
+ break;
165
+ case "modified":
166
+ cmp = new Date(a.modified).getTime() - new Date(b.modified).getTime();
167
+ break;
168
+ case "size":
169
+ cmp = a.size - b.size;
170
+ break;
171
+ case "lines":
172
+ cmp = a.lineCount - b.lineCount;
173
+ break;
174
+ case "created":
175
+ cmp = new Date(a.created).getTime() - new Date(b.created).getTime();
176
+ break;
177
+ }
178
+ return sortDir === "asc" ? cmp : -cmp;
179
+ });
180
+ }
181
+
182
+ function renderMarkdown(content: string): string {
183
+ let html = escapeHtml(content);
184
+
185
+ // Code blocks (with language hint and Prism highlighting)
186
+ html = html.replace(
187
+ /```(\w*)\n([\s\S]*?)```/g,
188
+ (_, lang, code) => {
189
+ const language = lang || 'plaintext';
190
+ const grammar = Prism.languages[language] || Prism.languages.plaintext;
191
+ try {
192
+ const highlighted = grammar
193
+ ? Prism.highlight(code, grammar, language)
194
+ : code;
195
+ return `<pre class="language-${language}"><code class="language-${language}">${highlighted}</code></pre>`;
196
+ } catch {
197
+ return `<pre class="language-${language}"><code class="language-${language}">${code}</code></pre>`;
198
+ }
199
+ }
200
+ );
201
+
202
+ // Inline code
203
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
204
+
205
+ // Headers
206
+ html = html.replace(/^#### (.+)$/gm, "<h4>$1</h4>");
207
+ html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
208
+ html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
209
+ html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
210
+
211
+ // Bold and italic
212
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
213
+ html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
214
+
215
+ // Blockquotes
216
+ html = html.replace(/^&gt; (.+)$/gm, "<blockquote><p>$1</p></blockquote>");
217
+
218
+ // Horizontal rules
219
+ html = html.replace(/^---$/gm, "<hr>");
220
+
221
+ // Links
222
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
223
+
224
+ // Tables
225
+ html = html.replace(
226
+ /\|(.+)\|\n\|[-| ]+\|\n((?:\|.+\|\n?)+)/g,
227
+ (_, header, body) => {
228
+ const headers = header
229
+ .split("|")
230
+ .filter((c: string) => c.trim())
231
+ .map((c: string) => `<th>${c.trim()}</th>`)
232
+ .join("");
233
+ const rows = body
234
+ .trim()
235
+ .split("\n")
236
+ .map((row: string) => {
237
+ const cells = row
238
+ .split("|")
239
+ .filter((c: string) => c.trim())
240
+ .map((c: string) => `<td>${c.trim()}</td>`)
241
+ .join("");
242
+ return `<tr>${cells}</tr>`;
243
+ })
244
+ .join("");
245
+ return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
246
+ }
247
+ );
248
+
249
+ // Checkbox lists
250
+ html = html.replace(/^- \[x\] (.+)$/gm, '<li><input type="checkbox" checked disabled> $1</li>');
251
+ html = html.replace(/^- \[ \] (.+)$/gm, '<li><input type="checkbox" disabled> $1</li>');
252
+
253
+ // Regular lists
254
+ html = html.replace(/^- (.+)$/gm, "<li>$1</li>");
255
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, "<ul>$&</ul>");
256
+
257
+ // Numbered lists
258
+ html = html.replace(/^\d+\. (.+)$/gm, "<li>$1</li>");
259
+
260
+ // Paragraphs - wrap remaining text blocks
261
+ const blocks = html.split(/\n\n+/);
262
+ html = blocks
263
+ .map((block) => {
264
+ const trimmed = block.trim();
265
+ if (!trimmed) return "";
266
+ if (
267
+ trimmed.startsWith("<h") ||
268
+ trimmed.startsWith("<ul") ||
269
+ trimmed.startsWith("<ol") ||
270
+ trimmed.startsWith("<pre") ||
271
+ trimmed.startsWith("<hr") ||
272
+ trimmed.startsWith("<blockquote") ||
273
+ trimmed.startsWith("<table")
274
+ ) {
275
+ return trimmed;
276
+ }
277
+ return "<p>" + trimmed.replace(/\n/g, "<br>") + "</p>";
278
+ })
279
+ .join("\n");
280
+
281
+ return html;
282
+ }
283
+
284
+ function applyFilters(): void {
285
+ const query = searchQuery.toLowerCase();
286
+ filteredPlans = plans.filter((p) => {
287
+ // Apply project filter (empty set = show all)
288
+ if (selectedProjects.size > 0 && (!p.project || !selectedProjects.has(p.project))) {
289
+ return false;
290
+ }
291
+ // Apply search filter
292
+ if (query) {
293
+ return (
294
+ p.title.toLowerCase().includes(query) ||
295
+ p.content.toLowerCase().includes(query) ||
296
+ p.filename.toLowerCase().includes(query) ||
297
+ (p.project && p.project.toLowerCase().includes(query))
298
+ );
299
+ }
300
+ return true;
301
+ });
302
+ sortPlans();
303
+ render();
304
+ }
305
+
306
+ function getProjectTriggerText(projects: string[]): string {
307
+ if (selectedProjects.size === 0) {
308
+ return "All projects";
309
+ }
310
+ const selected = projects.filter(p => selectedProjects.has(p));
311
+ const first = selected[0];
312
+ if (!first) return "All projects";
313
+ if (selected.length === 1) {
314
+ return first;
315
+ }
316
+ return `${first} <span class="badge">+${selected.length - 1}</span>`;
317
+ }
318
+
319
+ function updateTableAndStats(): void {
320
+ const totalSize = filteredPlans.reduce((sum, p) => sum + p.size, 0);
321
+
322
+ // Update stats
323
+ const statsEl = document.querySelector(".stats");
324
+ if (statsEl) {
325
+ statsEl.innerHTML = `
326
+ <span>${filteredPlans.length}/${plans.length}</span>
327
+ <span class="divider">|</span>
328
+ <span>${formatSize(totalSize)}</span>
329
+ `;
330
+ }
331
+
332
+ // Update trigger text and style
333
+ const projects = [...new Set(plans.map(p => p.project).filter(Boolean))] as string[];
334
+ const trigger = document.getElementById("project-trigger");
335
+ if (trigger) {
336
+ trigger.classList.toggle("has-selection", selectedProjects.size > 0);
337
+ const chevronHtml = `<svg class="chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>`;
338
+ trigger.innerHTML = getProjectTriggerText(projects) + chevronHtml;
339
+ }
340
+
341
+ // Update table
342
+ const tbody = document.getElementById("plans-table");
343
+ if (tbody) {
344
+ tbody.innerHTML = filteredPlans.map((plan) => `
345
+ <tr data-filename="${plan.filename}" class="${selectedPlan?.filename === plan.filename ? "selected" : ""}">
346
+ <td class="title-cell"><button class="title-btn" data-filename="${plan.filename}">${highlightText(plan.title, searchQuery)}</button></td>
347
+ <td class="project-cell">${plan.project ? highlightText(plan.project, searchQuery) : "—"}</td>
348
+ <td class="num-cell">${formatSize(plan.size)}</td>
349
+ <td class="num-cell">${plan.lineCount}</td>
350
+ <td class="meta-cell">${formatDate(plan.modified)}</td>
351
+ <td class="meta-cell">${formatDate(plan.created)}</td>
352
+ </tr>
353
+ `).join("");
354
+ }
355
+ }
356
+
357
+ function render(): void {
358
+ const totalSize = filteredPlans.reduce((sum, p) => sum + p.size, 0);
359
+
360
+ // Get unique projects for filter chips
361
+ const projects = [...new Set(plans.map(p => p.project).filter(Boolean))] as string[];
362
+ projects.sort();
363
+
364
+ app.innerHTML = `
365
+ <div class="container">
366
+ <div class="list-panel">
367
+ <div class="header">
368
+ <div class="header-row">
369
+ <h1>
370
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
371
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
372
+ </svg>
373
+ Plans
374
+ </h1>
375
+ <div class="header-spacer"></div>
376
+ <select class="sort-select" id="sort">
377
+ <option value="modified-desc" ${sortKey === "modified" && sortDir === "desc" ? "selected" : ""}>Modified (newest)</option>
378
+ <option value="modified-asc" ${sortKey === "modified" && sortDir === "asc" ? "selected" : ""}>Modified (oldest)</option>
379
+ <option value="project-asc" ${sortKey === "project" && sortDir === "asc" ? "selected" : ""}>Project (A-Z)</option>
380
+ <option value="project-desc" ${sortKey === "project" && sortDir === "desc" ? "selected" : ""}>Project (Z-A)</option>
381
+ <option value="title-asc" ${sortKey === "title" && sortDir === "asc" ? "selected" : ""}>Title (A-Z)</option>
382
+ <option value="title-desc" ${sortKey === "title" && sortDir === "desc" ? "selected" : ""}>Title (Z-A)</option>
383
+ <option value="size-desc" ${sortKey === "size" && sortDir === "desc" ? "selected" : ""}>Size (largest)</option>
384
+ <option value="size-asc" ${sortKey === "size" && sortDir === "asc" ? "selected" : ""}>Size (smallest)</option>
385
+ <option value="lines-desc" ${sortKey === "lines" && sortDir === "desc" ? "selected" : ""}>Lines (most)</option>
386
+ <option value="lines-asc" ${sortKey === "lines" && sortDir === "asc" ? "selected" : ""}>Lines (least)</option>
387
+ <option value="created-desc" ${sortKey === "created" && sortDir === "desc" ? "selected" : ""}>Created (newest)</option>
388
+ <option value="created-asc" ${sortKey === "created" && sortDir === "asc" ? "selected" : ""}>Created (oldest)</option>
389
+ </select>
390
+ <div class="search-wrapper">
391
+ <svg class="search-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
392
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
393
+ </svg>
394
+ <input type="search" class="search-input" id="search" placeholder="Search..." value="${escapeHtml(searchQuery)}" autofocus>
395
+ <span class="search-kbd">⌘K</span>
396
+ </div>
397
+ ${projects.length > 0 ? `
398
+ <div class="project-dropdown">
399
+ <button class="dropdown-trigger ${selectedProjects.size > 0 ? 'has-selection' : ''}" id="project-trigger" popovertarget="project-menu">
400
+ ${getProjectTriggerText(projects)}
401
+ <svg class="chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
402
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
403
+ </svg>
404
+ </button>
405
+ <div id="project-menu" popover class="dropdown-menu">
406
+ ${projects.map(p => `
407
+ <label class="dropdown-item">
408
+ <input type="checkbox" value="${escapeHtml(p)}" ${selectedProjects.has(p) ? 'checked' : ''}>
409
+ <span>${escapeHtml(p)}</span>
410
+ </label>
411
+ `).join('')}
412
+ ${selectedProjects.size > 0 ? `
413
+ <button class="dropdown-clear" id="clear-projects">Clear all</button>
414
+ ` : ''}
415
+ </div>
416
+ </div>
417
+ ` : ''}
418
+ <button class="action-btn" id="refresh-btn" title="Refresh plans">
419
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
420
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
421
+ </svg>
422
+ </button>
423
+ </div>
424
+ </div>
425
+ <div class="table-container">
426
+ <table>
427
+ <thead>
428
+ <tr>
429
+ <th data-sort="title" class="${sortKey === "title" ? "sorted " + sortDir : ""}">
430
+ Title <span class="sort-icon">▲</span>
431
+ </th>
432
+ <th data-sort="project" class="${sortKey === "project" ? "sorted " + sortDir : ""}">
433
+ Project <span class="sort-icon">▲</span>
434
+ </th>
435
+ <th data-sort="size" class="num-col ${sortKey === "size" ? "sorted " + sortDir : ""}">
436
+ Size <span class="sort-icon">▲</span>
437
+ </th>
438
+ <th data-sort="lines" class="num-col ${sortKey === "lines" ? "sorted " + sortDir : ""}">
439
+ Lines <span class="sort-icon">▲</span>
440
+ </th>
441
+ <th data-sort="modified" class="${sortKey === "modified" ? "sorted " + sortDir : ""}">
442
+ Modified <span class="sort-icon">▲</span>
443
+ </th>
444
+ <th data-sort="created" class="${sortKey === "created" ? "sorted " + sortDir : ""}">
445
+ Created <span class="sort-icon">▲</span>
446
+ </th>
447
+ </tr>
448
+ </thead>
449
+ <tbody id="plans-table">
450
+ ${filteredPlans
451
+ .map(
452
+ (plan) => `
453
+ <tr data-filename="${plan.filename}" class="${selectedPlan?.filename === plan.filename ? "selected" : ""}">
454
+ <td class="title-cell"><button class="title-btn" data-filename="${plan.filename}">${highlightText(plan.title, searchQuery)}</button></td>
455
+ <td class="project-cell">${plan.project ? highlightText(plan.project, searchQuery) : "—"}</td>
456
+ <td class="num-cell">${formatSize(plan.size)}</td>
457
+ <td class="num-cell">${plan.lineCount}</td>
458
+ <td class="meta-cell">${formatDate(plan.modified)}</td>
459
+ <td class="meta-cell">${formatDate(plan.created)}</td>
460
+ </tr>
461
+ `
462
+ )
463
+ .join("")}
464
+ </tbody>
465
+ </table>
466
+ </div>
467
+ </div>
468
+ <div class="detail-panel">
469
+ ${
470
+ selectedPlan
471
+ ? `
472
+ <div class="detail-header">
473
+ <div class="detail-header-top">
474
+ <div class="detail-title">${escapeHtml(selectedPlan.title)}</div>
475
+ <div class="detail-actions">
476
+ <button class="action-btn" id="copy-btn" title="Copy markdown">
477
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
478
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
479
+ </svg>
480
+ </button>
481
+ <button class="action-btn" id="copy-path-btn" title="Copy file path">
482
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
483
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
484
+ </svg>
485
+ </button>
486
+ <button class="action-btn" id="open-editor-btn" title="Open in editor">
487
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
488
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
489
+ </svg>
490
+ </button>
491
+ </div>
492
+ </div>
493
+ <div class="detail-meta">
494
+ ${selectedPlan.project ? `<span class="project-tag">${selectedPlan.project}</span>` : ""}
495
+ <span>${selectedPlan.filename}</span>
496
+ <span>${formatFullDate(selectedPlan.modified)}</span>
497
+ <span>${formatSize(selectedPlan.size)}</span>
498
+ <span>${selectedPlan.lineCount} lines</span>
499
+ </div>
500
+ </div>
501
+ <div class="detail-content">
502
+ <div class="markdown">${renderMarkdown(selectedPlan.content)}</div>
503
+ </div>
504
+ `
505
+ : `
506
+ <div class="empty-state">
507
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
508
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
509
+ </svg>
510
+ <p>Select a plan to view details</p>
511
+ <p class="hint">Use ↑↓ arrows to navigate</p>
512
+ </div>
513
+ `
514
+ }
515
+ </div>
516
+ </div>
517
+ ${showHelpModal ? `
518
+ <div class="modal-backdrop" id="help-modal">
519
+ <div class="modal">
520
+ <div class="modal-header">
521
+ <h2>Keyboard Shortcuts</h2>
522
+ <button class="modal-close" id="close-help">&times;</button>
523
+ </div>
524
+ <div class="modal-body">
525
+ <div class="shortcut-row"><kbd>↑</kbd> <kbd>↓</kbd> <span>Navigate plans</span></div>
526
+ <div class="shortcut-row"><kbd>Enter</kbd> <span>Open in editor</span></div>
527
+ <div class="shortcut-row"><kbd>⌘</kbd> <kbd>K</kbd> <span>Focus search</span></div>
528
+ <div class="shortcut-row"><kbd>Esc</kbd> <span>Blur search / Close modal</span></div>
529
+ <div class="shortcut-row"><kbd>?</kbd> <span>Toggle this help</span></div>
530
+ </div>
531
+ </div>
532
+ </div>
533
+ ` : ''}
534
+ `;
535
+
536
+ attachEventListeners();
537
+ }
538
+
539
+ function attachEventListeners(): void {
540
+ const searchInput = document.getElementById("search") as HTMLInputElement;
541
+ const sortSelect = document.getElementById("sort") as HTMLSelectElement;
542
+ const tbody = document.getElementById("plans-table")!;
543
+ const ths = document.querySelectorAll("th[data-sort]");
544
+
545
+ searchInput?.addEventListener("input", (e) => {
546
+ searchQuery = (e.target as HTMLInputElement).value;
547
+ applyFilters();
548
+ debouncedUpdateSearchQueryParam(searchQuery);
549
+ // Restore focus and cursor position after render
550
+ const newInput = document.getElementById("search") as HTMLInputElement;
551
+ newInput?.focus();
552
+ newInput?.setSelectionRange(searchQuery.length, searchQuery.length);
553
+ });
554
+
555
+ // Project dropdown handlers
556
+ const projectMenu = document.getElementById("project-menu") as HTMLElement | null;
557
+ projectMenu?.addEventListener("change", (e) => {
558
+ const checkbox = e.target as HTMLInputElement;
559
+ if (checkbox.type === "checkbox") {
560
+ if (checkbox.checked) {
561
+ selectedProjects.add(checkbox.value);
562
+ } else {
563
+ selectedProjects.delete(checkbox.value);
564
+ }
565
+ // Update filter without full re-render to keep popover open
566
+ const query = searchQuery.toLowerCase();
567
+ filteredPlans = plans.filter((p) => {
568
+ if (selectedProjects.size > 0 && (!p.project || !selectedProjects.has(p.project))) {
569
+ return false;
570
+ }
571
+ if (query) {
572
+ return (
573
+ p.title.toLowerCase().includes(query) ||
574
+ p.content.toLowerCase().includes(query) ||
575
+ p.filename.toLowerCase().includes(query) ||
576
+ (p.project && p.project.toLowerCase().includes(query))
577
+ );
578
+ }
579
+ return true;
580
+ });
581
+ sortPlans();
582
+ // Update just the table and stats without closing popover
583
+ updateTableAndStats();
584
+ }
585
+ });
586
+
587
+ document.getElementById("clear-projects")?.addEventListener("click", () => {
588
+ selectedProjects.clear();
589
+ projectMenu?.hidePopover();
590
+ applyFilters();
591
+ });
592
+
593
+ sortSelect?.addEventListener("change", (e) => {
594
+ const parts = (e.target as HTMLSelectElement).value.split("-");
595
+ const key = parts[0] ?? "modified";
596
+ const dir = parts[1] ?? "desc";
597
+ sortKey = key;
598
+ sortDir = dir as "asc" | "desc";
599
+ sortPlans();
600
+ render();
601
+ });
602
+
603
+ ths.forEach((th) => {
604
+ th.addEventListener("click", () => {
605
+ const key = (th as HTMLElement).dataset.sort!;
606
+ if (sortKey === key) {
607
+ sortDir = sortDir === "asc" ? "desc" : "asc";
608
+ } else {
609
+ sortKey = key;
610
+ sortDir = key === "title" ? "asc" : "desc";
611
+ }
612
+ sortPlans();
613
+ render();
614
+ });
615
+ });
616
+
617
+ tbody?.addEventListener("click", (e) => {
618
+ const row = (e.target as HTMLElement).closest("tr");
619
+ if (row) {
620
+ const filename = (row as HTMLElement).dataset.filename;
621
+ const plan = filteredPlans.find((p) => p.filename === filename);
622
+ if (plan) {
623
+ selectPlan(plan);
624
+ }
625
+ }
626
+ });
627
+
628
+ // Help modal close handlers
629
+ document.getElementById("close-help")?.addEventListener("click", () => {
630
+ showHelpModal = false;
631
+ render();
632
+ });
633
+ document.getElementById("help-modal")?.addEventListener("click", (e) => {
634
+ if (e.target === e.currentTarget) {
635
+ showHelpModal = false;
636
+ render();
637
+ }
638
+ });
639
+
640
+ // Copy markdown button handler
641
+ document.getElementById("copy-btn")?.addEventListener("click", async () => {
642
+ if (!selectedPlan) return;
643
+ try {
644
+ await navigator.clipboard.writeText(selectedPlan.content);
645
+ showCopiedFeedback("copy-btn");
646
+ } catch (err) {
647
+ console.error("Failed to copy:", err);
648
+ }
649
+ });
650
+
651
+ // Copy path button handler
652
+ document.getElementById("copy-path-btn")?.addEventListener("click", async () => {
653
+ if (!selectedPlan) return;
654
+ try {
655
+ await navigator.clipboard.writeText(selectedPlan.filepath);
656
+ showCopiedFeedback("copy-path-btn");
657
+ } catch (err) {
658
+ console.error("Failed to copy path:", err);
659
+ }
660
+ });
661
+
662
+ // Open in editor button handler
663
+ document.getElementById("open-editor-btn")?.addEventListener("click", async () => {
664
+ if (!selectedPlan) return;
665
+ try {
666
+ await fetch("/api/open", {
667
+ method: "POST",
668
+ headers: { "Content-Type": "application/json" },
669
+ body: JSON.stringify({ filepath: selectedPlan.filepath }),
670
+ });
671
+ } catch (err) {
672
+ console.error("Failed to open in editor:", err);
673
+ }
674
+ });
675
+
676
+ // Title button click handler for keyboard accessibility
677
+ document.querySelectorAll(".title-btn").forEach((btn) => {
678
+ btn.addEventListener("click", (e) => {
679
+ e.stopPropagation();
680
+ const filename = (btn as HTMLButtonElement).dataset.filename;
681
+ const plan = filteredPlans.find((p) => p.filename === filename);
682
+ if (plan) {
683
+ selectPlan(plan);
684
+ }
685
+ });
686
+ });
687
+
688
+ // Refresh button handler
689
+ document.getElementById("refresh-btn")?.addEventListener("click", async () => {
690
+ const btn = document.getElementById("refresh-btn");
691
+ if (btn) btn.classList.add("loading");
692
+ try {
693
+ const resp = await fetch("/api/plans");
694
+ plans = await resp.json();
695
+ filteredPlans = [...plans];
696
+ applyFilters();
697
+ } catch (err) {
698
+ console.error("Failed to refresh:", err);
699
+ } finally {
700
+ if (btn) btn.classList.remove("loading");
701
+ }
702
+ });
703
+ }
704
+
705
+ function showCopiedFeedback(btnId: string): void {
706
+ const btn = document.getElementById(btnId);
707
+ if (btn) {
708
+ btn.classList.add("copied");
709
+ const originalHtml = btn.innerHTML;
710
+ btn.innerHTML = `
711
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
712
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
713
+ </svg>
714
+ `;
715
+ setTimeout(() => {
716
+ btn.classList.remove("copied");
717
+ btn.innerHTML = originalHtml;
718
+ }, 1500);
719
+ }
720
+ }
721
+
722
+ // Global keyboard navigation
723
+ document.addEventListener("keydown", (e) => {
724
+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
725
+ e.preventDefault();
726
+ const search = document.getElementById("search") as HTMLInputElement;
727
+ search?.focus();
728
+ search?.select();
729
+ }
730
+
731
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
732
+ e.preventDefault();
733
+ const idx = filteredPlans.findIndex(
734
+ (p) => p.filename === selectedPlan?.filename
735
+ );
736
+ let newIdx = e.key === "ArrowDown" ? idx + 1 : idx - 1;
737
+ if (newIdx < 0) newIdx = 0;
738
+ if (newIdx >= filteredPlans.length) newIdx = filteredPlans.length - 1;
739
+ const plan = filteredPlans[newIdx];
740
+ if (plan) {
741
+ selectPlan(plan);
742
+ document
743
+ .querySelector(`tr[data-filename="${plan.filename}"]`)
744
+ ?.scrollIntoView({ block: "nearest" });
745
+ }
746
+ }
747
+
748
+ // Enter to open in editor
749
+ if (e.key === "Enter" && selectedPlan) {
750
+ const activeEl = document.activeElement;
751
+ if (activeEl?.tagName !== "INPUT" && activeEl?.tagName !== "TEXTAREA" && activeEl?.tagName !== "BUTTON") {
752
+ e.preventDefault();
753
+ openInEditor();
754
+ }
755
+ }
756
+
757
+ if (e.key === "Escape") {
758
+ if (showHelpModal) {
759
+ showHelpModal = false;
760
+ render();
761
+ } else {
762
+ const searchInput = document.getElementById("search") as HTMLInputElement;
763
+ if (document.activeElement === searchInput) {
764
+ if (searchQuery) {
765
+ // Clear search first
766
+ searchQuery = "";
767
+ searchInput.value = "";
768
+ updateSearchQueryParam("");
769
+ applyFilters();
770
+ } else {
771
+ // Already empty, blur
772
+ searchInput.blur();
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ // Toggle help modal with ?
779
+ if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
780
+ const activeEl = document.activeElement;
781
+ if (activeEl?.tagName !== "INPUT" && activeEl?.tagName !== "TEXTAREA") {
782
+ e.preventDefault();
783
+ showHelpModal = !showHelpModal;
784
+ render();
785
+ }
786
+ }
787
+ });
788
+
789
+ // Handle browser back/forward
790
+ window.addEventListener("popstate", () => {
791
+ const url = new URL(window.location.href);
792
+ const planFilename = url.searchParams.get("plan");
793
+ if (planFilename) {
794
+ const plan = plans.find((p) => p.filename === planFilename);
795
+ if (plan && plan !== selectedPlan) {
796
+ selectedPlan = plan;
797
+ render();
798
+ }
799
+ } else if (selectedPlan) {
800
+ selectedPlan = null;
801
+ render();
802
+ }
803
+ });
804
+
805
+ // Initial load
806
+ async function init(): Promise<void> {
807
+ app.innerHTML = '<div class="loading">Loading plans...</div>';
808
+
809
+ try {
810
+ const res = await fetch("/api/plans");
811
+ plans = await res.json();
812
+ filteredPlans = [...plans];
813
+
814
+ // Check URL for initial state
815
+ const url = new URL(window.location.href);
816
+
817
+ // Load search query from URL
818
+ const queryParam = url.searchParams.get("q");
819
+ if (queryParam) {
820
+ searchQuery = queryParam;
821
+ }
822
+
823
+ // Load plan selection from URL
824
+ const planFilename = url.searchParams.get("plan");
825
+ if (planFilename) {
826
+ const plan = plans.find((p) => p.filename === planFilename);
827
+ if (plan) {
828
+ selectedPlan = plan;
829
+ }
830
+ }
831
+
832
+ // Apply filters if search query was loaded
833
+ if (searchQuery) {
834
+ applyFilters();
835
+ } else {
836
+ render();
837
+ }
838
+ } catch (err) {
839
+ app.innerHTML = `<div class="empty-state"><p>Failed to load plans</p><p class="hint">${err}</p></div>`;
840
+ }
841
+ }
842
+
843
+ init();