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/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,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
- 'Questions JSON format: { "title": "...", "questions": [{ "id": "q1", "type": "single|multi|text|image", "question": "...", "options": ["A", "B"], "codeBlock": { "code": "...", "lang": "ts" } }] }. ' +
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
- "Questions can have a codeBlock field to display code above options. Types: single (radio), multi (checkbox), text (textarea), image (file upload).",
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 with browser support. " +
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 = () => finish("aborted");
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 in browser:",
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.4.5",
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