pi-interview 0.4.5 → 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 +91 -10
- package/form/index.html +6 -3
- package/form/script.js +311 -32
- package/form/styles.css +318 -57
- package/form/themes/default-dark.css +7 -1
- package/form/themes/default-light.css +7 -0
- package/form/themes/tufte-dark.css +10 -4
- package/form/themes/tufte-light.css +10 -4
- package/index.ts +118 -12
- package/package.json +7 -1
- package/schema.ts +108 -5
- package/server.ts +238 -12
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,13 +341,22 @@ 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. " +
|
|
276
|
-
|
|
349
|
+
"Pass questions as inline JSON string directly (preferred) or as a path to a JSON file. " +
|
|
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. " +
|
|
353
|
+
'Use conviction: "slight" when unsure (does NOT pre-select), conviction: "strong" when very confident (shows Recommended badge). ' +
|
|
354
|
+
"Omit conviction for normal recommendations (pre-selects). " +
|
|
355
|
+
'Use weight: "critical" for key decisions (visually prominent), weight: "minor" for low-stakes questions (compact card). ' +
|
|
356
|
+
"When questions have recommendations, set description to guide review (e.g., 'Review my suggestions and adjust as needed'). " +
|
|
357
|
+
"Questions can have a codeBlock field to display code above options. Types: single (radio), multi (checkbox), text (textarea), image (file upload), info (non-interactive). " +
|
|
358
|
+
'Media blocks: { type: "image", src, alt, caption }, { type: "table", table: { headers, rows, highlights }, caption }, { type: "chart", chart: { type, data, options }, caption }, { type: "mermaid", mermaid: "graph LR\\n..." }, { type: "html", html }. ' +
|
|
359
|
+
"Info type is a non-interactive content panel for displaying context with media. Media position: above (default), below, side (two-column).",
|
|
279
360
|
parameters: InterviewParams,
|
|
280
361
|
|
|
281
362
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -288,7 +369,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
288
369
|
|
|
289
370
|
if (!ctx.hasUI) {
|
|
290
371
|
throw new Error(
|
|
291
|
-
"Interview tool requires interactive mode
|
|
372
|
+
"Interview tool requires interactive mode. " +
|
|
292
373
|
"Cannot run in headless/RPC/print mode."
|
|
293
374
|
);
|
|
294
375
|
}
|
|
@@ -320,6 +401,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
320
401
|
const sessionId = randomUUID();
|
|
321
402
|
const sessionToken = randomUUID();
|
|
322
403
|
let server: { close: () => void } | null = null;
|
|
404
|
+
let glimpseWin: GlimpseWindow | null = null;
|
|
323
405
|
let resolved = false;
|
|
324
406
|
let url = "";
|
|
325
407
|
|
|
@@ -370,7 +452,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
370
452
|
});
|
|
371
453
|
};
|
|
372
454
|
|
|
373
|
-
const handleAbort = () =>
|
|
455
|
+
const handleAbort = () => {
|
|
456
|
+
if (glimpseWin) {
|
|
457
|
+
try { glimpseWin.close(); } catch {}
|
|
458
|
+
glimpseWin = null;
|
|
459
|
+
}
|
|
460
|
+
finish("aborted");
|
|
461
|
+
};
|
|
374
462
|
signal?.addEventListener("abort", handleAbort, { once: true });
|
|
375
463
|
|
|
376
464
|
startInterviewServer(
|
|
@@ -396,6 +484,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
396
484
|
}
|
|
397
485
|
)
|
|
398
486
|
.then(async (handle) => {
|
|
487
|
+
if (resolved) {
|
|
488
|
+
handle.close();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
399
491
|
server = handle;
|
|
400
492
|
url = handle.url;
|
|
401
493
|
|
|
@@ -405,7 +497,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
405
497
|
if (otherActive.length > 0) {
|
|
406
498
|
const active = otherActive[0];
|
|
407
499
|
const queuedLines = [
|
|
408
|
-
"Interview already active
|
|
500
|
+
"Interview already active:",
|
|
409
501
|
` Title: ${active.title}`,
|
|
410
502
|
` Project: ${active.cwd}${active.gitBranch ? ` (${active.gitBranch})` : ""}`,
|
|
411
503
|
` Session: ${active.id.slice(0, 8)}`,
|
|
@@ -446,13 +538,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
446
538
|
pi.ui.notify(queuedSummary, "info");
|
|
447
539
|
}
|
|
448
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
|
+
}
|
|
449
556
|
try {
|
|
450
557
|
await openUrl(pi, url, settings.browser);
|
|
451
558
|
} catch (err) {
|
|
452
559
|
cleanup();
|
|
453
560
|
const message = err instanceof Error ? err.message : String(err);
|
|
454
561
|
reject(new Error(`Failed to open browser: ${message}`));
|
|
455
|
-
return;
|
|
456
562
|
}
|
|
457
563
|
}
|
|
458
564
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-interview",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -29,9 +29,15 @@
|
|
|
29
29
|
"settings.ts",
|
|
30
30
|
"README.md"
|
|
31
31
|
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "vitest run"
|
|
34
|
+
},
|
|
32
35
|
"pi": {
|
|
33
36
|
"extensions": [
|
|
34
37
|
"./index.ts"
|
|
35
38
|
]
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"vitest": "^4.0.18"
|
|
36
42
|
}
|
|
37
43
|
}
|
package/schema.ts
CHANGED
|
@@ -14,14 +14,38 @@ export interface RichOption {
|
|
|
14
14
|
|
|
15
15
|
export type OptionValue = string | RichOption;
|
|
16
16
|
|
|
17
|
+
export interface MediaBlock {
|
|
18
|
+
type: "image" | "chart" | "mermaid" | "table" | "html";
|
|
19
|
+
src?: string;
|
|
20
|
+
alt?: string;
|
|
21
|
+
chart?: {
|
|
22
|
+
type: string;
|
|
23
|
+
data: Record<string, unknown>;
|
|
24
|
+
options?: Record<string, unknown>;
|
|
25
|
+
};
|
|
26
|
+
mermaid?: string;
|
|
27
|
+
table?: {
|
|
28
|
+
headers: string[];
|
|
29
|
+
rows: string[][];
|
|
30
|
+
highlights?: number[];
|
|
31
|
+
};
|
|
32
|
+
html?: string;
|
|
33
|
+
caption?: string;
|
|
34
|
+
position?: "above" | "below" | "side";
|
|
35
|
+
maxHeight?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
export interface Question {
|
|
18
39
|
id: string;
|
|
19
|
-
type: "single" | "multi" | "text" | "image";
|
|
40
|
+
type: "single" | "multi" | "text" | "image" | "info";
|
|
20
41
|
question: string;
|
|
21
42
|
options?: OptionValue[];
|
|
22
43
|
recommended?: string | string[];
|
|
44
|
+
conviction?: "strong" | "slight";
|
|
45
|
+
weight?: "critical" | "minor";
|
|
23
46
|
context?: string;
|
|
24
47
|
codeBlock?: CodeBlock;
|
|
48
|
+
media?: MediaBlock | MediaBlock[];
|
|
25
49
|
}
|
|
26
50
|
|
|
27
51
|
export interface QuestionsFile {
|
|
@@ -38,6 +62,60 @@ export function isRichOption(option: OptionValue): option is RichOption {
|
|
|
38
62
|
return typeof option === "object" && option !== null && "label" in option;
|
|
39
63
|
}
|
|
40
64
|
|
|
65
|
+
function validateMediaBlock(block: unknown, context: string): MediaBlock {
|
|
66
|
+
if (!block || typeof block !== "object") {
|
|
67
|
+
throw new Error(`${context}: media must be an object`);
|
|
68
|
+
}
|
|
69
|
+
const b = block as Record<string, unknown>;
|
|
70
|
+
const validMediaTypes = ["image", "chart", "mermaid", "table", "html"];
|
|
71
|
+
if (typeof b.type !== "string" || !validMediaTypes.includes(b.type)) {
|
|
72
|
+
throw new Error(`${context}: media.type must be one of: ${validMediaTypes.join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (b.type === "image" && typeof b.src !== "string") {
|
|
76
|
+
throw new Error(`${context}: media.src required for image type`);
|
|
77
|
+
}
|
|
78
|
+
if (b.type === "chart") {
|
|
79
|
+
if (!b.chart || typeof b.chart !== "object") {
|
|
80
|
+
throw new Error(`${context}: media.chart required for chart type`);
|
|
81
|
+
}
|
|
82
|
+
const chart = b.chart as Record<string, unknown>;
|
|
83
|
+
if (typeof chart.type !== "string") {
|
|
84
|
+
throw new Error(`${context}: media.chart.type must be a string`);
|
|
85
|
+
}
|
|
86
|
+
if (!chart.data || typeof chart.data !== "object") {
|
|
87
|
+
throw new Error(`${context}: media.chart.data must be an object`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (b.type === "mermaid" && typeof b.mermaid !== "string") {
|
|
91
|
+
throw new Error(`${context}: media.mermaid required for mermaid type`);
|
|
92
|
+
}
|
|
93
|
+
if (b.type === "table") {
|
|
94
|
+
if (!b.table || typeof b.table !== "object") {
|
|
95
|
+
throw new Error(`${context}: media.table required for table type`);
|
|
96
|
+
}
|
|
97
|
+
const table = b.table as Record<string, unknown>;
|
|
98
|
+
if (!Array.isArray(table.headers)) {
|
|
99
|
+
throw new Error(`${context}: media.table.headers must be an array`);
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(table.rows)) {
|
|
102
|
+
throw new Error(`${context}: media.table.rows must be an array`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (b.type === "html" && typeof b.html !== "string") {
|
|
106
|
+
throw new Error(`${context}: media.html required for html type`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (b.position !== undefined) {
|
|
110
|
+
const validPositions = ["above", "below", "side"];
|
|
111
|
+
if (!validPositions.includes(b.position as string)) {
|
|
112
|
+
throw new Error(`${context}: media.position must be one of: ${validPositions.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return b as unknown as MediaBlock;
|
|
117
|
+
}
|
|
118
|
+
|
|
41
119
|
const SCHEMA_EXAMPLE = `Expected format:
|
|
42
120
|
{
|
|
43
121
|
"title": "Optional Title",
|
|
@@ -48,7 +126,7 @@ const SCHEMA_EXAMPLE = `Expected format:
|
|
|
48
126
|
{ "id": "q4", "type": "image", "question": "Upload?" }
|
|
49
127
|
]
|
|
50
128
|
}
|
|
51
|
-
Valid types: single, multi, text, image
|
|
129
|
+
Valid types: single, multi, text, image, info
|
|
52
130
|
Options: array of strings or objects with { label, code? }`;
|
|
53
131
|
|
|
54
132
|
function validateCodeBlock(block: unknown, context: string): CodeBlock {
|
|
@@ -133,7 +211,7 @@ function validateBasicStructure(data: unknown): QuestionsFile {
|
|
|
133
211
|
);
|
|
134
212
|
}
|
|
135
213
|
|
|
136
|
-
const validTypes = ["single", "multi", "text", "image"];
|
|
214
|
+
const validTypes = ["single", "multi", "text", "image", "info"];
|
|
137
215
|
for (let i = 0; i < obj.questions.length; i++) {
|
|
138
216
|
const q = obj.questions[i] as Record<string, unknown>;
|
|
139
217
|
if (!q || typeof q !== "object") {
|
|
@@ -173,6 +251,27 @@ function validateBasicStructure(data: unknown): QuestionsFile {
|
|
|
173
251
|
if (q.codeBlock !== undefined) {
|
|
174
252
|
validateCodeBlock(q.codeBlock, `Question "${q.id}"`);
|
|
175
253
|
}
|
|
254
|
+
|
|
255
|
+
if (q.conviction !== undefined) {
|
|
256
|
+
const validConvictions = ["strong", "slight"];
|
|
257
|
+
if (typeof q.conviction !== "string" || !validConvictions.includes(q.conviction)) {
|
|
258
|
+
throw new Error(`Question "${q.id}": conviction must be "strong" or "slight"`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (q.weight !== undefined) {
|
|
263
|
+
const validWeights = ["critical", "minor"];
|
|
264
|
+
if (typeof q.weight !== "string" || !validWeights.includes(q.weight)) {
|
|
265
|
+
throw new Error(`Question "${q.id}": weight must be "critical" or "minor"`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (q.media !== undefined) {
|
|
270
|
+
const mediaItems = Array.isArray(q.media) ? q.media : [q.media];
|
|
271
|
+
for (let m = 0; m < mediaItems.length; m++) {
|
|
272
|
+
validateMediaBlock(mediaItems[m], `Question "${q.id}" media[${m}]`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
176
275
|
}
|
|
177
276
|
|
|
178
277
|
return obj as unknown as QuestionsFile;
|
|
@@ -194,14 +293,18 @@ export function validateQuestions(data: unknown): QuestionsFile {
|
|
|
194
293
|
if (!q.options || q.options.length === 0) {
|
|
195
294
|
throw new Error(`Question "${q.id}": options required for type "${q.type}"`);
|
|
196
295
|
}
|
|
197
|
-
} else if (q.type === "text" || q.type === "image") {
|
|
296
|
+
} else if (q.type === "text" || q.type === "image" || q.type === "info") {
|
|
198
297
|
if (q.options) {
|
|
199
298
|
throw new Error(`Question "${q.id}": options not allowed for type "${q.type}"`);
|
|
200
299
|
}
|
|
201
300
|
}
|
|
202
301
|
|
|
302
|
+
if (q.conviction !== undefined && q.recommended === undefined) {
|
|
303
|
+
throw new Error(`Question "${q.id}": conviction requires recommended`);
|
|
304
|
+
}
|
|
305
|
+
|
|
203
306
|
if (q.recommended !== undefined) {
|
|
204
|
-
if (q.type === "text" || q.type === "image") {
|
|
307
|
+
if (q.type === "text" || q.type === "image" || q.type === "info") {
|
|
205
308
|
throw new Error(`Question "${q.id}": recommended not allowed for type "${q.type}"`);
|
|
206
309
|
}
|
|
207
310
|
|