pi-interview 0.5.5 → 0.6.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
@@ -38,6 +38,7 @@ Restart pi to load the extension.
38
38
  - **Session Status Bar**: Shows project path, git branch, and session ID for identification
39
39
  - **Image Support**: Drag & drop anywhere on question, file picker, paste image or path
40
40
  - **Path Normalization**: Handles shell-escaped paths (`\ `) and macOS screenshot filenames (narrow no-break space before AM/PM)
41
+ - **Generate & Review Options**: Single/multi-select questions show "✦ Generate more" (appends new choices) and "↻ Review options" (validates and rewrites existing choices) buttons powered by an LLM
41
42
  - **Themes**: Built-in default + optional light/dark + custom theme CSS
42
43
 
43
44
  ## How It Works
@@ -287,6 +288,7 @@ Settings in `~/.pi/agent/settings.json`:
287
288
  "port": 19847,
288
289
  "snapshotDir": "~/.pi/interview-snapshots/",
289
290
  "autoSaveOnSubmit": true,
291
+ "generateModel": "anthropic/claude-haiku-4-5",
290
292
  "theme": {
291
293
  "mode": "auto",
292
294
  "name": "default",
@@ -306,6 +308,8 @@ Settings in `~/.pi/agent/settings.json`:
306
308
 
307
309
  **Port setting**: Set a fixed `port` (e.g., `19847`) to use a consistent port across sessions.
308
310
 
311
+ **Generate model**: `generateModel` sets the model for the generate/review option actions (e.g., `"anthropic/claude-haiku-4-5"`). Defaults to the agent's current model, then falls back to a cheap available model. If an explicitly configured generate model fails at request time and the current session is using a different model, interview retries once with the current session model.
312
+
309
313
  **Theme notes:**
310
314
  - `mode`: `dark` (default), `light`, or `auto` (follows OS unless overridden)
311
315
  - `name`: built-in themes are `default` and `tufte`
package/form/script.js CHANGED
@@ -1232,7 +1232,8 @@
1232
1232
 
1233
1233
  if (event.key === 'Tab') {
1234
1234
  const inAttachArea = document.activeElement?.closest('.attach-inline');
1235
- if (inAttachArea) return;
1235
+ const inGenerateArea = document.activeElement?.closest('.generate-more');
1236
+ if (inAttachArea || inGenerateArea) return;
1236
1237
 
1237
1238
  event.preventDefault();
1238
1239
 
@@ -1287,6 +1288,9 @@
1287
1288
  if (document.activeElement?.closest('.attach-inline')) {
1288
1289
  return;
1289
1290
  }
1291
+ if (document.activeElement?.closest('.generate-more')) {
1292
+ return;
1293
+ }
1290
1294
  event.preventDefault();
1291
1295
  const option = options[nav.optionIndex];
1292
1296
  if (option) {
@@ -1388,6 +1392,210 @@
1388
1392
  }
1389
1393
  }
1390
1394
 
1395
+ function createGenerateMoreUI(question, list) {
1396
+ if (!data.canGenerate) return null;
1397
+
1398
+ const container = document.createElement("div");
1399
+ container.className = "generate-more";
1400
+
1401
+ const btnRow = document.createElement("div");
1402
+ btnRow.className = "generate-more-row";
1403
+
1404
+ const addBtn = document.createElement("button");
1405
+ addBtn.type = "button";
1406
+ addBtn.className = "generate-more-btn";
1407
+ addBtn.innerHTML = '<span class="generate-more-icon">✦</span> Generate more';
1408
+
1409
+ const reviewBtn = document.createElement("button");
1410
+ reviewBtn.type = "button";
1411
+ reviewBtn.className = "generate-more-btn";
1412
+ reviewBtn.innerHTML = '<span class="generate-more-icon">↻</span> Review options';
1413
+
1414
+ const status = document.createElement("div");
1415
+ status.className = "generate-more-status hidden";
1416
+
1417
+ btnRow.appendChild(addBtn);
1418
+ btnRow.appendChild(reviewBtn);
1419
+ container.appendChild(btnRow);
1420
+ container.appendChild(status);
1421
+
1422
+ let generating = false;
1423
+ let abortController = null;
1424
+ let statusTimer = null;
1425
+
1426
+ function clearStatus() {
1427
+ if (statusTimer !== null) {
1428
+ clearTimeout(statusTimer);
1429
+ statusTimer = null;
1430
+ }
1431
+ status.classList.add("hidden");
1432
+ status.classList.remove("error");
1433
+ }
1434
+
1435
+ function showStatus(message, timeoutMs, isError = false) {
1436
+ if (statusTimer !== null) {
1437
+ clearTimeout(statusTimer);
1438
+ statusTimer = null;
1439
+ }
1440
+
1441
+ status.textContent = message;
1442
+ status.classList.remove("hidden");
1443
+ status.classList.toggle("error", isError);
1444
+
1445
+ if (timeoutMs == null) {
1446
+ return;
1447
+ }
1448
+
1449
+ statusTimer = setTimeout(() => {
1450
+ status.classList.add("hidden");
1451
+ statusTimer = null;
1452
+ }, timeoutMs);
1453
+ }
1454
+
1455
+ function getExistingOptions() {
1456
+ const inputs = list.querySelectorAll(
1457
+ 'input[name="' + escapeSelector(question.id) + '"]'
1458
+ );
1459
+ return Array.from(inputs)
1460
+ .map((input) => input.value)
1461
+ .filter((v) => v && v !== "__other__");
1462
+ }
1463
+
1464
+ async function runGenerate(btn, mode) {
1465
+ if (generating) {
1466
+ if (abortController) abortController.abort();
1467
+ return;
1468
+ }
1469
+
1470
+ generating = true;
1471
+ const icon = btn.querySelector(".generate-more-icon").textContent;
1472
+ btn.innerHTML = '<span class="generate-more-icon">' + icon + '</span> Cancel';
1473
+ btn.classList.add("loading");
1474
+ addBtn.disabled = true;
1475
+ reviewBtn.disabled = true;
1476
+ btn.disabled = false;
1477
+ clearStatus();
1478
+
1479
+ abortController = new AbortController();
1480
+ const existingOptions = getExistingOptions();
1481
+
1482
+ try {
1483
+ const response = await fetch("/generate", {
1484
+ method: "POST",
1485
+ headers: { "Content-Type": "application/json" },
1486
+ body: JSON.stringify({
1487
+ token: sessionToken,
1488
+ questionId: question.id,
1489
+ existingOptions,
1490
+ mode,
1491
+ }),
1492
+ signal: abortController.signal,
1493
+ });
1494
+
1495
+ const result = await response.json();
1496
+ if (!result.ok) throw new Error(result.error || "Generation failed");
1497
+ if (!Array.isArray(result.options) || result.options.length === 0) {
1498
+ throw new Error("No options generated");
1499
+ }
1500
+
1501
+ if (mode === "review") {
1502
+ const seen = new Set();
1503
+ const revisedOptions = result.options.filter((option) => {
1504
+ const key = option.toLowerCase().trim();
1505
+ if (seen.has(key)) return false;
1506
+ seen.add(key);
1507
+ return true;
1508
+ });
1509
+ if (revisedOptions.length === 0) {
1510
+ throw new Error("No valid options returned for review");
1511
+ }
1512
+
1513
+ list
1514
+ .querySelectorAll('.option-item:not(.option-other):not(.done-item)')
1515
+ .forEach((el) => el.remove());
1516
+ revisedOptions.forEach((optionText, i) => {
1517
+ const optionEl = createGeneratedOption(question, optionText, i);
1518
+ list.insertBefore(optionEl, container);
1519
+ });
1520
+ if (question.type === "multi") updateDoneState(question.id);
1521
+ debounceSave();
1522
+ showStatus(
1523
+ revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
1524
+ 2500,
1525
+ );
1526
+ } else {
1527
+ const existingSet = new Set(existingOptions.map((o) => o.toLowerCase().trim()));
1528
+ const seen = new Set();
1529
+ const newOptions = result.options.filter((o) => {
1530
+ const key = o.toLowerCase().trim();
1531
+ if (existingSet.has(key) || seen.has(key)) return false;
1532
+ seen.add(key);
1533
+ return true;
1534
+ });
1535
+
1536
+ if (newOptions.length === 0) {
1537
+ showStatus("All generated options already exist", 3000);
1538
+ } else {
1539
+ newOptions.forEach((optionText, i) => {
1540
+ const optionEl = createGeneratedOption(question, optionText, i);
1541
+ list.insertBefore(optionEl, container);
1542
+ });
1543
+ showStatus(
1544
+ newOptions.length + " option" + (newOptions.length > 1 ? "s" : "") + " added",
1545
+ 2500,
1546
+ );
1547
+ }
1548
+ }
1549
+ refreshCountdown();
1550
+ } catch (err) {
1551
+ if (!(err instanceof Error && err.name === "AbortError")) {
1552
+ showStatus(err instanceof Error ? err.message : "Generation failed", null, true);
1553
+ }
1554
+ } finally {
1555
+ generating = false;
1556
+ addBtn.innerHTML = '<span class="generate-more-icon">✦</span> Generate more';
1557
+ reviewBtn.innerHTML = '<span class="generate-more-icon">↻</span> Review options';
1558
+ addBtn.classList.remove("loading");
1559
+ reviewBtn.classList.remove("loading");
1560
+ addBtn.disabled = false;
1561
+ reviewBtn.disabled = false;
1562
+ abortController = null;
1563
+ }
1564
+ }
1565
+
1566
+ addBtn.addEventListener("click", () => runGenerate(addBtn, "add"));
1567
+ reviewBtn.addEventListener("click", () => runGenerate(reviewBtn, "review"));
1568
+
1569
+ return container;
1570
+ }
1571
+
1572
+ function createGeneratedOption(question, optionText, animIndex) {
1573
+ const label = document.createElement("label");
1574
+ label.className = "option-item generated";
1575
+ label.style.animationDelay = (animIndex * 0.08) + "s";
1576
+
1577
+ const input = document.createElement("input");
1578
+ input.type = question.type === "single" ? "radio" : "checkbox";
1579
+ input.name = question.id;
1580
+ input.value = optionText;
1581
+ input.setAttribute("tabindex", "-1");
1582
+
1583
+ input.addEventListener("change", () => {
1584
+ debounceSave();
1585
+ if (question.type === "multi") {
1586
+ updateDoneState(question.id);
1587
+ }
1588
+ });
1589
+
1590
+ const text = document.createElement("span");
1591
+ text.textContent = optionText;
1592
+
1593
+ label.appendChild(input);
1594
+ label.appendChild(text);
1595
+
1596
+ return label;
1597
+ }
1598
+
1391
1599
  function createQuestionCard(question, index, badgeNumber) {
1392
1600
  const card = document.createElement("section");
1393
1601
  card.className = "question-card";
@@ -1523,6 +1731,8 @@
1523
1731
  list.appendChild(label);
1524
1732
  });
1525
1733
 
1734
+ const generateMoreEl = createGenerateMoreUI(question, list);
1735
+ if (generateMoreEl) list.appendChild(generateMoreEl);
1526
1736
 
1527
1737
  const otherLabel = document.createElement("label");
1528
1738
  otherLabel.className = "option-item option-other";
package/form/styles.css CHANGED
@@ -1710,6 +1710,106 @@ button {
1710
1710
  background: color-mix(in srgb, var(--card-accent, var(--accent)) 4%, var(--bg-elevated));
1711
1711
  }
1712
1712
 
1713
+ /* Generate more options */
1714
+ .generate-more {
1715
+ margin: 4px 0 2px;
1716
+ }
1717
+
1718
+ .generate-more-row {
1719
+ display: flex;
1720
+ gap: 6px;
1721
+ }
1722
+
1723
+ .generate-more-btn {
1724
+ flex: 1;
1725
+ display: flex;
1726
+ align-items: center;
1727
+ justify-content: center;
1728
+ gap: 6px;
1729
+ padding: 10px 14px;
1730
+ border: 1px dashed var(--border-muted);
1731
+ border-radius: 8px;
1732
+ background: transparent;
1733
+ color: var(--fg-dim);
1734
+ font-family: var(--font-body);
1735
+ font-size: var(--font-size-option);
1736
+ cursor: pointer;
1737
+ transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
1738
+ }
1739
+
1740
+ .generate-more-btn:hover {
1741
+ border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 50%, transparent);
1742
+ color: var(--card-accent, var(--accent));
1743
+ background: color-mix(in srgb, var(--card-accent, var(--accent)) 5%, transparent);
1744
+ }
1745
+
1746
+ .generate-more-btn:focus-visible {
1747
+ outline: none;
1748
+ box-shadow: 0 0 0 2px var(--focus-ring);
1749
+ }
1750
+
1751
+ .generate-more-icon {
1752
+ font-size: 14px;
1753
+ line-height: 1;
1754
+ }
1755
+
1756
+ @keyframes generate-shimmer {
1757
+ 0% { background-position: 200% center; }
1758
+ 100% { background-position: -200% center; }
1759
+ }
1760
+
1761
+ @keyframes generate-spin {
1762
+ from { transform: rotate(0deg); }
1763
+ to { transform: rotate(360deg); }
1764
+ }
1765
+
1766
+ .generate-more-btn.loading {
1767
+ border-style: solid;
1768
+ border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 30%, transparent);
1769
+ color: var(--card-accent, var(--accent));
1770
+ background: linear-gradient(
1771
+ 90deg,
1772
+ transparent 0%,
1773
+ color-mix(in srgb, var(--card-accent, var(--accent)) 8%, transparent) 40%,
1774
+ color-mix(in srgb, var(--card-accent, var(--accent)) 15%, transparent) 50%,
1775
+ color-mix(in srgb, var(--card-accent, var(--accent)) 8%, transparent) 60%,
1776
+ transparent 100%
1777
+ );
1778
+ background-size: 200% 100%;
1779
+ animation: generate-shimmer 2s ease-in-out infinite;
1780
+ }
1781
+
1782
+ .generate-more-btn.loading .generate-more-icon {
1783
+ animation: generate-spin 1.5s linear infinite;
1784
+ display: inline-block;
1785
+ }
1786
+
1787
+ .generate-more-btn:disabled:not(.loading) {
1788
+ opacity: 0.4;
1789
+ pointer-events: none;
1790
+ }
1791
+
1792
+ .generate-more-status {
1793
+ margin-top: 8px;
1794
+ font-family: var(--font-mono);
1795
+ font-size: 11px;
1796
+ color: var(--fg-muted);
1797
+ }
1798
+
1799
+ .generate-more-status.error {
1800
+ color: var(--error);
1801
+ }
1802
+
1803
+ /* Generated option entrance */
1804
+ @keyframes option-slide-in {
1805
+ from { opacity: 0; transform: translateY(8px); }
1806
+ to { opacity: 1; transform: translateY(0); }
1807
+ }
1808
+
1809
+ .option-item.generated {
1810
+ animation: option-slide-in 0.25s ease-out backwards;
1811
+ }
1812
+
1713
1813
  /* Side-by-side layout */
1714
1814
  .question-side-layout {
1715
1815
  display: grid;
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import { StringEnum } from "@mariozechner/pi-ai";
2
+ import { StringEnum, complete, type Api, type AssistantMessage, type Model } from "@mariozechner/pi-ai";
3
3
  import { Text } from "@mariozechner/pi-tui";
4
4
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
5
  import * as path from "node:path";
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { execSync, execFileSync } from "node:child_process";
10
10
  import { createRequire } from "node:module";
11
- import { startInterviewServer, getActiveSessions, type ResponseItem } from "./server.js";
11
+ import { startInterviewServer, getActiveSessions, type ResponseItem, type InterviewServerCallbacks } from "./server.js";
12
12
  import { validateQuestions, sanitizeLLMJSON, type QuestionsFile } from "./schema.js";
13
13
  import { loadSettings, type InterviewThemeSettings } from "./settings.js";
14
14
 
@@ -232,6 +232,163 @@ function loadQuestions(questionsInput: string, cwd: string): SavedQuestionsFile
232
232
  return validateQuestions(data);
233
233
  }
234
234
 
235
+ interface GenerateModelCandidate {
236
+ provider: string;
237
+ id: string;
238
+ }
239
+
240
+ const PREFERRED_GENERATE_MODELS = [
241
+ "anthropic/claude-haiku-4-5",
242
+ "google/gemini-2.5-flash",
243
+ "openai/gpt-4.1-mini",
244
+ ];
245
+
246
+ const GENERATE_OPTIONS_SYSTEM_PROMPT =
247
+ "You generate interview answer options. Return only a JSON array of strings. Do not include explanations or markdown.";
248
+
249
+ function formatModelRef(model: GenerateModelCandidate): string {
250
+ return `${model.provider}/${model.id}`;
251
+ }
252
+
253
+ function findModelByRef<T extends GenerateModelCandidate>(models: T[], modelRef: string): T | null {
254
+ for (const model of models) {
255
+ if (formatModelRef(model) === modelRef) {
256
+ return model;
257
+ }
258
+ }
259
+ return null;
260
+ }
261
+
262
+ export function selectGenerateModels<T extends GenerateModelCandidate>(
263
+ configuredModel: T | null,
264
+ currentModel: T | null,
265
+ availableModels: T[],
266
+ ): { primary: T | null; fallback: T | null } {
267
+ if (configuredModel) {
268
+ if (!currentModel || formatModelRef(currentModel) === formatModelRef(configuredModel)) {
269
+ return { primary: configuredModel, fallback: null };
270
+ }
271
+ return { primary: configuredModel, fallback: currentModel };
272
+ }
273
+
274
+ if (currentModel) {
275
+ return { primary: currentModel, fallback: null };
276
+ }
277
+
278
+ for (const modelRef of PREFERRED_GENERATE_MODELS) {
279
+ const preferredModel = findModelByRef(availableModels, modelRef);
280
+ if (preferredModel) {
281
+ return { primary: preferredModel, fallback: null };
282
+ }
283
+ }
284
+
285
+ return { primary: availableModels[0] ?? null, fallback: null };
286
+ }
287
+
288
+ export function extractGenerateResponseText(
289
+ modelRef: string,
290
+ response: Pick<AssistantMessage, "content" | "stopReason" | "errorMessage">,
291
+ ): string {
292
+ if (response.stopReason === "aborted") {
293
+ throw new Error("Aborted");
294
+ }
295
+ if (response.stopReason === "error") {
296
+ throw new Error(response.errorMessage ? `${modelRef}: ${response.errorMessage}` : `${modelRef} failed`);
297
+ }
298
+
299
+ const text = response.content
300
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
301
+ .map((part) => part.text)
302
+ .join("")
303
+ .trim();
304
+ if (!text) {
305
+ throw new Error(`${modelRef} returned no text response`);
306
+ }
307
+ return text;
308
+ }
309
+
310
+ export function extractJSONArray(text: string): string {
311
+ const start = text.indexOf("[");
312
+ if (start === -1) return text;
313
+
314
+ let depth = 0;
315
+ let inString = false;
316
+ let escaping = false;
317
+
318
+ for (let i = start; i < text.length; i++) {
319
+ const char = text[i];
320
+
321
+ if (inString) {
322
+ if (escaping) {
323
+ escaping = false;
324
+ continue;
325
+ }
326
+ if (char === "\\") {
327
+ escaping = true;
328
+ continue;
329
+ }
330
+ if (char === '"') {
331
+ inString = false;
332
+ }
333
+ continue;
334
+ }
335
+
336
+ if (char === '"') {
337
+ inString = true;
338
+ continue;
339
+ }
340
+ if (char === "[") {
341
+ depth++;
342
+ continue;
343
+ }
344
+ if (char !== "]") {
345
+ continue;
346
+ }
347
+
348
+ depth--;
349
+ if (depth === 0) {
350
+ return text.slice(start, i + 1);
351
+ }
352
+ }
353
+
354
+ return text;
355
+ }
356
+
357
+ export function createGenerateContext(prompt: string) {
358
+ return {
359
+ systemPrompt: GENERATE_OPTIONS_SYSTEM_PROMPT,
360
+ messages: [{
361
+ role: "user" as const,
362
+ content: [{ type: "text" as const, text: prompt }],
363
+ timestamp: Date.now(),
364
+ }],
365
+ };
366
+ }
367
+
368
+ export function parseGeneratedOptions(text: string): string[] {
369
+ let parsed: unknown;
370
+ try {
371
+ parsed = JSON.parse(extractJSONArray(text));
372
+ } catch (err) {
373
+ const detail = err instanceof Error ? err.message : String(err);
374
+ throw new Error(`Failed to parse generated options: ${detail}`);
375
+ }
376
+ if (!Array.isArray(parsed)) {
377
+ throw new Error("Expected array of options");
378
+ }
379
+
380
+ const options = parsed
381
+ .filter(
382
+ (item: unknown): item is string =>
383
+ typeof item === "string" && item.trim().length > 0,
384
+ )
385
+ .map((option: string) => option.trim());
386
+ if (options.length === 0) {
387
+ throw new Error("No valid options generated");
388
+ }
389
+ return options;
390
+ }
391
+
235
392
  function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
236
393
  // Extract JSON from <script id="pi-interview-data">
237
394
  const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
@@ -395,6 +552,32 @@ export default function (pi: ExtensionAPI) {
395
552
  const themeConfig = mergeThemeConfig(settings.theme, theme, ctx.cwd);
396
553
  const questionsData = loadQuestions(questions, ctx.cwd);
397
554
 
555
+ let configuredGenerateModel: Model<Api> | null = null;
556
+ if (settings.generateModel) {
557
+ const slashIdx = settings.generateModel.indexOf("/");
558
+ if (slashIdx > 0) {
559
+ configuredGenerateModel = ctx.modelRegistry.find(
560
+ settings.generateModel.slice(0, slashIdx),
561
+ settings.generateModel.slice(slashIdx + 1),
562
+ );
563
+ }
564
+ }
565
+
566
+ let availableGenerateModels: Model<Api>[] = [];
567
+ if (!configuredGenerateModel && !ctx.model) {
568
+ try {
569
+ availableGenerateModels = ctx.modelRegistry.getAvailable();
570
+ } catch {
571
+ // Leave generation disabled when model discovery is unavailable.
572
+ }
573
+ }
574
+
575
+ const { primary: generateModel, fallback: fallbackGenerateModel } = selectGenerateModels(
576
+ configuredGenerateModel,
577
+ ctx.model ?? null,
578
+ availableGenerateModels,
579
+ );
580
+
398
581
  // Expand ~ in snapshotDir if present
399
582
  const snapshotDir = settings.snapshotDir
400
583
  ? expandHome(settings.snapshotDir)
@@ -469,6 +652,89 @@ export default function (pi: ExtensionAPI) {
469
652
  };
470
653
  signal?.addEventListener("abort", handleAbort, { once: true });
471
654
 
655
+ let onGenerate: InterviewServerCallbacks["onGenerate"];
656
+ if (generateModel) {
657
+ const generateOptions = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
658
+ const modelRef = formatModelRef(model);
659
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
660
+ if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
661
+ if (!auth.apiKey) throw new Error(`No API key for ${modelRef}`);
662
+
663
+ const response = await complete(
664
+ model,
665
+ createGenerateContext(prompt),
666
+ { apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
667
+ );
668
+
669
+ return parseGeneratedOptions(extractGenerateResponseText(modelRef, response));
670
+ };
671
+
672
+ onGenerate = async (questionId, existingOptions, generateSignal, mode) => {
673
+ const question = questionsData.questions.find((q) => q.id === questionId);
674
+ if (!question) throw new Error(`Unknown question: ${questionId}`);
675
+
676
+ const existingList = existingOptions.length > 0
677
+ ? existingOptions.map((option) => `- ${option}`).join("\n")
678
+ : "(none)";
679
+
680
+ let prompt: string;
681
+ if (mode === "review") {
682
+ let recommended = "";
683
+ if (question.recommended) {
684
+ const value = Array.isArray(question.recommended)
685
+ ? question.recommended.join(", ")
686
+ : question.recommended;
687
+ recommended = `\nRecommended: ${value}`;
688
+ }
689
+ prompt = [
690
+ "Review these options for the question below. Fix any issues: incorrect options, missing obvious choices, poor wording, redundancy.",
691
+ "Return ONLY a JSON array of the corrected option strings. Keep good options as-is, fix bad ones, add missing ones, remove bad ones.",
692
+ "",
693
+ questionsData.title ? `Interview: ${questionsData.title}` : null,
694
+ `Question: ${question.question}`,
695
+ question.context ? `Context: ${question.context}` : null,
696
+ recommended || null,
697
+ "",
698
+ "Current options:",
699
+ existingList,
700
+ "",
701
+ 'Format: ["Option A", "Option B", "Option C"]',
702
+ ].filter((line) => line !== null).join("\n");
703
+ } else {
704
+ prompt = [
705
+ "Generate 3 new, distinct options for this question.",
706
+ "Return ONLY a JSON array of short option strings. No explanation, no markdown.",
707
+ "",
708
+ `Question: ${question.question}`,
709
+ question.context ? `Context: ${question.context}` : null,
710
+ "",
711
+ "Existing options (do NOT repeat):",
712
+ existingList,
713
+ "",
714
+ 'Format: ["Option A", "Option B", "Option C"]',
715
+ ].filter((line) => line !== null).join("\n");
716
+ }
717
+
718
+ let options: string[];
719
+ try {
720
+ options = await generateOptions(generateModel, prompt, generateSignal);
721
+ } catch (err) {
722
+ if (!fallbackGenerateModel || generateSignal.aborted) {
723
+ throw err;
724
+ }
725
+ try {
726
+ options = await generateOptions(fallbackGenerateModel, prompt, generateSignal);
727
+ } catch (fallbackErr) {
728
+ const primaryMessage = err instanceof Error ? err.message : String(err);
729
+ const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
730
+ throw new Error(`${primaryMessage}. Fallback failed: ${fallbackMessage}`);
731
+ }
732
+ }
733
+
734
+ return { options };
735
+ };
736
+ }
737
+
472
738
  startInterviewServer(
473
739
  {
474
740
  questions: questionsData,
@@ -482,6 +748,7 @@ export default function (pi: ExtensionAPI) {
482
748
  snapshotDir,
483
749
  autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
484
750
  savedAnswers: questionsData.savedAnswers,
751
+ canGenerate: generateModel !== null,
485
752
  },
486
753
  {
487
754
  onSubmit: (responses) => finish("completed", responses),
@@ -489,6 +756,7 @@ export default function (pi: ExtensionAPI) {
489
756
  reason === "timeout"
490
757
  ? finish("timeout", partialResponses ?? [])
491
758
  : finish("cancelled", partialResponses ?? [], reason),
759
+ onGenerate,
492
760
  }
493
761
  )
494
762
  .then(async (handle) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/server.ts CHANGED
@@ -196,11 +196,18 @@ export interface InterviewServerOptions {
196
196
  snapshotDir?: string;
197
197
  autoSaveOnSubmit?: boolean;
198
198
  savedAnswers?: ResponseItem[];
199
+ canGenerate?: boolean;
199
200
  }
200
201
 
201
202
  export interface InterviewServerCallbacks {
202
203
  onSubmit: (responses: ResponseItem[]) => void;
203
204
  onCancel: (reason?: "timeout" | "user" | "stale", partialResponses?: ResponseItem[]) => void;
205
+ onGenerate?: (
206
+ questionId: string,
207
+ existingOptions: string[],
208
+ signal: AbortSignal,
209
+ mode: "add" | "review",
210
+ ) => Promise<{ options: string[] }>;
204
211
  }
205
212
 
206
213
  export interface InterviewServerHandle {
@@ -882,6 +889,7 @@ export async function startInterviewServer(
882
889
  },
883
890
  savedAnswers: options.savedAnswers,
884
891
  autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
892
+ canGenerate: options.canGenerate ?? false,
885
893
  });
886
894
  const html = TEMPLATE
887
895
  .replace("<!-- __CDN_SCRIPTS__ -->", cdnScripts)
@@ -1283,6 +1291,68 @@ export async function startInterviewServer(
1283
1291
  return;
1284
1292
  }
1285
1293
 
1294
+ if (method === "POST" && url.pathname === "/generate") {
1295
+ const body = await parseBodyOrRespond();
1296
+ if (!body) return;
1297
+ if (!validateTokenBody(body, sessionToken, res)) return;
1298
+ if (completed) {
1299
+ sendJson(res, 409, { ok: false, error: "Session closed" });
1300
+ return;
1301
+ }
1302
+
1303
+ if (!callbacks.onGenerate) {
1304
+ sendJson(res, 501, { ok: false, error: "Generation not available" });
1305
+ return;
1306
+ }
1307
+
1308
+ const payload = body as {
1309
+ questionId?: string;
1310
+ existingOptions?: string[];
1311
+ mode?: string;
1312
+ };
1313
+
1314
+ if (typeof payload.questionId !== "string") {
1315
+ sendJson(res, 400, { ok: false, error: "Missing questionId" });
1316
+ return;
1317
+ }
1318
+
1319
+ const question = questionById.get(payload.questionId);
1320
+ if (!question || (question.type !== "single" && question.type !== "multi")) {
1321
+ sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
1322
+ return;
1323
+ }
1324
+
1325
+ const existingOptions = Array.isArray(payload.existingOptions)
1326
+ ? payload.existingOptions.filter((o): o is string => typeof o === "string")
1327
+ : [];
1328
+
1329
+ const mode = payload.mode === "review" ? "review" : "add";
1330
+
1331
+ const controller = new AbortController();
1332
+ res.on("close", () => {
1333
+ if (!res.writableEnded) controller.abort();
1334
+ });
1335
+ touchHeartbeat();
1336
+
1337
+ try {
1338
+ const result = await callbacks.onGenerate(
1339
+ payload.questionId,
1340
+ existingOptions,
1341
+ controller.signal,
1342
+ mode,
1343
+ );
1344
+ sendJson(res, 200, { ok: true, options: result.options });
1345
+ } catch (err) {
1346
+ if (controller.signal.aborted) {
1347
+ sendJson(res, 409, { ok: false, error: "Request cancelled" });
1348
+ return;
1349
+ }
1350
+ const message = err instanceof Error ? err.message : "Generation failed";
1351
+ sendJson(res, 500, { ok: false, error: message });
1352
+ }
1353
+ return;
1354
+ }
1355
+
1286
1356
  sendText(res, 404, "Not found");
1287
1357
  } catch (err) {
1288
1358
  const message = err instanceof Error ? err.message : "Server error";
package/settings.ts CHANGED
@@ -19,6 +19,7 @@ export interface InterviewSettings {
19
19
  theme?: InterviewThemeSettings;
20
20
  snapshotDir?: string; // Default: ~/.pi/interview-snapshots/
21
21
  autoSaveOnSubmit?: boolean; // Default: true
22
+ generateModel?: string; // e.g., "anthropic/claude-haiku-4-5"
22
23
  }
23
24
 
24
25
  export function loadSettings(): InterviewSettings {