pi-interview 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
12
  import { validateQuestions, 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,23 @@ 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
+ if (trimmed.startsWith("{")) {
189
+ let data: unknown;
190
+ try {
191
+ data = JSON.parse(trimmed);
192
+ } catch (err) {
193
+ const message = err instanceof Error ? err.message : String(err);
194
+ throw new Error(`Invalid inline JSON: ${message}`);
195
+ }
196
+ return validateQuestions(data);
197
+ }
198
+
199
+ const expanded = expandHome(questionsInput);
128
200
  const absolutePath = path.isAbsolute(expanded)
129
201
  ? expanded
130
- : path.join(cwd, questionsPath); // Use original if relative (no ~)
202
+ : path.join(cwd, questionsInput);
131
203
 
132
204
  if (!fs.existsSync(absolutePath)) {
133
205
  throw new Error(`Questions file not found: ${absolutePath}`);
@@ -269,10 +341,12 @@ export default function (pi: ExtensionAPI) {
269
341
  label: "Interview",
270
342
  description:
271
343
  "Present an interactive form to gather user responses. " +
344
+ "On macOS, opens in a native window (Glimpse); falls back to a browser tab elsewhere. " +
272
345
  "Use proactively when: choosing between multiple approaches, gathering requirements before implementation, " +
273
346
  "exploring design tradeoffs, or when decisions have multiple dimensions worth discussing. " +
274
347
  "Provides better UX than back-and-forth chat for structured input. " +
275
348
  "Image responses and attachments are returned as file paths - use read tool directly to display them. " +
349
+ "Pass questions as inline JSON string directly (preferred) or as a path to a JSON file. " +
276
350
  '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
351
  "Options can be strings or objects: { label: string, code?: { code, lang?, file?, lines?, highlights? } }. " +
278
352
  "Always set recommended with context explaining your reasoning. Recommended options show a 'Recommended' badge and are pre-selected for the user. " +
@@ -295,7 +369,7 @@ export default function (pi: ExtensionAPI) {
295
369
 
296
370
  if (!ctx.hasUI) {
297
371
  throw new Error(
298
- "Interview tool requires interactive mode with browser support. " +
372
+ "Interview tool requires interactive mode. " +
299
373
  "Cannot run in headless/RPC/print mode."
300
374
  );
301
375
  }
@@ -327,6 +401,7 @@ export default function (pi: ExtensionAPI) {
327
401
  const sessionId = randomUUID();
328
402
  const sessionToken = randomUUID();
329
403
  let server: { close: () => void } | null = null;
404
+ let glimpseWin: GlimpseWindow | null = null;
330
405
  let resolved = false;
331
406
  let url = "";
332
407
 
@@ -377,7 +452,13 @@ export default function (pi: ExtensionAPI) {
377
452
  });
378
453
  };
379
454
 
380
- const handleAbort = () => finish("aborted");
455
+ const handleAbort = () => {
456
+ if (glimpseWin) {
457
+ try { glimpseWin.close(); } catch {}
458
+ glimpseWin = null;
459
+ }
460
+ finish("aborted");
461
+ };
381
462
  signal?.addEventListener("abort", handleAbort, { once: true });
382
463
 
383
464
  startInterviewServer(
@@ -403,6 +484,10 @@ export default function (pi: ExtensionAPI) {
403
484
  }
404
485
  )
405
486
  .then(async (handle) => {
487
+ if (resolved) {
488
+ handle.close();
489
+ return;
490
+ }
406
491
  server = handle;
407
492
  url = handle.url;
408
493
 
@@ -412,7 +497,7 @@ export default function (pi: ExtensionAPI) {
412
497
  if (otherActive.length > 0) {
413
498
  const active = otherActive[0];
414
499
  const queuedLines = [
415
- "Interview already active in browser:",
500
+ "Interview already active:",
416
501
  ` Title: ${active.title}`,
417
502
  ` Project: ${active.cwd}${active.gitBranch ? ` (${active.gitBranch})` : ""}`,
418
503
  ` Session: ${active.id.slice(0, 8)}`,
@@ -453,13 +538,27 @@ export default function (pi: ExtensionAPI) {
453
538
  pi.ui.notify(queuedSummary, "info");
454
539
  }
455
540
  } else {
541
+ const glimpseOpenFn = os.platform() === "darwin" ? await getGlimpseOpen() : null;
542
+ if (glimpseOpenFn) {
543
+ try {
544
+ glimpseWin = openInGlimpse(glimpseOpenFn, url, questionsData.title || "Interview");
545
+ glimpseWin.on("closed", () => {
546
+ glimpseWin = null;
547
+ if (!resolved) {
548
+ finish("cancelled", [], "user");
549
+ }
550
+ });
551
+ return;
552
+ } catch {
553
+ glimpseWin = null;
554
+ }
555
+ }
456
556
  try {
457
557
  await openUrl(pi, url, settings.browser);
458
558
  } catch (err) {
459
559
  cleanup();
460
560
  const message = err instanceof Error ? err.message : String(err);
461
561
  reject(new Error(`Failed to open browser: ${message}`));
462
- return;
463
562
  }
464
563
  }
465
564
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",