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 +5 -4
- package/form/script.js +16 -5
- package/index.ts +109 -10
- package/package.json +1 -1
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
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, "&").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,23 @@ function mergeThemeConfig(
|
|
|
122
183
|
};
|
|
123
184
|
}
|
|
124
185
|
|
|
125
|
-
function loadQuestions(
|
|
126
|
-
|
|
127
|
-
|
|
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,
|
|
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
|
|
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 = () =>
|
|
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
|
|
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
|
})
|