pi-interview 0.8.5 → 0.8.7

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
@@ -28,7 +28,7 @@ Restart pi to load the extension.
28
28
  - **Conviction & Weight**: Control recommendation strength (`conviction`) and visual prominence (`weight`)
29
29
  - **"Other" Option**: Single/multi select questions support custom text input
30
30
  - **Per-Question Attachments**: Attach images to any question via button or drag & drop
31
- - **Keyboard Navigation**: Full keyboard support with arrow keys, Tab, Enter
31
+ - **Keyboard Navigation**: Full keyboard support with `⌘+←`/`⌘+→` (`Ctrl` off macOS), arrow keys, Tab, Enter
32
32
  - **Auto-save**: Responses saved to localStorage, restored on reload
33
33
  - **Session Timeout**: Configurable timeout with countdown badge, refreshes on activity
34
34
  - **Multi-Agent Support**: Queue detection prevents focus stealing when multiple agents run interviews
@@ -39,7 +39,7 @@ Restart pi to load the extension.
39
39
  - **Image Support**: Drag & drop anywhere on question, file picker, or paste a path into the dedicated path field
40
40
  - **Path Normalization**: Handles shell-escaped paths (`\ `) and macOS screenshot filenames (narrow no-break space before AM/PM)
41
41
  - **Generate & Review Options**: Single/multi-select questions, including rich-option questions with inline content blocks, show "✦ Generate more" (appends new choices) and "↻ Review options" (reviews options and rewrites the question for clarity) buttons powered by an LLM
42
- - **Ask About an Option**: Single/multi options, including rich options with inline content blocks, can open an inline assistant panel with prompt chips, freeform follow-up questions, provider/model overrides under Advanced, and actions like pinning analysis or applying a suggested rewrite
42
+ - **Ask About an Option**: Single/multi options, including rich options with inline content blocks, can open an inline assistant panel with prompt chips, freeform follow-up questions, provider/model overrides under Advanced, and auto-saved option analysis
43
43
  - **Option Clarifications**: Single/multi options, including rich options with inline content blocks, can reveal a separate inline `Optional clarification...` field when selected, letting users attach a short note to a choice without using `Ask`
44
44
  - **Tool Discoverability (pi v0.59+)**: Registers a `promptSnippet` so `interview` remains eligible for inclusion in pi's default `Available tools` prompt section
45
45
  - **Themes**: Built-in default + optional light/dark + custom theme CSS
@@ -287,10 +287,10 @@ All media types support `position`: `"above"` (default), `"below"`, or `"side"`
287
287
  | Key | Action |
288
288
  |-----|--------|
289
289
  | `↑` `↓` | Navigate options |
290
- | `←` `→` | Navigate between questions |
290
+ | `⌘+←` `⌘+→` | Navigate between questions (`Ctrl` off macOS) |
291
291
  | `Tab` | Cycle through options |
292
292
  | `Enter` / `Space` | Select option |
293
- | `⌘+V` | Paste image or file path |
293
+ | `⌘+V` | Paste text in the focused input |
294
294
  | `⌘+Enter` | Submit form |
295
295
  | `Esc` | Show exit overlay (press twice to quit) |
296
296
  | `⌘+Shift+L` | Toggle theme (if enabled; appears in shortcuts bar) |
package/form/index.html CHANGED
@@ -42,7 +42,7 @@
42
42
  <span class="shortcut-label">Options</span>
43
43
  </div>
44
44
  <div class="shortcut">
45
- <span class="shortcut-keys"><kbd>←</kbd><kbd>→</kbd></span>
45
+ <span class="shortcut-keys"><kbd class="mod-key">⌘</kbd><kbd>←</kbd><kbd>→</kbd></span>
46
46
  <span class="shortcut-label">Questions</span>
47
47
  </div>
48
48
  <div class="shortcut-divider"></div>
package/form/script.js CHANGED
@@ -568,25 +568,6 @@
568
568
  return note ? { option, note } : { option };
569
569
  }
570
570
 
571
- function renameChoiceAnswerValue(question, value, previousOption, nextOption) {
572
- if (!nextOption || (question.type !== "single" && question.type !== "multi")) {
573
- return value;
574
- }
575
-
576
- if (question.type === "single") {
577
- const choiceValue = normalizeChoiceResponseValue(value);
578
- if (!choiceValue || choiceValue.option !== previousOption) return value;
579
- return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
580
- }
581
-
582
- if (!Array.isArray(value)) return value;
583
- return value.map((item) => {
584
- const choiceValue = normalizeChoiceResponseValue(item);
585
- if (!choiceValue || choiceValue.option !== previousOption) return item;
586
- return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
587
- });
588
- }
589
-
590
571
  function preserveChoiceAnswerValue(question, value, validLabels) {
591
572
  if (question.type === "single") {
592
573
  const choiceValue = normalizeChoiceResponseValue(value);
@@ -775,6 +756,7 @@
775
756
  selectedProvider: parsed.provider || getFirstProvider(),
776
757
  selectedModel,
777
758
  selectedDepth: "standard",
759
+ savedInsightId: null,
778
760
  abortController: null,
779
761
  };
780
762
  }
@@ -848,15 +830,6 @@
848
830
  }
849
831
  }
850
832
 
851
- function updatePinnedInsightOptionText(questionId, optionKey, optionText) {
852
- const existing = optionInsightState.pinned.get(questionId) || [];
853
- existing.forEach((insight) => {
854
- if (insight.optionKey === optionKey) {
855
- insight.optionText = optionText;
856
- }
857
- });
858
- }
859
-
860
833
  function pruneQuestionOptionInsights(questionId) {
861
834
  const validKeys = new Set(getOptionKeys(questionId));
862
835
  const existing = optionInsightState.pinned.get(questionId) || [];
@@ -930,7 +903,7 @@
930
903
  }
931
904
 
932
905
  function applyQuestionValue(question, value) {
933
- populateForm({ [question.id]: value }, { preserveChoiceNotes: true });
906
+ populateQuestion(question, { [question.id]: value }, { preserveChoiceNotes: true });
934
907
  if (question.type === "multi") {
935
908
  updateDoneState(question.id);
936
909
  }
@@ -962,55 +935,6 @@
962
935
  }
963
936
  }
964
937
 
965
- async function runOptionAction(question, optionKey, action, text) {
966
- const preservedValue = getQuestionValue(question);
967
- const previousText = getOptionTextByKey(question.id, optionKey);
968
- try {
969
- const response = await fetch("/option-action", {
970
- method: "POST",
971
- headers: { "Content-Type": "application/json" },
972
- body: JSON.stringify({ token: sessionToken, questionId: question.id, optionKey, action, text }),
973
- });
974
- const result = await response.json();
975
- if (!result.ok) throw new Error(result.error || "Option action failed");
976
-
977
- if (result.question && Array.isArray(result.question.options)) {
978
- question.options = result.question.options;
979
- question.recommended = result.question.recommended;
980
- question.conviction = result.question.conviction;
981
- }
982
- if (Array.isArray(result.optionKeys)) {
983
- setOptionKeys(question.id, result.optionKeys);
984
- pruneQuestionOptionInsights(question.id);
985
- }
986
-
987
- if (action === "replace-text") {
988
- const nextText = getOptionTextByKey(question.id, optionKey);
989
- updatePinnedInsightOptionText(question.id, optionKey, nextText);
990
- if (optionInsightState.active && optionInsightState.active.questionId === question.id && optionInsightState.active.optionKey === optionKey && optionInsightState.active.result) {
991
- optionInsightState.active.result.suggestedText = nextText;
992
- }
993
- }
994
-
995
- let nextValue = preservedValue;
996
- if (action === "replace-text" && text) {
997
- nextValue = renameChoiceAnswerValue(question, preservedValue, previousText, text);
998
- }
999
-
1000
- replaceQuestionOptionList(question, nextValue, optionKey);
1001
- debounceSave();
1002
- refreshCountdown();
1003
- return true;
1004
- } catch (err) {
1005
- const active = getActiveInsight(question.id, optionKey);
1006
- if (active) {
1007
- active.error = err instanceof Error ? err.message : "Option action failed";
1008
- replaceQuestionOptionList(question, preservedValue, optionKey);
1009
- }
1010
- return false;
1011
- }
1012
- }
1013
-
1014
938
  async function submitOptionInsight(question, optionKey) {
1015
939
  const active = getActiveInsight(question.id, optionKey);
1016
940
  if (!active) return;
@@ -1028,6 +952,8 @@
1028
952
 
1029
953
  active.loading = true;
1030
954
  active.error = "";
955
+ active.result = null;
956
+ active.savedInsightId = null;
1031
957
  active.abortController = new AbortController();
1032
958
  replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1033
959
 
@@ -1056,7 +982,7 @@
1056
982
  };
1057
983
  active.error = "";
1058
984
  const optionText = typeof result.optionText === "string" ? result.optionText : getOptionTextByKey(question.id, optionKey);
1059
- updatePinnedInsightOptionText(question.id, optionKey, optionText);
985
+ saveActiveInsight(question, optionKey, optionText);
1060
986
  refreshCountdown();
1061
987
  } catch (err) {
1062
988
  if (!(err instanceof Error && err.name === "AbortError")) {
@@ -1071,13 +997,13 @@
1071
997
  }
1072
998
  }
1073
999
 
1074
- function pinActiveInsight(question, optionKey) {
1000
+ function saveActiveInsight(question, optionKey, optionText) {
1075
1001
  const active = getActiveInsight(question.id, optionKey);
1076
1002
  if (!active || !active.result) return;
1077
- const optionText = getOptionTextByKey(question.id, optionKey);
1003
+ const insightId = active.savedInsightId || makeClientId("insight");
1078
1004
  const questionInsights = optionInsightState.pinned.get(question.id) || [];
1079
- questionInsights.push({
1080
- id: makeClientId("insight"),
1005
+ const nextInsight = {
1006
+ id: insightId,
1081
1007
  questionId: question.id,
1082
1008
  optionKey,
1083
1009
  optionText,
@@ -1087,10 +1013,16 @@
1087
1013
  suggestedText: active.result.suggestedText,
1088
1014
  modelUsed: active.result.modelUsed ?? null,
1089
1015
  createdAt: new Date().toISOString(),
1090
- });
1016
+ };
1017
+ const existingIndex = questionInsights.findIndex((insight) => insight.id === insightId);
1018
+ if (existingIndex === -1) {
1019
+ questionInsights.push(nextInsight);
1020
+ } else {
1021
+ questionInsights[existingIndex] = nextInsight;
1022
+ }
1023
+ active.savedInsightId = insightId;
1091
1024
  optionInsightState.pinned.set(question.id, questionInsights);
1092
1025
  debounceSave();
1093
- replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1094
1026
  }
1095
1027
 
1096
1028
  function createPinnedInsightCard(question, optionKey, insight) {
@@ -1105,18 +1037,18 @@
1105
1037
  prompt.textContent = insight.prompt;
1106
1038
  head.appendChild(prompt);
1107
1039
 
1108
- const unpin = document.createElement("button");
1109
- unpin.type = "button";
1110
- unpin.className = "option-insight-unpin";
1111
- unpin.textContent = "Unpin";
1112
- unpin.addEventListener("click", (event) => {
1040
+ const remove = document.createElement("button");
1041
+ remove.type = "button";
1042
+ remove.className = "option-insight-remove";
1043
+ remove.textContent = "Remove";
1044
+ remove.addEventListener("click", (event) => {
1113
1045
  event.preventDefault();
1114
1046
  event.stopPropagation();
1115
1047
  removePinnedInsight(question.id, insight.id);
1116
1048
  debounceSave();
1117
1049
  replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1118
1050
  });
1119
- head.appendChild(unpin);
1051
+ head.appendChild(remove);
1120
1052
  card.appendChild(head);
1121
1053
 
1122
1054
  const summary = document.createElement("p");
@@ -1367,57 +1299,6 @@
1367
1299
  result.appendChild(meta);
1368
1300
  }
1369
1301
 
1370
- const resultActions = document.createElement("div");
1371
- resultActions.className = "option-insight-result-actions";
1372
-
1373
- const pinBtn = document.createElement("button");
1374
- pinBtn.type = "button";
1375
- pinBtn.className = "option-insight-secondary";
1376
- pinBtn.textContent = "Pin";
1377
- pinBtn.addEventListener("click", (event) => {
1378
- event.preventDefault();
1379
- event.stopPropagation();
1380
- pinActiveInsight(question, optionKey);
1381
- });
1382
- resultActions.appendChild(pinBtn);
1383
-
1384
- const moveUpBtn = document.createElement("button");
1385
- moveUpBtn.type = "button";
1386
- moveUpBtn.className = "option-insight-secondary";
1387
- moveUpBtn.textContent = "Move up";
1388
- moveUpBtn.disabled = getOptionIndexByKey(question.id, optionKey) <= 0;
1389
- moveUpBtn.addEventListener("click", (event) => {
1390
- event.preventDefault();
1391
- event.stopPropagation();
1392
- runOptionAction(question, optionKey, "move-up");
1393
- });
1394
- resultActions.appendChild(moveUpBtn);
1395
-
1396
- if (active.result.suggestedText) {
1397
- const replaceBtn = document.createElement("button");
1398
- replaceBtn.type = "button";
1399
- replaceBtn.className = "option-insight-primary";
1400
- replaceBtn.textContent = "Use rewrite";
1401
- replaceBtn.addEventListener("click", (event) => {
1402
- event.preventDefault();
1403
- event.stopPropagation();
1404
- runOptionAction(question, optionKey, "replace-text", active.result.suggestedText);
1405
- });
1406
- resultActions.appendChild(replaceBtn);
1407
-
1408
- const addBtn = document.createElement("button");
1409
- addBtn.type = "button";
1410
- addBtn.className = "option-insight-secondary";
1411
- addBtn.textContent = "Add rewrite as option";
1412
- addBtn.addEventListener("click", (event) => {
1413
- event.preventDefault();
1414
- event.stopPropagation();
1415
- runOptionAction(question, optionKey, "add-option", active.result.suggestedText);
1416
- });
1417
- resultActions.appendChild(addBtn);
1418
- }
1419
-
1420
- result.appendChild(resultActions);
1421
1302
  panel.appendChild(result);
1422
1303
  }
1423
1304
 
@@ -1441,7 +1322,6 @@
1441
1322
  setChoiceNote(question.id, optionLabel, input.value);
1442
1323
  debounceSave();
1443
1324
  });
1444
- setupEdgeNavigation(input);
1445
1325
  wrap.appendChild(input);
1446
1326
 
1447
1327
  return wrap;
@@ -1468,10 +1348,10 @@
1468
1348
  const main = document.createElement("div");
1469
1349
  main.className = "option-row-main";
1470
1350
 
1471
- const label = document.createElement("label");
1472
- label.className = "option-item";
1351
+ const item = document.createElement("div");
1352
+ item.className = "option-item";
1473
1353
  if (optionContent) {
1474
- label.classList.add("has-code");
1354
+ item.classList.add("has-code");
1475
1355
  }
1476
1356
  const input = document.createElement("input");
1477
1357
  input.type = question.type === "single" ? "radio" : "checkbox";
@@ -1492,7 +1372,9 @@
1492
1372
 
1493
1373
  const text = document.createElement("span");
1494
1374
  text.className = "option-item-label";
1375
+ text.id = `${input.id}-label`;
1495
1376
  text.textContent = optionLabel;
1377
+ input.setAttribute("aria-labelledby", text.id);
1496
1378
 
1497
1379
  const recommendedList = resolveRecommendedLabels(question.recommended, question.options || []);
1498
1380
  const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
@@ -1516,10 +1398,19 @@
1516
1398
  }
1517
1399
  }
1518
1400
 
1519
- label.appendChild(input);
1520
- label.appendChild(body);
1401
+ item.appendChild(input);
1402
+ item.appendChild(body);
1403
+ item.addEventListener("click", (event) => {
1404
+ if (!(event.target instanceof Element)) return;
1405
+ if (event.target === input || event.target.closest("button, input, textarea, select, a")) return;
1406
+ const selection = window.getSelection();
1407
+ if (selection && !selection.isCollapsed && item.contains(selection.anchorNode) && item.contains(selection.focusNode)) {
1408
+ return;
1409
+ }
1410
+ input.click();
1411
+ });
1521
1412
 
1522
- main.appendChild(label);
1413
+ main.appendChild(item);
1523
1414
  const selectedLabels = new Set(getSelectedOptionLabels(question.id));
1524
1415
  const noteInput = createOptionNoteInput(question, optionLabel, input.checked || selectedLabels.has(optionLabel));
1525
1416
 
@@ -1545,7 +1436,8 @@
1545
1436
  if (noteInput) row.appendChild(noteInput);
1546
1437
  }
1547
1438
 
1548
- const pinnedInsights = getPinnedInsights(question.id, optionKey);
1439
+ const pinnedInsights = getPinnedInsights(question.id, optionKey)
1440
+ .filter((insight) => insight.id !== activeInsight?.savedInsightId);
1549
1441
  if (pinnedInsights.length > 0) {
1550
1442
  const pinnedWrap = document.createElement("div");
1551
1443
  pinnedWrap.className = "option-insight-pinned-list";
@@ -1614,7 +1506,6 @@
1614
1506
  if (question.type === "multi") updateDoneState(question.id);
1615
1507
  if (otherCheck.checked) otherInput.focus();
1616
1508
  });
1617
- setupEdgeNavigation(otherInput);
1618
1509
  otherLabel.appendChild(otherCheck);
1619
1510
  otherLabel.appendChild(otherInput);
1620
1511
  list.appendChild(otherLabel);
@@ -1913,6 +1804,37 @@
1913
1804
  return event.key.length === 1;
1914
1805
  }
1915
1806
 
1807
+ function isQuestionNavShortcut(event, direction) {
1808
+ const key = direction === "prev" ? "ArrowLeft" : "ArrowRight";
1809
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
1810
+ const modPressed = isMac ? event.metaKey : event.ctrlKey;
1811
+ const otherModPressed = isMac ? event.ctrlKey : event.metaKey;
1812
+ return event.key === key && modPressed && !otherModPressed && !event.altKey && !event.shiftKey;
1813
+ }
1814
+
1815
+ function isEditableTextControl(element) {
1816
+ if (element instanceof HTMLTextAreaElement) return true;
1817
+ if (!(element instanceof HTMLInputElement)) return false;
1818
+ return ["password", "search", "tel", "text", "url"].includes(element.type);
1819
+ }
1820
+
1821
+ function handlePaste(event) {
1822
+ const active = document.activeElement;
1823
+ if (!isEditableTextControl(active)) return;
1824
+
1825
+ const text = event.clipboardData?.getData("text/plain");
1826
+ if (typeof text !== "string" || text.length === 0) return;
1827
+
1828
+ event.preventDefault();
1829
+ event.stopPropagation();
1830
+ const start = active.selectionStart ?? active.value.length;
1831
+ const end = active.selectionEnd ?? start;
1832
+ active.setRangeText(text, start, end, "end");
1833
+ active.dispatchEvent(new Event("input", { bubbles: true }));
1834
+ refreshCountdown();
1835
+ debounceSave();
1836
+ }
1837
+
1916
1838
  function maybeStartOtherInput(event) {
1917
1839
  const active = document.activeElement;
1918
1840
  if (!(active instanceof HTMLInputElement)) return false;
@@ -2345,21 +2267,6 @@
2345
2267
  });
2346
2268
  }
2347
2269
 
2348
- function setupEdgeNavigation(element) {
2349
- element.addEventListener("keydown", (e) => {
2350
- if (e.key === "ArrowRight" && element.selectionStart === element.value.length) {
2351
- e.preventDefault();
2352
- e.stopPropagation();
2353
- nextQuestion();
2354
- }
2355
- if (e.key === "ArrowLeft" && element.selectionStart === 0) {
2356
- e.preventDefault();
2357
- e.stopPropagation();
2358
- prevQuestion();
2359
- }
2360
- });
2361
- }
2362
-
2363
2270
  function highlightOption(card, optionIndex, isKeyboard = true) {
2364
2271
  const options = getOptionsForCard(card);
2365
2272
  options.forEach((opt, i) => {
@@ -2538,19 +2445,15 @@
2538
2445
 
2539
2446
  if (inAskArea || inOptionNote) return;
2540
2447
 
2541
- if (event.key === 'ArrowLeft') {
2542
- if (isTextFocused || isPathInput(document.activeElement)) {
2543
- return;
2544
- }
2448
+ if (isQuestionNavShortcut(event, "prev")) {
2449
+ if (isEditableTextControl(document.activeElement)) return;
2545
2450
  event.preventDefault();
2546
2451
  prevQuestion();
2547
2452
  return;
2548
2453
  }
2549
-
2550
- if (event.key === 'ArrowRight') {
2551
- if (isTextFocused || isPathInput(document.activeElement)) {
2552
- return;
2553
- }
2454
+
2455
+ if (isQuestionNavShortcut(event, "next")) {
2456
+ if (isEditableTextControl(document.activeElement)) return;
2554
2457
  event.preventDefault();
2555
2458
  nextQuestion();
2556
2459
  return;
@@ -2685,6 +2588,7 @@
2685
2588
  input.setAttribute('tabindex', '-1');
2686
2589
  });
2687
2590
 
2591
+ document.addEventListener("paste", handlePaste, true);
2688
2592
  document.addEventListener('keydown', handleQuestionKeydown);
2689
2593
 
2690
2594
  if (nav.cards.length > 0) {
@@ -2944,7 +2848,6 @@
2944
2848
  textarea.addEventListener("input", () => {
2945
2849
  debounceSave();
2946
2850
  });
2947
- setupEdgeNavigation(textarea);
2948
2851
  card.appendChild(textarea);
2949
2852
  }
2950
2853
 
@@ -2995,7 +2898,6 @@
2995
2898
  pathInput.value = "";
2996
2899
  }
2997
2900
  });
2998
- setupEdgeNavigation(pathInput);
2999
2901
 
3000
2902
  const selectedItems = document.createElement("div");
3001
2903
  selectedItems.className = "image-selected-items";
@@ -3015,12 +2917,12 @@
3015
2917
  input.click();
3016
2918
  }
3017
2919
  }
3018
- if (e.key === "ArrowRight") {
2920
+ if (isQuestionNavShortcut(e, "next")) {
3019
2921
  e.preventDefault();
3020
2922
  e.stopPropagation();
3021
2923
  nextQuestion();
3022
2924
  }
3023
- if (e.key === "ArrowLeft") {
2925
+ if (isQuestionNavShortcut(e, "prev")) {
3024
2926
  e.preventDefault();
3025
2927
  e.stopPropagation();
3026
2928
  prevQuestion();
@@ -3131,7 +3033,6 @@
3131
3033
  attachBtn.focus();
3132
3034
  }
3133
3035
  });
3134
- setupEdgeNavigation(attachPath);
3135
3036
 
3136
3037
  attachInline.appendChild(attachFileInput);
3137
3038
  attachInline.appendChild(attachDrop);
@@ -3395,76 +3296,34 @@
3395
3296
  return value.map((item) => normalizeChoiceResponseValue(item)).filter(Boolean);
3396
3297
  }
3397
3298
 
3398
- function populateForm(saved, options = {}) {
3299
+ function populateQuestion(question, saved, options = {}) {
3399
3300
  const { preserveChoiceNotes = false } = options;
3400
- if (!saved) return;
3401
- questions.forEach((question) => {
3402
- const hasSavedValue = Object.prototype.hasOwnProperty.call(saved, question.id);
3403
- const value = saved[question.id];
3404
- if (question.type === "single") {
3405
- const radios = formEl.querySelectorAll(
3406
- `input[name="${escapeSelector(question.id)}"]`
3407
- );
3408
- radios.forEach((radio) => {
3409
- radio.checked = false;
3410
- });
3411
- if (!preserveChoiceNotes) {
3412
- clearChoiceNotes(question.id);
3413
- }
3414
- if (!hasSavedValue) return;
3415
- const choiceValue = getSavedSingleChoiceValue(value);
3416
- if (!choiceValue) return;
3417
- if (choiceValue.option !== "") {
3418
- const input = formEl.querySelector(
3419
- `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
3420
- );
3421
- if (input) {
3422
- input.checked = true;
3423
- if (questionSupportsOptionInsights(question) && choiceValue.note) {
3424
- setChoiceNote(question.id, choiceValue.option, choiceValue.note);
3425
- }
3426
- } else {
3427
- const otherCheck = formEl.querySelector(
3428
- `input[name="${escapeSelector(question.id)}"][value="__other__"]`
3429
- );
3430
- const otherInput = formEl.querySelector(
3431
- `.other-input[data-question-id="${escapeSelector(question.id)}"]`
3432
- );
3433
- if (otherCheck && otherInput) {
3434
- otherCheck.checked = true;
3435
- otherInput.value = choiceValue.option;
3436
- otherInput.dispatchEvent(new Event("input", { bubbles: true }));
3437
- }
3438
- }
3439
- }
3440
- }
3441
- if (question.type === "multi") {
3442
- const checkboxes = formEl.querySelectorAll(
3443
- `input[name="${escapeSelector(question.id)}"]`
3301
+ const hasSavedValue = saved && Object.prototype.hasOwnProperty.call(saved, question.id);
3302
+ const value = hasSavedValue ? saved[question.id] : undefined;
3303
+
3304
+ if (question.type === "single") {
3305
+ if (!hasSavedValue) return;
3306
+ const radios = formEl.querySelectorAll(
3307
+ `input[name="${escapeSelector(question.id)}"]`
3308
+ );
3309
+ radios.forEach((radio) => {
3310
+ radio.checked = false;
3311
+ });
3312
+ if (!preserveChoiceNotes) {
3313
+ clearChoiceNotes(question.id);
3314
+ }
3315
+ const choiceValue = getSavedSingleChoiceValue(value);
3316
+ if (!choiceValue) return;
3317
+ if (choiceValue.option !== "") {
3318
+ const input = formEl.querySelector(
3319
+ `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
3444
3320
  );
3445
- checkboxes.forEach((checkbox) => {
3446
- checkbox.checked = false;
3447
- });
3448
- if (!preserveChoiceNotes) {
3449
- clearChoiceNotes(question.id);
3450
- }
3451
- if (!hasSavedValue) return;
3452
- const choiceValues = getSavedMultiChoiceValues(value);
3453
- let otherValue = "";
3454
- choiceValues.forEach((choiceValue) => {
3455
- const input = formEl.querySelector(
3456
- `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
3457
- );
3458
- if (input) {
3459
- input.checked = true;
3460
- if (questionSupportsOptionInsights(question) && choiceValue.note) {
3461
- setChoiceNote(question.id, choiceValue.option, choiceValue.note);
3462
- }
3463
- } else if (choiceValue.option) {
3464
- otherValue = choiceValue.option;
3321
+ if (input) {
3322
+ input.checked = true;
3323
+ if (questionSupportsOptionInsights(question) && choiceValue.note) {
3324
+ setChoiceNote(question.id, choiceValue.option, choiceValue.note);
3465
3325
  }
3466
- });
3467
- if (otherValue) {
3326
+ } else {
3468
3327
  const otherCheck = formEl.querySelector(
3469
3328
  `input[name="${escapeSelector(question.id)}"][value="__other__"]`
3470
3329
  );
@@ -3473,17 +3332,68 @@
3473
3332
  );
3474
3333
  if (otherCheck && otherInput) {
3475
3334
  otherCheck.checked = true;
3476
- otherInput.value = otherValue;
3335
+ otherInput.value = choiceValue.option;
3477
3336
  otherInput.dispatchEvent(new Event("input", { bubbles: true }));
3478
3337
  }
3479
3338
  }
3480
3339
  }
3481
- if (question.type === "text" && typeof value === "string") {
3482
- const textarea = formEl.querySelector(
3483
- `textarea[data-question-id="${escapeSelector(question.id)}"]`
3340
+ return;
3341
+ }
3342
+
3343
+ if (question.type === "multi") {
3344
+ if (!hasSavedValue) return;
3345
+ const checkboxes = formEl.querySelectorAll(
3346
+ `input[name="${escapeSelector(question.id)}"]`
3347
+ );
3348
+ checkboxes.forEach((checkbox) => {
3349
+ checkbox.checked = false;
3350
+ });
3351
+ if (!preserveChoiceNotes) {
3352
+ clearChoiceNotes(question.id);
3353
+ }
3354
+ const choiceValues = getSavedMultiChoiceValues(value);
3355
+ let otherValue = "";
3356
+ choiceValues.forEach((choiceValue) => {
3357
+ const input = formEl.querySelector(
3358
+ `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
3359
+ );
3360
+ if (input) {
3361
+ input.checked = true;
3362
+ if (questionSupportsOptionInsights(question) && choiceValue.note) {
3363
+ setChoiceNote(question.id, choiceValue.option, choiceValue.note);
3364
+ }
3365
+ } else if (choiceValue.option) {
3366
+ otherValue = choiceValue.option;
3367
+ }
3368
+ });
3369
+ if (otherValue) {
3370
+ const otherCheck = formEl.querySelector(
3371
+ `input[name="${escapeSelector(question.id)}"][value="__other__"]`
3484
3372
  );
3485
- if (textarea) textarea.value = value;
3373
+ const otherInput = formEl.querySelector(
3374
+ `.other-input[data-question-id="${escapeSelector(question.id)}"]`
3375
+ );
3376
+ if (otherCheck && otherInput) {
3377
+ otherCheck.checked = true;
3378
+ otherInput.value = otherValue;
3379
+ otherInput.dispatchEvent(new Event("input", { bubbles: true }));
3380
+ }
3486
3381
  }
3382
+ return;
3383
+ }
3384
+
3385
+ if (question.type === "text" && hasSavedValue && typeof value === "string") {
3386
+ const textarea = formEl.querySelector(
3387
+ `textarea[data-question-id="${escapeSelector(question.id)}"]`
3388
+ );
3389
+ if (textarea) textarea.value = value;
3390
+ }
3391
+ }
3392
+
3393
+ function populateForm(saved, options = {}) {
3394
+ if (!saved) return;
3395
+ questions.forEach((question) => {
3396
+ populateQuestion(question, saved, options);
3487
3397
  });
3488
3398
  }
3489
3399
 
@@ -3796,10 +3706,9 @@
3796
3706
  normalizeOptionKeysFromData();
3797
3707
 
3798
3708
  const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
3799
- const modKey = document.querySelector(".mod-key");
3800
- if (modKey) {
3709
+ document.querySelectorAll(".mod-key").forEach((modKey) => {
3801
3710
  modKey.textContent = isMac ? "⌘" : "Ctrl";
3802
- }
3711
+ });
3803
3712
 
3804
3713
  setText(titleEl, data.title || "Interview");
3805
3714
  setText(descriptionEl, data.description || "");
@@ -3893,7 +3802,7 @@
3893
3802
  true
3894
3803
  );
3895
3804
  submitBtn.addEventListener("keydown", (e) => {
3896
- if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
3805
+ if (isQuestionNavShortcut(e, "prev") || e.key === "ArrowUp") {
3897
3806
  e.preventDefault();
3898
3807
  e.stopImmediatePropagation();
3899
3808
  focusQuestion(nav.cards.length - 1, 'prev');
package/form/styles.css CHANGED
@@ -232,7 +232,8 @@ body {
232
232
  border-radius: 8px;
233
233
  background: var(--bg-elevated);
234
234
  border: 1px solid var(--border-muted);
235
- cursor: pointer;
235
+ cursor: default;
236
+ user-select: text;
236
237
  font-family: var(--font-body);
237
238
  font-size: var(--font-size-option);
238
239
  transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
@@ -244,6 +245,14 @@ body {
244
245
  min-width: 0;
245
246
  display: grid;
246
247
  gap: 10px;
248
+ cursor: text;
249
+ user-select: text;
250
+ }
251
+
252
+ .option-item input[type="radio"],
253
+ .option-item input[type="checkbox"] {
254
+ cursor: pointer;
255
+ user-select: none;
247
256
  }
248
257
 
249
258
  .option-item-label {
@@ -253,6 +262,8 @@ body {
253
262
  min-height: 20px;
254
263
  min-width: 0;
255
264
  flex-wrap: wrap;
265
+ cursor: text;
266
+ user-select: text;
256
267
  }
257
268
 
258
269
  .option-item:hover {
@@ -2063,11 +2074,9 @@ button {
2063
2074
  }
2064
2075
 
2065
2076
  .option-insight-chip,
2066
- .option-insight-secondary,
2067
- .option-insight-primary,
2068
2077
  .option-insight-submit,
2069
2078
  .option-insight-advanced-toggle,
2070
- .option-insight-unpin {
2079
+ .option-insight-remove {
2071
2080
  border: 1px solid var(--border-muted);
2072
2081
  border-radius: 999px;
2073
2082
  background: transparent;
@@ -2078,15 +2087,12 @@ button {
2078
2087
  }
2079
2088
 
2080
2089
  .option-insight-chip,
2081
- .option-insight-secondary,
2082
- .option-insight-primary,
2083
2090
  .option-insight-submit,
2084
- .option-insight-unpin {
2091
+ .option-insight-remove {
2085
2092
  padding: 6px 10px;
2086
2093
  }
2087
2094
 
2088
2095
  .option-insight-chip.active,
2089
- .option-insight-primary,
2090
2096
  .option-insight-submit {
2091
2097
  border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 55%, transparent);
2092
2098
  color: var(--card-accent, var(--accent));
@@ -2227,8 +2233,7 @@ button {
2227
2233
  50% { opacity: 1; }
2228
2234
  }
2229
2235
 
2230
- .option-insight-actions,
2231
- .option-insight-result-actions {
2236
+ .option-insight-actions {
2232
2237
  display: flex;
2233
2238
  flex-wrap: wrap;
2234
2239
  gap: 8px;
@@ -2314,7 +2319,7 @@ button {
2314
2319
  font-family: var(--font-mono);
2315
2320
  }
2316
2321
 
2317
- .option-insight-unpin {
2322
+ .option-insight-remove {
2318
2323
  padding: 4px 8px;
2319
2324
  }
2320
2325
 
@@ -2337,8 +2342,7 @@ button {
2337
2342
  }
2338
2343
 
2339
2344
  .option-insight-meta-row,
2340
- .option-insight-actions,
2341
- .option-insight-result-actions {
2345
+ .option-insight-actions {
2342
2346
  align-items: flex-start;
2343
2347
  flex-direction: column;
2344
2348
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -32,6 +32,9 @@
32
32
  "scripts": {
33
33
  "test": "vitest run"
34
34
  },
35
+ "dependencies": {
36
+ "typebox": "^1.1.24"
37
+ },
35
38
  "pi": {
36
39
  "extensions": [
37
40
  "./index.ts"
@@ -39,8 +42,5 @@
39
42
  },
40
43
  "devDependencies": {
41
44
  "vitest": "^4.0.18"
42
- },
43
- "dependencies": {
44
- "typebox": "^1.1.31"
45
45
  }
46
46
  }
package/server.ts CHANGED
@@ -2014,107 +2014,6 @@ export async function startInterviewServer(
2014
2014
  return;
2015
2015
  }
2016
2016
 
2017
- if (method === "POST" && url.pathname === "/option-action") {
2018
- const body = await parseBodyOrRespond();
2019
- if (!body) return;
2020
- if (!validateTokenBody(body, sessionToken, res)) return;
2021
- if (completed) {
2022
- sendJson(res, 409, { ok: false, error: "Session closed" });
2023
- return;
2024
- }
2025
-
2026
- const payload = body as {
2027
- questionId?: string;
2028
- optionKey?: string;
2029
- action?: string;
2030
- text?: string;
2031
- };
2032
-
2033
- if (typeof payload.questionId !== "string") {
2034
- sendJson(res, 400, { ok: false, error: "Missing questionId" });
2035
- return;
2036
- }
2037
- if (typeof payload.optionKey !== "string") {
2038
- sendJson(res, 400, { ok: false, error: "Missing optionKey" });
2039
- return;
2040
- }
2041
- touchHeartbeat();
2042
-
2043
- const questionCheck = ensureQuestionId(payload.questionId, questionById);
2044
- if (questionCheck.ok === false) {
2045
- sendJson(res, 400, { ok: false, error: questionCheck.error });
2046
- return;
2047
- }
2048
- const question = questionCheck.question;
2049
- if ((question.type !== "single" && question.type !== "multi") || !question.options) {
2050
- sendJson(res, 400, { ok: false, error: "Invalid question for option actions" });
2051
- return;
2052
- }
2053
-
2054
- const optionIndex = getOptionIndexByKey(question, optionKeysByQuestion, payload.optionKey);
2055
- if (optionIndex === -1) {
2056
- sendJson(res, 400, { ok: false, error: "Invalid option for action" });
2057
- return;
2058
- }
2059
-
2060
- const currentOptions = [...question.options];
2061
- const currentKeys = [...(optionKeysByQuestion[question.id] ?? [])];
2062
- const action = payload.action;
2063
- const rawText = typeof payload.text === "string" ? payload.text.trim() : "";
2064
-
2065
- if (action === "move-up") {
2066
- if (optionIndex === 0) {
2067
- sendJson(res, 400, { ok: false, error: "Option is already at the top" });
2068
- return;
2069
- }
2070
- [currentOptions[optionIndex - 1], currentOptions[optionIndex]] = [currentOptions[optionIndex], currentOptions[optionIndex - 1]];
2071
- [currentKeys[optionIndex - 1], currentKeys[optionIndex]] = [currentKeys[optionIndex], currentKeys[optionIndex - 1]];
2072
- } else if (action === "replace-text") {
2073
- if (!rawText) {
2074
- sendJson(res, 400, { ok: false, error: "Replacement text is required" });
2075
- return;
2076
- }
2077
- const duplicateIndex = currentOptions.findIndex((option, index) => index !== optionIndex && getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase());
2078
- if (duplicateIndex !== -1) {
2079
- sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
2080
- return;
2081
- }
2082
- const previousText = getOptionLabel(currentOptions[optionIndex]);
2083
- currentOptions[optionIndex] = setOptionLabel(currentOptions[optionIndex], rawText);
2084
- if (question.type === "single" && question.recommended === previousText) {
2085
- question.recommended = rawText;
2086
- } else if (question.type === "multi" && Array.isArray(question.recommended)) {
2087
- question.recommended = question.recommended.map((option) => option === previousText ? rawText : option);
2088
- }
2089
- } else if (action === "add-option") {
2090
- if (!rawText) {
2091
- sendJson(res, 400, { ok: false, error: "New option text is required" });
2092
- return;
2093
- }
2094
- if (currentOptions.some((option) => getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase())) {
2095
- sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
2096
- return;
2097
- }
2098
- const newOptionKey = makeOptionKey();
2099
- currentOptions.splice(optionIndex + 1, 0, rawText);
2100
- currentKeys.splice(optionIndex + 1, 0, newOptionKey);
2101
- } else {
2102
- sendJson(res, 400, { ok: false, error: "Unsupported option action" });
2103
- return;
2104
- }
2105
-
2106
- question.options = currentOptions;
2107
- optionKeysByQuestion[question.id] = currentKeys;
2108
- syncRecommendations(question, currentOptions);
2109
-
2110
- sendJson(res, 200, {
2111
- ok: true,
2112
- question,
2113
- optionKeys: currentKeys,
2114
- });
2115
- return;
2116
- }
2117
-
2118
2017
  sendText(res, 404, "Not found");
2119
2018
  } catch (err) {
2120
2019
  const message = err instanceof Error ? err.message : "Server error";