pi-design-deck 0.1.0 → 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/index.ts CHANGED
@@ -9,6 +9,7 @@ import { loadSettings } from "./settings.js";
9
9
  import { startDeckServer, type DeckServerHandle, type ModelInfo } from "./deck-server.js";
10
10
  import { isDeckOption, validateDeckConfig, validateSavedDeck } from "./deck-schema.js";
11
11
  import { buildGenerateMoreResult, buildRegenerateResult } from "./generate-prompts.js";
12
+ import { generateWithModel } from "./model-runner.js";
12
13
 
13
14
  async function openUrl(pi: ExtensionAPI, url: string, browser?: string): Promise<void> {
14
15
  const platform = os.platform();
@@ -41,6 +42,8 @@ interface DeckDetails {
41
42
  status: "completed" | "cancelled" | "generate-more" | "aborted" | "error";
42
43
  url: string;
43
44
  selections?: Record<string, string>;
45
+ notes?: Record<string, string>;
46
+ finalNotes?: string;
44
47
  slideId?: string;
45
48
  reason?: string;
46
49
  }
@@ -117,7 +120,7 @@ function clearDeckIdleTimer(): void {
117
120
  }
118
121
  }
119
122
 
120
- function cleanupActiveDeck(): void {
123
+ function cleanupActiveDeck(reason?: string): void {
121
124
  clearDeckIdleTimer();
122
125
  if (restoreDeckThinking) {
123
126
  restoreDeckThinking();
@@ -125,20 +128,23 @@ function cleanupActiveDeck(): void {
125
128
  }
126
129
  if (!activeDeckServer) return;
127
130
  try {
128
- activeDeckServer.handle.close();
131
+ activeDeckServer.handle.close(reason);
129
132
  } catch {}
130
133
  activeDeckServer = null;
131
134
  }
132
135
 
133
136
  function cleanupActiveDeckAndStoreResult(result: DeckToolResult): void {
134
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;
135
141
  if (activeDeckServer.currentResolve) {
136
142
  const resolve = activeDeckServer.currentResolve;
137
- cleanupActiveDeck();
143
+ cleanupActiveDeck(closeReason);
138
144
  resolve(result);
139
145
  } else {
140
146
  pendingDeckResult = result;
141
- cleanupActiveDeck();
147
+ cleanupActiveDeck(closeReason);
142
148
  }
143
149
  }
144
150
 
@@ -180,10 +186,16 @@ function attachDeckAbortHandler(signal: AbortSignal | undefined): void {
180
186
  signal.addEventListener("abort", abortHandler, { once: true });
181
187
  }
182
188
 
183
- function formatDeckSelections(selections: Record<string, string>): string {
189
+ function formatDeckSelections(selections: Record<string, string>, notes?: Record<string, string>): string {
184
190
  const entries = Object.entries(selections);
185
191
  if (entries.length === 0) return "(none)";
186
- 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");
187
199
  }
188
200
 
189
201
  export default function (pi: ExtensionAPI) {
@@ -273,17 +285,8 @@ export default function (pi: ExtensionAPI) {
273
285
  };
274
286
  }
275
287
 
276
- if (activeDeckServer.currentResolve !== null) {
277
- return {
278
- content: [
279
- {
280
- type: "text",
281
- text: "Design deck is not waiting for a generated option right now.",
282
- },
283
- ],
284
- details: { status: "error", url: activeDeckServer.handle.url },
285
- };
286
- }
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)
287
290
 
288
291
  const slideId = p.slideId as string;
289
292
  const option = p.option as string;
@@ -319,14 +322,14 @@ export default function (pi: ExtensionAPI) {
319
322
  };
320
323
  }
321
324
 
322
- if (onUpdate) {
323
- onUpdate({
324
- content: [{ type: "text", text: `Pushed new option to slide ${slideId}.` }],
325
- details: { status: "generate-more", url: activeDeckServer.handle.url, slideId },
326
- });
327
- }
328
- attachDeckAbortHandler(signal);
329
- 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
+ };
330
333
  }
331
334
 
332
335
  if (p.action === "replace-options") {
@@ -471,17 +474,28 @@ export default function (pi: ExtensionAPI) {
471
474
  };
472
475
  }
473
476
 
474
- const handleSubmit = (selections: Record<string, string>) => {
477
+ const handleSubmit = (selections: Record<string, string>, notes?: Record<string, string>, finalNotes?: string) => {
475
478
  if (!activeDeckServer) return;
476
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
+ }
477
485
  cleanupActiveDeckAndStoreResult({
478
486
  content: [
479
487
  {
480
488
  type: "text",
481
- text: `Design deck completed.\n\nSelections:\n${formatDeckSelections(selections)}`,
489
+ text: textParts.join(""),
482
490
  },
483
491
  ],
484
- details: { status: "completed", url, selections },
492
+ details: {
493
+ status: "completed",
494
+ url,
495
+ selections,
496
+ ...(hasNotes ? { notes } : {}),
497
+ ...(finalNotes ? { finalNotes } : {}),
498
+ },
485
499
  });
486
500
  };
487
501
 
@@ -502,8 +516,12 @@ export default function (pi: ExtensionAPI) {
502
516
  });
503
517
  };
504
518
 
505
- const handleGenerateMore = (slideId: string, prompt?: string, model?: string, thinking?: string) => {
506
- 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
+ }
507
525
  const resolve = activeDeckServer.currentResolve;
508
526
  activeDeckServer.currentResolve = null;
509
527
  armDeckIdleTimer();
@@ -512,8 +530,9 @@ export default function (pi: ExtensionAPI) {
512
530
  if (thinking && !effectiveModel) {
513
531
  pi.setThinkingLevel(thinking as "off" | "minimal" | "low" | "medium" | "high" | "xhigh");
514
532
  }
533
+ const effectiveCount = count && count >= 1 && count <= 5 ? count : 1;
515
534
  resolve({
516
- content: [{ type: "text", text: buildGenerateMoreResult(slideId, slide, prompt, effectiveModel, thinking) }],
535
+ content: [{ type: "text", text: buildGenerateMoreResult(slideId, slide, prompt, effectiveModel, thinking, effectiveCount) }],
517
536
  details: {
518
537
  status: "generate-more",
519
538
  url: activeDeckServer.handle.url,
@@ -523,7 +542,11 @@ export default function (pi: ExtensionAPI) {
523
542
  };
524
543
 
525
544
  const handleRegenerateSlide = (slideId: string, prompt?: string, model?: string, thinking?: string) => {
526
- if (!activeDeckServer?.currentResolve) return;
545
+ if (!activeDeckServer?.currentResolve) {
546
+ // Agent is no longer listening - close the deck
547
+ cleanupActiveDeck("stale");
548
+ return;
549
+ }
527
550
  const resolve = activeDeckServer.currentResolve;
528
551
  activeDeckServer.currentResolve = null;
529
552
  armDeckIdleTimer();
@@ -664,6 +687,25 @@ export default function (pi: ExtensionAPI) {
664
687
  },
665
688
  });
666
689
 
690
+ pi.registerTool({
691
+ name: "deck_generate",
692
+ description: "Generate text using a specific model (for design deck option generation). Use this when the generate-more prompt specifies a model override.",
693
+ parameters: Type.Object({
694
+ model: Type.String({ description: "Full model ID in 'provider/model-id' format (e.g., 'anthropic/claude-haiku-4-5')" }),
695
+ task: Type.String({ description: "The generation task/prompt" }),
696
+ }),
697
+ async execute(_toolCallId, params) {
698
+ const { model, task } = params as { model: string; task: string };
699
+ try {
700
+ const result = await generateWithModel(model, task);
701
+ return { content: [{ type: "text", text: result }] };
702
+ } catch (err) {
703
+ const message = err instanceof Error ? err.message : String(err);
704
+ return { content: [{ type: "text", text: `Generation failed: ${message}` }], isError: true };
705
+ }
706
+ },
707
+ });
708
+
667
709
  pi.on("session_shutdown", () => {
668
710
  pendingDeckResult = null;
669
711
  cleanupActiveDeck();
@@ -0,0 +1,50 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const TIMEOUT_MS = 120_000;
4
+
5
+ export async function generateWithModel(fullModel: string, task: string): Promise<string> {
6
+ const parts = fullModel.split("/");
7
+ if (parts.length < 2) {
8
+ throw new Error(`Invalid model format: ${fullModel}. Expected "provider/model-id"`);
9
+ }
10
+ const provider = parts[0];
11
+ const modelId = parts.slice(1).join("/");
12
+
13
+ return new Promise((resolve, reject) => {
14
+ const args = ["--provider", provider, "--model", modelId, "--no-tools", "--no-session", "-p", task];
15
+
16
+ const proc = spawn("pi", args, {
17
+ stdio: ["ignore", "pipe", "pipe"],
18
+ env: process.env,
19
+ });
20
+
21
+ let stdout = "";
22
+ let stderr = "";
23
+
24
+ proc.stdout.on("data", (chunk) => {
25
+ stdout += chunk.toString();
26
+ });
27
+ proc.stderr.on("data", (chunk) => {
28
+ stderr += chunk.toString();
29
+ });
30
+
31
+ const timer = setTimeout(() => {
32
+ proc.kill("SIGTERM");
33
+ reject(new Error("Generation timed out after 2 minutes"));
34
+ }, TIMEOUT_MS);
35
+
36
+ proc.on("error", (err) => {
37
+ clearTimeout(timer);
38
+ reject(err);
39
+ });
40
+
41
+ proc.on("close", (code) => {
42
+ clearTimeout(timer);
43
+ if (code === 0) {
44
+ resolve(stdout.trim());
45
+ } else {
46
+ reject(new Error(`pi exited with code ${code}: ${stderr.slice(0, 500)}`));
47
+ }
48
+ });
49
+ });
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-design-deck",
3
- "version": "0.1.0",
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",
@@ -13,6 +13,7 @@
13
13
  "deck-schema.ts",
14
14
  "deck-server.ts",
15
15
  "generate-prompts.ts",
16
+ "model-runner.ts",
16
17
  "server-utils.ts",
17
18
  "settings.ts",
18
19
  "index.ts"
@@ -234,13 +234,13 @@ The `add-option` call pushes the new option into the live deck and blocks again
234
234
 
235
235
  The deck shows a model dropdown below the header (when 2+ models are available). Users can pick which model generates new options and optionally save it as the default.
236
236
 
237
- When the generate-more result includes a model instruction (e.g. "Generate using model X via subagent"), delegate the option generation to a subagent with that model. Use a bare generator agent (no tools, no file output) rather than scout — this is pure text generation, not codebase investigation:
237
+ When the generate-more result includes a model instruction (e.g. "Generate using model X via deck_generate"), use the built-in `deck_generate` tool to generate the option with that model:
238
238
 
239
239
  ```
240
- subagent({ agent: "generator", model: "google/gemini-3.1-pro", output: false, task: "Generate a JSON deck option..." })
240
+ deck_generate({ model: "google/gemini-3.1-pro", task: "Generate a JSON deck option..." })
241
241
  ```
242
242
 
243
- Parse the subagent's text output as the option JSON and pass it to `add-option`.
243
+ Parse the output as the option JSON and pass it to `add-option`.
244
244
 
245
245
  The default model can also be set in `~/.pi/agent/settings.json`:
246
246