pi-interview 0.6.0 → 0.6.1

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
package/form/script.js CHANGED
@@ -401,6 +401,35 @@
401
401
  return typeof option === "object" && option !== null && "label" in option;
402
402
  }
403
403
 
404
+ function syncRecommendations(question, options) {
405
+ if (!question.recommended) return;
406
+
407
+ if (question.type === "single") {
408
+ if (typeof question.recommended === "string" && options.includes(question.recommended)) {
409
+ return;
410
+ }
411
+ delete question.recommended;
412
+ delete question.conviction;
413
+ return;
414
+ }
415
+
416
+ if (question.type !== "multi") {
417
+ delete question.recommended;
418
+ delete question.conviction;
419
+ return;
420
+ }
421
+
422
+ const nextRecommended = (Array.isArray(question.recommended)
423
+ ? question.recommended
424
+ : [question.recommended]).filter((option) => options.includes(option));
425
+ if (nextRecommended.length === 0) {
426
+ delete question.recommended;
427
+ delete question.conviction;
428
+ return;
429
+ }
430
+ question.recommended = nextRecommended;
431
+ }
432
+
404
433
  function renderCodeBlock(block) {
405
434
  if (!block || !block.code) return null;
406
435
 
@@ -1394,6 +1423,7 @@
1394
1423
 
1395
1424
  function createGenerateMoreUI(question, list) {
1396
1425
  if (!data.canGenerate) return null;
1426
+ if (question.options.some(isRichOption)) return null;
1397
1427
 
1398
1428
  const container = document.createElement("div");
1399
1429
  container.className = "generate-more";
@@ -1499,6 +1529,10 @@
1499
1529
  }
1500
1530
 
1501
1531
  if (mode === "review") {
1532
+ if (typeof result.question !== "string" || !result.question.trim()) {
1533
+ throw new Error("No revised question returned");
1534
+ }
1535
+
1502
1536
  const seen = new Set();
1503
1537
  const revisedOptions = result.options.filter((option) => {
1504
1538
  const key = option.toLowerCase().trim();
@@ -1510,6 +1544,14 @@
1510
1544
  throw new Error("No valid options returned for review");
1511
1545
  }
1512
1546
 
1547
+ question.question = result.question.trim();
1548
+ question.options = revisedOptions;
1549
+ syncRecommendations(question, revisedOptions);
1550
+ const title = list.closest('.question-card')?.querySelector('.question-title');
1551
+ if (title) {
1552
+ title.innerHTML = renderLightMarkdown(question.question);
1553
+ }
1554
+
1513
1555
  list
1514
1556
  .querySelectorAll('.option-item:not(.option-other):not(.done-item)')
1515
1557
  .forEach((el) => el.remove());
@@ -1520,7 +1562,7 @@
1520
1562
  if (question.type === "multi") updateDoneState(question.id);
1521
1563
  debounceSave();
1522
1564
  showStatus(
1523
- revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
1565
+ "Question updated and " + revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
1524
1566
  2500,
1525
1567
  );
1526
1568
  } else {
@@ -1536,6 +1578,7 @@
1536
1578
  if (newOptions.length === 0) {
1537
1579
  showStatus("All generated options already exist", 3000);
1538
1580
  } else {
1581
+ question.options = question.options.concat(newOptions);
1539
1582
  newOptions.forEach((optionText, i) => {
1540
1583
  const optionEl = createGeneratedOption(question, optionText, i);
1541
1584
  list.insertBefore(optionEl, container);
@@ -2578,7 +2621,8 @@
2578
2621
  }
2579
2622
  } catch (err) {
2580
2623
  if (!submitted) {
2581
- showSaveError("Failed to save interview");
2624
+ const message = err instanceof Error ? err.message : String(err);
2625
+ showSaveError(`Failed to save interview: ${message}`);
2582
2626
  }
2583
2627
  return false;
2584
2628
  }
@@ -2659,7 +2703,8 @@
2659
2703
  if (isNetworkError(err)) {
2660
2704
  showSessionExpired();
2661
2705
  } else {
2662
- showGlobalError("Failed to submit responses.");
2706
+ const message = err instanceof Error ? err.message : String(err);
2707
+ showGlobalError(`Failed to submit responses: ${message}`);
2663
2708
  submitBtn.disabled = false;
2664
2709
  }
2665
2710
  }
package/form/styles.css CHANGED
@@ -1391,7 +1391,7 @@ 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
1396
  }
1397
1397
 
@@ -1404,7 +1404,8 @@ button {
1404
1404
 
1405
1405
  .code-block-lines-container {
1406
1406
  display: table;
1407
- min-width: 100%;
1407
+ width: 100%;
1408
+ table-layout: fixed;
1408
1409
  }
1409
1410
 
1410
1411
  .code-block-line {
@@ -1425,7 +1426,8 @@ button {
1425
1426
 
1426
1427
  .code-block-line-content {
1427
1428
  display: table-cell;
1428
- white-space: pre;
1429
+ white-space: pre-wrap;
1430
+ overflow-wrap: anywhere;
1429
1431
  padding-right: 0.75rem;
1430
1432
  }
1431
1433
 
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[] {
@@ -523,6 +565,8 @@ export default function (pi: ExtensionAPI) {
523
565
  "Questions can have a codeBlock field to display code above options. 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.1",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
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;
@@ -1258,9 +1287,7 @@ export async function startInterviewServer(
1258
1287
  }
1259
1288
 
1260
1289
  // Copy local media images to snapshot and rewrite paths
1261
- const rewrittenQuestions = await copyMediaImages(
1262
- questions.questions, imagesPath, cwd
1263
- );
1290
+ const rewrittenQuestions = await copyMediaImages(questions.questions, imagesPath, cwd);
1264
1291
  const snapshotQuestions: QuestionsFile = {
1265
1292
  ...questions,
1266
1293
  questions: rewrittenQuestions,
@@ -1321,6 +1348,10 @@ export async function startInterviewServer(
1321
1348
  sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
1322
1349
  return;
1323
1350
  }
1351
+ if (question.options.some((option) => typeof option !== "string")) {
1352
+ sendJson(res, 400, { ok: false, error: "Generation is not available for rich options" });
1353
+ return;
1354
+ }
1324
1355
 
1325
1356
  const existingOptions = Array.isArray(payload.existingOptions)
1326
1357
  ? payload.existingOptions.filter((o): o is string => typeof o === "string")
@@ -1341,7 +1372,35 @@ export async function startInterviewServer(
1341
1372
  controller.signal,
1342
1373
  mode,
1343
1374
  );
1344
- sendJson(res, 200, { ok: true, options: result.options });
1375
+
1376
+ const uniqueOptions: string[] = [];
1377
+ const seenOptions = new Set<string>();
1378
+ for (const option of result.options) {
1379
+ const trimmed = option.trim();
1380
+ if (!trimmed) continue;
1381
+ const key = trimmed.toLowerCase();
1382
+ if (seenOptions.has(key)) continue;
1383
+ seenOptions.add(key);
1384
+ uniqueOptions.push(trimmed);
1385
+ }
1386
+
1387
+ const reviewedQuestion = typeof result.question === "string" ? result.question.trim() : undefined;
1388
+ const storedQuestion = questions.questions.find((q) => q.id === payload.questionId);
1389
+ if (storedQuestion) {
1390
+ if (mode === "review" && reviewedQuestion && uniqueOptions.length > 0) {
1391
+ storedQuestion.question = reviewedQuestion;
1392
+ storedQuestion.options = uniqueOptions;
1393
+ syncRecommendations(storedQuestion, uniqueOptions);
1394
+ } else if (mode === "add") {
1395
+ const existingKeys = new Set(existingOptions.map((option) => option.trim().toLowerCase()));
1396
+ const newOptions = uniqueOptions.filter((option) => !existingKeys.has(option.toLowerCase()));
1397
+ if (newOptions.length > 0) {
1398
+ storedQuestion.options = storedQuestion.options.concat(newOptions);
1399
+ }
1400
+ }
1401
+ }
1402
+
1403
+ sendJson(res, 200, { ok: true, options: uniqueOptions, question: reviewedQuestion });
1345
1404
  } catch (err) {
1346
1405
  if (controller.signal.aborted) {
1347
1406
  sendJson(res, 409, { ok: false, error: "Request cancelled" });