-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathindex.ts
More file actions
611 lines (554 loc) · 20.7 KB
/
index.ts
File metadata and controls
611 lines (554 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import { Text } from "@mariozechner/pi-tui";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import * as path from "node:path";
import * as os from "node:os";
import * as fs from "node:fs";
import { randomUUID } from "node:crypto";
import { execSync, execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { startInterviewServer, getActiveSessions, type ResponseItem } from "./server.js";
import { validateQuestions, sanitizeLLMJSON, type QuestionsFile } from "./schema.js";
import { loadSettings, type InterviewThemeSettings } from "./settings.js";
interface GlimpseWindow {
on(event: "closed", handler: () => void): void;
close(): void;
}
let glimpseOpen: ((html: string, opts: Record<string, unknown>) => GlimpseWindow) | null | undefined;
function findGlimpseMjs(): string | null {
// Local node_modules
try {
const req = createRequire(import.meta.url);
return req.resolve("glimpseui");
} catch {}
// Global npm install
try {
const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8" }).trim();
const entry = path.join(globalRoot, "glimpseui", "src", "glimpse.mjs");
if (fs.existsSync(entry)) return entry;
} catch {}
return null;
}
async function getGlimpseOpen() {
if (glimpseOpen !== undefined) return glimpseOpen;
const resolved = findGlimpseMjs();
if (resolved) {
try {
glimpseOpen = (await import(resolved)).open;
return glimpseOpen;
} catch {}
}
glimpseOpen = null;
return glimpseOpen;
}
function escapeHtml(str: string): string {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
function openInGlimpse(
open: (html: string, opts: Record<string, unknown>) => GlimpseWindow,
url: string,
title?: string,
): GlimpseWindow {
const safeTitle = escapeHtml(title || "Interview");
const shellHTML = `<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>${safeTitle}</title></head>
<body style="margin:0; background:#1a1a2e;">
<script>window.location.replace(${JSON.stringify(url)});</script>
</body>
</html>`;
return open(shellHTML, {
width: 800,
height: 700,
title: title || "Interview",
});
}
function formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 0) return "just now";
if (seconds < 60) return `${seconds} seconds ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
const hours = Math.floor(minutes / 60);
return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
}
async function openUrl(pi: ExtensionAPI, url: string, browser?: string): Promise<void> {
const platform = os.platform();
let result;
if (platform === "darwin") {
if (browser) {
result = await pi.exec("open", ["-a", browser, url]);
} else {
result = await pi.exec("open", [url]);
}
} else if (platform === "win32") {
if (browser) {
result = await pi.exec("cmd", ["/c", "start", "", browser, url]);
} else {
result = await pi.exec("cmd", ["/c", "start", "", url]);
}
} else {
if (browser) {
result = await pi.exec(browser, [url]);
} else {
result = await pi.exec("xdg-open", [url]);
}
}
if (result.code !== 0) {
throw new Error(result.stderr || `Failed to open browser (exit code ${result.code})`);
}
}
interface InterviewDetails {
status: "completed" | "cancelled" | "timeout" | "aborted" | "queued";
responses: ResponseItem[];
url: string;
queuedMessage?: string;
}
// Types for saved interviews
interface SavedFromMeta {
cwd: string;
branch: string | null;
sessionId: string;
}
interface SavedQuestionsFile extends QuestionsFile {
savedAnswers?: ResponseItem[];
savedAt?: string;
wasSubmitted?: boolean;
savedFrom?: SavedFromMeta;
}
const InterviewParams = Type.Object({
questions: Type.String({ description: "Inline JSON string with questions, or path to a questions JSON / saved interview HTML file" }),
timeout: Type.Optional(
Type.Number({ description: "Seconds before auto-timeout", default: 600 })
),
verbose: Type.Optional(Type.Boolean({ description: "Enable debug logging", default: false })),
theme: Type.Optional(
Type.Object(
{
mode: Type.Optional(StringEnum(["auto", "light", "dark"])),
name: Type.Optional(Type.String()),
lightPath: Type.Optional(Type.String()),
darkPath: Type.Optional(Type.String()),
toggleHotkey: Type.Optional(Type.String()),
},
{ additionalProperties: false }
)
),
});
function expandHome(value: string): string {
if (value === "~") {
return os.homedir();
}
// Handle both Unix (/) and Windows (\) separators for user convenience
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(os.homedir(), value.slice(2));
}
return value;
}
function resolveOptionalPath(value: string | undefined, cwd: string): string | undefined {
if (!value) return undefined;
const expanded = expandHome(value);
return path.isAbsolute(expanded) ? expanded : path.join(cwd, expanded);
}
const DEFAULT_THEME_HOTKEY = "mod+shift+l";
function mergeThemeConfig(
base: InterviewThemeSettings | undefined,
override: InterviewThemeSettings | undefined,
cwd: string
): InterviewThemeSettings {
const merged: InterviewThemeSettings = { ...(base ?? {}), ...(override ?? {}) };
return {
...merged,
toggleHotkey: merged.toggleHotkey ?? DEFAULT_THEME_HOTKEY,
lightPath: resolveOptionalPath(merged.lightPath, cwd),
darkPath: resolveOptionalPath(merged.darkPath, cwd),
};
}
function loadQuestions(questionsInput: string, cwd: string): SavedQuestionsFile {
const trimmed = questionsInput.trimStart();
const looksLikeInlineJSON =
trimmed.startsWith("{") ||
/^`{3,}(?:json|jsonc)?\s*\n?\s*\{/i.test(trimmed);
if (looksLikeInlineJSON) {
let data: unknown;
try {
data = JSON.parse(trimmed);
} catch {
try {
data = JSON.parse(sanitizeLLMJSON(trimmed));
} catch (repairErr) {
const message = repairErr instanceof Error ? repairErr.message : String(repairErr);
throw new Error(`Invalid inline JSON: ${message}`);
}
}
return validateQuestions(data);
}
const expanded = expandHome(questionsInput);
const absolutePath = path.isAbsolute(expanded)
? expanded
: path.join(cwd, questionsInput);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Questions file not found: ${absolutePath}`);
}
const content = fs.readFileSync(absolutePath, "utf-8");
// Handle HTML files (saved interviews)
if (absolutePath.endsWith(".html") || absolutePath.endsWith(".htm")) {
return loadSavedInterview(content, absolutePath);
}
// Original JSON handling
let data: unknown;
try {
data = JSON.parse(content);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Invalid JSON in questions file: ${message}`);
}
return validateQuestions(data);
}
function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
// Extract JSON from <script id="pi-interview-data">
const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
if (!match) {
throw new Error("Invalid saved interview: missing embedded data");
}
let data: unknown;
try {
data = JSON.parse(match[1]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Invalid saved interview: malformed JSON (${message})`);
}
const raw = data as Record<string, unknown>;
const validated = validateQuestions(data);
// Resolve relative image paths to absolute based on HTML file location
const snapshotDir = path.dirname(filePath);
const savedAnswers = Array.isArray(raw.savedAnswers)
? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir)
: undefined;
// Validate savedFrom if present
let savedFrom: SavedFromMeta | undefined;
if (raw.savedFrom && typeof raw.savedFrom === "object") {
const sf = raw.savedFrom as Record<string, unknown>;
if (typeof sf.cwd === "string" && typeof sf.sessionId === "string") {
savedFrom = {
cwd: sf.cwd,
branch: typeof sf.branch === "string" ? sf.branch : null,
sessionId: sf.sessionId,
};
}
}
// Return validated questions plus saved interview metadata
return {
...validated,
savedAnswers,
savedAt: typeof raw.savedAt === "string" ? raw.savedAt : undefined,
wasSubmitted: typeof raw.wasSubmitted === "boolean" ? raw.wasSubmitted : undefined,
savedFrom,
};
}
function resolveAnswerPaths(answers: ResponseItem[], baseDir: string): ResponseItem[] {
return answers.map((ans) => ({
...ans,
value: resolvePathValue(ans.value, baseDir),
attachments: ans.attachments?.map((p) => resolveImagePath(p, baseDir)),
}));
}
function resolveImagePath(p: string, baseDir: string): string {
if (!p) return p;
// Skip URLs
if (p.includes("://")) return p;
// Expand ~ first
const expanded = expandHome(p);
// Don't resolve if already absolute (cross-platform check)
if (path.isAbsolute(expanded)) {
return expanded;
}
// Resolve relative path against snapshot directory
return path.join(baseDir, p);
}
function resolvePathValue(value: string | string[], baseDir: string): string | string[] {
if (Array.isArray(value)) {
return value.map((v) => resolveImagePath(v, baseDir));
}
return typeof value === "string" && value ? resolveImagePath(value, baseDir) : value;
}
function formatResponses(responses: ResponseItem[]): string {
if (responses.length === 0) return "(none)";
return responses
.map((resp) => {
const value = Array.isArray(resp.value) ? resp.value.join(", ") : resp.value;
let line = `- ${resp.id}: ${value}`;
if (resp.attachments && resp.attachments.length > 0) {
line += ` [attachments: ${resp.attachments.join(", ")}]`;
}
return line;
})
.join("\n");
}
function hasAnyAnswers(responses: ResponseItem[]): boolean {
if (!responses || responses.length === 0) return false;
return responses.some((resp) => {
if (!resp || resp.value == null) return false;
if (Array.isArray(resp.value)) {
return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
}
return typeof resp.value === "string" && resp.value.trim() !== "";
});
}
function filterAnsweredResponses(responses: ResponseItem[]): ResponseItem[] {
if (!responses) return [];
return responses.filter((resp) => {
if (!resp || resp.value == null) return false;
if (Array.isArray(resp.value)) {
return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
}
return typeof resp.value === "string" && resp.value.trim() !== "";
});
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "interview",
label: "Interview",
description:
"Present an interactive form to gather user responses. " +
"On macOS, opens in a native window (Glimpse); falls back to a browser tab elsewhere. " +
"Use proactively when: choosing between multiple approaches, gathering requirements before implementation, " +
"exploring design tradeoffs, or when decisions have multiple dimensions worth discussing. " +
"Provides better UX than back-and-forth chat for structured input. " +
"Image responses and attachments are returned as file paths - use read tool directly to display them. " +
"Pass questions as inline JSON string directly (preferred) or as a path to a JSON file. " +
'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", ... } }] }. ' +
"Options can be strings or objects: { label: string, code?: { code, lang?, file?, lines?, highlights? } }. " +
"Always set recommended with context explaining your reasoning. Recommended options show a 'Recommended' badge and are pre-selected for the user. " +
'Use conviction: "slight" when unsure (does NOT pre-select), conviction: "strong" when very confident (shows Recommended badge). ' +
"Omit conviction for normal recommendations (pre-selects). " +
'Use weight: "critical" for key decisions (visually prominent), weight: "minor" for low-stakes questions (compact card). ' +
"When questions have recommendations, set description to guide review (e.g., 'Review my suggestions and adjust as needed'). " +
"Questions can have a codeBlock field to display code above options. Types: single (radio), multi (checkbox), text (textarea), image (file upload), info (non-interactive). " +
'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 }. ' +
"Info type is a non-interactive content panel for displaying context with media. Media position: above (default), below, side (two-column).",
parameters: InterviewParams,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const { questions, timeout, verbose, theme } = params as {
questions: string;
timeout?: number;
verbose?: boolean;
theme?: InterviewThemeSettings;
};
if (!ctx.hasUI) {
throw new Error(
"Interview tool requires interactive mode. " +
"Cannot run in headless/RPC/print mode."
);
}
if (typeof ctx.hasQueuedMessages === "function" && ctx.hasQueuedMessages()) {
return {
content: [{ type: "text", text: "Interview skipped - user has queued input." }],
details: { status: "cancelled", url: "", responses: [] },
};
}
const settings = loadSettings();
const timeoutSeconds = timeout ?? settings.timeout ?? 600;
const themeConfig = mergeThemeConfig(settings.theme, theme, ctx.cwd);
const questionsData = loadQuestions(questions, ctx.cwd);
// Expand ~ in snapshotDir if present
const snapshotDir = settings.snapshotDir
? expandHome(settings.snapshotDir)
: undefined; // Server will use default
if (signal?.aborted) {
return {
content: [{ type: "text", text: "Interview was aborted." }],
details: { status: "aborted", url: "", responses: [] },
};
}
const sessionId = randomUUID();
const sessionToken = randomUUID();
let server: { close: () => void } | null = null;
let glimpseWin: GlimpseWindow | null = null;
let resolved = false;
let url = "";
const cleanup = () => {
if (server) {
server.close();
server = null;
}
};
return new Promise((resolve, reject) => {
const finish = (
status: InterviewDetails["status"],
responses: ResponseItem[] = [],
cancelReason?: "timeout" | "user" | "stale"
) => {
if (resolved) return;
resolved = true;
cleanup();
let text = "";
if (status === "completed") {
text = `User completed the interview form.\n\nResponses:\n${formatResponses(responses)}`;
} else if (status === "cancelled") {
if (cancelReason === "stale") {
text =
"Interview session ended due to lost heartbeat.\n\nQuestions saved to: ~/.pi/interview-recovery/";
} else if (hasAnyAnswers(responses)) {
const answered = filterAnsweredResponses(responses);
text = `User cancelled the interview with partial responses:\n${formatResponses(answered)}\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
} else {
text = "User skipped the interview without providing answers. Proceed with your best judgment - use recommended options where specified, make reasonable choices elsewhere. Don't ask for clarification unless absolutely necessary.";
}
} else if (status === "timeout") {
if (hasAnyAnswers(responses)) {
const answered = filterAnsweredResponses(responses);
text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nPartial responses before timeout:\n${formatResponses(answered)}\n\nQuestions saved to: ~/.pi/interview-recovery/\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
} else {
text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nQuestions saved to: ~/.pi/interview-recovery/`;
}
} else {
text = "Interview was aborted.";
}
resolve({
content: [{ type: "text", text }],
details: { status, url, responses },
});
};
const handleAbort = () => {
if (glimpseWin) {
try { glimpseWin.close(); } catch {}
glimpseWin = null;
}
finish("aborted");
};
signal?.addEventListener("abort", handleAbort, { once: true });
startInterviewServer(
{
questions: questionsData,
sessionToken,
sessionId,
cwd: ctx.cwd,
timeout: timeoutSeconds,
port: settings.port,
verbose,
theme: themeConfig,
snapshotDir,
autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
savedAnswers: questionsData.savedAnswers,
},
{
onSubmit: (responses) => finish("completed", responses),
onCancel: (reason, partialResponses) =>
reason === "timeout"
? finish("timeout", partialResponses ?? [])
: finish("cancelled", partialResponses ?? [], reason),
}
)
.then(async (handle) => {
if (resolved) {
handle.close();
return;
}
server = handle;
url = handle.url;
const activeSessions = getActiveSessions();
const otherActive = activeSessions.filter((s) => s.id !== sessionId);
if (otherActive.length > 0) {
const active = otherActive[0];
const queuedLines = [
"Interview already active:",
` Title: ${active.title}`,
` Project: ${active.cwd}${active.gitBranch ? ` (${active.gitBranch})` : ""}`,
` Session: ${active.id.slice(0, 8)}`,
` Started: ${formatTimeAgo(active.startedAt)}`,
"",
"New interview ready:",
` Title: ${questionsData.title || "Interview"}`,
];
const normalizedCwd = ctx.cwd.startsWith(os.homedir())
? "~" + ctx.cwd.slice(os.homedir().length)
: ctx.cwd;
const gitBranch = (() => {
try {
return execSync("git rev-parse --abbrev-ref HEAD", {
cwd: ctx.cwd,
encoding: "utf8",
timeout: 2000,
stdio: ["pipe", "pipe", "pipe"],
}).trim() || null;
} catch {
return null;
}
})();
queuedLines.push(` Project: ${normalizedCwd}${gitBranch ? ` (${gitBranch})` : ""}`);
queuedLines.push(` Session: ${sessionId.slice(0, 8)}`);
queuedLines.push("");
queuedLines.push(`Open when ready: ${url}`);
queuedLines.push("");
queuedLines.push("Server waiting until you open the link.");
const queuedMessage = queuedLines.join("\n");
const queuedSummary = "Interview queued; see tool panel for link.";
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: queuedSummary }],
details: { status: "queued", url, responses: [], queuedMessage },
});
} else if (pi.hasUI) {
pi.ui.notify(queuedSummary, "info");
}
} else {
const glimpseOpenFn = os.platform() === "darwin" ? await getGlimpseOpen() : null;
if (glimpseOpenFn) {
try {
glimpseWin = openInGlimpse(glimpseOpenFn, url, questionsData.title || "Interview");
glimpseWin.on("closed", () => {
glimpseWin = null;
if (!resolved) {
finish("cancelled", [], "user");
}
});
return;
} catch {
glimpseWin = null;
}
}
try {
await openUrl(pi, url, settings.browser);
} catch (err) {
cleanup();
const message = err instanceof Error ? err.message : String(err);
reject(new Error(`Failed to open browser: ${message}`));
}
}
})
.catch((err) => {
cleanup();
reject(err);
});
});
},
renderCall(args, theme) {
const { questions } = args as { questions?: string };
const label = questions ? `Interview: ${questions}` : "Interview";
return new Text(theme.fg("toolTitle", theme.bold(label)), 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as InterviewDetails | undefined;
if (!details) return new Text("Interview", 0, 0);
if (details.status === "queued" && details.queuedMessage) {
const header = theme.fg("warning", "QUEUED");
const body = theme.fg("dim", details.queuedMessage);
return new Text(`${header}\n${body}`, 0, 0);
}
const statusColor =
details.status === "completed"
? "success"
: details.status === "cancelled"
? "warning"
: details.status === "timeout"
? "warning"
: details.status === "queued"
? "warning"
: "error";
const line = `${details.status.toUpperCase()} (${details.responses.length} responses)`;
return new Text(theme.fg(statusColor, line), 0, 0);
},
});
}