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 +5 -4
- package/form/script.js +16 -5
- package/index.ts +118 -11
- package/package.json +1 -1
- package/schema.ts +17 -0
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
|
|
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 │ │
|
|
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 →
|
|
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
|
|
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(() =>
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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: "
|
|
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(
|
|
126
|
-
|
|
127
|
-
const
|
|
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,
|
|
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
|
|
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 = () =>
|
|
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
|
|
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
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
|
+
}
|