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 +39 -23
- package/form/script.js +203 -31
- package/form/styles.css +93 -3
- package/index.ts +121 -40
- package/package.json +1 -1
- package/schema.ts +81 -20
- package/server.ts +221 -14
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" (
|
|
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,
|
|
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
|
-
| `
|
|
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
|
-
###
|
|
150
|
+
### Content Blocks
|
|
150
151
|
|
|
151
|
-
Questions and options can include
|
|
152
|
+
Questions and options can include `content` blocks for code snippets, diffs, and Markdown.
|
|
152
153
|
|
|
153
|
-
**Question-level code
|
|
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
|
-
"
|
|
160
|
-
"
|
|
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
|
|
171
|
+
**Options with content blocks**:
|
|
171
172
|
```json
|
|
172
173
|
{
|
|
173
174
|
"options": [
|
|
174
175
|
{
|
|
175
176
|
"label": "Use async/await",
|
|
176
|
-
"
|
|
177
|
+
"content": { "source": "const data = await fetch(url);", "lang": "ts" }
|
|
177
178
|
},
|
|
178
179
|
{
|
|
179
180
|
"label": "Use promises",
|
|
180
|
-
"
|
|
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** (
|
|
188
|
+
**Diff display** (`lang: "diff"`):
|
|
188
189
|
```json
|
|
189
190
|
{
|
|
190
|
-
"
|
|
191
|
-
"
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
let html = text
|
|
383
|
+
function escapeHtml(text) {
|
|
384
|
+
return String(text || "")
|
|
386
385
|
.replace(/&/g, "&")
|
|
387
386
|
.replace(/</g, "<")
|
|
388
387
|
.replace(/>/g, ">");
|
|
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
|
|
405
|
-
if (!
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1637
|
-
const
|
|
1638
|
-
if (
|
|
1639
|
-
|
|
1640
|
-
card.appendChild(
|
|
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
|
|
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 (
|
|
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 (
|
|
1725
|
-
const
|
|
1726
|
-
if (
|
|
1727
|
-
label.appendChild(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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"], "
|
|
517
|
-
"Options can be strings or objects: { label: string,
|
|
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
|
|
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
|
|
691
|
-
"
|
|
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 ? `
|
|
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
package/schema.ts
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
export interface
|
|
2
|
-
|
|
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
|
-
|
|
18
|
+
showSource?: never;
|
|
8
19
|
}
|
|
9
20
|
|
|
21
|
+
export type ContentBlock = MarkdownContentBlock | CodeContentBlock;
|
|
22
|
+
|
|
10
23
|
export interface RichOption {
|
|
11
24
|
label: string;
|
|
12
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
151
|
+
function validateContentBlock(block: unknown, context: string): ContentBlock {
|
|
133
152
|
if (!block || typeof block !== "object") {
|
|
134
|
-
throw new Error(`${context}:
|
|
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.
|
|
138
|
-
throw new Error(`${context}:
|
|
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}:
|
|
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}:
|
|
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}:
|
|
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}:
|
|
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}:
|
|
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
|
-
|
|
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
|
-
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Question "${questionId}" option "${o.label}": legacy "code" is no longer supported; use "content"`
|
|
224
|
+
);
|
|
173
225
|
}
|
|
174
|
-
|
|
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
|
-
|
|
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, """);
|
|
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
|
|
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
|
-
${
|
|
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
|
|
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
|
-
${
|
|
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:
|
|
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
|
-
|
|
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" });
|