pi-design-deck 0.1.1 → 0.2.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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  A tool for [Pi coding agent](https://github.com/badlogic/pi-mono/) that presents multi-slide visual decision decks in the browser. Each slide shows 2-4 high-fidelity previews — code diffs, architecture diagrams, UI mockups — and you pick one per slide. The agent gets back a clean selection map and moves on to implementation.
8
8
 
9
- https://github.com/user-attachments/assets/aff1bac6-8bc2-461a-8828-f588ce655f7f
9
+ <img width="1340" alt="Design Deck screenshot" src="https://github.com/user-attachments/assets/20864ac6-9223-4e2e-ba3c-db3eaae0abd8" />
10
10
 
11
11
  ## Usage
12
12
 
@@ -42,6 +42,8 @@ Restart pi to load the extension and the bundled `design-deck` skill.
42
42
  **Requirements:**
43
43
  - pi-agent v0.35.0 or later (extensions API)
44
44
 
45
+ https://github.com/user-attachments/assets/aff1bac6-8bc2-461a-8828-f588ce655f7f
46
+
45
47
  ## Quick Start
46
48
 
47
49
  The agent builds slides as JSON. Each slide is one decision, each option is one approach:
@@ -178,7 +180,7 @@ The browser shows the new option with an entry animation. The tool blocks again,
178
180
 
179
181
  ### Model Override
180
182
 
181
- The deck shows a model dropdown when 2+ models are available. Users pick which model generates new options. When a model other than the current one is selected, the generate-more result instructs the agent to delegate to a subagent with that model.
183
+ The deck shows a model dropdown when 2+ models are available. Users pick which model generates new options. When a model other than the current one is selected, the generate-more result instructs the agent to use the built-in `deck_generate` tool, which spawns pi headlessly with that model.
182
184
 
183
185
  The default model can be set in the UI (saved to settings) or in `settings.json`:
184
186
 
@@ -286,6 +288,7 @@ Three modes of invocation:
286
288
  pi-design-deck/
287
289
  ├── index.ts # Tool registration, module-level state, lifecycle
288
290
  ├── generate-prompts.ts # Prompt builders for generate-more / regenerate
291
+ ├── model-runner.ts # Headless pi spawner for deck_generate tool
289
292
  ├── deck-schema.ts # TypeScript types and validation (no dependencies)
290
293
  ├── deck-server.ts # HTTP server, SSE, asset serving, snapshots
291
294
  ├── server-utils.ts # Shared HTTP/session utilities
package/deck-server.ts CHANGED
@@ -173,16 +173,16 @@ export interface DeckServerOptions {
173
173
  }
174
174
 
175
175
  export interface DeckServerCallbacks {
176
- onSubmit: (selections: Record<string, string>) => void;
176
+ onSubmit: (selections: Record<string, string>, notes?: Record<string, string>, finalNotes?: string) => void;
177
177
  onCancel: (reason?: "user" | "stale" | "aborted") => void;
178
- onGenerateMore: (slideId: string, prompt?: string, model?: string, thinking?: string) => void;
178
+ onGenerateMore: (slideId: string, prompt?: string, model?: string, thinking?: string, count?: number) => void;
179
179
  onRegenerateSlide: (slideId: string, prompt?: string, model?: string, thinking?: string) => void;
180
180
  }
181
181
 
182
182
  export interface DeckServerHandle {
183
183
  url: string;
184
184
  port: number;
185
- close: () => void;
185
+ close: (reason?: string) => void;
186
186
  pushOption: (slideId: string, option: DeckOption) => void;
187
187
  cancelGenerate: () => void;
188
188
  replaceSlideOptions: (slideId: string, options: DeckOption[]) => void;
@@ -414,12 +414,14 @@ export async function startDeckServer(
414
414
  return;
415
415
  }
416
416
 
417
- const payload = body as { selections?: unknown };
417
+ const payload = body as { selections?: unknown; notes?: unknown; finalNotes?: unknown };
418
418
  const selections = toStringMap(payload.selections);
419
419
  if (!selections) {
420
420
  sendJson(res, 400, { ok: false, error: "Invalid selections payload" });
421
421
  return;
422
422
  }
423
+ const notes = toStringMap(payload.notes) ?? undefined;
424
+ const finalNotes = typeof payload.finalNotes === "string" ? payload.finalNotes.trim() : undefined;
423
425
 
424
426
  touchHeartbeat();
425
427
  if (autoSaveOnSubmit !== false) {
@@ -431,7 +433,7 @@ export async function startDeckServer(
431
433
  unregisterSession(sessionId);
432
434
  pushEvent("deck-close", { reason: "submitted" });
433
435
  sendJson(res, 200, { ok: true });
434
- setImmediate(() => callbacks.onSubmit(selections));
436
+ setImmediate(() => callbacks.onSubmit(selections, notes, finalNotes || undefined));
435
437
  return;
436
438
  }
437
439
 
@@ -492,7 +494,7 @@ export async function startDeckServer(
492
494
  return;
493
495
  }
494
496
 
495
- const payload = body as { slideId?: string; prompt?: string; model?: string; thinking?: string };
497
+ const payload = body as { slideId?: string; prompt?: string; model?: string; thinking?: string; count?: number };
496
498
  if (typeof payload.slideId !== "string" || payload.slideId.trim() === "") {
497
499
  sendJson(res, 400, { ok: false, error: "slideId is required" });
498
500
  return;
@@ -509,12 +511,13 @@ export async function startDeckServer(
509
511
  const prompt = typeof payload.prompt === "string" ? payload.prompt.trim() || undefined : undefined;
510
512
  const model = typeof payload.model === "string" ? (payload.model.trim() || "") : undefined;
511
513
  const thinking = typeof payload.thinking === "string" ? payload.thinking.trim() || undefined : undefined;
514
+ const count = typeof payload.count === "number" && payload.count >= 1 && payload.count <= 5 ? payload.count : 1;
512
515
 
513
516
  setPendingGenerate(payload.slideId as string, false);
514
517
  touchHeartbeat();
515
518
  sendJson(res, 200, { ok: true });
516
519
  setImmediate(() => {
517
- callbacks.onGenerateMore(payload.slideId as string, prompt, model, thinking);
520
+ callbacks.onGenerateMore(payload.slideId as string, prompt, model, thinking, count);
518
521
  });
519
522
  return;
520
523
  }
@@ -603,11 +606,11 @@ export async function startDeckServer(
603
606
  resolve({
604
607
  url,
605
608
  port: addr.port,
606
- close: () => {
609
+ close: (reason?: string) => {
607
610
  if (!completed) {
608
611
  markCompleted();
609
612
  unregisterSession(sessionId);
610
- pushEvent("deck-close", { reason: "closed" });
613
+ pushEvent("deck-close", { reason: reason || "closed" });
611
614
  }
612
615
  try {
613
616
  server.close();
@@ -27,6 +27,30 @@
27
27
  }
28
28
  .btn-gen-more:disabled { opacity: 0.3; cursor: not-allowed; }
29
29
 
30
+ .gen-group { display: flex; align-items: center; gap: 6px; }
31
+
32
+ .gen-count {
33
+ appearance: none;
34
+ -webkit-appearance: none;
35
+ background: rgba(var(--dk-ink),0.12);
36
+ border: 1px solid rgba(var(--dk-ink),0.15);
37
+ border-radius: 4px;
38
+ padding: 4px 8px;
39
+ font-size: 12px;
40
+ font-weight: 600;
41
+ color: var(--dk-accent-text);
42
+ cursor: pointer;
43
+ text-align: center;
44
+ min-width: 32px;
45
+ }
46
+ .gen-count:focus { outline: none; border-color: rgba(138,190,183,0.4); background: rgba(138,190,183,0.1); }
47
+ .gen-count:disabled { opacity: 0.4; cursor: not-allowed; }
48
+
49
+ .gen-count-label {
50
+ font-size: 12px;
51
+ color: var(--dk-text-dim);
52
+ }
53
+
30
54
  .btn-regen {
31
55
  display: inline-flex; align-items: center; gap: 6px;
32
56
  padding: 8px 14px; border-radius: 8px;
@@ -120,6 +144,102 @@
120
144
  }
121
145
  @keyframes skel-shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
122
146
 
147
+ /* Regeneration overlay - covers options grid during regenerate-all */
148
+ .regen-overlay {
149
+ position: absolute;
150
+ inset: 0;
151
+ display: grid;
152
+ gap: 20px;
153
+ padding: 0;
154
+ z-index: 10;
155
+ animation: regen-fade-in 0.3s ease-out;
156
+ }
157
+ .regen-overlay.cols-3 { grid-template-columns: repeat(3, 1fr); }
158
+ .regen-overlay.cols-2 { grid-template-columns: repeat(2, 1fr); }
159
+ .regen-overlay.cols-1 { grid-template-columns: 1fr; }
160
+
161
+ @keyframes regen-fade-in {
162
+ from { opacity: 0; }
163
+ to { opacity: 1; }
164
+ }
165
+
166
+ .regen-skeleton {
167
+ border: 2px dashed rgba(138,190,183,0.15);
168
+ border-radius: 12px;
169
+ background: var(--dk-surface);
170
+ min-height: 280px;
171
+ position: relative;
172
+ overflow: hidden;
173
+ }
174
+ .regen-skeleton::before {
175
+ content: '';
176
+ position: absolute;
177
+ top: 0; left: 0; right: 0;
178
+ height: 44px;
179
+ background: linear-gradient(110deg, rgba(var(--dk-ink),0.03) 30%, rgba(var(--dk-ink),0.07) 50%, rgba(var(--dk-ink),0.03) 70%);
180
+ background-size: 200% 100%;
181
+ animation: skel-shimmer 1.5s ease-in-out infinite;
182
+ border-bottom: 1px solid rgba(var(--dk-ink),0.06);
183
+ }
184
+ .regen-skeleton::after {
185
+ content: '';
186
+ position: absolute;
187
+ top: 45px; left: 0; right: 0; bottom: 0;
188
+ background: linear-gradient(110deg, var(--dk-surface) 30%, var(--dk-surface-shimmer) 50%, var(--dk-surface) 70%);
189
+ background-size: 200% 100%;
190
+ animation: skel-shimmer 1.5s ease-in-out infinite;
191
+ animation-delay: 0.1s;
192
+ }
193
+ .regen-skeleton:nth-child(2)::before,
194
+ .regen-skeleton:nth-child(2)::after { animation-delay: 0.2s; }
195
+ .regen-skeleton:nth-child(3)::before,
196
+ .regen-skeleton:nth-child(3)::after { animation-delay: 0.3s; }
197
+ .regen-skeleton:nth-child(4)::before,
198
+ .regen-skeleton:nth-child(4)::after { animation-delay: 0.4s; }
199
+
200
+ /* Regeneration status text */
201
+ .regen-status {
202
+ position: absolute;
203
+ bottom: 16px; left: 50%;
204
+ transform: translateX(-50%);
205
+ font-family: var(--dk-font-mono);
206
+ font-size: 11px;
207
+ color: var(--dk-accent-text);
208
+ letter-spacing: 0.3px;
209
+ display: flex;
210
+ align-items: center;
211
+ gap: 8px;
212
+ }
213
+ .regen-status::before {
214
+ content: '';
215
+ width: 14px; height: 14px;
216
+ border: 2px solid rgba(138,190,183,0.2);
217
+ border-top-color: var(--dk-accent);
218
+ border-radius: 50%;
219
+ animation: spin 0.8s linear infinite;
220
+ }
221
+
222
+ /* Options grid regenerating state */
223
+ .options.regenerating {
224
+ opacity: 0;
225
+ pointer-events: none;
226
+ transition: opacity 0.25s ease-out;
227
+ }
228
+
229
+ /* Regenerated options entry animation */
230
+ .option-regenerated {
231
+ animation: opt-regen-appear 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
232
+ opacity: 0;
233
+ }
234
+ .option-regenerated:nth-child(1) { animation-delay: 0.05s; }
235
+ .option-regenerated:nth-child(2) { animation-delay: 0.1s; }
236
+ .option-regenerated:nth-child(3) { animation-delay: 0.15s; }
237
+ .option-regenerated:nth-child(4) { animation-delay: 0.2s; }
238
+ @keyframes opt-regen-appear {
239
+ 0% { opacity: 0; transform: translateY(12px) scale(0.96); }
240
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
241
+ }
242
+
123
243
  /* ─────────────────────────────────────────────────────────────
124
244
  MODEL BAR
125
245
  ───────────────────────────────────────────────────────────── */
@@ -268,6 +268,21 @@ body {
268
268
  color: var(--dk-text-secondary); font-family: var(--dk-font-mono);
269
269
  font-style: italic;
270
270
  }
271
+ .summary-notes {
272
+ margin-top: 10px; padding: 8px 10px;
273
+ font-size: 11px; line-height: 1.5;
274
+ color: var(--dk-accent-text);
275
+ background: rgba(138,190,183,0.08);
276
+ border-radius: 4px;
277
+ border-left: 2px solid rgba(138,190,183,0.3);
278
+ }
279
+ .summary-notes-label {
280
+ font-weight: 600;
281
+ font-size: 9px;
282
+ text-transform: uppercase;
283
+ letter-spacing: 0.3px;
284
+ opacity: 0.8;
285
+ }
271
286
  .summary-preview {
272
287
  margin-top: 12px; border-radius: 6px; overflow: hidden;
273
288
  border: 1px solid rgba(var(--dk-ink),0.12); max-height: 120px;
@@ -288,6 +303,47 @@ body {
288
303
  }
289
304
  .summary-mermaid svg { max-width: 100%; max-height: 100%; }
290
305
 
306
+ /* Final notes input on summary slide */
307
+ .final-notes {
308
+ width: 100%;
309
+ max-width: 560px;
310
+ margin-top: 24px;
311
+ display: flex;
312
+ flex-direction: column;
313
+ gap: 8px;
314
+ }
315
+ .final-notes-label {
316
+ font-size: 11px;
317
+ font-weight: 500;
318
+ color: var(--dk-text-dim);
319
+ text-transform: uppercase;
320
+ letter-spacing: 0.5px;
321
+ }
322
+ .final-notes-input {
323
+ width: 100%;
324
+ padding: 12px 14px;
325
+ border-radius: 8px;
326
+ border: 1px solid rgba(var(--dk-ink),0.12);
327
+ background: rgba(var(--dk-ink),0.04);
328
+ font-size: 13px;
329
+ font-family: inherit;
330
+ color: var(--dk-text);
331
+ resize: vertical;
332
+ min-height: 60px;
333
+ max-height: 120px;
334
+ transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
335
+ }
336
+ .final-notes-input::placeholder {
337
+ color: var(--dk-text-placeholder);
338
+ font-style: italic;
339
+ }
340
+ .final-notes-input:focus {
341
+ outline: none;
342
+ border-color: rgba(138,190,183,0.4);
343
+ background: rgba(var(--dk-ink),0.06);
344
+ box-shadow: 0 0 0 3px rgba(138,190,183,0.1);
345
+ }
346
+
291
347
  .btn-generate {
292
348
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
293
349
  padding: 14px 48px; border-radius: 10px; font-size: 14px; font-weight: 600;
@@ -59,17 +59,71 @@ code[class*="language-"], pre[class*="language-"] {
59
59
  OPTION ASIDE
60
60
  ───────────────────────────────────────────────────────────── */
61
61
 
62
- .option-aside {
63
- margin: 0 14px 14px;
62
+ /* Option footer - contains aside + notes */
63
+ .option-footer {
64
64
  padding: 12px 14px;
65
- border-left: 2px solid rgba(138,190,183,0.25);
66
- border-radius: 0 6px 6px 0;
67
- background: rgba(var(--dk-ink),0.04);
68
- font-size: 12px; line-height: 1.7;
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 8px;
68
+ }
69
+
70
+ .option-aside {
71
+ padding: 8px 10px;
72
+ border-left: 2px solid rgba(138,190,183,0.2);
73
+ background: rgba(var(--dk-ink),0.02);
74
+ font-size: 11px; line-height: 1.55;
69
75
  color: var(--dk-text-aside);
70
76
  font-family: var(--dk-font-mono);
71
77
  font-style: italic;
72
78
  letter-spacing: -0.01em;
79
+ border-radius: 0 4px 4px 0;
80
+ }
81
+
82
+ /* Option notes input */
83
+ .option-notes {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 8px;
87
+ }
88
+ .option-notes-label {
89
+ font-size: 9px;
90
+ font-weight: 500;
91
+ color: var(--dk-text-hint);
92
+ text-transform: uppercase;
93
+ letter-spacing: 0.3px;
94
+ white-space: nowrap;
95
+ opacity: 0.6;
96
+ }
97
+ .option-notes-input {
98
+ flex: 1;
99
+ padding: 5px 8px;
100
+ border-radius: 4px;
101
+ border: 1px dashed rgba(var(--dk-ink),0.12);
102
+ background: transparent;
103
+ font-size: 11px;
104
+ font-family: var(--dk-font-mono);
105
+ color: var(--dk-text);
106
+ resize: none;
107
+ min-height: 24px;
108
+ transition: border-color 0.2s, background 0.2s;
109
+ }
110
+ .option-notes-input::placeholder {
111
+ color: var(--dk-text-placeholder);
112
+ opacity: 0.4;
113
+ font-size: 10px;
114
+ }
115
+ .option-notes-input:focus {
116
+ outline: none;
117
+ border-style: solid;
118
+ border-color: rgba(138,190,183,0.3);
119
+ background: rgba(var(--dk-ink),0.02);
120
+ }
121
+ .option.selected .option-notes-input {
122
+ border-color: rgba(138,190,183,0.2);
123
+ }
124
+ .option-notes-input:disabled {
125
+ opacity: 0.4;
126
+ cursor: not-allowed;
73
127
  }
74
128
 
75
129
  /* ─────────────────────────────────────────────────────────────
@@ -23,6 +23,8 @@ let isClosed = false;
23
23
  let isSubmitting = false;
24
24
 
25
25
  const selections = {};
26
+ const optionNotes = {};
27
+ let finalNotes = "";
26
28
  const pendingGenerate = new Map();
27
29
  let selectedModel = "";
28
30
  let selectedThinking = "off";
@@ -184,13 +186,23 @@ function initTheme() {
184
186
  const SELECTIONS_KEY = `pi-deck-${typeof deckData.sessionId === "string" ? deckData.sessionId : "unknown"}`;
185
187
 
186
188
  function saveSelectionsToStorage() {
187
- try { localStorage.setItem(SELECTIONS_KEY, JSON.stringify(selections)); } catch {}
189
+ try {
190
+ const data = { selections, optionNotes };
191
+ if (finalNotes) data.finalNotes = finalNotes;
192
+ localStorage.setItem(SELECTIONS_KEY, JSON.stringify(data));
193
+ } catch {}
188
194
  }
189
195
 
190
196
  function loadSelectionsFromStorage() {
191
197
  try {
192
198
  const saved = localStorage.getItem(SELECTIONS_KEY);
193
- return saved ? JSON.parse(saved) : null;
199
+ if (!saved) return null;
200
+ const parsed = JSON.parse(saved);
201
+ // Handle both old format (just selections) and new format ({ selections, optionNotes })
202
+ if (parsed && typeof parsed === "object" && !parsed.selections) {
203
+ return { selections: parsed, optionNotes: {} };
204
+ }
205
+ return parsed;
194
206
  } catch { return null; }
195
207
  }
196
208
 
@@ -1,8 +1,8 @@
1
1
  // ─── SELECTION ───────────────────────────────────────────────
2
2
 
3
- function applySavedSelections(saved) {
4
- if (!saved || typeof saved !== "object") return;
5
- for (const [slideId, label] of Object.entries(saved)) {
3
+ function applySavedSelections(savedSelections, savedNotes) {
4
+ if (!savedSelections || typeof savedSelections !== "object") return;
5
+ for (const [slideId, label] of Object.entries(savedSelections)) {
6
6
  if (typeof label !== "string") continue;
7
7
  const slideEl = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
8
8
  if (!slideEl) continue;
@@ -13,16 +13,35 @@ function applySavedSelections(saved) {
13
13
  }
14
14
  }
15
15
  }
16
+ // Restore notes
17
+ if (savedNotes && typeof savedNotes === "object") {
18
+ for (const [slideId, noteData] of Object.entries(savedNotes)) {
19
+ if (noteData && typeof noteData === "object" && noteData.label && noteData.notes) {
20
+ optionNotes[slideId] = noteData;
21
+ // Find and populate the textarea
22
+ const input = document.querySelector(`.option-notes-input[data-slide-id="${CSS.escape(slideId)}"][data-option-label="${CSS.escape(noteData.label)}"]`);
23
+ if (input) input.value = noteData.notes;
24
+ }
25
+ }
26
+ }
16
27
  }
17
28
 
18
29
  function restoreSelections() {
19
30
  const serverSaved = deckData.savedSelections;
20
31
  if (serverSaved && typeof serverSaved === "object" && Object.keys(serverSaved).length > 0) {
21
- applySavedSelections(serverSaved);
32
+ applySavedSelections(serverSaved, null);
22
33
  return;
23
34
  }
24
35
  const stored = loadSelectionsFromStorage();
25
- if (stored) applySavedSelections(stored);
36
+ if (stored) {
37
+ applySavedSelections(stored.selections || stored, stored.optionNotes);
38
+ // Restore final notes if present
39
+ if (stored.finalNotes) {
40
+ finalNotes = stored.finalNotes;
41
+ const input = document.getElementById("final-notes-input");
42
+ if (input) input.value = stored.finalNotes;
43
+ }
44
+ }
26
45
  }
27
46
 
28
47
  function applySelectionClasses(slideElement, selectedElement) {
@@ -337,7 +356,8 @@ function initModelBar(modelsData) {
337
356
  }
338
357
 
339
358
  function syncDefaultCheck() {
340
- defaultCheck.checked = modelsData.defaultModel != null && selectedModel === modelsData.defaultModel;
359
+ const effectiveModel = selectedModel || modelsData.current;
360
+ defaultCheck.checked = modelsData.defaultModel != null && effectiveModel === modelsData.defaultModel;
341
361
  }
342
362
 
343
363
  function activateProvider(provider) {
@@ -369,12 +389,18 @@ function initModelBar(modelsData) {
369
389
  });
370
390
 
371
391
  defaultCheck.addEventListener("change", async () => {
372
- if (defaultCheck.checked && !selectedModel) { defaultCheck.checked = false; return; }
373
- const model = defaultCheck.checked ? selectedModel : null;
392
+ // Use selectedModel, or fall back to current model when on "Current" pill
393
+ const modelToSave = selectedModel || modelsData.current;
394
+ if (defaultCheck.checked && !modelToSave) { defaultCheck.checked = false; return; }
395
+ const model = defaultCheck.checked ? modelToSave : null;
374
396
  try {
375
397
  await postJson("/save-model-default", { token: sessionToken, model });
376
398
  modelsData.defaultModel = model;
377
- } catch {}
399
+ } catch (err) {
400
+ console.error("Failed to save default model:", err);
401
+ // Revert checkbox to match actual state
402
+ syncDefaultCheck();
403
+ }
378
404
  });
379
405
 
380
406
  if (modelsData.defaultModel) {
@@ -114,6 +114,10 @@ function applyPreviewHtml(preview, previewHtml) {
114
114
  if (dataset.fonts) {
115
115
  preview.dataset.fonts = dataset.fonts;
116
116
  }
117
+ // Copy inline styles from source preview div
118
+ if (first.style.cssText) {
119
+ preview.style.cssText = first.style.cssText;
120
+ }
117
121
  preview.innerHTML = first.innerHTML;
118
122
  return;
119
123
  }
@@ -186,11 +190,47 @@ function createOptionCard(option, slideId, generatedBy) {
186
190
  el.tabIndex = -1;
187
191
  });
188
192
 
193
+ // Footer contains aside (optional) + notes input
194
+ const footer = createElement("div", "option-footer");
195
+
189
196
  if (option.aside) {
190
197
  const aside = createElement("div", "option-aside");
191
- aside.innerHTML = escapeHtml(option.aside).replace(/\n/g, "<br>");
192
- card.appendChild(aside);
198
+ // Handle both actual newlines and literal \n sequences
199
+ aside.innerHTML = escapeHtml(option.aside).replace(/\\n/g, "<br>").replace(/\n/g, "<br>");
200
+ footer.appendChild(aside);
201
+ }
202
+
203
+ // Notes input for user instructions
204
+ const notesContainer = createElement("div", "option-notes");
205
+ const notesLabel = createElement("label", "option-notes-label", "Your notes (optional)");
206
+ const notesInput = createElement("textarea", "option-notes-input");
207
+ notesInput.placeholder = "Add notes...";
208
+ notesInput.rows = 1;
209
+ notesInput.dataset.slideId = slideId;
210
+ notesInput.dataset.optionLabel = option.label;
211
+
212
+ // Restore saved notes if this option was previously selected with notes
213
+ if (optionNotes[slideId]?.label === option.label && optionNotes[slideId]?.notes) {
214
+ notesInput.value = optionNotes[slideId].notes;
193
215
  }
216
+
217
+ notesInput.addEventListener("input", (e) => {
218
+ const value = e.target.value.trim();
219
+ if (value) {
220
+ optionNotes[slideId] = { label: option.label, notes: value };
221
+ } else if (optionNotes[slideId]?.label === option.label) {
222
+ delete optionNotes[slideId];
223
+ }
224
+ saveSelectionsToStorage();
225
+ });
226
+
227
+ // Prevent click from bubbling to card (which would select it)
228
+ notesInput.addEventListener("click", (e) => e.stopPropagation());
229
+
230
+ notesContainer.appendChild(notesLabel);
231
+ notesContainer.appendChild(notesInput);
232
+ footer.appendChild(notesContainer);
233
+ card.appendChild(footer);
194
234
 
195
235
  if (option.description) {
196
236
  card.setAttribute("title", option.description);
@@ -207,10 +247,33 @@ function createGenerateBar(slideId) {
207
247
  const bar = createElement("div", "gen-bar");
208
248
 
209
249
  const row = createElement("div", "gen-row");
250
+
251
+ // Generate button with separate count selector
252
+ const genGroup = createElement("div", "gen-group");
210
253
  const button = createElement("button", "btn-gen-more");
211
254
  button.type = "button";
212
255
  button.appendChild(createElement("span", "btn-gen-plus", "+"));
213
- button.appendChild(document.createTextNode(" Generate another option"));
256
+ button.appendChild(document.createTextNode("Generate"));
257
+
258
+ const countSelect = document.createElement("select");
259
+ countSelect.className = "gen-count";
260
+ countSelect.setAttribute("aria-label", "Number of options to generate");
261
+ [1, 2, 3].forEach((n) => {
262
+ const opt = document.createElement("option");
263
+ opt.value = n;
264
+ opt.textContent = n;
265
+ countSelect.appendChild(opt);
266
+ });
267
+
268
+ const countLabel = createElement("span", "gen-count-label", "option");
269
+ countSelect.addEventListener("change", () => {
270
+ const count = parseInt(countSelect.value, 10);
271
+ countLabel.textContent = count === 1 ? "option" : "options";
272
+ });
273
+
274
+ genGroup.appendChild(button);
275
+ genGroup.appendChild(countSelect);
276
+ genGroup.appendChild(countLabel);
214
277
 
215
278
  const regenButton = createElement("button", "btn-regen");
216
279
  regenButton.type = "button";
@@ -231,8 +294,8 @@ function createGenerateBar(slideId) {
231
294
  e.stopPropagation();
232
295
  });
233
296
 
234
- button.addEventListener("click", () => generateMore(button, slideId, input));
235
- row.appendChild(button);
297
+ button.addEventListener("click", () => generateMore(button, slideId, input, countSelect));
298
+ row.appendChild(genGroup);
236
299
  row.appendChild(regenButton);
237
300
  row.appendChild(input);
238
301
  bar.appendChild(row);
@@ -301,6 +364,22 @@ function createSummarySlide(index) {
301
364
  summaryGrid.id = "summary-grid";
302
365
  section.appendChild(summaryGrid);
303
366
 
367
+ // Final instructions textarea
368
+ const finalNotesContainer = createElement("div", "final-notes");
369
+ const finalNotesLabel = createElement("label", "final-notes-label", "Additional instructions (optional)");
370
+ finalNotesLabel.setAttribute("for", "final-notes-input");
371
+ const finalNotesInput = createElement("textarea", "final-notes-input");
372
+ finalNotesInput.id = "final-notes-input";
373
+ finalNotesInput.placeholder = "Add any final notes or instructions for implementation...";
374
+ finalNotesInput.rows = 2;
375
+ finalNotesInput.addEventListener("input", (e) => {
376
+ finalNotes = e.target.value.trim();
377
+ saveSelectionsToStorage();
378
+ });
379
+ finalNotesContainer.appendChild(finalNotesLabel);
380
+ finalNotesContainer.appendChild(finalNotesInput);
381
+ section.appendChild(finalNotesContainer);
382
+
304
383
  const submitButton = createElement("button", "btn-generate", "Submit Selections");
305
384
  submitButton.type = "button";
306
385
  submitButton.id = "btn-generate";
@@ -382,10 +461,18 @@ function createSummaryCard(slide) {
382
461
  const text = selectedOption.aside.length > 120
383
462
  ? selectedOption.aside.slice(0, 120).trimEnd() + "..."
384
463
  : selectedOption.aside;
385
- aside.textContent = text;
464
+ aside.innerHTML = escapeHtml(text).replace(/\\n/g, "<br>").replace(/\n/g, "<br>");
386
465
  card.appendChild(aside);
387
466
  }
388
467
  }
468
+
469
+ // Show user notes if present
470
+ const noteData = optionNotes[slide.id];
471
+ if (noteData && noteData.label === selectedLabel && noteData.notes) {
472
+ const notesDisplay = createElement("div", "summary-notes");
473
+ notesDisplay.innerHTML = `<span class="summary-notes-label">Your notes:</span> ${escapeHtml(noteData.notes)}`;
474
+ card.appendChild(notesDisplay);
475
+ }
389
476
  }
390
477
 
391
478
  return card;
@@ -74,7 +74,7 @@ function disableDeckInteractions() {
74
74
  document.querySelectorAll(".btn-gen-more, .btn-regen").forEach((button) => {
75
75
  button.disabled = true;
76
76
  });
77
- document.querySelectorAll(".gen-prompt").forEach((input) => {
77
+ document.querySelectorAll(".gen-prompt, .gen-count").forEach((input) => {
78
78
  input.disabled = true;
79
79
  });
80
80
  document.querySelectorAll(".model-pill, .model-list-item, .model-default-check").forEach((el) => {
@@ -98,7 +98,16 @@ async function submitDeck() {
98
98
  }
99
99
 
100
100
  try {
101
- await postJson("/submit", { token: sessionToken, selections });
101
+ // Build notes object with only notes for selected options
102
+ const notes = {};
103
+ for (const [slideId, noteData] of Object.entries(optionNotes)) {
104
+ if (noteData && selections[slideId] === noteData.label && noteData.notes) {
105
+ notes[slideId] = noteData.notes;
106
+ }
107
+ }
108
+ const payload = { token: sessionToken, selections, notes };
109
+ if (finalNotes) payload.finalNotes = finalNotes;
110
+ await postJson("/submit", payload);
102
111
  clearSelectionsStorage();
103
112
  isClosed = true;
104
113
  if (submitButton) {
@@ -242,16 +251,22 @@ function restoreGenerateButton(slideId) {
242
251
  const pending = pendingGenerate.get(slideId);
243
252
  if (!pending || pending.isRegen) return;
244
253
 
245
- if (pending.skeleton && pending.skeleton.parentElement) {
246
- pending.skeleton.remove();
254
+ // Clear timeout if any
255
+ if (pending.timeoutId) clearTimeout(pending.timeoutId);
256
+
257
+ // Remove all skeletons from DOM (query directly, don't rely on array)
258
+ const slideElement = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
259
+ if (slideElement) {
260
+ slideElement.querySelectorAll(".option-skeleton").forEach((skel) => skel.remove());
247
261
  }
262
+
248
263
  pending.button.classList.remove("loading");
249
264
  const plus = pending.button.querySelector(".btn-gen-plus");
250
265
  if (plus) plus.textContent = "+";
251
- if (pending.button.childNodes[1]) {
252
- pending.button.childNodes[1].textContent = pending.originalText;
253
- }
266
+ pending.button.lastChild.textContent = "Generate";
267
+
254
268
  if (pending.input && !isClosed) pending.input.disabled = false;
269
+ if (pending.countSelect && !isClosed) pending.countSelect.disabled = false;
255
270
 
256
271
  pendingGenerate.delete(slideId);
257
272
  }
@@ -301,6 +316,7 @@ function replaceSlideOptions(slideId, newOptions) {
301
316
  slide.options = newOptions;
302
317
 
303
318
  delete selections[slideId];
319
+ delete optionNotes[slideId];
304
320
  saveSelectionsToStorage();
305
321
 
306
322
  const slideElement = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
@@ -309,16 +325,28 @@ function replaceSlideOptions(slideId, newOptions) {
309
325
  const optionsGrid = slideElement.querySelector(".options");
310
326
  if (!optionsGrid) return;
311
327
 
328
+ // Remove regeneration overlay and state
329
+ const overlay = optionsGrid.querySelector(".regen-overlay");
330
+ if (overlay) overlay.remove();
331
+ optionsGrid.classList.remove("regenerating");
332
+ optionsGrid.style.position = "";
333
+
312
334
  optionsGrid.innerHTML = "";
313
- optionsGrid.style.opacity = "";
314
- optionsGrid.style.pointerEvents = "";
315
335
  optionsGrid.className = `options ${optionCountClass(newOptions.length, slide.columns)}`;
316
336
 
317
337
  newOptions.forEach((option) => {
318
338
  const card = createOptionCard(option, slideId, false);
339
+ card.classList.add("option-regenerated");
319
340
  optionsGrid.appendChild(card);
320
341
  });
321
342
 
343
+ // Remove animation class after animation completes
344
+ setTimeout(() => {
345
+ optionsGrid.querySelectorAll(".option-regenerated").forEach((el) => {
346
+ el.classList.remove("option-regenerated");
347
+ });
348
+ }, 500);
349
+
322
350
  equalizeBlockHeights(slideElement);
323
351
 
324
352
  const pick = slideElement.querySelector(".slide-pick");
@@ -343,9 +371,37 @@ function connectEvents() {
343
371
  if (!payload || typeof payload.slideId !== "string" || !payload.option) {
344
372
  return;
345
373
  }
346
- const model = pendingGenerate.get(payload.slideId)?.model || null;
347
- restoreGenerateButton(payload.slideId);
374
+ const pending = pendingGenerate.get(payload.slideId);
375
+ const model = pending?.model || null;
376
+
377
+ // Remove one skeleton
378
+ if (pending?.skeletons?.length > 0) {
379
+ const skel = pending.skeletons.shift();
380
+ if (skel.parentElement) skel.remove();
381
+ }
382
+
348
383
  insertGeneratedOption(payload.slideId, payload.option, model);
384
+
385
+ // Track received count and restore button when all received
386
+ if (pending) {
387
+ pending.receivedCount = (pending.receivedCount || 0) + 1;
388
+ if (pending.receivedCount >= (pending.expectedCount || 1)) {
389
+ restoreGenerateButton(payload.slideId);
390
+ } else {
391
+ // Reset timeout for next option
392
+ if (pending.timeoutId) clearTimeout(pending.timeoutId);
393
+ pending.timeoutId = setTimeout(() => {
394
+ const current = pendingGenerate.get(payload.slideId);
395
+ if (!current || current.isRegen) return;
396
+ const received = current.receivedCount || 0;
397
+ const expected = current.expectedCount || 1;
398
+ restoreGenerateButton(payload.slideId);
399
+ if (received < expected) {
400
+ showSaveToast(`Generated ${received} of ${expected} options`, true);
401
+ }
402
+ }, 30000);
403
+ }
404
+ }
349
405
  });
350
406
 
351
407
  events.addEventListener("generate-failed", (event) => {
@@ -359,6 +415,8 @@ function connectEvents() {
359
415
  restoreGenerateButton(payload.slideId);
360
416
  if (payload.reason === "timeout") {
361
417
  showSaveToast("Generation timed out — try again", true);
418
+ } else {
419
+ showSaveToast("Generation failed", true);
362
420
  }
363
421
  }
364
422
  });
@@ -388,6 +446,8 @@ function connectEvents() {
388
446
  restoreRegenButton(payload.slideId);
389
447
  if (payload.reason === "timeout") {
390
448
  showSaveToast("Regeneration timed out — try again", true);
449
+ } else {
450
+ showSaveToast("Regeneration failed", true);
391
451
  }
392
452
  }
393
453
  });
@@ -415,7 +475,7 @@ function connectEvents() {
415
475
  });
416
476
  }
417
477
 
418
- async function generateMore(button, slideId, input) {
478
+ async function generateMore(button, slideId, input, countSelect) {
419
479
  if (isClosed || pendingGenerate.size > 0) return;
420
480
 
421
481
  const slideElement = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
@@ -428,21 +488,42 @@ async function generateMore(button, slideId, input) {
428
488
 
429
489
  const prompt = input ? input.value.trim() : "";
430
490
  if (input) input.value = "";
491
+
492
+ const count = countSelect ? parseInt(countSelect.value, 10) || 1 : 1;
493
+
494
+ // Create skeletons for each option being generated
495
+ const skeletons = [];
496
+ for (let i = 0; i < count; i++) {
497
+ const skeleton = createElement("div", "option-skeleton");
498
+ optionsGrid.appendChild(skeleton);
499
+ skeletons.push(skeleton);
500
+ }
431
501
 
432
- const skeleton = createElement("div", "option-skeleton");
433
- optionsGrid.appendChild(skeleton);
434
-
435
- const originalText = button.childNodes[1] ? button.childNodes[1].textContent || "" : "";
436
502
  button.classList.add("loading");
437
503
  const plus = button.querySelector(".btn-gen-plus");
438
504
  if (plus) plus.textContent = "";
439
- if (button.childNodes[1]) button.childNodes[1].textContent = " Generating...";
505
+ button.lastChild.textContent = "Generating...";
440
506
  if (input) input.disabled = true;
507
+ if (countSelect) countSelect.disabled = true;
508
+
509
+ // Set timeout for generation (30s per option)
510
+ const timeoutId = setTimeout(() => {
511
+ const pending = pendingGenerate.get(slideId);
512
+ if (pending && !pending.isRegen) {
513
+ const received = pending.receivedCount || 0;
514
+ restoreGenerateButton(slideId);
515
+ if (received === 0) {
516
+ showSaveToast("Generation timed out — try again", true);
517
+ } else {
518
+ showSaveToast(`Generated ${received} of ${count} options`, true);
519
+ }
520
+ }
521
+ }, 30000 * count);
441
522
 
442
- pendingGenerate.set(slideId, { button, skeleton, originalText, input, model: selectedModel || null });
523
+ pendingGenerate.set(slideId, { button, skeletons, input, countSelect, model: selectedModel || null, expectedCount: count, receivedCount: 0, timeoutId });
443
524
 
444
525
  try {
445
- const body = { token: sessionToken, slideId };
526
+ const body = { token: sessionToken, slideId, count };
446
527
  if (prompt) body.prompt = prompt;
447
528
  if (hasModelBar) {
448
529
  body.model = selectedModel;
@@ -470,8 +551,21 @@ async function regenerateSlide(button, slideId) {
470
551
  const prompt = input ? input.value.trim() : "";
471
552
  if (input) input.value = "";
472
553
 
473
- optionsGrid.style.opacity = "0.4";
474
- optionsGrid.style.pointerEvents = "none";
554
+ // Create skeleton overlay
555
+ const optionCount = slide.options?.length || 2;
556
+ const colClass = optionsGrid.classList.contains("cols-3") ? "cols-3" :
557
+ optionsGrid.classList.contains("cols-1") ? "cols-1" : "cols-2";
558
+ const overlay = createElement("div", `regen-overlay ${colClass}`);
559
+ for (let i = 0; i < optionCount; i++) {
560
+ overlay.appendChild(createElement("div", "regen-skeleton"));
561
+ }
562
+ const status = createElement("div", "regen-status", "Regenerating options...");
563
+ overlay.appendChild(status);
564
+
565
+ // Position overlay relative to options grid
566
+ optionsGrid.style.position = "relative";
567
+ optionsGrid.classList.add("regenerating");
568
+ optionsGrid.appendChild(overlay);
475
569
 
476
570
  const originalText = button.textContent || "";
477
571
  button.classList.add("loading");
@@ -482,7 +576,7 @@ async function regenerateSlide(button, slideId) {
482
576
  const genMoreBtn = slideElement.querySelector(".btn-gen-more");
483
577
  if (genMoreBtn) genMoreBtn.disabled = true;
484
578
 
485
- pendingGenerate.set(slideId, { button, originalText, input, isRegen: true, optionsGrid, genMoreBtn });
579
+ pendingGenerate.set(slideId, { button, originalText, input, isRegen: true, optionsGrid, genMoreBtn, overlay });
486
580
 
487
581
  try {
488
582
  const body = { token: sessionToken, slideId };
@@ -502,7 +596,7 @@ function restoreRegenButton(slideId) {
502
596
  if (!pending || !pending.isRegen) return;
503
597
  pendingGenerate.delete(slideId);
504
598
 
505
- const { button, originalText, input, optionsGrid, genMoreBtn } = pending;
599
+ const { button, originalText, input, optionsGrid, genMoreBtn, overlay } = pending;
506
600
  if (button) {
507
601
  button.classList.remove("loading");
508
602
  button.disabled = false;
@@ -510,8 +604,11 @@ function restoreRegenButton(slideId) {
510
604
  }
511
605
  if (input) input.disabled = false;
512
606
  if (optionsGrid) {
513
- optionsGrid.style.opacity = "";
514
- optionsGrid.style.pointerEvents = "";
607
+ optionsGrid.classList.remove("regenerating");
608
+ optionsGrid.style.position = "";
609
+ }
610
+ if (overlay && overlay.parentElement) {
611
+ overlay.remove();
515
612
  }
516
613
  if (genMoreBtn) genMoreBtn.disabled = false;
517
614
  }
@@ -36,7 +36,7 @@ function modelHints(generateModel?: string, thinking?: string, action?: string):
36
36
  return hint;
37
37
  }
38
38
 
39
- export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | undefined, prompt?: string, generateModel?: string, thinking?: string): string {
39
+ export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | undefined, prompt?: string, generateModel?: string, thinking?: string, count: number = 1): string {
40
40
  const title = slide?.title ?? slideId;
41
41
  const context = slide?.context ? `\nContext: ${slide.context}` : "";
42
42
 
@@ -54,15 +54,22 @@ export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | unde
54
54
  const { hasBlocks, formatHint } = formatHintForSlide(slide);
55
55
  const template = optionTemplate(hasBlocks);
56
56
  const userInstructions = prompt ? `\nUser instructions: "${prompt}"` : "";
57
+
58
+ const optionWord = count === 1 ? "option" : "options";
59
+ const callInstructions = count === 1
60
+ ? `YOU MUST generate one distinctive additional option and call design_deck with add-option.`
61
+ : `YOU MUST generate ${count} distinctive additional options. Call design_deck with add-option ${count} times (once per option).`;
57
62
 
58
63
  return (
59
- "The design deck is still open.\n\n" +
60
- `User requested another option for slide \"${title}\".${context}${userInstructions}\n\n` +
64
+ "The design deck is still open and waiting for your response.\n\n" +
65
+ `User clicked "Generate ${count} ${optionWord}" for slide \"${title}\".${context}${userInstructions}\n\n` +
61
66
  `Existing options:\n${existingText}\n\n` +
62
- `Generate one distinctive additional option and call design_deck again:${modelHints(generateModel, thinking, "add-option")}\n\n` +
67
+ `${callInstructions} ` +
68
+ `Do not skip this step or decide the user has enough options — they explicitly requested ${count === 1 ? "another one" : `${count} more`}.${modelHints(generateModel, thinking, "add-option")}\n\n` +
63
69
  `design_deck({\"action\":\"add-option\",\"slideId\":\"${slideId}\",\"option\":${JSON.stringify(template)}})\n\n` +
64
70
  `The option field must be a JSON string with: label, optional description, optional aside (explanatory notes below preview), and optional recommended.\n` +
65
- `${formatHint}`
71
+ `${formatHint}` +
72
+ (count > 1 ? `\n\nRemember: Call add-option ${count} times, each with a different option. Make each option distinctive.` : "")
66
73
  );
67
74
  }
68
75
 
@@ -75,9 +82,10 @@ export function buildRegenerateResult(slideId: string, slide: DeckSlide | undefi
75
82
  const userInstructions = prompt ? `\nUser instructions: "${prompt}"` : "";
76
83
 
77
84
  return (
78
- "The design deck is still open.\n\n" +
79
- `User requested ALL options be regenerated for slide \"${title}\".${context}${userInstructions}\n\n` +
80
- `Generate ${optionCount} fresh, distinctive options and call design_deck to replace all existing options:${modelHints(generateModel, thinking, "replace-options")}\n\n` +
85
+ "The design deck is still open and waiting for your response.\n\n" +
86
+ `User clicked "Regenerate all" for slide \"${title}\".${context}${userInstructions}\n\n` +
87
+ `YOU MUST generate ${optionCount} fresh, distinctive options and call design_deck with replace-options. ` +
88
+ `Do not skip this step — the user explicitly requested regeneration.${modelHints(generateModel, thinking, "replace-options")}\n\n` +
81
89
  `design_deck({\"action\":\"replace-options\",\"slideId\":\"${slideId}\",\"options\":\"[${template}, ...]\"})` +
82
90
  `\n\nThe options field must be a JSON string containing an array of ${optionCount} option objects.\n` +
83
91
  `Each option needs: label, optional description, optional aside, optional recommended, and either previewHtml or previewBlocks.\n` +
package/index.ts CHANGED
@@ -42,6 +42,8 @@ interface DeckDetails {
42
42
  status: "completed" | "cancelled" | "generate-more" | "aborted" | "error";
43
43
  url: string;
44
44
  selections?: Record<string, string>;
45
+ notes?: Record<string, string>;
46
+ finalNotes?: string;
45
47
  slideId?: string;
46
48
  reason?: string;
47
49
  }
@@ -118,7 +120,7 @@ function clearDeckIdleTimer(): void {
118
120
  }
119
121
  }
120
122
 
121
- function cleanupActiveDeck(): void {
123
+ function cleanupActiveDeck(reason?: string): void {
122
124
  clearDeckIdleTimer();
123
125
  if (restoreDeckThinking) {
124
126
  restoreDeckThinking();
@@ -126,20 +128,23 @@ function cleanupActiveDeck(): void {
126
128
  }
127
129
  if (!activeDeckServer) return;
128
130
  try {
129
- activeDeckServer.handle.close();
131
+ activeDeckServer.handle.close(reason);
130
132
  } catch {}
131
133
  activeDeckServer = null;
132
134
  }
133
135
 
134
136
  function cleanupActiveDeckAndStoreResult(result: DeckToolResult): void {
135
137
  if (!activeDeckServer) return;
138
+ // Extract close reason from result details
139
+ const details = result.details as DeckDetails | undefined;
140
+ const closeReason = details?.status === "aborted" ? "aborted" : details?.reason;
136
141
  if (activeDeckServer.currentResolve) {
137
142
  const resolve = activeDeckServer.currentResolve;
138
- cleanupActiveDeck();
143
+ cleanupActiveDeck(closeReason);
139
144
  resolve(result);
140
145
  } else {
141
146
  pendingDeckResult = result;
142
- cleanupActiveDeck();
147
+ cleanupActiveDeck(closeReason);
143
148
  }
144
149
  }
145
150
 
@@ -181,10 +186,16 @@ function attachDeckAbortHandler(signal: AbortSignal | undefined): void {
181
186
  signal.addEventListener("abort", abortHandler, { once: true });
182
187
  }
183
188
 
184
- function formatDeckSelections(selections: Record<string, string>): string {
189
+ function formatDeckSelections(selections: Record<string, string>, notes?: Record<string, string>): string {
185
190
  const entries = Object.entries(selections);
186
191
  if (entries.length === 0) return "(none)";
187
- return entries.map(([key, value]) => `- ${key}: ${value}`).join("\n");
192
+ return entries.map(([key, value]) => {
193
+ const note = notes?.[key];
194
+ if (note) {
195
+ return `- ${key}: ${value}\n Notes: ${note}`;
196
+ }
197
+ return `- ${key}: ${value}`;
198
+ }).join("\n");
188
199
  }
189
200
 
190
201
  export default function (pi: ExtensionAPI) {
@@ -274,17 +285,8 @@ export default function (pi: ExtensionAPI) {
274
285
  };
275
286
  }
276
287
 
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
- }
288
+ // Note: We don't check currentResolve here because multiple parallel
289
+ // add-option calls are valid (e.g., user requests 3 options at once)
288
290
 
289
291
  const slideId = p.slideId as string;
290
292
  const option = p.option as string;
@@ -320,14 +322,14 @@ export default function (pi: ExtensionAPI) {
320
322
  };
321
323
  }
322
324
 
323
- if (onUpdate) {
324
- onUpdate({
325
- content: [{ type: "text", text: `Pushed new option to slide ${slideId}.` }],
326
- details: { status: "generate-more", url: activeDeckServer.handle.url, slideId },
327
- });
328
- }
329
- attachDeckAbortHandler(signal);
330
- return blockOnDeck();
325
+ // For add-option, return immediately without blocking.
326
+ // This allows parallel add-option calls to all succeed.
327
+ // The deck stays open and will send a new prompt when the user
328
+ // clicks generate-more again or submits.
329
+ return {
330
+ content: [{ type: "text", text: `Pushed option "${parsedOption.label}" to slide ${slideId}.` }],
331
+ details: { status: "generate-more", url: activeDeckServer.handle.url, slideId },
332
+ };
331
333
  }
332
334
 
333
335
  if (p.action === "replace-options") {
@@ -472,17 +474,28 @@ export default function (pi: ExtensionAPI) {
472
474
  };
473
475
  }
474
476
 
475
- const handleSubmit = (selections: Record<string, string>) => {
477
+ const handleSubmit = (selections: Record<string, string>, notes?: Record<string, string>, finalNotes?: string) => {
476
478
  if (!activeDeckServer) return;
477
479
  const url = activeDeckServer.handle.url;
480
+ const hasNotes = notes && Object.keys(notes).length > 0;
481
+ const textParts = [`Design deck completed.\n\nSelections:\n${formatDeckSelections(selections, notes)}`];
482
+ if (finalNotes) {
483
+ textParts.push(`\nAdditional instructions:\n${finalNotes}`);
484
+ }
478
485
  cleanupActiveDeckAndStoreResult({
479
486
  content: [
480
487
  {
481
488
  type: "text",
482
- text: `Design deck completed.\n\nSelections:\n${formatDeckSelections(selections)}`,
489
+ text: textParts.join(""),
483
490
  },
484
491
  ],
485
- details: { status: "completed", url, selections },
492
+ details: {
493
+ status: "completed",
494
+ url,
495
+ selections,
496
+ ...(hasNotes ? { notes } : {}),
497
+ ...(finalNotes ? { finalNotes } : {}),
498
+ },
486
499
  });
487
500
  };
488
501
 
@@ -503,8 +516,12 @@ export default function (pi: ExtensionAPI) {
503
516
  });
504
517
  };
505
518
 
506
- const handleGenerateMore = (slideId: string, prompt?: string, model?: string, thinking?: string) => {
507
- if (!activeDeckServer?.currentResolve) return;
519
+ const handleGenerateMore = (slideId: string, prompt?: string, model?: string, thinking?: string, count?: number) => {
520
+ if (!activeDeckServer?.currentResolve) {
521
+ // Agent is no longer listening - close the deck
522
+ cleanupActiveDeck("stale");
523
+ return;
524
+ }
508
525
  const resolve = activeDeckServer.currentResolve;
509
526
  activeDeckServer.currentResolve = null;
510
527
  armDeckIdleTimer();
@@ -513,8 +530,9 @@ export default function (pi: ExtensionAPI) {
513
530
  if (thinking && !effectiveModel) {
514
531
  pi.setThinkingLevel(thinking as "off" | "minimal" | "low" | "medium" | "high" | "xhigh");
515
532
  }
533
+ const effectiveCount = count && count >= 1 && count <= 5 ? count : 1;
516
534
  resolve({
517
- content: [{ type: "text", text: buildGenerateMoreResult(slideId, slide, prompt, effectiveModel, thinking) }],
535
+ content: [{ type: "text", text: buildGenerateMoreResult(slideId, slide, prompt, effectiveModel, thinking, effectiveCount) }],
518
536
  details: {
519
537
  status: "generate-more",
520
538
  url: activeDeckServer.handle.url,
@@ -524,7 +542,11 @@ export default function (pi: ExtensionAPI) {
524
542
  };
525
543
 
526
544
  const handleRegenerateSlide = (slideId: string, prompt?: string, model?: string, thinking?: string) => {
527
- if (!activeDeckServer?.currentResolve) return;
545
+ if (!activeDeckServer?.currentResolve) {
546
+ // Agent is no longer listening - close the deck
547
+ cleanupActiveDeck("stale");
548
+ return;
549
+ }
528
550
  const resolve = activeDeckServer.currentResolve;
529
551
  activeDeckServer.currentResolve = null;
530
552
  armDeckIdleTimer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-design-deck",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",