pi-interview 0.6.0 → 0.6.2

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,7 +38,8 @@ 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
+ - **Generate & Review Options**: Single/multi-select questions show "✦ Generate more" (appends new choices) and "↻ Review options" (reviews options and rewrites the question for clarity) buttons powered by an LLM
42
+ - **Tool Discoverability (pi v0.59+)**: Registers a `promptSnippet` so `interview` remains eligible for inclusion in pi's default `Available tools` prompt section
42
43
  - **Themes**: Built-in default + optional light/dark + custom theme CSS
43
44
 
44
45
  ## How It Works
@@ -138,26 +139,26 @@ await interview({
138
139
  | `id` | string | Unique identifier |
139
140
  | `type` | string | `single`, `multi`, `text`, `image`, or `info` |
140
141
  | `question` | string | Question text |
141
- | `options` | string[] or object[] | Choices (required for single/multi). Can be strings or `{ label, code? }` objects |
142
+ | `options` | string[] or object[] | Choices (required for single/multi). Can be strings or `{ label, content? }` objects |
142
143
  | `recommended` | string or string[] | Shows "Recommended" badge and pre-selects option(s) |
143
144
  | `conviction` | string | `"strong"` or `"slight"`. Slight opts out of pre-selection. Requires `recommended` |
144
145
  | `weight` | string | `"critical"` (prominent card) or `"minor"` (compact card) |
145
146
  | `context` | string | Help text shown below question |
146
- | `codeBlock` | object | Code block displayed below question text |
147
+ | `content` | object | Content block displayed below question text (`lang: "md"|"markdown"` previews Markdown by default) |
147
148
  | `media` | object or object[] | Media content: image, chart, mermaid, table, or html |
148
149
 
149
- ### Code Blocks
150
+ ### Content Blocks
150
151
 
151
- Questions and options can include code blocks for displaying code snippets, diffs, and file references.
152
+ Questions and options can include `content` blocks for code snippets, diffs, and Markdown.
152
153
 
153
- **Question-level code block** (displayed above options):
154
+ **Question-level code content** (displayed above options):
154
155
  ```json
155
156
  {
156
157
  "id": "review",
157
158
  "type": "single",
158
159
  "question": "Review this implementation",
159
- "codeBlock": {
160
- "code": "function add(a, b) {\n return a + b;\n}",
160
+ "content": {
161
+ "source": "function add(a, b) {\n return a + b;\n}",
161
162
  "lang": "ts",
162
163
  "file": "src/math.ts",
163
164
  "lines": "10-12",
@@ -167,44 +168,59 @@ Questions and options can include code blocks for displaying code snippets, diff
167
168
  }
168
169
  ```
169
170
 
170
- **Options with code blocks**:
171
+ **Options with content blocks**:
171
172
  ```json
172
173
  {
173
174
  "options": [
174
175
  {
175
176
  "label": "Use async/await",
176
- "code": { "code": "const data = await fetch(url);", "lang": "ts" }
177
+ "content": { "source": "const data = await fetch(url);", "lang": "ts" }
177
178
  },
178
179
  {
179
180
  "label": "Use promises",
180
- "code": { "code": "fetch(url).then(data => ...);", "lang": "ts" }
181
+ "content": { "source": "fetch(url).then(data => ...);", "lang": "ts" }
181
182
  },
182
183
  "Keep current implementation"
183
184
  ]
184
185
  }
185
186
  ```
186
187
 
187
- **Diff display** (set `lang: "diff"`):
188
+ **Diff display** (`lang: "diff"`):
188
189
  ```json
189
190
  {
190
- "codeBlock": {
191
- "code": "--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,4 @@\n const x = 1;\n+const y = 2;\n const z = 3;",
191
+ "content": {
192
+ "source": "--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,4 @@\n const x = 1;\n+const y = 2;\n const z = 3;",
192
193
  "lang": "diff",
193
194
  "file": "src/file.ts"
194
195
  }
195
196
  }
196
197
  ```
197
198
 
198
- | CodeBlock Field | Type | Description |
199
- |-----------------|------|-------------|
200
- | `code` | string | The code content (required) |
201
- | `lang` | string | Language for display (e.g., "ts", "diff") |
202
- | `file` | string | File path to display in header |
203
- | `lines` | string | Line range to display (e.g., "10-25") |
204
- | `highlights` | number[] | Line numbers to highlight |
205
- | `title` | string | Optional title above code |
199
+ **Markdown preview by default** (`lang: "md"` or `"markdown"`):
200
+ ```json
201
+ {
202
+ "content": {
203
+ "source": "# Release notes\n\n- Added preview mode\n- Fixed wrapping",
204
+ "lang": "md"
205
+ }
206
+ }
207
+ ```
206
208
 
207
- Line numbers are shown when `file` or `lines` is specified. Diff syntax (`+`/`-` lines) is automatically styled when `lang` is "diff".
209
+ Set `showSource: true` on Markdown content to show raw Markdown instead of preview.
210
+
211
+ | Content Field | Type | Description |
212
+ |---------------|------|-------------|
213
+ | `source` | string | Content text (required) |
214
+ | `lang` | string | Language hint (e.g., `ts`, `diff`, `md`) |
215
+ | `file` | string | File path shown in the header |
216
+ | `lines` | string | Line range shown in the header (code content only) |
217
+ | `highlights` | number[] | Line highlights (code content only) |
218
+ | `title` | string | Optional title above content |
219
+ | `showSource` | boolean | Markdown only: `true` forces raw source instead of preview |
220
+
221
+ Rules:
222
+ - `lang: "md"` or `"markdown"`: preview by default, `showSource: true` shows raw source.
223
+ - Any other `lang`: renders as raw source; `showSource` is not allowed.
208
224
 
209
225
  ### Info Panels
210
226
 
package/form/script.js CHANGED
@@ -380,12 +380,16 @@
380
380
  el.textContent = text || "";
381
381
  }
382
382
 
383
- function renderLightMarkdown(text) {
384
- if (!text) return "";
385
- let html = text
383
+ function escapeHtml(text) {
384
+ return String(text || "")
386
385
  .replace(/&/g, "&")
387
386
  .replace(/</g, "&lt;")
388
387
  .replace(/>/g, "&gt;");
388
+ }
389
+
390
+ function renderLightMarkdown(text) {
391
+ if (!text) return "";
392
+ let html = escapeHtml(text);
389
393
  html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
390
394
  html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
391
395
  html = html.replace(/\n/g, "<br>");
@@ -393,6 +397,118 @@
393
397
  return html;
394
398
  }
395
399
 
400
+ function isMarkdownLang(lang) {
401
+ if (typeof lang !== "string") return false;
402
+ const normalized = lang.trim().toLowerCase();
403
+ return normalized === "md" || normalized === "markdown";
404
+ }
405
+
406
+ function renderMarkdownPreviewFallback(markdown) {
407
+ const lines = String(markdown || "").replace(/\r\n?/g, "\n").split("\n");
408
+ const html = [];
409
+ const paragraph = [];
410
+ let listType = null;
411
+ let inFence = false;
412
+ let fenceLang = "";
413
+ let fenceLines = [];
414
+
415
+ const flushParagraph = () => {
416
+ if (paragraph.length === 0) return;
417
+ html.push(`<p>${renderLightMarkdown(paragraph.join(" "))}</p>`);
418
+ paragraph.length = 0;
419
+ };
420
+
421
+ const closeList = () => {
422
+ if (!listType) return;
423
+ html.push(listType === "ol" ? "</ol>" : "</ul>");
424
+ listType = null;
425
+ };
426
+
427
+ for (const rawLine of lines) {
428
+ const line = rawLine ?? "";
429
+
430
+ if (inFence) {
431
+ if (/^```/.test(line.trim())) {
432
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
433
+ inFence = false;
434
+ fenceLang = "";
435
+ fenceLines = [];
436
+ } else {
437
+ fenceLines.push(line);
438
+ }
439
+ continue;
440
+ }
441
+
442
+ const fenceStart = line.match(/^```\s*([^\s`]*)\s*$/);
443
+ if (fenceStart) {
444
+ flushParagraph();
445
+ closeList();
446
+ inFence = true;
447
+ fenceLang = fenceStart[1] || "";
448
+ fenceLines = [];
449
+ continue;
450
+ }
451
+
452
+ if (!line.trim()) {
453
+ flushParagraph();
454
+ closeList();
455
+ continue;
456
+ }
457
+
458
+ const headingMatch = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
459
+ if (headingMatch) {
460
+ flushParagraph();
461
+ closeList();
462
+ const level = headingMatch[1].length;
463
+ html.push(`<h${level}>${renderLightMarkdown(headingMatch[2].trim())}</h${level}>`);
464
+ continue;
465
+ }
466
+
467
+ const quoteMatch = line.match(/^>\s?(.*)$/);
468
+ if (quoteMatch) {
469
+ flushParagraph();
470
+ closeList();
471
+ html.push(`<blockquote><p>${renderLightMarkdown(quoteMatch[1])}</p></blockquote>`);
472
+ continue;
473
+ }
474
+
475
+ const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
476
+ if (orderedMatch) {
477
+ flushParagraph();
478
+ if (listType !== "ol") {
479
+ closeList();
480
+ html.push("<ol>");
481
+ listType = "ol";
482
+ }
483
+ html.push(`<li>${renderLightMarkdown(orderedMatch[1])}</li>`);
484
+ continue;
485
+ }
486
+
487
+ const unorderedMatch = line.match(/^\s*[-*]\s+(.+)$/);
488
+ if (unorderedMatch) {
489
+ flushParagraph();
490
+ if (listType !== "ul") {
491
+ closeList();
492
+ html.push("<ul>");
493
+ listType = "ul";
494
+ }
495
+ html.push(`<li>${renderLightMarkdown(unorderedMatch[1])}</li>`);
496
+ continue;
497
+ }
498
+
499
+ closeList();
500
+ paragraph.push(line.trim());
501
+ }
502
+
503
+ if (inFence) {
504
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
505
+ }
506
+
507
+ flushParagraph();
508
+ closeList();
509
+ return html.join("\n");
510
+ }
511
+
396
512
  function getOptionLabel(option) {
397
513
  return typeof option === "string" ? option : option.label;
398
514
  }
@@ -401,22 +517,43 @@
401
517
  return typeof option === "object" && option !== null && "label" in option;
402
518
  }
403
519
 
404
- function renderCodeBlock(block) {
405
- if (!block || !block.code) return null;
520
+ function syncRecommendations(question, options) {
521
+ if (!question.recommended) return;
406
522
 
523
+ if (question.type === "single") {
524
+ if (typeof question.recommended === "string" && options.includes(question.recommended)) {
525
+ return;
526
+ }
527
+ delete question.recommended;
528
+ delete question.conviction;
529
+ return;
530
+ }
531
+
532
+ if (question.type !== "multi") {
533
+ delete question.recommended;
534
+ delete question.conviction;
535
+ return;
536
+ }
537
+
538
+ const nextRecommended = (Array.isArray(question.recommended)
539
+ ? question.recommended
540
+ : [question.recommended]).filter((option) => options.includes(option));
541
+ if (nextRecommended.length === 0) {
542
+ delete question.recommended;
543
+ delete question.conviction;
544
+ return;
545
+ }
546
+ question.recommended = nextRecommended;
547
+ }
548
+
549
+ function renderContentBlock(block) {
550
+ if (!block || !block.source) return null;
551
+
552
+ const markdownPreview = isMarkdownLang(block.lang) && block.showSource !== true;
407
553
  const container = document.createElement("div");
408
554
  container.className = "code-block";
409
-
410
- const showLineNumbers = !!block.file || !!block.lines;
411
- const isDiff = block.lang === "diff";
412
- const lines = block.code.split("\n");
413
- const highlights = new Set(block.highlights || []);
414
-
415
- // Parse starting line number from lines prop (e.g., "10-16" -> 10, "42" -> 42)
416
- let startLineNum = 1;
417
- if (block.lines) {
418
- const match = block.lines.match(/^(\d+)/);
419
- if (match) startLineNum = parseInt(match[1], 10);
555
+ if (markdownPreview) {
556
+ container.classList.add("markdown-content-block");
420
557
  }
421
558
 
422
559
  if (block.file || block.lines || block.lang || block.title) {
@@ -454,6 +591,25 @@
454
591
  container.appendChild(header);
455
592
  }
456
593
 
594
+ if (markdownPreview) {
595
+ const preview = document.createElement("div");
596
+ preview.className = "markdown-preview";
597
+ preview.innerHTML = renderMarkdownPreviewFallback(block.source);
598
+ container.appendChild(preview);
599
+ return container;
600
+ }
601
+
602
+ const showLineNumbers = !!block.file || !!block.lines;
603
+ const isDiff = block.lang === "diff";
604
+ const lines = block.source.split("\n");
605
+ const highlights = new Set(block.highlights || []);
606
+
607
+ let startLineNum = 1;
608
+ if (block.lines) {
609
+ const match = block.lines.match(/^(\d+)/);
610
+ if (match) startLineNum = parseInt(match[1], 10);
611
+ }
612
+
457
613
  const pre = document.createElement("pre");
458
614
  const code = document.createElement("code");
459
615
 
@@ -497,7 +653,7 @@
497
653
 
498
654
  code.appendChild(linesContainer);
499
655
  } else {
500
- code.textContent = block.code;
656
+ code.textContent = block.source;
501
657
  }
502
658
 
503
659
  pre.appendChild(code);
@@ -1394,6 +1550,7 @@
1394
1550
 
1395
1551
  function createGenerateMoreUI(question, list) {
1396
1552
  if (!data.canGenerate) return null;
1553
+ if (question.options.some(isRichOption)) return null;
1397
1554
 
1398
1555
  const container = document.createElement("div");
1399
1556
  container.className = "generate-more";
@@ -1499,6 +1656,10 @@
1499
1656
  }
1500
1657
 
1501
1658
  if (mode === "review") {
1659
+ if (typeof result.question !== "string" || !result.question.trim()) {
1660
+ throw new Error("No revised question returned");
1661
+ }
1662
+
1502
1663
  const seen = new Set();
1503
1664
  const revisedOptions = result.options.filter((option) => {
1504
1665
  const key = option.toLowerCase().trim();
@@ -1510,6 +1671,14 @@
1510
1671
  throw new Error("No valid options returned for review");
1511
1672
  }
1512
1673
 
1674
+ question.question = result.question.trim();
1675
+ question.options = revisedOptions;
1676
+ syncRecommendations(question, revisedOptions);
1677
+ const title = list.closest('.question-card')?.querySelector('.question-title');
1678
+ if (title) {
1679
+ title.innerHTML = renderLightMarkdown(question.question);
1680
+ }
1681
+
1513
1682
  list
1514
1683
  .querySelectorAll('.option-item:not(.option-other):not(.done-item)')
1515
1684
  .forEach((el) => el.remove());
@@ -1520,7 +1689,7 @@
1520
1689
  if (question.type === "multi") updateDoneState(question.id);
1521
1690
  debounceSave();
1522
1691
  showStatus(
1523
- revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
1692
+ "Question updated and " + revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
1524
1693
  2500,
1525
1694
  );
1526
1695
  } else {
@@ -1536,6 +1705,7 @@
1536
1705
  if (newOptions.length === 0) {
1537
1706
  showStatus("All generated options already exist", 3000);
1538
1707
  } else {
1708
+ question.options = question.options.concat(newOptions);
1539
1709
  newOptions.forEach((optionText, i) => {
1540
1710
  const optionEl = createGeneratedOption(question, optionText, i);
1541
1711
  list.insertBefore(optionEl, container);
@@ -1633,11 +1803,11 @@
1633
1803
  card.appendChild(context);
1634
1804
  }
1635
1805
 
1636
- if (question.codeBlock) {
1637
- const codeBlockEl = renderCodeBlock(question.codeBlock);
1638
- if (codeBlockEl) {
1639
- codeBlockEl.classList.add("question-code-block");
1640
- card.appendChild(codeBlockEl);
1806
+ if (question.content) {
1807
+ const contentBlockEl = renderContentBlock(question.content);
1808
+ if (contentBlockEl) {
1809
+ contentBlockEl.classList.add("question-code-block");
1810
+ card.appendChild(contentBlockEl);
1641
1811
  }
1642
1812
  }
1643
1813
 
@@ -1683,11 +1853,11 @@
1683
1853
 
1684
1854
  question.options.forEach((option, optionIndex) => {
1685
1855
  const optionLabel = getOptionLabel(option);
1686
- const optionCode = isRichOption(option) ? option.code : null;
1856
+ const optionContent = isRichOption(option) ? option.content : null;
1687
1857
 
1688
1858
  const label = document.createElement("label");
1689
1859
  label.className = "option-item";
1690
- if (optionCode) {
1860
+ if (optionContent) {
1691
1861
  label.classList.add("has-code");
1692
1862
  }
1693
1863
 
@@ -1721,10 +1891,10 @@
1721
1891
  label.appendChild(input);
1722
1892
  label.appendChild(text);
1723
1893
 
1724
- if (optionCode) {
1725
- const codeBlockEl = renderCodeBlock(optionCode);
1726
- if (codeBlockEl) {
1727
- label.appendChild(codeBlockEl);
1894
+ if (optionContent) {
1895
+ const contentBlockEl = renderContentBlock(optionContent);
1896
+ if (contentBlockEl) {
1897
+ label.appendChild(contentBlockEl);
1728
1898
  }
1729
1899
  }
1730
1900
 
@@ -2578,7 +2748,8 @@
2578
2748
  }
2579
2749
  } catch (err) {
2580
2750
  if (!submitted) {
2581
- showSaveError("Failed to save interview");
2751
+ const message = err instanceof Error ? err.message : String(err);
2752
+ showSaveError(`Failed to save interview: ${message}`);
2582
2753
  }
2583
2754
  return false;
2584
2755
  }
@@ -2659,7 +2830,8 @@
2659
2830
  if (isNetworkError(err)) {
2660
2831
  showSessionExpired();
2661
2832
  } else {
2662
- showGlobalError("Failed to submit responses.");
2833
+ const message = err instanceof Error ? err.message : String(err);
2834
+ showGlobalError(`Failed to submit responses: ${message}`);
2663
2835
  submitBtn.disabled = false;
2664
2836
  }
2665
2837
  }
package/form/styles.css CHANGED
@@ -1391,8 +1391,11 @@ button {
1391
1391
  .code-block pre {
1392
1392
  margin: 0;
1393
1393
  padding: 0.75rem;
1394
- overflow-x: auto;
1394
+ overflow-x: hidden;
1395
1395
  line-height: 1.5;
1396
+ white-space: pre-wrap;
1397
+ overflow-wrap: anywhere;
1398
+ word-break: break-word;
1396
1399
  }
1397
1400
 
1398
1401
  .code-block code {
@@ -1400,11 +1403,97 @@ button {
1400
1403
  font-size: var(--font-size-code);
1401
1404
  background: none;
1402
1405
  padding: 0;
1406
+ white-space: inherit;
1407
+ overflow-wrap: inherit;
1408
+ word-break: inherit;
1409
+ }
1410
+
1411
+ .markdown-content-block .code-block-header {
1412
+ border-bottom: 0;
1413
+ }
1414
+
1415
+ .markdown-preview {
1416
+ padding: 0.875rem;
1417
+ display: grid;
1418
+ gap: 0.625rem;
1419
+ color: var(--fg);
1420
+ line-height: 1.6;
1421
+ text-wrap: pretty;
1422
+ }
1423
+
1424
+ .markdown-preview > * {
1425
+ margin: 0;
1426
+ }
1427
+
1428
+ .markdown-preview h1,
1429
+ .markdown-preview h2,
1430
+ .markdown-preview h3,
1431
+ .markdown-preview h4,
1432
+ .markdown-preview h5,
1433
+ .markdown-preview h6 {
1434
+ font-family: var(--font-display);
1435
+ line-height: 1.2;
1436
+ text-wrap: balance;
1437
+ }
1438
+
1439
+ .markdown-preview h1 { font-size: 1.2rem; }
1440
+ .markdown-preview h2 { font-size: 1.1rem; }
1441
+ .markdown-preview h3 { font-size: 1rem; }
1442
+
1443
+ .markdown-preview ul,
1444
+ .markdown-preview ol {
1445
+ margin: 0;
1446
+ padding-left: 1.25rem;
1447
+ display: grid;
1448
+ gap: 0.35rem;
1449
+ }
1450
+
1451
+ .markdown-preview blockquote {
1452
+ margin: 0;
1453
+ padding-left: 0.75rem;
1454
+ border-left: 3px solid var(--accent-muted);
1455
+ color: var(--fg-muted);
1456
+ }
1457
+
1458
+ .markdown-preview pre {
1459
+ margin: 0;
1460
+ padding: 0.75rem;
1461
+ border-radius: 6px;
1462
+ background: color-mix(in srgb, var(--bg-body) 90%, transparent);
1463
+ border: 1px solid var(--border-muted);
1464
+ overflow-x: auto;
1465
+ }
1466
+
1467
+ .markdown-preview pre code {
1468
+ font-family: var(--font-mono);
1469
+ font-size: var(--font-size-code);
1470
+ white-space: pre;
1471
+ }
1472
+
1473
+ .markdown-preview code {
1474
+ font-family: var(--font-mono);
1475
+ font-size: 0.75rem;
1476
+ background: color-mix(in srgb, var(--bg-body) 80%, transparent);
1477
+ padding: 0.1rem 0.3rem;
1478
+ border-radius: 4px;
1479
+ }
1480
+
1481
+ .markdown-preview a {
1482
+ color: var(--accent);
1483
+ text-underline-offset: 2px;
1484
+ }
1485
+
1486
+ .markdown-preview p code,
1487
+ .markdown-preview li code,
1488
+ .markdown-preview blockquote code {
1489
+ white-space: normal;
1490
+ overflow-wrap: anywhere;
1403
1491
  }
1404
1492
 
1405
1493
  .code-block-lines-container {
1406
1494
  display: table;
1407
- min-width: 100%;
1495
+ width: 100%;
1496
+ table-layout: fixed;
1408
1497
  }
1409
1498
 
1410
1499
  .code-block-line {
@@ -1425,7 +1514,8 @@ button {
1425
1514
 
1426
1515
  .code-block-line-content {
1427
1516
  display: table-cell;
1428
- white-space: pre;
1517
+ white-space: pre-wrap;
1518
+ overflow-wrap: anywhere;
1429
1519
  padding-right: 0.75rem;
1430
1520
  }
1431
1521
 
package/index.ts CHANGED
@@ -246,6 +246,9 @@ const PREFERRED_GENERATE_MODELS = [
246
246
  const GENERATE_OPTIONS_SYSTEM_PROMPT =
247
247
  "You generate interview answer options. Return only a JSON array of strings. Do not include explanations or markdown.";
248
248
 
249
+ const REVIEW_QUESTION_SYSTEM_PROMPT =
250
+ "You review interview questions and answer options. Preserve intent. Return only JSON with a rewritten question string and an options array.";
251
+
249
252
  function formatModelRef(model: GenerateModelCandidate): string {
250
253
  return `${model.provider}/${model.id}`;
251
254
  }
@@ -307,8 +310,8 @@ export function extractGenerateResponseText(
307
310
  return text;
308
311
  }
309
312
 
310
- export function extractJSONArray(text: string): string {
311
- const start = text.indexOf("[");
313
+ function extractJSONBlock(text: string, openChar: "[" | "{", closeChar: "]" | "}"): string {
314
+ const start = text.indexOf(openChar);
312
315
  if (start === -1) return text;
313
316
 
314
317
  let depth = 0;
@@ -337,11 +340,11 @@ export function extractJSONArray(text: string): string {
337
340
  inString = true;
338
341
  continue;
339
342
  }
340
- if (char === "[") {
343
+ if (char === openChar) {
341
344
  depth++;
342
345
  continue;
343
346
  }
344
- if (char !== "]") {
347
+ if (char !== closeChar) {
345
348
  continue;
346
349
  }
347
350
 
@@ -354,9 +357,17 @@ export function extractJSONArray(text: string): string {
354
357
  return text;
355
358
  }
356
359
 
357
- export function createGenerateContext(prompt: string) {
360
+ export function extractJSONArray(text: string): string {
361
+ return extractJSONBlock(text, "[", "]");
362
+ }
363
+
364
+ function extractJSONObject(text: string): string {
365
+ return extractJSONBlock(text, "{", "}");
366
+ }
367
+
368
+ export function createGenerateContext(prompt: string, systemPrompt = GENERATE_OPTIONS_SYSTEM_PROMPT) {
358
369
  return {
359
- systemPrompt: GENERATE_OPTIONS_SYSTEM_PROMPT,
370
+ systemPrompt,
360
371
  messages: [{
361
372
  role: "user" as const,
362
373
  content: [{ type: "text" as const, text: prompt }],
@@ -365,14 +376,7 @@ export function createGenerateContext(prompt: string) {
365
376
  };
366
377
  }
367
378
 
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
- }
379
+ function normalizeGeneratedOptions(parsed: unknown): string[] {
376
380
  if (!Array.isArray(parsed)) {
377
381
  throw new Error("Expected array of options");
378
382
  }
@@ -389,7 +393,41 @@ export function parseGeneratedOptions(text: string): string[] {
389
393
  return options;
390
394
  }
391
395
 
392
- function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
396
+ export function parseGeneratedOptions(text: string): string[] {
397
+ let parsed: unknown;
398
+ try {
399
+ parsed = JSON.parse(extractJSONArray(text));
400
+ } catch (err) {
401
+ const detail = err instanceof Error ? err.message : String(err);
402
+ throw new Error(`Failed to parse generated options: ${detail}`);
403
+ }
404
+ return normalizeGeneratedOptions(parsed);
405
+ }
406
+
407
+ export function parseReviewedQuestion(text: string): { question: string; options: string[] } {
408
+ let parsed: unknown;
409
+ try {
410
+ parsed = JSON.parse(extractJSONObject(text));
411
+ } catch (err) {
412
+ const detail = err instanceof Error ? err.message : String(err);
413
+ throw new Error(`Failed to parse reviewed question: ${detail}`);
414
+ }
415
+ if (typeof parsed !== "object" || parsed === null) {
416
+ throw new Error("Expected reviewed question object");
417
+ }
418
+
419
+ const review = parsed as Record<string, unknown>;
420
+ if (typeof review.question !== "string" || !review.question.trim()) {
421
+ throw new Error("Reviewed question must include a non-empty question string");
422
+ }
423
+
424
+ return {
425
+ question: review.question.trim(),
426
+ options: normalizeGeneratedOptions(review.options),
427
+ };
428
+ }
429
+
430
+ export function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
393
431
  // Extract JSON from <script id="pi-interview-data">
394
432
  const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
395
433
  if (!match) {
@@ -406,11 +444,13 @@ function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile
406
444
 
407
445
  const raw = data as Record<string, unknown>;
408
446
  const validated = validateQuestions(data);
447
+ const questionTypeById = new Map(validated.questions.map((question) => [question.id, question.type]));
409
448
 
410
- // Resolve relative image paths to absolute based on HTML file location
449
+ // Resolve relative image paths to absolute based on HTML file location.
450
+ // Only image-question values are treated as paths; text/single/multi values must stay literal.
411
451
  const snapshotDir = path.dirname(filePath);
412
452
  const savedAnswers = Array.isArray(raw.savedAnswers)
413
- ? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir)
453
+ ? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir, questionTypeById)
414
454
  : undefined;
415
455
 
416
456
  // Validate savedFrom if present
@@ -436,26 +476,28 @@ function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile
436
476
  };
437
477
  }
438
478
 
439
- function resolveAnswerPaths(answers: ResponseItem[], baseDir: string): ResponseItem[] {
440
- return answers.map((ans) => ({
441
- ...ans,
442
- value: resolvePathValue(ans.value, baseDir),
443
- attachments: ans.attachments?.map((p) => resolveImagePath(p, baseDir)),
444
- }));
479
+ function resolveAnswerPaths(
480
+ answers: ResponseItem[],
481
+ baseDir: string,
482
+ questionTypeById: Map<string, "single" | "multi" | "text" | "image" | "info">,
483
+ ): ResponseItem[] {
484
+ return answers.map((ans) => {
485
+ const questionType = questionTypeById.get(ans.id);
486
+ return {
487
+ ...ans,
488
+ value: questionType === "image" ? resolvePathValue(ans.value, baseDir) : ans.value,
489
+ attachments: ans.attachments?.map((attachmentPath) => resolveImagePath(attachmentPath, baseDir)),
490
+ };
491
+ });
445
492
  }
446
493
 
447
494
  function resolveImagePath(p: string, baseDir: string): string {
448
495
  if (!p) return p;
449
- // Skip URLs
450
- if (p.includes("://")) return p;
451
- // Expand ~ first
496
+ // Skip URLs and data/file URIs
497
+ if (p.includes("://") || p.startsWith("data:") || p.startsWith("file:")) return p;
452
498
  const expanded = expandHome(p);
453
- // Don't resolve if already absolute (cross-platform check)
454
- if (path.isAbsolute(expanded)) {
455
- return expanded;
456
- }
457
- // Resolve relative path against snapshot directory
458
- return path.join(baseDir, p);
499
+ if (path.isAbsolute(expanded)) return expanded;
500
+ return path.join(baseDir, expanded);
459
501
  }
460
502
 
461
503
  function resolvePathValue(value: string | string[], baseDir: string): string | string[] {
@@ -513,16 +555,18 @@ export default function (pi: ExtensionAPI) {
513
555
  "Provides better UX than back-and-forth chat for structured input. " +
514
556
  "Image responses and attachments are returned as file paths - use read tool directly to display them. " +
515
557
  "Pass questions as inline JSON string directly (preferred) or as a path to a JSON file. " +
516
- 'Questions JSON format: { "title": "...", "description": "...", "questions": [{ "id": "q1", "type": "single|multi|text|image|info", "question": "...", "options": ["A", "B"], "codeBlock": { "code": "...", "lang": "ts" }, "media": { "type": "image|chart|mermaid|table|html", ... } }] }. ' +
517
- "Options can be strings or objects: { label: string, code?: { code, lang?, file?, lines?, highlights? } }. " +
558
+ 'Questions JSON format: { "title": "...", "description": "...", "questions": [{ "id": "q1", "type": "single|multi|text|image|info", "question": "...", "options": ["A", "B"], "content": { "source": "...", "lang": "ts" }, "media": { "type": "image|chart|mermaid|table|html", ... } }] }. ' +
559
+ "Options can be strings or objects: { label: string, content?: { source, lang?, file?, lines?, highlights?, title?, showSource? } }. " +
518
560
  "Always set recommended with context explaining your reasoning. Recommended options show a 'Recommended' badge and are pre-selected for the user. " +
519
561
  'Use conviction: "slight" when unsure (does NOT pre-select), conviction: "strong" when very confident (shows Recommended badge). ' +
520
562
  "Omit conviction for normal recommendations (pre-selects). " +
521
563
  'Use weight: "critical" for key decisions (visually prominent), weight: "minor" for low-stakes questions (compact card). ' +
522
564
  "When questions have recommendations, set description to guide review (e.g., 'Review my suggestions and adjust as needed'). " +
523
- "Questions can have a codeBlock field to display code above options. Types: single (radio), multi (checkbox), text (textarea), image (file upload), info (non-interactive). " +
565
+ "Questions can have a content field to display code or markdown above options. lang: \"md\" or \"markdown\" defaults to markdown preview unless showSource is true. Types: single (radio), multi (checkbox), text (textarea), image (file upload), info (non-interactive). " +
524
566
  'Media blocks: { type: "image", src, alt, caption }, { type: "table", table: { headers, rows, highlights }, caption }, { type: "chart", chart: { type, data, options }, caption }, { type: "mermaid", mermaid: "graph LR\\n..." }, { type: "html", html }. ' +
525
567
  "Info type is a non-interactive content panel for displaying context with media. Media position: above (default), below, side (two-column).",
568
+ promptSnippet:
569
+ "Gather structured user input through an interactive form for requirements, tradeoffs, or multi-dimensional decisions.",
526
570
  parameters: InterviewParams,
527
571
 
528
572
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -669,6 +713,21 @@ export default function (pi: ExtensionAPI) {
669
713
  return parseGeneratedOptions(extractGenerateResponseText(modelRef, response));
670
714
  };
671
715
 
716
+ const reviewQuestion = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
717
+ const modelRef = formatModelRef(model);
718
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
719
+ if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
720
+ if (!auth.apiKey) throw new Error(`No API key for ${modelRef}`);
721
+
722
+ const response = await complete(
723
+ model,
724
+ createGenerateContext(prompt, REVIEW_QUESTION_SYSTEM_PROMPT),
725
+ { apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
726
+ );
727
+
728
+ return parseReviewedQuestion(extractGenerateResponseText(modelRef, response));
729
+ };
730
+
672
731
  onGenerate = async (questionId, existingOptions, generateSignal, mode) => {
673
732
  const question = questionsData.questions.find((q) => q.id === questionId);
674
733
  if (!question) throw new Error(`Unknown question: ${questionId}`);
@@ -687,18 +746,20 @@ export default function (pi: ExtensionAPI) {
687
746
  recommended = `\nRecommended: ${value}`;
688
747
  }
689
748
  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.",
749
+ "Review this interview question and its options.",
750
+ "Rewrite the question so it is easier to understand while preserving the original intent.",
751
+ "Review the options the same way you already would: keep good ones as-is, fix bad ones, add missing ones, and remove bad ones.",
752
+ "Return ONLY JSON in this format:",
753
+ '{"question":"Clearer question text","options":["Option A","Option B","Option C"]}',
692
754
  "",
693
755
  questionsData.title ? `Interview: ${questionsData.title}` : null,
756
+ questionsData.description ? `Interview context: ${questionsData.description}` : null,
694
757
  `Question: ${question.question}`,
695
- question.context ? `Context: ${question.context}` : null,
758
+ question.context ? `Question context: ${question.context}` : null,
696
759
  recommended || null,
697
760
  "",
698
761
  "Current options:",
699
762
  existingList,
700
- "",
701
- 'Format: ["Option A", "Option B", "Option C"]',
702
763
  ].filter((line) => line !== null).join("\n");
703
764
  } else {
704
765
  prompt = [
@@ -715,6 +776,26 @@ export default function (pi: ExtensionAPI) {
715
776
  ].filter((line) => line !== null).join("\n");
716
777
  }
717
778
 
779
+ if (mode === "review") {
780
+ let result: { question: string; options: string[] };
781
+ try {
782
+ result = await reviewQuestion(generateModel, prompt, generateSignal);
783
+ } catch (err) {
784
+ if (!fallbackGenerateModel || generateSignal.aborted) {
785
+ throw err;
786
+ }
787
+ try {
788
+ result = await reviewQuestion(fallbackGenerateModel, prompt, generateSignal);
789
+ } catch (fallbackErr) {
790
+ const primaryMessage = err instanceof Error ? err.message : String(err);
791
+ const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
792
+ throw new Error(`${primaryMessage}. Fallback failed: ${fallbackMessage}`);
793
+ }
794
+ }
795
+
796
+ return result;
797
+ }
798
+
718
799
  let options: string[];
719
800
  try {
720
801
  options = await generateOptions(generateModel, prompt, generateSignal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/schema.ts CHANGED
@@ -1,15 +1,28 @@
1
- export interface CodeBlock {
2
- code: string;
1
+ export interface BaseContentBlock {
2
+ source: string;
3
3
  lang?: string;
4
4
  file?: string;
5
+ title?: string;
6
+ }
7
+
8
+ export interface MarkdownContentBlock extends BaseContentBlock {
9
+ lang: "md" | "markdown";
10
+ showSource?: boolean;
11
+ lines?: never;
12
+ highlights?: never;
13
+ }
14
+
15
+ export interface CodeContentBlock extends BaseContentBlock {
5
16
  lines?: string;
6
17
  highlights?: number[];
7
- title?: string;
18
+ showSource?: never;
8
19
  }
9
20
 
21
+ export type ContentBlock = MarkdownContentBlock | CodeContentBlock;
22
+
10
23
  export interface RichOption {
11
24
  label: string;
12
- code?: CodeBlock;
25
+ content?: ContentBlock;
13
26
  }
14
27
 
15
28
  export type OptionValue = string | RichOption;
@@ -44,7 +57,7 @@ export interface Question {
44
57
  conviction?: "strong" | "slight";
45
58
  weight?: "critical" | "minor";
46
59
  context?: string;
47
- codeBlock?: CodeBlock;
60
+ content?: ContentBlock;
48
61
  media?: MediaBlock | MediaBlock[];
49
62
  }
50
63
 
@@ -127,34 +140,71 @@ const SCHEMA_EXAMPLE = `Expected format:
127
140
  ]
128
141
  }
129
142
  Valid types: single, multi, text, image, info
130
- Options: array of strings or objects with { label, code? }`;
143
+ Options: array of strings or objects with { label, content? }`;
144
+
145
+ function isMarkdownLang(lang: unknown): lang is "md" | "markdown" {
146
+ if (typeof lang !== "string") return false;
147
+ const normalized = lang.trim().toLowerCase();
148
+ return normalized === "md" || normalized === "markdown";
149
+ }
131
150
 
132
- function validateCodeBlock(block: unknown, context: string): CodeBlock {
151
+ function validateContentBlock(block: unknown, context: string): ContentBlock {
133
152
  if (!block || typeof block !== "object") {
134
- throw new Error(`${context}: codeBlock must be an object`);
153
+ throw new Error(`${context}: content must be an object`);
135
154
  }
136
155
  const b = block as Record<string, unknown>;
137
- if (typeof b.code !== "string") {
138
- throw new Error(`${context}: codeBlock.code must be a string`);
156
+ if (typeof b.source !== "string") {
157
+ throw new Error(`${context}: content.source must be a string`);
139
158
  }
140
159
  if (b.lang !== undefined && typeof b.lang !== "string") {
141
- throw new Error(`${context}: codeBlock.lang must be a string`);
160
+ throw new Error(`${context}: content.lang must be a string`);
142
161
  }
143
162
  if (b.file !== undefined && typeof b.file !== "string") {
144
- throw new Error(`${context}: codeBlock.file must be a string`);
163
+ throw new Error(`${context}: content.file must be a string`);
145
164
  }
146
165
  if (b.lines !== undefined && typeof b.lines !== "string") {
147
- throw new Error(`${context}: codeBlock.lines must be a string`);
166
+ throw new Error(`${context}: content.lines must be a string`);
148
167
  }
149
168
  if (b.title !== undefined && typeof b.title !== "string") {
150
- throw new Error(`${context}: codeBlock.title must be a string`);
169
+ throw new Error(`${context}: content.title must be a string`);
170
+ }
171
+ if (b.showSource !== undefined && typeof b.showSource !== "boolean") {
172
+ throw new Error(`${context}: content.showSource must be a boolean`);
151
173
  }
152
174
  if (b.highlights !== undefined) {
153
175
  if (!Array.isArray(b.highlights) || b.highlights.some((h) => typeof h !== "number")) {
154
- throw new Error(`${context}: codeBlock.highlights must be an array of numbers`);
176
+ throw new Error(`${context}: content.highlights must be an array of numbers`);
177
+ }
178
+ }
179
+
180
+ if (isMarkdownLang(b.lang)) {
181
+ if (b.lines !== undefined) {
182
+ throw new Error(`${context}: content.lines is not allowed for markdown content`);
183
+ }
184
+ if (b.highlights !== undefined) {
185
+ throw new Error(`${context}: content.highlights is not allowed for markdown content`);
155
186
  }
187
+ return {
188
+ source: b.source,
189
+ lang: b.lang.trim().toLowerCase() as "md" | "markdown",
190
+ file: b.file,
191
+ title: b.title,
192
+ showSource: b.showSource,
193
+ };
156
194
  }
157
- return b as unknown as CodeBlock;
195
+
196
+ if (b.showSource !== undefined) {
197
+ throw new Error(`${context}: content.showSource is only valid when content.lang is "md" or "markdown"`);
198
+ }
199
+
200
+ return {
201
+ source: b.source,
202
+ lang: b.lang,
203
+ file: b.file,
204
+ title: b.title,
205
+ lines: b.lines,
206
+ highlights: b.highlights as number[] | undefined,
207
+ };
158
208
  }
159
209
 
160
210
  function validateOption(option: unknown, questionId: string, index: number): OptionValue {
@@ -169,9 +219,17 @@ function validateOption(option: unknown, questionId: string, index: number): Opt
169
219
  );
170
220
  }
171
221
  if (o.code !== undefined) {
172
- validateCodeBlock(o.code, `Question "${questionId}" option "${o.label}"`);
222
+ throw new Error(
223
+ `Question "${questionId}" option "${o.label}": legacy "code" is no longer supported; use "content"`
224
+ );
173
225
  }
174
- return option as RichOption;
226
+ if (o.content !== undefined) {
227
+ return {
228
+ label: o.label,
229
+ content: validateContentBlock(o.content, `Question "${questionId}" option "${o.label}"`),
230
+ };
231
+ }
232
+ return { label: o.label };
175
233
  }
176
234
  throw new Error(
177
235
  `Question "${questionId}": option at index ${index} must be a string or object with label`
@@ -240,7 +298,7 @@ function validateBasicStructure(data: unknown): QuestionsFile {
240
298
  throw new Error(`Question "${q.id}": options must be a non-empty array`);
241
299
  }
242
300
  for (let j = 0; j < q.options.length; j++) {
243
- validateOption(q.options[j], q.id as string, j);
301
+ q.options[j] = validateOption(q.options[j], q.id as string, j);
244
302
  }
245
303
  }
246
304
 
@@ -249,7 +307,10 @@ function validateBasicStructure(data: unknown): QuestionsFile {
249
307
  }
250
308
 
251
309
  if (q.codeBlock !== undefined) {
252
- validateCodeBlock(q.codeBlock, `Question "${q.id}"`);
310
+ throw new Error(`Question "${q.id}": legacy "codeBlock" is no longer supported; use "content"`);
311
+ }
312
+ if (q.content !== undefined) {
313
+ q.content = validateContentBlock(q.content, `Question "${q.id}"`);
253
314
  }
254
315
 
255
316
  if (q.conviction !== undefined) {
package/server.ts CHANGED
@@ -207,7 +207,7 @@ export interface InterviewServerCallbacks {
207
207
  existingOptions: string[],
208
208
  signal: AbortSignal,
209
209
  mode: "add" | "review",
210
- ) => Promise<{ options: string[] }>;
210
+ ) => Promise<{ options: string[]; question?: string }>;
211
211
  }
212
212
 
213
213
  export interface InterviewServerHandle {
@@ -387,6 +387,35 @@ function ensureQuestionId(
387
387
  return { ok: true, question };
388
388
  }
389
389
 
390
+ function syncRecommendations(question: Question, options: string[]): void {
391
+ if (!question.recommended) return;
392
+
393
+ if (question.type === "single") {
394
+ if (typeof question.recommended === "string" && options.includes(question.recommended)) {
395
+ return;
396
+ }
397
+ delete question.recommended;
398
+ delete question.conviction;
399
+ return;
400
+ }
401
+
402
+ if (question.type !== "multi") {
403
+ delete question.recommended;
404
+ delete question.conviction;
405
+ return;
406
+ }
407
+
408
+ const nextRecommended = (Array.isArray(question.recommended)
409
+ ? question.recommended
410
+ : [question.recommended]).filter((option) => options.includes(option));
411
+ if (nextRecommended.length === 0) {
412
+ delete question.recommended;
413
+ delete question.conviction;
414
+ return;
415
+ }
416
+ question.recommended = nextRecommended;
417
+ }
418
+
390
419
  // HTML generation for saved interviews
391
420
  interface SavedFromMeta {
392
421
  cwd: string;
@@ -408,6 +437,155 @@ function escapeHtml(str: string): string {
408
437
  .replace(/"/g, "&quot;");
409
438
  }
410
439
 
440
+ function isMarkdownLang(lang: string | undefined): boolean {
441
+ if (!lang) return false;
442
+ const normalized = lang.trim().toLowerCase();
443
+ return normalized === "md" || normalized === "markdown";
444
+ }
445
+
446
+ function renderLightMarkdownHtml(text: string): string {
447
+ let html = escapeHtml(text);
448
+ html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
449
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
450
+ html = html.replace(/\n/g, "<br>");
451
+ html = html.replace(/\s(\d+\.)\s/g, "<br>$1 ");
452
+ return html;
453
+ }
454
+
455
+ function renderMarkdownPreviewHtml(markdown: string): string {
456
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
457
+ const html: string[] = [];
458
+ const paragraph: string[] = [];
459
+ let listType: "ul" | "ol" | null = null;
460
+ let inFence = false;
461
+ let fenceLang = "";
462
+ let fenceLines: string[] = [];
463
+
464
+ const flushParagraph = () => {
465
+ if (paragraph.length === 0) return;
466
+ html.push(`<p>${renderLightMarkdownHtml(paragraph.join(" "))}</p>`);
467
+ paragraph.length = 0;
468
+ };
469
+
470
+ const closeList = () => {
471
+ if (!listType) return;
472
+ html.push(listType === "ol" ? "</ol>" : "</ul>");
473
+ listType = null;
474
+ };
475
+
476
+ for (const rawLine of lines) {
477
+ const line = rawLine ?? "";
478
+
479
+ if (inFence) {
480
+ if (/^```/.test(line.trim())) {
481
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
482
+ inFence = false;
483
+ fenceLang = "";
484
+ fenceLines = [];
485
+ } else {
486
+ fenceLines.push(line);
487
+ }
488
+ continue;
489
+ }
490
+
491
+ const fenceStart = line.match(/^```\s*([^\s`]*)\s*$/);
492
+ if (fenceStart) {
493
+ flushParagraph();
494
+ closeList();
495
+ inFence = true;
496
+ fenceLang = fenceStart[1] || "";
497
+ fenceLines = [];
498
+ continue;
499
+ }
500
+
501
+ if (!line.trim()) {
502
+ flushParagraph();
503
+ closeList();
504
+ continue;
505
+ }
506
+
507
+ const headingMatch = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
508
+ if (headingMatch) {
509
+ flushParagraph();
510
+ closeList();
511
+ const level = headingMatch[1].length;
512
+ html.push(`<h${level}>${renderLightMarkdownHtml(headingMatch[2].trim())}</h${level}>`);
513
+ continue;
514
+ }
515
+
516
+ const quoteMatch = line.match(/^>\s?(.*)$/);
517
+ if (quoteMatch) {
518
+ flushParagraph();
519
+ closeList();
520
+ html.push(`<blockquote><p>${renderLightMarkdownHtml(quoteMatch[1])}</p></blockquote>`);
521
+ continue;
522
+ }
523
+
524
+ const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
525
+ if (orderedMatch) {
526
+ flushParagraph();
527
+ if (listType !== "ol") {
528
+ closeList();
529
+ html.push("<ol>");
530
+ listType = "ol";
531
+ }
532
+ html.push(`<li>${renderLightMarkdownHtml(orderedMatch[1])}</li>`);
533
+ continue;
534
+ }
535
+
536
+ const unorderedMatch = line.match(/^\s*[-*]\s+(.+)$/);
537
+ if (unorderedMatch) {
538
+ flushParagraph();
539
+ if (listType !== "ul") {
540
+ closeList();
541
+ html.push("<ul>");
542
+ listType = "ul";
543
+ }
544
+ html.push(`<li>${renderLightMarkdownHtml(unorderedMatch[1])}</li>`);
545
+ continue;
546
+ }
547
+
548
+ closeList();
549
+ paragraph.push(line.trim());
550
+ }
551
+
552
+ if (inFence) {
553
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
554
+ }
555
+
556
+ flushParagraph();
557
+ closeList();
558
+ return html.join("\n");
559
+ }
560
+
561
+ function renderContentBlockHtml(content: Question["content"] | undefined): string {
562
+ if (!content?.source) return "";
563
+
564
+ const markdownPreview = isMarkdownLang(content.lang) && content.showSource !== true;
565
+ const headerParts: string[] = [];
566
+ if (content.title) {
567
+ headerParts.push(`<span class="code-block-title">${escapeHtml(content.title)}</span>`);
568
+ }
569
+ if (content.file) {
570
+ headerParts.push(`<span class="code-block-file">${escapeHtml(content.file)}</span>`);
571
+ }
572
+ if (content.lines) {
573
+ headerParts.push(`<span class="code-block-lines">L${escapeHtml(content.lines)}</span>`);
574
+ }
575
+ if (content.lang && content.lang !== "diff") {
576
+ headerParts.push(`<span class="code-block-lang">${escapeHtml(content.lang)}</span>`);
577
+ }
578
+ const headerHtml = headerParts.length > 0
579
+ ? `<div class="code-block-header">${headerParts.join("")}</div>`
580
+ : "";
581
+
582
+ if (markdownPreview) {
583
+ return `<div class="code-block markdown-content-block">${headerHtml}<div class="markdown-preview">${renderMarkdownPreviewHtml(content.source)}</div></div>`;
584
+ }
585
+
586
+ return `<div class="code-block">${headerHtml}<pre class="saved-code"><code>${escapeHtml(content.source)}</code></pre></div>`;
587
+ }
588
+
411
589
  function renderMediaCaptionHtml(media: MediaBlock): string {
412
590
  if (!media.caption) return "";
413
591
  return `<div class="media-caption">${escapeHtml(media.caption)}</div>`;
@@ -516,14 +694,12 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
516
694
  const mediaHtml = renderMediaListHtml(q.media);
517
695
 
518
696
  if (q.type === "info") {
519
- const codeHtml = q.codeBlock
520
- ? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
521
- : "";
697
+ const contentHtml = renderContentBlockHtml(q.content);
522
698
  return `
523
699
  <div class="${weightClasses(q)}">
524
700
  <h2>${escapeHtml(q.question)}</h2>
525
701
  ${q.context ? `<p class="question-context">${escapeHtml(q.context)}</p>` : ""}
526
- ${codeHtml}
702
+ ${contentHtml}
527
703
  ${mediaHtml}
528
704
  </div>
529
705
  `;
@@ -550,9 +726,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
550
726
  answerHtml = `<div class="saved-answer">${savedAnswerItemHtml(String(value), q)}</div>`;
551
727
  }
552
728
 
553
- const codeHtml = q.codeBlock
554
- ? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
555
- : "";
729
+ const contentHtml = renderContentBlockHtml(q.content);
556
730
 
557
731
  const attachHtml =
558
732
  attachments.length > 0
@@ -569,7 +743,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
569
743
  <div class="${weightClasses(q)}">
570
744
  <h2>${numPrefix}${escapeHtml(q.question)}</h2>
571
745
  ${contextHtml}
572
- ${codeHtml}
746
+ ${contentHtml}
573
747
  ${mediaHtml}
574
748
  ${answerHtml}
575
749
  ${attachHtml}
@@ -631,8 +805,11 @@ const SAVED_VIEW_STYLES = `
631
805
  padding: 12px;
632
806
  background: var(--bg-body);
633
807
  border-radius: var(--radius);
634
- overflow-x: auto;
808
+ overflow-x: hidden;
635
809
  font-size: 13px;
810
+ white-space: pre-wrap;
811
+ overflow-wrap: anywhere;
812
+ word-break: break-word;
636
813
  }
637
814
  .saved-answer {
638
815
  color: var(--fg);
@@ -1258,9 +1435,7 @@ export async function startInterviewServer(
1258
1435
  }
1259
1436
 
1260
1437
  // Copy local media images to snapshot and rewrite paths
1261
- const rewrittenQuestions = await copyMediaImages(
1262
- questions.questions, imagesPath, cwd
1263
- );
1438
+ const rewrittenQuestions = await copyMediaImages(questions.questions, imagesPath, cwd);
1264
1439
  const snapshotQuestions: QuestionsFile = {
1265
1440
  ...questions,
1266
1441
  questions: rewrittenQuestions,
@@ -1321,6 +1496,10 @@ export async function startInterviewServer(
1321
1496
  sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
1322
1497
  return;
1323
1498
  }
1499
+ if (question.options.some((option) => typeof option !== "string")) {
1500
+ sendJson(res, 400, { ok: false, error: "Generation is not available for rich options" });
1501
+ return;
1502
+ }
1324
1503
 
1325
1504
  const existingOptions = Array.isArray(payload.existingOptions)
1326
1505
  ? payload.existingOptions.filter((o): o is string => typeof o === "string")
@@ -1341,7 +1520,35 @@ export async function startInterviewServer(
1341
1520
  controller.signal,
1342
1521
  mode,
1343
1522
  );
1344
- sendJson(res, 200, { ok: true, options: result.options });
1523
+
1524
+ const uniqueOptions: string[] = [];
1525
+ const seenOptions = new Set<string>();
1526
+ for (const option of result.options) {
1527
+ const trimmed = option.trim();
1528
+ if (!trimmed) continue;
1529
+ const key = trimmed.toLowerCase();
1530
+ if (seenOptions.has(key)) continue;
1531
+ seenOptions.add(key);
1532
+ uniqueOptions.push(trimmed);
1533
+ }
1534
+
1535
+ const reviewedQuestion = typeof result.question === "string" ? result.question.trim() : undefined;
1536
+ const storedQuestion = questions.questions.find((q) => q.id === payload.questionId);
1537
+ if (storedQuestion) {
1538
+ if (mode === "review" && reviewedQuestion && uniqueOptions.length > 0) {
1539
+ storedQuestion.question = reviewedQuestion;
1540
+ storedQuestion.options = uniqueOptions;
1541
+ syncRecommendations(storedQuestion, uniqueOptions);
1542
+ } else if (mode === "add") {
1543
+ const existingKeys = new Set(existingOptions.map((option) => option.trim().toLowerCase()));
1544
+ const newOptions = uniqueOptions.filter((option) => !existingKeys.has(option.toLowerCase()));
1545
+ if (newOptions.length > 0) {
1546
+ storedQuestion.options = storedQuestion.options.concat(newOptions);
1547
+ }
1548
+ }
1549
+ }
1550
+
1551
+ sendJson(res, 200, { ok: true, options: uniqueOptions, question: reviewedQuestion });
1345
1552
  } catch (err) {
1346
1553
  if (controller.signal.aborted) {
1347
1554
  sendJson(res, 409, { ok: false, error: "Request cancelled" });