pi-design-deck 0.1.1 → 0.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.
Files changed (31) hide show
  1. package/README.md +54 -13
  2. package/deck-schema.ts +30 -0
  3. package/deck-server.ts +76 -19
  4. package/export-html.ts +329 -0
  5. package/form/css/controls.css +171 -0
  6. package/form/css/layout.css +56 -0
  7. package/form/css/preview.css +60 -6
  8. package/form/deck.html +2 -0
  9. package/form/js/deck-core.js +60 -2
  10. package/form/js/deck-interact.js +63 -19
  11. package/form/js/deck-render.js +95 -6
  12. package/form/js/deck-session.js +140 -27
  13. package/generate-prompts.ts +18 -12
  14. package/index.ts +364 -66
  15. package/package.json +2 -1
  16. package/prompts/deck-discover.md +3 -1
  17. package/prompts/deck-plan.md +3 -1
  18. package/prompts/deck.md +3 -1
  19. package/skills/design-deck/SKILL.md +44 -8
  20. package/skills/design-deck/references/component-gallery/INDEX.md +88 -0
  21. package/skills/design-deck/references/component-gallery/LOOKUP.md +592 -0
  22. package/skills/design-deck/references/component-gallery/components/INDEX.md +106 -0
  23. package/skills/design-deck/references/component-gallery/components/actions.md +354 -0
  24. package/skills/design-deck/references/component-gallery/components/data-display.md +812 -0
  25. package/skills/design-deck/references/component-gallery/components/feedback.md +513 -0
  26. package/skills/design-deck/references/component-gallery/components/inputs.md +921 -0
  27. package/skills/design-deck/references/component-gallery/components/layout.md +167 -0
  28. package/skills/design-deck/references/component-gallery/components/navigation.md +350 -0
  29. package/skills/design-deck/references/component-gallery/components/overlays.md +208 -0
  30. package/skills/design-deck/references/component-gallery/components/utilities.md +29 -0
  31. package/skills/design-deck/references/component-gallery/components.md +1383 -0
package/index.ts CHANGED
@@ -6,10 +6,11 @@ import * as os from "node:os";
6
6
  import * as fs from "node:fs";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { loadSettings } from "./settings.js";
9
- import { startDeckServer, type DeckServerHandle, type ModelInfo } from "./deck-server.js";
10
- import { isDeckOption, validateDeckConfig, validateSavedDeck } from "./deck-schema.js";
9
+ import { getDefaultSnapshotDir, startDeckServer, type DeckServerHandle, type ModelInfo } from "./deck-server.js";
10
+ import { deriveDeckStatusFromFolderName, isDeckOption, validateDeckConfig, validateSavedDeck, type SavedDeckData, type SavedDeckStatus } from "./deck-schema.js";
11
11
  import { buildGenerateMoreResult, buildRegenerateResult } from "./generate-prompts.js";
12
12
  import { generateWithModel } from "./model-runner.js";
13
+ import { buildStandaloneDeckHtml } from "./export-html.js";
13
14
 
14
15
  async function openUrl(pi: ExtensionAPI, url: string, browser?: string): Promise<void> {
15
16
  const platform = os.platform();
@@ -42,6 +43,8 @@ interface DeckDetails {
42
43
  status: "completed" | "cancelled" | "generate-more" | "aborted" | "error";
43
44
  url: string;
44
45
  selections?: Record<string, string>;
46
+ notes?: Record<string, string>;
47
+ finalNotes?: string;
45
48
  slideId?: string;
46
49
  reason?: string;
47
50
  }
@@ -62,6 +65,24 @@ let restoreDeckThinking: (() => void) | null = null;
62
65
 
63
66
  const DECK_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
64
67
 
68
+ interface SavedDeckListItem {
69
+ id: string;
70
+ title: string;
71
+ savedAt: string;
72
+ modifiedAt: string;
73
+ status: SavedDeckStatus;
74
+ cwd?: string;
75
+ branch?: string | null;
76
+ slideCount: number;
77
+ }
78
+
79
+ interface LoadedDeckSource {
80
+ configData: unknown;
81
+ savedSelections?: Record<string, string>;
82
+ savedNotes?: Record<string, { label: string; notes: string }>;
83
+ savedFinalNotes?: string;
84
+ }
85
+
65
86
  const DeckParams = Type.Object(
66
87
  {
67
88
  slides: Type.Optional(
@@ -76,11 +97,15 @@ const DeckParams = Type.Object(
76
97
  action: Type.Optional(
77
98
  Type.Union([
78
99
  Type.Literal("add-option", { description: "Push a single generated option into a running deck session" }),
100
+ Type.Literal("add-options", { description: "Push multiple generated options into a running deck session (blocks until next user action)" }),
79
101
  Type.Literal("replace-options", { description: "Replace all options for a slide with fresh alternatives" }),
102
+ Type.Literal("list", { description: "List saved decks from the snapshot directory" }),
103
+ Type.Literal("open", { description: "Open a saved deck by deck ID" }),
104
+ Type.Literal("export", { description: "Export a saved deck as standalone HTML" }),
80
105
  ])
81
106
  ),
82
107
  slideId: Type.Optional(
83
- Type.String({ description: "Target slide ID (required with action: 'add-option' or 'replace-options')" })
108
+ Type.String({ description: "Target slide ID (required with action: 'add-option', 'add-options', or 'replace-options')" })
84
109
  ),
85
110
  option: Type.Optional(
86
111
  Type.String({
@@ -91,9 +116,15 @@ const DeckParams = Type.Object(
91
116
  options: Type.Optional(
92
117
  Type.String({
93
118
  description:
94
- "JSON string of array of deck options (required with action: 'replace-options')",
119
+ "JSON string of array of deck options (required with action: 'add-options' or 'replace-options')",
95
120
  })
96
121
  ),
122
+ deckId: Type.Optional(
123
+ Type.String({ description: "Deck ID for open/export actions (folder name from list)" })
124
+ ),
125
+ format: Type.Optional(
126
+ Type.String({ description: "Export format: 'html' (default)" })
127
+ ),
97
128
  },
98
129
  { additionalProperties: false }
99
130
  );
@@ -109,6 +140,104 @@ function expandHome(value: string): string {
109
140
  return value;
110
141
  }
111
142
 
143
+ function resolveSnapshotDir(snapshotDir?: string): string {
144
+ return snapshotDir ? expandHome(snapshotDir) : getDefaultSnapshotDir();
145
+ }
146
+
147
+ function listSavedDecks(snapshotDir: string): { decks: SavedDeckListItem[]; warnings: string[] } {
148
+ if (!fs.existsSync(snapshotDir)) {
149
+ return { decks: [], warnings: [] };
150
+ }
151
+
152
+ const entries = fs.readdirSync(snapshotDir, { withFileTypes: true });
153
+ const decks: SavedDeckListItem[] = [];
154
+ const warnings: string[] = [];
155
+
156
+ for (const entry of entries) {
157
+ if (!entry.isDirectory()) continue;
158
+ const deckJsonPath = path.join(snapshotDir, entry.name, "deck.json");
159
+ if (!fs.existsSync(deckJsonPath)) continue;
160
+
161
+ try {
162
+ const raw = JSON.parse(fs.readFileSync(deckJsonPath, "utf-8"));
163
+ const saved = validateSavedDeck(raw);
164
+ decks.push({
165
+ id: saved.id ?? entry.name,
166
+ title: saved.config.title || "Design Deck",
167
+ savedAt: saved.savedAt,
168
+ modifiedAt: saved.modifiedAt ?? saved.savedAt,
169
+ status: saved.status ?? deriveDeckStatusFromFolderName(entry.name),
170
+ cwd: saved.savedFrom?.cwd,
171
+ branch: saved.savedFrom?.branch,
172
+ slideCount: saved.config.slides.length,
173
+ });
174
+ } catch (err) {
175
+ const message = err instanceof Error ? err.message : String(err);
176
+ warnings.push(`${entry.name}: ${message}`);
177
+ }
178
+ }
179
+
180
+ return { decks, warnings };
181
+ }
182
+
183
+ function resolveDeckFilePath(snapshotDir: string, deckId: string): string | null {
184
+ if (!deckId || deckId === "." || deckId === ".." || deckId.includes("/") || deckId.includes("\\")) {
185
+ return null;
186
+ }
187
+ const root = path.resolve(snapshotDir);
188
+ const deckDir = path.resolve(snapshotDir, deckId);
189
+ if (deckDir !== root && !deckDir.startsWith(root + path.sep)) {
190
+ return null;
191
+ }
192
+ return path.join(deckDir, "deck.json");
193
+ }
194
+
195
+ function buildSavedNotesForClient(saved: SavedDeckData): Record<string, { label: string; notes: string }> | undefined {
196
+ const savedNotesForClient: Record<string, { label: string; notes: string }> = {};
197
+ for (const [slideId, noteString] of Object.entries(saved.notes ?? {})) {
198
+ const label = saved.selections[slideId];
199
+ if (label && noteString) {
200
+ savedNotesForClient[slideId] = { label, notes: noteString };
201
+ }
202
+ }
203
+ return Object.keys(savedNotesForClient).length > 0 ? savedNotesForClient : undefined;
204
+ }
205
+
206
+ function loadDeckFile(absolutePath: string): LoadedDeckSource {
207
+ const content = fs.readFileSync(absolutePath, "utf-8");
208
+ let fileData: unknown;
209
+ try {
210
+ fileData = JSON.parse(content);
211
+ } catch (parseErr) {
212
+ const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
213
+ throw new Error(`Invalid JSON in saved deck file: ${message}`);
214
+ }
215
+
216
+ const raw = fileData as Record<string, unknown>;
217
+ if (raw.config && typeof raw.config === "object") {
218
+ const saved = validateSavedDeck(fileData);
219
+ const snapshotDir = path.dirname(absolutePath);
220
+ for (const slide of saved.config.slides) {
221
+ for (const option of slide.options) {
222
+ if (!option.previewBlocks) continue;
223
+ for (const block of option.previewBlocks) {
224
+ if (block.type === "image" && !path.isAbsolute(block.src)) {
225
+ block.src = path.join(snapshotDir, block.src);
226
+ }
227
+ }
228
+ }
229
+ }
230
+ return {
231
+ configData: saved.config,
232
+ savedSelections: Object.keys(saved.selections).length > 0 ? saved.selections : undefined,
233
+ savedNotes: buildSavedNotesForClient(saved),
234
+ savedFinalNotes: saved.finalNotes,
235
+ };
236
+ }
237
+
238
+ return { configData: fileData };
239
+ }
240
+
112
241
  const DEFAULT_THEME_HOTKEY = "mod+shift+l";
113
242
 
114
243
  function clearDeckIdleTimer(): void {
@@ -118,7 +247,7 @@ function clearDeckIdleTimer(): void {
118
247
  }
119
248
  }
120
249
 
121
- function cleanupActiveDeck(): void {
250
+ function cleanupActiveDeck(reason?: string): void {
122
251
  clearDeckIdleTimer();
123
252
  if (restoreDeckThinking) {
124
253
  restoreDeckThinking();
@@ -126,20 +255,23 @@ function cleanupActiveDeck(): void {
126
255
  }
127
256
  if (!activeDeckServer) return;
128
257
  try {
129
- activeDeckServer.handle.close();
258
+ activeDeckServer.handle.close(reason);
130
259
  } catch {}
131
260
  activeDeckServer = null;
132
261
  }
133
262
 
134
263
  function cleanupActiveDeckAndStoreResult(result: DeckToolResult): void {
135
264
  if (!activeDeckServer) return;
265
+ // Extract close reason from result details
266
+ const details = result.details as DeckDetails | undefined;
267
+ const closeReason = details?.status === "aborted" ? "aborted" : details?.reason;
136
268
  if (activeDeckServer.currentResolve) {
137
269
  const resolve = activeDeckServer.currentResolve;
138
- cleanupActiveDeck();
270
+ cleanupActiveDeck(closeReason);
139
271
  resolve(result);
140
272
  } else {
141
273
  pendingDeckResult = result;
142
- cleanupActiveDeck();
274
+ cleanupActiveDeck(closeReason);
143
275
  }
144
276
  }
145
277
 
@@ -181,10 +313,16 @@ function attachDeckAbortHandler(signal: AbortSignal | undefined): void {
181
313
  signal.addEventListener("abort", abortHandler, { once: true });
182
314
  }
183
315
 
184
- function formatDeckSelections(selections: Record<string, string>): string {
316
+ function formatDeckSelections(selections: Record<string, string>, notes?: Record<string, string>): string {
185
317
  const entries = Object.entries(selections);
186
318
  if (entries.length === 0) return "(none)";
187
- return entries.map(([key, value]) => `- ${key}: ${value}`).join("\n");
319
+ return entries.map(([key, value]) => {
320
+ const note = notes?.[key];
321
+ if (note) {
322
+ return `- ${key}: ${value}\n Notes: ${note}`;
323
+ }
324
+ return `- ${key}: ${value}`;
325
+ }).join("\n");
188
326
  }
189
327
 
190
328
  export default function (pi: ExtensionAPI) {
@@ -196,19 +334,32 @@ export default function (pi: ExtensionAPI) {
196
334
  "Present a multi-slide design deck with visual options for decisions. " +
197
335
  "Slides JSON: { title?, slides: [{ id, title, context?, columns?, options }] }. " +
198
336
  "When the user requests more options, tool returns generate-more instructions — " +
199
- 'call design_deck with action:"add-option" to push into the live deck. ' +
337
+ 'call design_deck with action:"add-options" to push all new options at once. ' +
200
338
  "previewBlocks for code/architecture comparisons, previewHtml for custom UI mockups.",
201
339
  parameters: DeckParams,
202
340
 
203
341
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
204
- if (!ctx.hasUI) {
342
+ const p = params as Record<string, unknown>;
343
+
344
+ if (!ctx.hasUI && p.action !== "list" && p.action !== "export") {
205
345
  throw new Error(
206
346
  "design_deck requires interactive mode with browser support. " +
207
347
  "Cannot run in headless/RPC/print mode."
208
348
  );
209
349
  }
210
350
 
211
- const p = params as Record<string, unknown>;
351
+ if (p.action === "list") {
352
+ const settings = loadSettings();
353
+ const snapshotDir = resolveSnapshotDir(settings.snapshotDir);
354
+ const { decks, warnings } = listSavedDecks(snapshotDir);
355
+ const content: Array<{ type: "text"; text: string }> = [
356
+ { type: "text", text: JSON.stringify(decks, null, 2) },
357
+ ];
358
+ if (warnings.length > 0) {
359
+ content.push({ type: "text", text: `Warnings:\n${warnings.map((w) => `- ${w}`).join("\n")}` });
360
+ }
361
+ return { content };
362
+ }
212
363
 
213
364
  if (p.action === "add-option") {
214
365
  if (typeof p.slideId !== "string" || p.slideId.trim() === "") {
@@ -225,6 +376,21 @@ export default function (pi: ExtensionAPI) {
225
376
  details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
226
377
  };
227
378
  }
379
+ } else if (p.action === "add-options") {
380
+ if (typeof p.slideId !== "string" || p.slideId.trim() === "") {
381
+ activeDeckServer?.handle.cancelGenerate();
382
+ return {
383
+ content: [{ type: "text", text: 'add-options requires slideId (string).' }],
384
+ details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
385
+ };
386
+ }
387
+ if (typeof p.options !== "string" || p.options.trim() === "") {
388
+ activeDeckServer?.handle.cancelGenerate();
389
+ return {
390
+ content: [{ type: "text", text: 'add-options requires options (JSON array string).' }],
391
+ details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
392
+ };
393
+ }
228
394
  } else if (p.action === "replace-options") {
229
395
  if (typeof p.slideId !== "string" || p.slideId.trim() === "") {
230
396
  activeDeckServer?.handle.cancelGenerate();
@@ -240,6 +406,13 @@ export default function (pi: ExtensionAPI) {
240
406
  details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
241
407
  };
242
408
  }
409
+ } else if (p.action === "open" || p.action === "export") {
410
+ if (typeof p.deckId !== "string" || p.deckId.trim() === "") {
411
+ return {
412
+ content: [{ type: "text", text: `${p.action} requires deckId (string). Example: { action: "${p.action}", deckId: "tabs-component-myapp-main-2026-03-01-103045-submitted" }` }],
413
+ details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
414
+ };
415
+ }
243
416
  } else if (typeof p.slides !== "string" || p.slides.trim() === "") {
244
417
  return {
245
418
  content: [{
@@ -247,7 +420,10 @@ export default function (pi: ExtensionAPI) {
247
420
  text:
248
421
  "design_deck requires one of:\n\n" +
249
422
  '1. Start a new deck: { slides: "<JSON string of { title?, slides: [{ id, title, options }] }>" }\n' +
250
- '2. Add option to running deck: { action: "add-option", slideId: "...", option: "<JSON string>" }\n\n' +
423
+ '2. Open a saved deck: { action: "open", deckId: "..." }\n' +
424
+ '3. Export a saved deck: { action: "export", deckId: "...", format: "html" }\n' +
425
+ '4. Add options to running deck: { action: "add-options", slideId: "...", options: "[<JSON array>]" }\n' +
426
+ '5. Add single option: { action: "add-option", slideId: "...", option: "<JSON string>" }\n\n' +
251
427
  "Each option needs label + either previewHtml (raw HTML) or previewBlocks (array of {type, ...} blocks).\n" +
252
428
  "Block types: html, mermaid, code, image.",
253
429
  }],
@@ -274,17 +450,8 @@ export default function (pi: ExtensionAPI) {
274
450
  };
275
451
  }
276
452
 
277
- if (activeDeckServer.currentResolve !== null) {
278
- return {
279
- content: [
280
- {
281
- type: "text",
282
- text: "Design deck is not waiting for a generated option right now.",
283
- },
284
- ],
285
- details: { status: "error", url: activeDeckServer.handle.url },
286
- };
287
- }
453
+ // Note: We don't check currentResolve here because multiple parallel
454
+ // add-option calls are valid (e.g., user requests 3 options at once)
288
455
 
289
456
  const slideId = p.slideId as string;
290
457
  const option = p.option as string;
@@ -320,9 +487,86 @@ export default function (pi: ExtensionAPI) {
320
487
  };
321
488
  }
322
489
 
490
+ // For add-option, return immediately without blocking.
491
+ // This allows parallel add-option calls to all succeed.
492
+ // The deck stays open and will send a new prompt when the user
493
+ // clicks generate-more again or submits.
494
+ return {
495
+ content: [{ type: "text", text: `Pushed option "${parsedOption.label}" to slide ${slideId}.` }],
496
+ details: { status: "generate-more", url: activeDeckServer.handle.url, slideId },
497
+ };
498
+ }
499
+
500
+ if (p.action === "add-options") {
501
+ if (pendingDeckResult) {
502
+ const result = pendingDeckResult;
503
+ pendingDeckResult = null;
504
+ return result;
505
+ }
506
+
507
+ if (!activeDeckServer) {
508
+ return {
509
+ content: [{ type: "text", text: "No active design deck session. Start a new deck before adding options." }],
510
+ details: { status: "error", url: "" },
511
+ };
512
+ }
513
+
514
+ if (activeDeckServer.currentResolve !== null) {
515
+ return {
516
+ content: [{ type: "text", text: "Design deck is not waiting for new options right now." }],
517
+ details: { status: "error", url: activeDeckServer.handle.url },
518
+ };
519
+ }
520
+
521
+ const slideId = p.slideId as string;
522
+ const optionsStr = p.options as string;
523
+
524
+ let parsedOptions: unknown;
525
+ try {
526
+ parsedOptions = JSON.parse(optionsStr);
527
+ } catch (err) {
528
+ activeDeckServer.handle.cancelGenerate();
529
+ const message = err instanceof Error ? err.message : String(err);
530
+ const snippet = optionsStr.length > 300 ? optionsStr.slice(0, 300) + "..." : optionsStr;
531
+ return {
532
+ content: [{ type: "text", text: `Invalid options JSON: ${message}\n\nReceived:\n${snippet}\n\nFix the JSON and call design_deck add-options again.` }],
533
+ details: { status: "error", url: activeDeckServer.handle.url },
534
+ };
535
+ }
536
+
537
+ if (!Array.isArray(parsedOptions)) {
538
+ activeDeckServer.handle.cancelGenerate();
539
+ return {
540
+ content: [{ type: "text", text: "options must be a JSON array of deck options. Fix and call design_deck add-options again." }],
541
+ details: { status: "error", url: activeDeckServer.handle.url },
542
+ };
543
+ }
544
+
545
+ for (const opt of parsedOptions) {
546
+ if (!isDeckOption(opt)) {
547
+ activeDeckServer.handle.cancelGenerate();
548
+ return {
549
+ content: [{ type: "text", text: "One or more options in the array are invalid — each needs label and either previewHtml or previewBlocks. Fix and call design_deck add-options again." }],
550
+ details: { status: "error", url: activeDeckServer.handle.url },
551
+ };
552
+ }
553
+ }
554
+
555
+ try {
556
+ for (const opt of parsedOptions) {
557
+ activeDeckServer.handle.pushOption(slideId, opt);
558
+ }
559
+ } catch (err) {
560
+ const message = err instanceof Error ? err.message : String(err);
561
+ return {
562
+ content: [{ type: "text", text: `Failed to push options: ${message}` }],
563
+ details: { status: "error", url: activeDeckServer.handle.url },
564
+ };
565
+ }
566
+
323
567
  if (onUpdate) {
324
568
  onUpdate({
325
- content: [{ type: "text", text: `Pushed new option to slide ${slideId}.` }],
569
+ content: [{ type: "text", text: `Pushed ${parsedOptions.length} options to slide ${slideId}.` }],
326
570
  details: { status: "generate-more", url: activeDeckServer.handle.url, slideId },
327
571
  });
328
572
  }
@@ -407,7 +651,7 @@ export default function (pi: ExtensionAPI) {
407
651
 
408
652
  pendingDeckResult = null;
409
653
 
410
- if (activeDeckServer) {
654
+ if (activeDeckServer && p.action !== "export") {
411
655
  return {
412
656
  content: [
413
657
  {
@@ -419,10 +663,61 @@ export default function (pi: ExtensionAPI) {
419
663
  };
420
664
  }
421
665
 
422
- const slides = p.slides as string;
666
+ const settings = loadSettings();
667
+ const snapshotDir = resolveSnapshotDir(settings.snapshotDir);
668
+ let slides = p.slides as string;
669
+ if (p.action === "open" || p.action === "export") {
670
+ const deckId = (p.deckId as string).trim();
671
+ const deckPath = resolveDeckFilePath(snapshotDir, deckId);
672
+ if (!deckPath || !fs.existsSync(deckPath)) {
673
+ const { decks } = listSavedDecks(snapshotDir);
674
+ const availableHint = decks.length > 0
675
+ ? ` Available deck IDs: ${decks.slice(0, 10).map((deck) => deck.id).join(", ")}`
676
+ : " Snapshot directory is empty.";
677
+ return {
678
+ content: [{ type: "text", text: `Saved deck "${deckId}" not found in ${snapshotDir}.${availableHint}` }],
679
+ details: { status: "error", url: "" },
680
+ };
681
+ }
682
+ if (p.action === "export") {
683
+ const format = typeof p.format === "string" && p.format.trim() !== "" ? p.format.trim().toLowerCase() : "html";
684
+ if (format !== "html") {
685
+ return {
686
+ content: [{ type: "text", text: `Unsupported export format: ${format}` }],
687
+ details: { status: "error", url: "" },
688
+ };
689
+ }
690
+ try {
691
+ const saved = validateSavedDeck(JSON.parse(fs.readFileSync(deckPath, "utf-8")));
692
+ const enrichedSaved: SavedDeckData = {
693
+ ...saved,
694
+ id: saved.id ?? deckId,
695
+ status: saved.status ?? deriveDeckStatusFromFolderName(deckId),
696
+ };
697
+ const html = buildStandaloneDeckHtml(deckPath, enrichedSaved);
698
+ const exportPath = path.join(path.dirname(deckPath), "export.html");
699
+ fs.writeFileSync(exportPath, html, "utf-8");
700
+ const relativePath = exportPath.startsWith(os.homedir())
701
+ ? "~" + exportPath.slice(os.homedir().length)
702
+ : exportPath;
703
+ return {
704
+ content: [{ type: "text", text: `Exported HTML to ${relativePath}` }],
705
+ };
706
+ } catch (err) {
707
+ const message = err instanceof Error ? err.message : String(err);
708
+ return {
709
+ content: [{ type: "text", text: `Failed to export deck "${deckId}": ${message}` }],
710
+ details: { status: "error", url: "" },
711
+ };
712
+ }
713
+ }
714
+ slides = deckPath;
715
+ }
423
716
 
424
717
  let configData: unknown;
425
718
  let savedSelections: Record<string, string> | undefined;
719
+ let savedNotes: Record<string, { label: string; notes: string }> | undefined;
720
+ let savedFinalNotes: string | undefined;
426
721
  try {
427
722
  configData = JSON.parse(slides);
428
723
  } catch {
@@ -431,37 +726,13 @@ export default function (pi: ExtensionAPI) {
431
726
  if (!fs.existsSync(absolutePath)) {
432
727
  throw new Error(`Invalid slides: not valid JSON and file not found at ${absolutePath}`);
433
728
  }
434
- const content = fs.readFileSync(absolutePath, "utf-8");
435
- let fileData: unknown;
436
- try {
437
- fileData = JSON.parse(content);
438
- } catch (parseErr) {
439
- const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
440
- throw new Error(`Invalid JSON in saved deck file: ${message}`);
441
- }
442
- const raw = fileData as Record<string, unknown>;
443
- if (raw.config && typeof raw.config === "object") {
444
- const saved = validateSavedDeck(fileData);
445
- configData = saved.config;
446
- savedSelections = Object.keys(saved.selections).length > 0 ? saved.selections : undefined;
447
- const snapshotDir = path.dirname(absolutePath);
448
- for (const slide of saved.config.slides) {
449
- for (const option of slide.options) {
450
- if (!option.previewBlocks) continue;
451
- for (const block of option.previewBlocks) {
452
- if (block.type === "image" && !path.isAbsolute(block.src)) {
453
- block.src = path.join(snapshotDir, block.src);
454
- }
455
- }
456
- }
457
- }
458
- } else {
459
- configData = fileData;
460
- }
729
+ const loaded = loadDeckFile(absolutePath);
730
+ configData = loaded.configData;
731
+ savedSelections = loaded.savedSelections;
732
+ savedNotes = loaded.savedNotes;
733
+ savedFinalNotes = loaded.savedFinalNotes;
461
734
  }
462
735
  const config = validateDeckConfig(configData);
463
-
464
- const settings = loadSettings();
465
736
  const sessionId = randomUUID();
466
737
  const sessionToken = randomUUID();
467
738
 
@@ -472,17 +743,28 @@ export default function (pi: ExtensionAPI) {
472
743
  };
473
744
  }
474
745
 
475
- const handleSubmit = (selections: Record<string, string>) => {
746
+ const handleSubmit = (selections: Record<string, string>, notes?: Record<string, string>, finalNotes?: string) => {
476
747
  if (!activeDeckServer) return;
477
748
  const url = activeDeckServer.handle.url;
749
+ const hasNotes = notes && Object.keys(notes).length > 0;
750
+ const textParts = [`Design deck completed.\n\nSelections:\n${formatDeckSelections(selections, notes)}`];
751
+ if (finalNotes) {
752
+ textParts.push(`\nAdditional instructions:\n${finalNotes}`);
753
+ }
478
754
  cleanupActiveDeckAndStoreResult({
479
755
  content: [
480
756
  {
481
757
  type: "text",
482
- text: `Design deck completed.\n\nSelections:\n${formatDeckSelections(selections)}`,
758
+ text: textParts.join(""),
483
759
  },
484
760
  ],
485
- details: { status: "completed", url, selections },
761
+ details: {
762
+ status: "completed",
763
+ url,
764
+ selections,
765
+ ...(hasNotes ? { notes } : {}),
766
+ ...(finalNotes ? { finalNotes } : {}),
767
+ },
486
768
  });
487
769
  };
488
770
 
@@ -503,8 +785,12 @@ export default function (pi: ExtensionAPI) {
503
785
  });
504
786
  };
505
787
 
506
- const handleGenerateMore = (slideId: string, prompt?: string, model?: string, thinking?: string) => {
507
- if (!activeDeckServer?.currentResolve) return;
788
+ const handleGenerateMore = (slideId: string, prompt?: string, model?: string, thinking?: string, count?: number) => {
789
+ if (!activeDeckServer?.currentResolve) {
790
+ // Agent is no longer listening - close the deck
791
+ cleanupActiveDeck("stale");
792
+ return;
793
+ }
508
794
  const resolve = activeDeckServer.currentResolve;
509
795
  activeDeckServer.currentResolve = null;
510
796
  armDeckIdleTimer();
@@ -513,8 +799,9 @@ export default function (pi: ExtensionAPI) {
513
799
  if (thinking && !effectiveModel) {
514
800
  pi.setThinkingLevel(thinking as "off" | "minimal" | "low" | "medium" | "high" | "xhigh");
515
801
  }
802
+ const effectiveCount = count && count >= 1 && count <= 5 ? count : 1;
516
803
  resolve({
517
- content: [{ type: "text", text: buildGenerateMoreResult(slideId, slide, prompt, effectiveModel, thinking) }],
804
+ content: [{ type: "text", text: buildGenerateMoreResult(slideId, slide, prompt, effectiveModel, thinking, effectiveCount) }],
518
805
  details: {
519
806
  status: "generate-more",
520
807
  url: activeDeckServer.handle.url,
@@ -524,7 +811,11 @@ export default function (pi: ExtensionAPI) {
524
811
  };
525
812
 
526
813
  const handleRegenerateSlide = (slideId: string, prompt?: string, model?: string, thinking?: string) => {
527
- if (!activeDeckServer?.currentResolve) return;
814
+ if (!activeDeckServer?.currentResolve) {
815
+ // Agent is no longer listening - close the deck
816
+ cleanupActiveDeck("stale");
817
+ return;
818
+ }
528
819
  const resolve = activeDeckServer.currentResolve;
529
820
  activeDeckServer.currentResolve = null;
530
821
  armDeckIdleTimer();
@@ -545,8 +836,6 @@ export default function (pi: ExtensionAPI) {
545
836
  };
546
837
 
547
838
  const themeConfig = settings.theme ?? {};
548
- const snapshotDir = settings.snapshotDir ? expandHome(settings.snapshotDir) : undefined;
549
-
550
839
  const currentModelStr = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null;
551
840
  const availableModels: ModelInfo[] = ctx.modelRegistry.getAvailable().map((m) => ({
552
841
  provider: m.provider,
@@ -570,6 +859,8 @@ export default function (pi: ExtensionAPI) {
570
859
  toggleHotkey: themeConfig.toggleHotkey ?? DEFAULT_THEME_HOTKEY,
571
860
  },
572
861
  savedSelections,
862
+ savedNotes,
863
+ savedFinalNotes,
573
864
  snapshotDir,
574
865
  autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
575
866
  models: {
@@ -618,6 +909,13 @@ export default function (pi: ExtensionAPI) {
618
909
  0
619
910
  );
620
911
  }
912
+ if (data.action === "add-options") {
913
+ return new Text(
914
+ theme.fg("toolTitle", theme.bold(`Design Deck: add options (${data.slideId || "unknown"})`)),
915
+ 0,
916
+ 0
917
+ );
918
+ }
621
919
  if (data.action === "replace-options") {
622
920
  return new Text(
623
921
  theme.fg("toolTitle", theme.bold(`Design Deck: replace options (${data.slideId || "unknown"})`)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-design-deck",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Visual design deck for presenting multi-slide options with high-fidelity previews",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -16,6 +16,7 @@
16
16
  "model-runner.ts",
17
17
  "server-utils.ts",
18
18
  "settings.ts",
19
+ "export-html.ts",
19
20
  "index.ts"
20
21
  ],
21
22
  "scripts": {
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  description: Interview to gather requirements, then present design options
3
3
  ---
4
- Load the `design-deck` skill for the full format reference. This is a two-phase flow.
4
+ Load the `design-deck` skill for the full format reference. If presenting UI component options (tabs, trees, buttons, accordions, etc.), also read the relevant file from the skill's `references/component-gallery/` directory — it has visual vocabulary across design systems plus guidance on when to use distinct systems vs variations of the same approach.
5
+
6
+ This is a two-phase flow.
5
7
 
6
8
  **Phase 1 — Discovery.** Before proposing anything, interview me in depth. Read relevant codebase context first so your questions are informed. Then hit me with detailed questions about goals, constraints, audience, aesthetics, technical requirements, trade-offs, edge cases, and anything else that would affect the design options. No obvious questions. Keep interviewing in multiple rounds if earlier answers surface new dimensions worth exploring — only move on when you have enough to generate genuinely distinct options.
7
9
 
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  description: Analyze a plan/PRD and present design & architecture options
3
3
  ---
4
- Load the `design-deck` skill for the full format reference. Then read and analyze the plan or PRD at `$1`. Also read the actual codebase files it references — in full to understand the real state of the code, not just what the plan assumes.
4
+ Load the `design-deck` skill for the full format reference. If presenting UI component options (tabs, trees, buttons, accordions, etc.), also read the relevant file from the skill's `references/component-gallery/` directory it has visual vocabulary across design systems plus guidance on when to use distinct systems vs variations of the same approach.
5
+
6
+ Then read and analyze the plan or PRD at `$1`. Also read the actual codebase files it references — in full — to understand the real state of the code, not just what the plan assumes.
5
7
 
6
8
  Identify the key design and architecture decisions embedded in the plan. For each decision point, build a slide with 2-4 concrete options that are faithful to the plan's goals but offer genuinely different approaches. Mix preview types as appropriate: mermaid diagrams for architecture, code blocks for API design, HTML for UI mockups, images if available.
7
9