pi-interview 0.3.0

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.
@@ -0,0 +1,5 @@
1
+ /* Default dark theme uses base styles.css tokens. */
2
+ :root {
3
+ --overlay-bg: rgba(24, 24, 30, 0.95);
4
+ --error-bg: rgba(204, 102, 102, 0.12);
5
+ }
@@ -0,0 +1,24 @@
1
+ :root {
2
+ --bg-body: #f8f8f8;
3
+ --bg-card: #ffffff;
4
+ --bg-elevated: #f0f0f0;
5
+ --bg-selected: #d0d0e0;
6
+ --bg-hover: #e8e8e8;
7
+ --bg-active-tint: rgba(95, 135, 135, 0.12);
8
+ --fg: #1a1a1a;
9
+ --fg-muted: #6c6c6c;
10
+ --fg-dim: #8a8a8a;
11
+ --accent: #5f8787;
12
+ --accent-hover: #4a7272;
13
+ --accent-muted: rgba(95, 135, 135, 0.15);
14
+ --border: #5f87af;
15
+ --border-muted: #b0b0b0;
16
+ --border-focus: #8a8a9a;
17
+ --border-active: #9090a0;
18
+ --success: #87af87;
19
+ --warning: #d7af5f;
20
+ --error: #af5f5f;
21
+ --error-bg: rgba(175, 95, 95, 0.12);
22
+ --focus-ring: rgba(95, 135, 175, 0.2);
23
+ --overlay-bg: rgba(255, 255, 255, 0.95);
24
+ }
@@ -0,0 +1,29 @@
1
+ :root {
2
+ --bg-body: #1c1917;
3
+ --bg-card: #201d1b;
4
+ --bg-elevated: #252220;
5
+ --bg-selected: #2d2925;
6
+ --bg-hover: #302b26;
7
+ --bg-active-tint: rgba(201, 183, 154, 0.1);
8
+ --fg: #e7e2d9;
9
+ --fg-muted: #a9a194;
10
+ --fg-dim: #8d8476;
11
+ --accent: #c9b79a;
12
+ --accent-hover: #d8c5a5;
13
+ --accent-muted: rgba(201, 183, 154, 0.15);
14
+ --border: #5a5046;
15
+ --border-muted: #423a31;
16
+ --border-focus: #6a6050;
17
+ --border-active: #5a5046;
18
+ --success: #b5bd68;
19
+ --warning: #d2a456;
20
+ --error: #cc6666;
21
+ --error-bg: rgba(204, 102, 102, 0.12);
22
+ --focus-ring: rgba(201, 183, 154, 0.2);
23
+ --overlay-bg: rgba(28, 25, 23, 0.95);
24
+
25
+ --font-body: "Cormorant Garamond", Cormorant, "Crimson Pro", "Libre Baskerville", Georgia, serif;
26
+ --font-ui: "Cormorant Garamond", Cormorant, "Crimson Pro", "Libre Baskerville", Georgia, serif;
27
+ --font-mono: "IBM Plex Mono", "JetBrains Mono", "SF Mono", Consolas, monospace;
28
+ --font-size-base: 15px;
29
+ }
@@ -0,0 +1,29 @@
1
+ :root {
2
+ --bg-body: #f9f7f3;
3
+ --bg-card: #fbfaf7;
4
+ --bg-elevated: #f2ede5;
5
+ --bg-selected: #e8e1d6;
6
+ --bg-hover: #eee6db;
7
+ --bg-active-tint: rgba(125, 104, 80, 0.12);
8
+ --fg: #2a2520;
9
+ --fg-muted: #635c52;
10
+ --fg-dim: #7a7267;
11
+ --accent: #7d6850;
12
+ --accent-hover: #927b61;
13
+ --accent-muted: rgba(125, 104, 80, 0.15);
14
+ --border: #a09080;
15
+ --border-muted: #d9cfc0;
16
+ --border-focus: #8a7a6a;
17
+ --border-active: #b0a090;
18
+ --success: #7a8b5a;
19
+ --warning: #b9833a;
20
+ --error: #b05a5a;
21
+ --error-bg: rgba(176, 90, 90, 0.12);
22
+ --focus-ring: rgba(125, 104, 80, 0.2);
23
+ --overlay-bg: rgba(251, 250, 247, 0.95);
24
+
25
+ --font-body: "Cormorant Garamond", Cormorant, "Crimson Pro", "Libre Baskerville", Georgia, serif;
26
+ --font-ui: "Cormorant Garamond", Cormorant, "Crimson Pro", "Libre Baskerville", Georgia, serif;
27
+ --font-mono: "IBM Plex Mono", "JetBrains Mono", "SF Mono", Consolas, monospace;
28
+ --font-size-base: 15px;
29
+ }
package/index.ts ADDED
@@ -0,0 +1,354 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+ import * as fs from "node:fs";
7
+ import { randomUUID } from "node:crypto";
8
+ import { execSync } from "node:child_process";
9
+ import { startInterviewServer, getActiveSessions, type ResponseItem } from "./server.js";
10
+ import { validateQuestions, type QuestionsFile } from "./schema.js";
11
+ import { loadSettings, type InterviewThemeSettings } from "./settings.js";
12
+
13
+ function formatTimeAgo(timestamp: number): string {
14
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
15
+ if (seconds < 0) return "just now";
16
+ if (seconds < 60) return `${seconds} seconds ago`;
17
+ const minutes = Math.floor(seconds / 60);
18
+ if (minutes < 60) return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
19
+ const hours = Math.floor(minutes / 60);
20
+ return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
21
+ }
22
+
23
+ async function openUrl(pi: ExtensionAPI, url: string, browser?: string): Promise<void> {
24
+ const platform = os.platform();
25
+ let result;
26
+ if (platform === "darwin") {
27
+ if (browser) {
28
+ result = await pi.exec("open", ["-a", browser, url]);
29
+ } else {
30
+ result = await pi.exec("open", [url]);
31
+ }
32
+ } else if (platform === "win32") {
33
+ if (browser) {
34
+ result = await pi.exec("cmd", ["/c", "start", "", browser, url]);
35
+ } else {
36
+ result = await pi.exec("cmd", ["/c", "start", "", url]);
37
+ }
38
+ } else {
39
+ if (browser) {
40
+ result = await pi.exec(browser, [url]);
41
+ } else {
42
+ result = await pi.exec("xdg-open", [url]);
43
+ }
44
+ }
45
+ if (result.code !== 0) {
46
+ throw new Error(result.stderr || `Failed to open browser (exit code ${result.code})`);
47
+ }
48
+ }
49
+
50
+ interface InterviewDetails {
51
+ status: "completed" | "cancelled" | "timeout" | "aborted" | "queued";
52
+ responses: ResponseItem[];
53
+ url: string;
54
+ queuedMessage?: string;
55
+ }
56
+
57
+ const InterviewParams = Type.Object({
58
+ questions: Type.String({ description: "Path to questions JSON file" }),
59
+ timeout: Type.Optional(
60
+ Type.Number({ description: "Seconds before auto-timeout", default: 600 })
61
+ ),
62
+ verbose: Type.Optional(Type.Boolean({ description: "Enable debug logging", default: false })),
63
+ theme: Type.Optional(
64
+ Type.Object(
65
+ {
66
+ mode: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("light"), Type.Literal("dark")])),
67
+ name: Type.Optional(Type.String()),
68
+ lightPath: Type.Optional(Type.String()),
69
+ darkPath: Type.Optional(Type.String()),
70
+ toggleHotkey: Type.Optional(Type.String()),
71
+ },
72
+ { additionalProperties: false }
73
+ )
74
+ ),
75
+ });
76
+
77
+ function expandHome(value: string): string {
78
+ if (value.startsWith("~" + path.sep)) {
79
+ return path.join(os.homedir(), value.slice(2));
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function resolveOptionalPath(value: string | undefined, cwd: string): string | undefined {
85
+ if (!value) return undefined;
86
+ const expanded = expandHome(value);
87
+ return path.isAbsolute(expanded) ? expanded : path.join(cwd, expanded);
88
+ }
89
+
90
+ const DEFAULT_THEME_HOTKEY = "mod+shift+l";
91
+
92
+ function mergeThemeConfig(
93
+ base: InterviewThemeSettings | undefined,
94
+ override: InterviewThemeSettings | undefined,
95
+ cwd: string
96
+ ): InterviewThemeSettings {
97
+ const merged: InterviewThemeSettings = { ...(base ?? {}), ...(override ?? {}) };
98
+ return {
99
+ ...merged,
100
+ toggleHotkey: merged.toggleHotkey ?? DEFAULT_THEME_HOTKEY,
101
+ lightPath: resolveOptionalPath(merged.lightPath, cwd),
102
+ darkPath: resolveOptionalPath(merged.darkPath, cwd),
103
+ };
104
+ }
105
+
106
+ function loadQuestions(questionsPath: string, cwd: string): QuestionsFile {
107
+ const absolutePath = path.isAbsolute(questionsPath)
108
+ ? questionsPath
109
+ : path.join(cwd, questionsPath);
110
+
111
+ if (!fs.existsSync(absolutePath)) {
112
+ throw new Error(`Questions file not found: ${absolutePath}`);
113
+ }
114
+
115
+ let data: unknown;
116
+ try {
117
+ const content = fs.readFileSync(absolutePath, "utf-8");
118
+ data = JSON.parse(content);
119
+ } catch (err) {
120
+ const message = err instanceof Error ? err.message : String(err);
121
+ throw new Error(`Invalid JSON in questions file: ${message}`);
122
+ }
123
+
124
+ return validateQuestions(data);
125
+ }
126
+
127
+ function formatResponses(responses: ResponseItem[]): string {
128
+ if (responses.length === 0) return "(none)";
129
+ return responses
130
+ .map((resp) => {
131
+ const value = Array.isArray(resp.value) ? resp.value.join(", ") : resp.value;
132
+ let line = `- ${resp.id}: ${value}`;
133
+ if (resp.attachments && resp.attachments.length > 0) {
134
+ line += ` [attachments: ${resp.attachments.join(", ")}]`;
135
+ }
136
+ return line;
137
+ })
138
+ .join("\n");
139
+ }
140
+
141
+ export default function (pi: ExtensionAPI) {
142
+ pi.registerTool({
143
+ name: "interview",
144
+ label: "Interview",
145
+ description:
146
+ "Present an interactive form to gather user responses. " +
147
+ "Use proactively when: choosing between multiple approaches, gathering requirements before implementation, " +
148
+ "exploring design tradeoffs, or when decisions have multiple dimensions worth discussing. " +
149
+ "Provides better UX than back-and-forth chat for structured input. " +
150
+ "Image responses and attachments are returned as file paths - use read tool directly to display them. " +
151
+ 'Questions JSON format: { "title": "...", "questions": [{ "id": "q1", "type": "single|multi|text|image", "question": "...", "options": ["A", "B"], "codeBlock": { "code": "...", "lang": "ts" } }] }. ' +
152
+ "Options can be strings or objects: { label: string, code?: { code, lang?, file?, lines?, highlights? } }. " +
153
+ "Questions can have a codeBlock field to display code above options. Types: single (radio), multi (checkbox), text (textarea), image (file upload).",
154
+ parameters: InterviewParams,
155
+
156
+ async execute(_toolCallId, params, onUpdate, ctx, signal) {
157
+ const { questions, timeout, verbose, theme } = params as {
158
+ questions: string;
159
+ timeout?: number;
160
+ verbose?: boolean;
161
+ theme?: InterviewThemeSettings;
162
+ };
163
+
164
+ if (!ctx.hasUI) {
165
+ throw new Error(
166
+ "Interview tool requires interactive mode with browser support. " +
167
+ "Cannot run in headless/RPC/print mode."
168
+ );
169
+ }
170
+
171
+ if (typeof ctx.hasQueuedMessages === "function" && ctx.hasQueuedMessages()) {
172
+ return {
173
+ content: [{ type: "text", text: "Interview skipped - user has queued input." }],
174
+ details: { status: "cancelled", url: "", responses: [] },
175
+ };
176
+ }
177
+
178
+ const settings = loadSettings();
179
+ const timeoutSeconds = timeout ?? settings.timeout ?? 600;
180
+ const themeConfig = mergeThemeConfig(settings.theme, theme, ctx.cwd);
181
+ const questionsData = loadQuestions(questions, ctx.cwd);
182
+
183
+ if (signal?.aborted) {
184
+ return {
185
+ content: [{ type: "text", text: "Interview was aborted." }],
186
+ details: { status: "aborted", url: "", responses: [] },
187
+ };
188
+ }
189
+
190
+ const sessionId = randomUUID();
191
+ const sessionToken = randomUUID();
192
+ let server: { close: () => void } | null = null;
193
+ let resolved = false;
194
+ let url = "";
195
+
196
+ const cleanup = () => {
197
+ if (server) {
198
+ server.close();
199
+ server = null;
200
+ }
201
+ };
202
+
203
+ return new Promise((resolve, reject) => {
204
+ const finish = (
205
+ status: InterviewDetails["status"],
206
+ responses: ResponseItem[] = [],
207
+ cancelReason?: "timeout" | "user" | "stale"
208
+ ) => {
209
+ if (resolved) return;
210
+ resolved = true;
211
+ cleanup();
212
+
213
+ let text = "";
214
+ if (status === "completed") {
215
+ text = `User completed the interview form.\n\nResponses:\n${formatResponses(responses)}`;
216
+ } else if (status === "cancelled") {
217
+ if (cancelReason === "stale") {
218
+ text =
219
+ "Interview session ended due to lost heartbeat.\n\nQuestions saved to: ~/.pi/interview-recovery/";
220
+ } else {
221
+ text = "User cancelled the interview form.";
222
+ }
223
+ } else if (status === "timeout") {
224
+ text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nQuestions saved to: ~/.pi/interview-recovery/`;
225
+ } else {
226
+ text = "Interview was aborted.";
227
+ }
228
+
229
+ resolve({
230
+ content: [{ type: "text", text }],
231
+ details: { status, url, responses },
232
+ });
233
+ };
234
+
235
+ const handleAbort = () => finish("aborted");
236
+ signal?.addEventListener("abort", handleAbort, { once: true });
237
+
238
+ startInterviewServer(
239
+ {
240
+ questions: questionsData,
241
+ sessionToken,
242
+ sessionId,
243
+ cwd: ctx.cwd,
244
+ timeout: timeoutSeconds,
245
+ port: settings.port,
246
+ verbose,
247
+ theme: themeConfig,
248
+ },
249
+ {
250
+ onSubmit: (responses) => finish("completed", responses),
251
+ onCancel: (reason) =>
252
+ reason === "timeout" ? finish("timeout") : finish("cancelled", [], reason),
253
+ }
254
+ )
255
+ .then(async (handle) => {
256
+ server = handle;
257
+ url = handle.url;
258
+
259
+ const activeSessions = getActiveSessions();
260
+ const otherActive = activeSessions.filter((s) => s.id !== sessionId);
261
+
262
+ if (otherActive.length > 0) {
263
+ const active = otherActive[0];
264
+ const queuedLines = [
265
+ "Interview already active in browser:",
266
+ ` Title: ${active.title}`,
267
+ ` Project: ${active.cwd}${active.gitBranch ? ` (${active.gitBranch})` : ""}`,
268
+ ` Session: ${active.id.slice(0, 8)}`,
269
+ ` Started: ${formatTimeAgo(active.startedAt)}`,
270
+ "",
271
+ "New interview ready:",
272
+ ` Title: ${questionsData.title || "Interview"}`,
273
+ ];
274
+ const normalizedCwd = ctx.cwd.startsWith(os.homedir())
275
+ ? "~" + ctx.cwd.slice(os.homedir().length)
276
+ : ctx.cwd;
277
+ const gitBranch = (() => {
278
+ try {
279
+ return execSync("git rev-parse --abbrev-ref HEAD", {
280
+ cwd: ctx.cwd,
281
+ encoding: "utf8",
282
+ timeout: 2000,
283
+ stdio: ["pipe", "pipe", "pipe"],
284
+ }).trim() || null;
285
+ } catch {
286
+ return null;
287
+ }
288
+ })();
289
+ queuedLines.push(` Project: ${normalizedCwd}${gitBranch ? ` (${gitBranch})` : ""}`);
290
+ queuedLines.push(` Session: ${sessionId.slice(0, 8)}`);
291
+ queuedLines.push("");
292
+ queuedLines.push(`Open when ready: ${url}`);
293
+ queuedLines.push("");
294
+ queuedLines.push("Server waiting until you open the link.");
295
+ const queuedMessage = queuedLines.join("\n");
296
+ const queuedSummary = "Interview queued; see tool panel for link.";
297
+ if (onUpdate) {
298
+ onUpdate({
299
+ content: [{ type: "text", text: queuedSummary }],
300
+ details: { status: "queued", url, responses: [], queuedMessage },
301
+ });
302
+ } else if (pi.hasUI) {
303
+ pi.ui.notify(queuedSummary, "info");
304
+ }
305
+ } else {
306
+ try {
307
+ await openUrl(pi, url, settings.browser);
308
+ } catch (err) {
309
+ cleanup();
310
+ const message = err instanceof Error ? err.message : String(err);
311
+ reject(new Error(`Failed to open browser: ${message}`));
312
+ return;
313
+ }
314
+ }
315
+ })
316
+ .catch((err) => {
317
+ cleanup();
318
+ reject(err);
319
+ });
320
+ });
321
+ },
322
+
323
+ renderCall(args, theme) {
324
+ const { questions } = args as { questions?: string };
325
+ const label = questions ? `Interview: ${questions}` : "Interview";
326
+ return new Text(theme.fg("toolTitle", theme.bold(label)), 0, 0);
327
+ },
328
+
329
+ renderResult(result, _options, theme) {
330
+ const details = result.details as InterviewDetails | undefined;
331
+ if (!details) return new Text("Interview", 0, 0);
332
+
333
+ if (details.status === "queued" && details.queuedMessage) {
334
+ const header = theme.fg("warning", "QUEUED");
335
+ const body = theme.fg("dim", details.queuedMessage);
336
+ return new Text(`${header}\n${body}`, 0, 0);
337
+ }
338
+
339
+ const statusColor =
340
+ details.status === "completed"
341
+ ? "success"
342
+ : details.status === "cancelled"
343
+ ? "warning"
344
+ : details.status === "timeout"
345
+ ? "warning"
346
+ : details.status === "queued"
347
+ ? "warning"
348
+ : "error";
349
+
350
+ const line = `${details.status.toUpperCase()} (${details.responses.length} responses)`;
351
+ return new Text(theme.fg(statusColor, line), 0, 0);
352
+ },
353
+ });
354
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "pi-interview",
3
+ "version": "0.3.0",
4
+ "description": "Interactive interview form extension for pi coding agent",
5
+ "author": "Nico Bailon",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/nicobailon/pi-interview-tool.git"
10
+ },
11
+ "keywords": [
12
+ "pi",
13
+ "pi-coding-agent",
14
+ "interview",
15
+ "form",
16
+ "extension",
17
+ "cli"
18
+ ],
19
+ "bin": {
20
+ "pi-interview": "./bin/install.js"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "form/",
25
+ "index.ts",
26
+ "schema.ts",
27
+ "server.ts",
28
+ "settings.ts",
29
+ "README.md"
30
+ ]
31
+ }