pi-interview 0.5.1 → 0.5.3

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  # Interview Tool
6
6
 
7
- A custom tool for pi-agent that opens a web-based form to gather user responses to clarification questions.
7
+ A custom tool for pi-agent that opens an interactive form to gather user responses to clarification questions. On macOS, uses [Glimpse](https://github.com/hazat/glimpse) to render in a native WKWebView window; falls back to a browser tab on other platforms.
8
8
 
9
9
  https://github.com/user-attachments/assets/52285bd9-956e-4020-aca5-9fbd82916934
10
10
 
@@ -18,6 +18,7 @@ Restart pi to load the extension.
18
18
 
19
19
  **Requirements:**
20
20
  - pi-agent v0.35.0 or later (extensions API)
21
+ - For native macOS window: `pi install npm:glimpseui` (optional, falls back to browser if not installed)
21
22
 
22
23
  ## Features
23
24
 
@@ -43,7 +44,7 @@ Restart pi to load the extension.
43
44
 
44
45
  ```
45
46
  ┌─────────┐ ┌──────────────────────────────────────────┐ ┌─────────┐
46
- │ Agent │ │ Browser Form │ │ Agent │
47
+ │ Agent │ │ Glimpse / Browser Form │ │ Agent │
47
48
  │ invokes ├─────►│ ├─────►│receives │
48
49
  │interview│ │ answer → answer → attach img → answer │ │responses│
49
50
  └─────────┘ │ ↑ │ └─────────┘
@@ -52,7 +53,7 @@ Restart pi to load the extension.
52
53
  ```
53
54
 
54
55
  **Lifecycle:**
55
- 1. Agent calls `interview()` → local server starts → browser opens form
56
+ 1. Agent calls `interview()` → local server starts → Glimpse window opens (macOS) or browser tab (elsewhere)
56
57
  2. User answers at their own pace; each change auto-saves and resets the timeout
57
58
  3. Session ends via:
58
59
  - **Submit** (`⌘+Enter`) → responses returned to agent
@@ -62,7 +63,7 @@ Restart pi to load the extension.
62
63
 
63
64
  **Timeout behavior:** The countdown (visible in corner) resets on any activity - typing, clicking, or mouse movement. When it expires, an overlay appears giving the user a chance to continue. Progress is never lost thanks to localStorage auto-save.
64
65
 
65
- **Multi-agent behavior:** When multiple agents run interviews simultaneously, only the first auto-opens the browser. Subsequent interviews are queued and shown as a URL in the tool output, preventing focus stealing. Active interviews also surface a top-right toast with a dropdown to open queued sessions. A session status bar at the top of each form shows the project path, git branch, and session ID for easy identification.
66
+ **Multi-agent behavior:** When multiple agents run interviews simultaneously, only the first auto-opens the window. Subsequent interviews are queued and shown as a URL in the tool output, preventing focus stealing. Active interviews also surface a top-right toast with a dropdown to open queued sessions. A session status bar at the top of each form shows the project path, git branch, and session ID for easy identification.
66
67
 
67
68
  ## Usage
68
69
 
package/form/script.js CHANGED
@@ -60,6 +60,14 @@
60
60
  heartbeat: null,
61
61
  queuePoll: null,
62
62
  };
63
+ function closeWindow() {
64
+ if (window.glimpse && typeof window.glimpse.close === "function") {
65
+ window.glimpse.close();
66
+ } else {
67
+ window.close();
68
+ }
69
+ }
70
+
63
71
  let filePickerOpen = false;
64
72
  const CLOSE_DELAY = 10;
65
73
  const RING_CIRCUMFERENCE = 100.53;
@@ -162,7 +170,7 @@
162
170
 
163
171
  if (closeIn <= 0) {
164
172
  clearInterval(timers.countdown);
165
- cancelInterview("timeout").finally(() => window.close());
173
+ cancelInterview("timeout").finally(() => closeWindow());
166
174
  }
167
175
  }, 1000);
168
176
  }
@@ -1197,7 +1205,7 @@
1197
1205
  if (event.key === 'Escape') {
1198
1206
  if (!expiredOverlay.classList.contains('hidden')) {
1199
1207
  if (timers.countdown) clearInterval(timers.countdown);
1200
- cancelInterview("user").finally(() => window.close());
1208
+ cancelInterview("user").finally(() => closeWindow());
1201
1209
  return;
1202
1210
  }
1203
1211
  showSessionExpired();
@@ -2421,7 +2429,7 @@
2421
2429
  session.ended = true;
2422
2430
  successOverlay.classList.remove("hidden");
2423
2431
  setTimeout(() => {
2424
- window.close();
2432
+ closeWindow();
2425
2433
  }, 800);
2426
2434
  } catch (err) {
2427
2435
  if (isNetworkError(err)) {
@@ -2506,7 +2514,10 @@
2506
2514
  if (!url) return;
2507
2515
  const selectedOption = queueSessionSelect.options[queueSessionSelect.selectedIndex];
2508
2516
  if (selectedOption?.disabled) return;
2509
- window.open(url, "_blank", "noopener");
2517
+ const opened = window.open(url, "_blank", "noopener");
2518
+ if (!opened) {
2519
+ window.location.href = url;
2520
+ }
2510
2521
  });
2511
2522
  }
2512
2523
  window.addEventListener("pagehide", (event) => {
@@ -2539,7 +2550,7 @@
2539
2550
  closeTabBtn.addEventListener("click", async () => {
2540
2551
  if (timers.countdown) clearInterval(timers.countdown);
2541
2552
  await cancelInterview("user");
2542
- window.close();
2553
+ closeWindow();
2543
2554
  });
2544
2555
 
2545
2556
  stayBtn.addEventListener("click", () => {
package/index.ts CHANGED
@@ -6,11 +6,72 @@ import * as path from "node:path";
6
6
  import * as os from "node:os";
7
7
  import * as fs from "node:fs";
8
8
  import { randomUUID } from "node:crypto";
9
- import { execSync } from "node:child_process";
9
+ import { execSync, execFileSync } from "node:child_process";
10
+ import { createRequire } from "node:module";
10
11
  import { startInterviewServer, getActiveSessions, type ResponseItem } from "./server.js";
11
- import { validateQuestions, type QuestionsFile } from "./schema.js";
12
+ import { validateQuestions, sanitizeLLMJSON, type QuestionsFile } from "./schema.js";
12
13
  import { loadSettings, type InterviewThemeSettings } from "./settings.js";
13
14
 
15
+ interface GlimpseWindow {
16
+ on(event: "closed", handler: () => void): void;
17
+ close(): void;
18
+ }
19
+
20
+ let glimpseOpen: ((html: string, opts: Record<string, unknown>) => GlimpseWindow) | null | undefined;
21
+
22
+ function findGlimpseMjs(): string | null {
23
+ // Local node_modules
24
+ try {
25
+ const req = createRequire(import.meta.url);
26
+ return req.resolve("glimpseui");
27
+ } catch {}
28
+ // Global npm install
29
+ try {
30
+ const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8" }).trim();
31
+ const entry = path.join(globalRoot, "glimpseui", "src", "glimpse.mjs");
32
+ if (fs.existsSync(entry)) return entry;
33
+ } catch {}
34
+ return null;
35
+ }
36
+
37
+ async function getGlimpseOpen() {
38
+ if (glimpseOpen !== undefined) return glimpseOpen;
39
+ const resolved = findGlimpseMjs();
40
+ if (resolved) {
41
+ try {
42
+ glimpseOpen = (await import(resolved)).open;
43
+ return glimpseOpen;
44
+ } catch {}
45
+ }
46
+ glimpseOpen = null;
47
+ return glimpseOpen;
48
+ }
49
+
50
+ function escapeHtml(str: string): string {
51
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
52
+ }
53
+
54
+ function openInGlimpse(
55
+ open: (html: string, opts: Record<string, unknown>) => GlimpseWindow,
56
+ url: string,
57
+ title?: string,
58
+ ): GlimpseWindow {
59
+ const safeTitle = escapeHtml(title || "Interview");
60
+ const shellHTML = `<!DOCTYPE html>
61
+ <html>
62
+ <head><meta charset="UTF-8"><title>${safeTitle}</title></head>
63
+ <body style="margin:0; background:#1a1a2e;">
64
+ <script>window.location.replace(${JSON.stringify(url)});</script>
65
+ </body>
66
+ </html>`;
67
+
68
+ return open(shellHTML, {
69
+ width: 800,
70
+ height: 700,
71
+ title: title || "Interview",
72
+ });
73
+ }
74
+
14
75
  function formatTimeAgo(timestamp: number): string {
15
76
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
16
77
  if (seconds < 0) return "just now";
@@ -70,7 +131,7 @@ interface SavedQuestionsFile extends QuestionsFile {
70
131
  }
71
132
 
72
133
  const InterviewParams = Type.Object({
73
- questions: Type.String({ description: "Path to questions JSON or saved interview HTML file" }),
134
+ questions: Type.String({ description: "Inline JSON string with questions, or path to a questions JSON / saved interview HTML file" }),
74
135
  timeout: Type.Optional(
75
136
  Type.Number({ description: "Seconds before auto-timeout", default: 600 })
76
137
  ),
@@ -122,12 +183,31 @@ function mergeThemeConfig(
122
183
  };
123
184
  }
124
185
 
125
- function loadQuestions(questionsPath: string, cwd: string): SavedQuestionsFile {
126
- // Expand ~ first, then check if absolute
127
- const expanded = expandHome(questionsPath);
186
+ function loadQuestions(questionsInput: string, cwd: string): SavedQuestionsFile {
187
+ const trimmed = questionsInput.trimStart();
188
+ const looksLikeInlineJSON =
189
+ trimmed.startsWith("{") ||
190
+ /^`{3,}(?:json|jsonc)?\s*\n?\s*\{/i.test(trimmed);
191
+
192
+ if (looksLikeInlineJSON) {
193
+ let data: unknown;
194
+ try {
195
+ data = JSON.parse(trimmed);
196
+ } catch {
197
+ try {
198
+ data = JSON.parse(sanitizeLLMJSON(trimmed));
199
+ } catch (repairErr) {
200
+ const message = repairErr instanceof Error ? repairErr.message : String(repairErr);
201
+ throw new Error(`Invalid inline JSON: ${message}`);
202
+ }
203
+ }
204
+ return validateQuestions(data);
205
+ }
206
+
207
+ const expanded = expandHome(questionsInput);
128
208
  const absolutePath = path.isAbsolute(expanded)
129
209
  ? expanded
130
- : path.join(cwd, questionsPath); // Use original if relative (no ~)
210
+ : path.join(cwd, questionsInput);
131
211
 
132
212
  if (!fs.existsSync(absolutePath)) {
133
213
  throw new Error(`Questions file not found: ${absolutePath}`);
@@ -269,10 +349,12 @@ export default function (pi: ExtensionAPI) {
269
349
  label: "Interview",
270
350
  description:
271
351
  "Present an interactive form to gather user responses. " +
352
+ "On macOS, opens in a native window (Glimpse); falls back to a browser tab elsewhere. " +
272
353
  "Use proactively when: choosing between multiple approaches, gathering requirements before implementation, " +
273
354
  "exploring design tradeoffs, or when decisions have multiple dimensions worth discussing. " +
274
355
  "Provides better UX than back-and-forth chat for structured input. " +
275
356
  "Image responses and attachments are returned as file paths - use read tool directly to display them. " +
357
+ "Pass questions as inline JSON string directly (preferred) or as a path to a JSON file. " +
276
358
  'Questions JSON format: { "title": "...", "description": "...", "questions": [{ "id": "q1", "type": "single|multi|text|image|info", "question": "...", "options": ["A", "B"], "codeBlock": { "code": "...", "lang": "ts" }, "media": { "type": "image|chart|mermaid|table|html", ... } }] }. ' +
277
359
  "Options can be strings or objects: { label: string, code?: { code, lang?, file?, lines?, highlights? } }. " +
278
360
  "Always set recommended with context explaining your reasoning. Recommended options show a 'Recommended' badge and are pre-selected for the user. " +
@@ -295,7 +377,7 @@ export default function (pi: ExtensionAPI) {
295
377
 
296
378
  if (!ctx.hasUI) {
297
379
  throw new Error(
298
- "Interview tool requires interactive mode with browser support. " +
380
+ "Interview tool requires interactive mode. " +
299
381
  "Cannot run in headless/RPC/print mode."
300
382
  );
301
383
  }
@@ -327,6 +409,7 @@ export default function (pi: ExtensionAPI) {
327
409
  const sessionId = randomUUID();
328
410
  const sessionToken = randomUUID();
329
411
  let server: { close: () => void } | null = null;
412
+ let glimpseWin: GlimpseWindow | null = null;
330
413
  let resolved = false;
331
414
  let url = "";
332
415
 
@@ -377,7 +460,13 @@ export default function (pi: ExtensionAPI) {
377
460
  });
378
461
  };
379
462
 
380
- const handleAbort = () => finish("aborted");
463
+ const handleAbort = () => {
464
+ if (glimpseWin) {
465
+ try { glimpseWin.close(); } catch {}
466
+ glimpseWin = null;
467
+ }
468
+ finish("aborted");
469
+ };
381
470
  signal?.addEventListener("abort", handleAbort, { once: true });
382
471
 
383
472
  startInterviewServer(
@@ -403,6 +492,10 @@ export default function (pi: ExtensionAPI) {
403
492
  }
404
493
  )
405
494
  .then(async (handle) => {
495
+ if (resolved) {
496
+ handle.close();
497
+ return;
498
+ }
406
499
  server = handle;
407
500
  url = handle.url;
408
501
 
@@ -412,7 +505,7 @@ export default function (pi: ExtensionAPI) {
412
505
  if (otherActive.length > 0) {
413
506
  const active = otherActive[0];
414
507
  const queuedLines = [
415
- "Interview already active in browser:",
508
+ "Interview already active:",
416
509
  ` Title: ${active.title}`,
417
510
  ` Project: ${active.cwd}${active.gitBranch ? ` (${active.gitBranch})` : ""}`,
418
511
  ` Session: ${active.id.slice(0, 8)}`,
@@ -453,13 +546,27 @@ export default function (pi: ExtensionAPI) {
453
546
  pi.ui.notify(queuedSummary, "info");
454
547
  }
455
548
  } else {
549
+ const glimpseOpenFn = os.platform() === "darwin" ? await getGlimpseOpen() : null;
550
+ if (glimpseOpenFn) {
551
+ try {
552
+ glimpseWin = openInGlimpse(glimpseOpenFn, url, questionsData.title || "Interview");
553
+ glimpseWin.on("closed", () => {
554
+ glimpseWin = null;
555
+ if (!resolved) {
556
+ finish("cancelled", [], "user");
557
+ }
558
+ });
559
+ return;
560
+ } catch {
561
+ glimpseWin = null;
562
+ }
563
+ }
456
564
  try {
457
565
  await openUrl(pi, url, settings.browser);
458
566
  } catch (err) {
459
567
  cleanup();
460
568
  const message = err instanceof Error ? err.message : String(err);
461
569
  reject(new Error(`Failed to open browser: ${message}`));
462
- return;
463
570
  }
464
571
  }
465
572
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/schema.ts CHANGED
@@ -337,3 +337,20 @@ export function validateQuestions(data: unknown): QuestionsFile {
337
337
 
338
338
  return parsed;
339
339
  }
340
+
341
+ // Repair common LLM JSON mistakes (code fences, trailing commas, comments, smart quotes).
342
+ // Only called as a fallback when JSON.parse() already failed.
343
+ export function sanitizeLLMJSON(input: string): string {
344
+ let json = input.trim();
345
+
346
+ const fenceMatch = json.match(/^`{3,}(?:json|jsonc)?\s*\n([\s\S]*?)\n\s*`{3,}\s*$/i);
347
+ if (fenceMatch) {
348
+ json = fenceMatch[1];
349
+ }
350
+
351
+ json = json.replace(/^\s*\/\/.*$/gm, "");
352
+ json = json.replace(/,(\s*[}\]])/g, "$1");
353
+ json = json.replace(/\u201C|\u201D/g, '"');
354
+
355
+ return json.trim();
356
+ }