pi-interview 0.3.1 → 0.4.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.
package/README.md CHANGED
@@ -32,6 +32,7 @@ The tool is automatically discovered on next pi session. No build step required.
32
32
  - **Multi-Agent Support**: Queue detection prevents focus stealing when multiple agents run interviews
33
33
  - **Queue Toast Switcher**: Active interviews show a top-right toast with a dropdown to open queued sessions
34
34
  - **Session Recovery**: Abandoned/timed-out interviews save questions for later retry
35
+ - **Save Snapshots**: Save interview state to HTML for later review or revival
35
36
  - **Session Status Bar**: Shows project path, git branch, and session ID for identification
36
37
  - **Image Support**: Drag & drop anywhere on question, file picker, paste image or path
37
38
  - **Path Normalization**: Handles shell-escaped paths (`\ `) and macOS screenshot filenames (narrow no-break space before AM/PM)
@@ -205,6 +206,8 @@ Settings in `~/.pi/agent/settings.json`:
205
206
  "interview": {
206
207
  "timeout": 600,
207
208
  "port": 19847,
209
+ "snapshotDir": "~/.pi/interview-snapshots/",
210
+ "autoSaveOnSubmit": true,
208
211
  "theme": {
209
212
  "mode": "auto",
210
213
  "name": "default",
@@ -218,6 +221,10 @@ Settings in `~/.pi/agent/settings.json`:
218
221
 
219
222
  **Timeout precedence**: params > settings > default (600s)
220
223
 
224
+ **Snapshot settings:**
225
+ - `snapshotDir`: Directory for saved interview snapshots (default: `~/.pi/interview-snapshots/`)
226
+ - `autoSaveOnSubmit`: Automatically save snapshot on successful submit (default: `true`)
227
+
221
228
  **Port setting**: Set a fixed `port` (e.g., `19847`) to use a consistent port across sessions.
222
229
 
223
230
  **Theme notes:**
@@ -338,6 +345,45 @@ If an interview times out or is abandoned (tab closed, lost connection), the que
338
345
  interview({ questions: "~/.pi/interview-recovery/2026-01-02_093000_myproject_main_65bec3f4.json" })
339
346
  ```
340
347
 
348
+ ## Saving Interviews
349
+
350
+ Save a snapshot of your interview at any time for later review or to resume.
351
+
352
+ **Manual Save:**
353
+ - Click the Save button (header or footer)
354
+ - Saves to `~/.pi/interview-snapshots/` by default
355
+ - Creates folder with `index.html` + `images/` subfolder
356
+
357
+ **Auto-save on Submit:**
358
+ - Enabled by default (`autoSaveOnSubmit: true` in settings)
359
+ - Automatically saves after successful submission
360
+ - Folder name includes `-submitted` suffix
361
+
362
+ **Reviving a Saved Interview:**
363
+ ```javascript
364
+ interview({ questions: "~/.pi/interview-snapshots/project-setup-myapp-main-2026-01-20-141523/index.html" })
365
+ ```
366
+ The form opens with answers pre-populated. Edit and submit as normal.
367
+
368
+ **Configuration:**
369
+ ```json
370
+ {
371
+ "interview": {
372
+ "snapshotDir": "~/.pi/interview-snapshots/",
373
+ "autoSaveOnSubmit": true
374
+ }
375
+ }
376
+ ```
377
+
378
+ **Snapshot Structure:**
379
+ ```
380
+ ~/.pi/interview-snapshots/
381
+ {title}-{project}-{branch}-{timestamp}[-submitted]/
382
+ index.html # Human-readable + embedded JSON for revival
383
+ images/
384
+ mockup.png # Uploaded images (relative paths in HTML)
385
+ ```
386
+
341
387
  ## Limits
342
388
 
343
389
  - Max 12 images total per submission
package/form/index.html CHANGED
@@ -17,6 +17,7 @@
17
17
  <header class="interview-header">
18
18
  <div class="header-row">
19
19
  <h1 id="form-title"></h1>
20
+ <button type="button" class="save-btn-header" id="save-btn-header" title="Save snapshot">Save</button>
20
21
  </div>
21
22
  <p id="form-description"></p>
22
23
  </header>
@@ -27,6 +28,7 @@
27
28
  <div id="error-container" aria-live="polite" class="error-message hidden"></div>
28
29
 
29
30
  <footer class="form-footer">
31
+ <button type="button" id="save-btn-footer" class="btn-secondary">Save</button>
30
32
  <button type="submit" id="submit-btn" class="btn-primary">Submit</button>
31
33
  </footer>
32
34
  </form>
@@ -109,6 +111,8 @@
109
111
  </div>
110
112
  </div>
111
113
  </div>
114
+
115
+ <div id="save-toast" class="save-toast hidden" aria-live="polite"></div>
112
116
  </main>
113
117
 
114
118
  <script>
package/form/script.js CHANGED
@@ -323,7 +323,8 @@
323
323
  function sendCancelBeacon(reason) {
324
324
  if (session.cancelSent || session.ended) return;
325
325
  session.cancelSent = true;
326
- const payload = JSON.stringify({ token: sessionToken, reason });
326
+ const responses = collectResponses();
327
+ const payload = JSON.stringify({ token: sessionToken, reason, responses });
327
328
  if (navigator.sendBeacon) {
328
329
  const blob = new Blob([payload], { type: "application/json" });
329
330
  navigator.sendBeacon("/cancel", blob);
@@ -343,11 +344,12 @@
343
344
  session.cancelSent = true;
344
345
  stopHeartbeat();
345
346
  stopQueuePolling();
347
+ const responses = collectResponses();
346
348
  try {
347
349
  await fetch("/cancel", {
348
350
  method: "POST",
349
351
  headers: { "Content-Type": "application/json" },
350
- body: JSON.stringify({ token: sessionToken, reason }),
352
+ body: JSON.stringify({ token: sessionToken, reason, responses }),
351
353
  });
352
354
  } catch (_err) {}
353
355
  }
@@ -1968,6 +1970,65 @@
1968
1970
  }
1969
1971
  }
1970
1972
 
1973
+ // Set storage key without loading (for revival from saved interview)
1974
+ async function initStorageKeyOnly() {
1975
+ try {
1976
+ const hash = await hashQuestions();
1977
+ session.storageKey = `pi-interview-${hash}`;
1978
+ } catch (_err) {
1979
+ session.storageKey = null;
1980
+ }
1981
+ }
1982
+
1983
+ // Pre-populate form from saved interview answers
1984
+ function populateFromSavedAnswers(savedAnswers) {
1985
+ // Convert ResponseItem[] to Record for existing populateForm()
1986
+ const valueMap = {};
1987
+ savedAnswers.forEach((ans) => {
1988
+ const question = questions.find((q) => q.id === ans.id);
1989
+ if (question?.type !== "image") {
1990
+ valueMap[ans.id] = ans.value;
1991
+ }
1992
+ });
1993
+ populateForm(valueMap);
1994
+
1995
+ // Restore attachments to attachPathState
1996
+ savedAnswers.forEach((ans) => {
1997
+ if (ans.attachments && ans.attachments.length > 0) {
1998
+ attachPathState.set(ans.id, [...ans.attachments]);
1999
+ attachments.render(ans.id);
2000
+ const panel = document.querySelector(
2001
+ `[data-attach-inline-for="${escapeSelector(ans.id)}"]`
2002
+ );
2003
+ if (panel) panel.classList.remove("hidden");
2004
+ const btn = document.querySelector(
2005
+ `.attach-btn[data-question-id="${escapeSelector(ans.id)}"]`
2006
+ );
2007
+ if (btn) btn.classList.add("has-attachment");
2008
+ }
2009
+ });
2010
+
2011
+ // Restore image paths for image-type questions
2012
+ savedAnswers.forEach((ans) => {
2013
+ const question = questions.find((q) => q.id === ans.id);
2014
+ if (question?.type === "image" && ans.value) {
2015
+ const paths = Array.isArray(ans.value) ? ans.value : [ans.value];
2016
+ const validPaths = paths.filter((p) => typeof p === "string" && p);
2017
+ if (validPaths.length > 0) {
2018
+ imagePathState.set(ans.id, validPaths);
2019
+ questionImages.render(ans.id);
2020
+ }
2021
+ }
2022
+ });
2023
+
2024
+ // Update done states for multi-select
2025
+ questions.forEach((q) => {
2026
+ if (q.type === "multi") {
2027
+ updateDoneState(q.id);
2028
+ }
2029
+ });
2030
+ }
2031
+
1971
2032
  function readFileBase64(file) {
1972
2033
  return new Promise((resolve, reject) => {
1973
2034
  const reader = new FileReader();
@@ -2020,6 +2081,54 @@
2020
2081
  return { responses, images };
2021
2082
  }
2022
2083
 
2084
+ // Save interview snapshot
2085
+ async function saveInterview(options = {}) {
2086
+ const { submitted = false } = options;
2087
+
2088
+ try {
2089
+ const payload = await buildPayload();
2090
+ const response = await fetch("/save", {
2091
+ method: "POST",
2092
+ headers: { "Content-Type": "application/json" },
2093
+ body: JSON.stringify({
2094
+ token: sessionToken,
2095
+ responses: payload.responses,
2096
+ images: payload.images,
2097
+ submitted,
2098
+ }),
2099
+ });
2100
+ const result = await response.json();
2101
+ if (result.ok) {
2102
+ showSaveSuccess(result.relativePath);
2103
+ return true;
2104
+ } else {
2105
+ if (!submitted) showSaveError(result.error);
2106
+ return false;
2107
+ }
2108
+ } catch (err) {
2109
+ if (!submitted) {
2110
+ showSaveError("Failed to save interview");
2111
+ }
2112
+ return false;
2113
+ }
2114
+ }
2115
+
2116
+ function showSaveSuccess(savePath) {
2117
+ const toast = document.getElementById("save-toast");
2118
+ if (!toast) return;
2119
+ toast.textContent = `Saved to ${savePath}`;
2120
+ toast.className = "save-toast success";
2121
+ setTimeout(() => toast.classList.add("hidden"), 3000);
2122
+ }
2123
+
2124
+ function showSaveError(message) {
2125
+ const toast = document.getElementById("save-toast");
2126
+ if (!toast) return;
2127
+ toast.textContent = message || "Save failed";
2128
+ toast.className = "save-toast error";
2129
+ setTimeout(() => toast.classList.add("hidden"), 3000);
2130
+ }
2131
+
2023
2132
  async function submitForm(event) {
2024
2133
  event.preventDefault();
2025
2134
  clearGlobalError();
@@ -2035,18 +2144,24 @@
2035
2144
  body: JSON.stringify({ token: sessionToken, ...payload }),
2036
2145
  });
2037
2146
 
2038
- const data = await response.json().catch(() => ({ ok: false, error: "Invalid server response" }));
2147
+ const result = await response.json().catch(() => ({ ok: false, error: "Invalid server response" }));
2039
2148
 
2040
- if (!response.ok || !data.ok) {
2041
- if (data.field) {
2042
- setFieldError(data.field, data.error || "Invalid input");
2149
+ if (!response.ok || !result.ok) {
2150
+ if (result.field) {
2151
+ setFieldError(result.field, result.error || "Invalid input");
2043
2152
  } else {
2044
- showGlobalError(data.error || "Submission failed.");
2153
+ showGlobalError(result.error || "Submission failed.");
2045
2154
  }
2046
2155
  submitBtn.disabled = false;
2047
2156
  return;
2048
2157
  }
2049
2158
 
2159
+ // Auto-save on successful submit (fire-and-forget)
2160
+ // Note: data is window.__INTERVIEW_DATA__, result is server response
2161
+ if (data.autoSaveOnSubmit !== false) {
2162
+ saveInterview({ submitted: true });
2163
+ }
2164
+
2050
2165
  clearProgress();
2051
2166
  stopHeartbeat();
2052
2167
  stopQueuePolling();
@@ -2096,11 +2211,28 @@
2096
2211
  containerEl.appendChild(createQuestionCard(question, index));
2097
2212
  });
2098
2213
 
2099
- initStorage();
2214
+ // Pre-populate: savedAnswers takes precedence over localStorage
2215
+ if (data.savedAnswers && Array.isArray(data.savedAnswers)) {
2216
+ populateFromSavedAnswers(data.savedAnswers);
2217
+ initStorageKeyOnly();
2218
+ } else {
2219
+ initStorage();
2220
+ }
2221
+
2100
2222
  startHeartbeat();
2101
2223
  startQueuePolling();
2102
2224
 
2103
2225
  formEl.addEventListener("submit", submitForm);
2226
+
2227
+ // Wire up save buttons
2228
+ const saveBtnHeader = document.getElementById("save-btn-header");
2229
+ const saveBtnFooter = document.getElementById("save-btn-footer");
2230
+ if (saveBtnHeader) {
2231
+ saveBtnHeader.addEventListener("click", () => saveInterview());
2232
+ }
2233
+ if (saveBtnFooter) {
2234
+ saveBtnFooter.addEventListener("click", () => saveInterview());
2235
+ }
2104
2236
  if (queueToastClose) {
2105
2237
  queueToastClose.addEventListener("click", () => {
2106
2238
  queueState.dismissed = true;
package/form/styles.css CHANGED
@@ -1409,3 +1409,62 @@ button {
1409
1409
  margin-bottom: 1rem;
1410
1410
  }
1411
1411
 
1412
+ /* Save button in header */
1413
+ .save-btn-header {
1414
+ background: transparent;
1415
+ border: 1px solid var(--border-muted);
1416
+ color: var(--fg-muted);
1417
+ padding: 4px 8px;
1418
+ font-size: 11px;
1419
+ border-radius: var(--radius);
1420
+ cursor: pointer;
1421
+ transition: border-color 150ms ease, color 150ms ease;
1422
+ font-family: var(--font-ui);
1423
+ }
1424
+
1425
+ .save-btn-header:hover {
1426
+ border-color: var(--fg-dim);
1427
+ color: var(--fg);
1428
+ }
1429
+
1430
+ .save-btn-header:focus {
1431
+ outline: none;
1432
+ border-color: var(--border-focus);
1433
+ }
1434
+
1435
+ /* Save toast notification */
1436
+ .save-toast {
1437
+ position: fixed;
1438
+ bottom: 80px;
1439
+ left: 50%;
1440
+ transform: translateX(-50%);
1441
+ background: var(--bg-elevated);
1442
+ border: 1px solid var(--border-muted);
1443
+ border-radius: var(--radius);
1444
+ padding: 10px 16px;
1445
+ font-size: 12px;
1446
+ z-index: 150;
1447
+ animation: toast-in 200ms ease-out;
1448
+ }
1449
+
1450
+ .save-toast.success {
1451
+ border-color: var(--success);
1452
+ color: var(--success);
1453
+ }
1454
+
1455
+ .save-toast.error {
1456
+ border-color: var(--error);
1457
+ color: var(--error);
1458
+ }
1459
+
1460
+ @keyframes toast-in {
1461
+ from {
1462
+ opacity: 0;
1463
+ transform: translateX(-50%) translateY(10px);
1464
+ }
1465
+ to {
1466
+ opacity: 1;
1467
+ transform: translateX(-50%) translateY(0);
1468
+ }
1469
+ }
1470
+
package/index.ts CHANGED
@@ -54,8 +54,22 @@ interface InterviewDetails {
54
54
  queuedMessage?: string;
55
55
  }
56
56
 
57
+ // Types for saved interviews
58
+ interface SavedFromMeta {
59
+ cwd: string;
60
+ branch: string | null;
61
+ sessionId: string;
62
+ }
63
+
64
+ interface SavedQuestionsFile extends QuestionsFile {
65
+ savedAnswers?: ResponseItem[];
66
+ savedAt?: string;
67
+ wasSubmitted?: boolean;
68
+ savedFrom?: SavedFromMeta;
69
+ }
70
+
57
71
  const InterviewParams = Type.Object({
58
- questions: Type.String({ description: "Path to questions JSON file" }),
72
+ questions: Type.String({ description: "Path to questions JSON or saved interview HTML file" }),
59
73
  timeout: Type.Optional(
60
74
  Type.Number({ description: "Seconds before auto-timeout", default: 600 })
61
75
  ),
@@ -63,7 +77,7 @@ const InterviewParams = Type.Object({
63
77
  theme: Type.Optional(
64
78
  Type.Object(
65
79
  {
66
- mode: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("light"), Type.Literal("dark")])),
80
+ mode: Type.Optional(Type.String({ description: "Theme mode: 'auto', 'light', or 'dark'" })),
67
81
  name: Type.Optional(Type.String()),
68
82
  lightPath: Type.Optional(Type.String()),
69
83
  darkPath: Type.Optional(Type.String()),
@@ -75,7 +89,11 @@ const InterviewParams = Type.Object({
75
89
  });
76
90
 
77
91
  function expandHome(value: string): string {
78
- if (value.startsWith("~" + path.sep)) {
92
+ if (value === "~") {
93
+ return os.homedir();
94
+ }
95
+ // Handle both Unix (/) and Windows (\) separators for user convenience
96
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
79
97
  return path.join(os.homedir(), value.slice(2));
80
98
  }
81
99
  return value;
@@ -103,18 +121,27 @@ function mergeThemeConfig(
103
121
  };
104
122
  }
105
123
 
106
- function loadQuestions(questionsPath: string, cwd: string): QuestionsFile {
107
- const absolutePath = path.isAbsolute(questionsPath)
108
- ? questionsPath
109
- : path.join(cwd, questionsPath);
124
+ function loadQuestions(questionsPath: string, cwd: string): SavedQuestionsFile {
125
+ // Expand ~ first, then check if absolute
126
+ const expanded = expandHome(questionsPath);
127
+ const absolutePath = path.isAbsolute(expanded)
128
+ ? expanded
129
+ : path.join(cwd, questionsPath); // Use original if relative (no ~)
110
130
 
111
131
  if (!fs.existsSync(absolutePath)) {
112
132
  throw new Error(`Questions file not found: ${absolutePath}`);
113
133
  }
114
134
 
135
+ const content = fs.readFileSync(absolutePath, "utf-8");
136
+
137
+ // Handle HTML files (saved interviews)
138
+ if (absolutePath.endsWith(".html") || absolutePath.endsWith(".htm")) {
139
+ return loadSavedInterview(content, absolutePath);
140
+ }
141
+
142
+ // Original JSON handling
115
143
  let data: unknown;
116
144
  try {
117
- const content = fs.readFileSync(absolutePath, "utf-8");
118
145
  data = JSON.parse(content);
119
146
  } catch (err) {
120
147
  const message = err instanceof Error ? err.message : String(err);
@@ -124,6 +151,81 @@ function loadQuestions(questionsPath: string, cwd: string): QuestionsFile {
124
151
  return validateQuestions(data);
125
152
  }
126
153
 
154
+ function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
155
+ // Extract JSON from <script id="pi-interview-data">
156
+ const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
157
+ if (!match) {
158
+ throw new Error("Invalid saved interview: missing embedded data");
159
+ }
160
+
161
+ let data: unknown;
162
+ try {
163
+ data = JSON.parse(match[1]);
164
+ } catch {
165
+ throw new Error("Invalid saved interview: malformed JSON");
166
+ }
167
+
168
+ const raw = data as Record<string, unknown>;
169
+ const validated = validateQuestions(data);
170
+
171
+ // Resolve relative image paths to absolute based on HTML file location
172
+ const snapshotDir = path.dirname(filePath);
173
+ const savedAnswers = Array.isArray(raw.savedAnswers)
174
+ ? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir)
175
+ : undefined;
176
+
177
+ // Validate savedFrom if present
178
+ let savedFrom: SavedFromMeta | undefined;
179
+ if (raw.savedFrom && typeof raw.savedFrom === "object") {
180
+ const sf = raw.savedFrom as Record<string, unknown>;
181
+ if (typeof sf.cwd === "string" && typeof sf.sessionId === "string") {
182
+ savedFrom = {
183
+ cwd: sf.cwd,
184
+ branch: typeof sf.branch === "string" ? sf.branch : null,
185
+ sessionId: sf.sessionId,
186
+ };
187
+ }
188
+ }
189
+
190
+ // Return validated questions plus saved interview metadata
191
+ return {
192
+ ...validated,
193
+ savedAnswers,
194
+ savedAt: typeof raw.savedAt === "string" ? raw.savedAt : undefined,
195
+ wasSubmitted: typeof raw.wasSubmitted === "boolean" ? raw.wasSubmitted : undefined,
196
+ savedFrom,
197
+ };
198
+ }
199
+
200
+ function resolveAnswerPaths(answers: ResponseItem[], baseDir: string): ResponseItem[] {
201
+ return answers.map((ans) => ({
202
+ ...ans,
203
+ value: resolvePathValue(ans.value, baseDir),
204
+ attachments: ans.attachments?.map((p) => resolveImagePath(p, baseDir)),
205
+ }));
206
+ }
207
+
208
+ function resolveImagePath(p: string, baseDir: string): string {
209
+ if (!p) return p;
210
+ // Skip URLs
211
+ if (p.includes("://")) return p;
212
+ // Expand ~ first
213
+ const expanded = expandHome(p);
214
+ // Don't resolve if already absolute (cross-platform check)
215
+ if (path.isAbsolute(expanded)) {
216
+ return expanded;
217
+ }
218
+ // Resolve relative path against snapshot directory
219
+ return path.join(baseDir, p);
220
+ }
221
+
222
+ function resolvePathValue(value: string | string[], baseDir: string): string | string[] {
223
+ if (Array.isArray(value)) {
224
+ return value.map((v) => resolveImagePath(v, baseDir));
225
+ }
226
+ return typeof value === "string" && value ? resolveImagePath(value, baseDir) : value;
227
+ }
228
+
127
229
  function formatResponses(responses: ResponseItem[]): string {
128
230
  if (responses.length === 0) return "(none)";
129
231
  return responses
@@ -138,6 +240,28 @@ function formatResponses(responses: ResponseItem[]): string {
138
240
  .join("\n");
139
241
  }
140
242
 
243
+ function hasAnyAnswers(responses: ResponseItem[]): boolean {
244
+ if (!responses || responses.length === 0) return false;
245
+ return responses.some((resp) => {
246
+ if (!resp || resp.value == null) return false;
247
+ if (Array.isArray(resp.value)) {
248
+ return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
249
+ }
250
+ return typeof resp.value === "string" && resp.value.trim() !== "";
251
+ });
252
+ }
253
+
254
+ function filterAnsweredResponses(responses: ResponseItem[]): ResponseItem[] {
255
+ if (!responses) return [];
256
+ return responses.filter((resp) => {
257
+ if (!resp || resp.value == null) return false;
258
+ if (Array.isArray(resp.value)) {
259
+ return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
260
+ }
261
+ return typeof resp.value === "string" && resp.value.trim() !== "";
262
+ });
263
+ }
264
+
141
265
  export default function (pi: ExtensionAPI) {
142
266
  pi.registerTool({
143
267
  name: "interview",
@@ -180,6 +304,11 @@ export default function (pi: ExtensionAPI) {
180
304
  const themeConfig = mergeThemeConfig(settings.theme, theme, ctx.cwd);
181
305
  const questionsData = loadQuestions(questions, ctx.cwd);
182
306
 
307
+ // Expand ~ in snapshotDir if present
308
+ const snapshotDir = settings.snapshotDir
309
+ ? expandHome(settings.snapshotDir)
310
+ : undefined; // Server will use default
311
+
183
312
  if (signal?.aborted) {
184
313
  return {
185
314
  content: [{ type: "text", text: "Interview was aborted." }],
@@ -217,11 +346,19 @@ export default function (pi: ExtensionAPI) {
217
346
  if (cancelReason === "stale") {
218
347
  text =
219
348
  "Interview session ended due to lost heartbeat.\n\nQuestions saved to: ~/.pi/interview-recovery/";
349
+ } else if (hasAnyAnswers(responses)) {
350
+ const answered = filterAnsweredResponses(responses);
351
+ text = `User cancelled the interview with partial responses:\n${formatResponses(answered)}\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
220
352
  } else {
221
- text = "User cancelled the interview form.";
353
+ 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.";
222
354
  }
223
355
  } else if (status === "timeout") {
224
- text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nQuestions saved to: ~/.pi/interview-recovery/`;
356
+ if (hasAnyAnswers(responses)) {
357
+ const answered = filterAnsweredResponses(responses);
358
+ 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.`;
359
+ } else {
360
+ text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nQuestions saved to: ~/.pi/interview-recovery/`;
361
+ }
225
362
  } else {
226
363
  text = "Interview was aborted.";
227
364
  }
@@ -245,11 +382,16 @@ export default function (pi: ExtensionAPI) {
245
382
  port: settings.port,
246
383
  verbose,
247
384
  theme: themeConfig,
385
+ snapshotDir,
386
+ autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
387
+ savedAnswers: questionsData.savedAnswers,
248
388
  },
249
389
  {
250
390
  onSubmit: (responses) => finish("completed", responses),
251
- onCancel: (reason) =>
252
- reason === "timeout" ? finish("timeout") : finish("cancelled", [], reason),
391
+ onCancel: (reason, partialResponses) =>
392
+ reason === "timeout"
393
+ ? finish("timeout", partialResponses ?? [])
394
+ : finish("cancelled", partialResponses ?? [], reason),
253
395
  }
254
396
  )
255
397
  .then(async (handle) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/server.ts CHANGED
@@ -46,6 +46,7 @@ interface SessionsFile {
46
46
 
47
47
  const SESSIONS_FILE = join(homedir(), ".pi", "interview-sessions.json");
48
48
  const RECOVERY_DIR = join(homedir(), ".pi", "interview-recovery");
49
+ const SNAPSHOTS_DIR = join(homedir(), ".pi", "interview-snapshots");
49
50
  const STALE_THRESHOLD_MS = 30000;
50
51
  const STALE_PRUNE_MS = 60000;
51
52
  const RECOVERY_MAX_AGE_DAYS = 7;
@@ -192,11 +193,14 @@ export interface InterviewServerOptions {
192
193
  port?: number;
193
194
  verbose?: boolean;
194
195
  theme?: InterviewThemeConfig;
196
+ snapshotDir?: string;
197
+ autoSaveOnSubmit?: boolean;
198
+ savedAnswers?: ResponseItem[];
195
199
  }
196
200
 
197
201
  export interface InterviewServerCallbacks {
198
202
  onSubmit: (responses: ResponseItem[]) => void;
199
- onCancel: (reason?: "timeout" | "user" | "stale") => void;
203
+ onCancel: (reason?: "timeout" | "user" | "stale", partialResponses?: ResponseItem[]) => void;
200
204
  }
201
205
 
202
206
  export interface InterviewServerHandle {
@@ -310,7 +314,8 @@ async function parseJSONBody(req: IncomingMessage): Promise<unknown> {
310
314
 
311
315
  async function handleImageUpload(
312
316
  image: { id: string; filename: string; mimeType: string; data: string },
313
- sessionId: string
317
+ sessionId: string,
318
+ targetDir?: string
314
319
  ): Promise<string> {
315
320
  if (!ALLOWED_TYPES.includes(image.mimeType)) {
316
321
  throw new Error(`Invalid image type: ${image.mimeType}`);
@@ -322,7 +327,7 @@ async function handleImageUpload(
322
327
  }
323
328
 
324
329
  const sanitized = image.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
325
- const basename = sanitized.split(/[/\\]/).pop() || `image_${randomUUID()}`;
330
+ const fileBasename = sanitized.split(/[/\\]/).pop() || `image_${randomUUID()}`;
326
331
  const extMap: Record<string, string> = {
327
332
  "image/png": ".png",
328
333
  "image/jpeg": ".jpg",
@@ -330,12 +335,12 @@ async function handleImageUpload(
330
335
  "image/webp": ".webp",
331
336
  };
332
337
  const ext = extMap[image.mimeType] ?? "";
333
- const filename = basename.includes(".") ? basename : `${basename}${ext}`;
338
+ const filename = fileBasename.includes(".") ? fileBasename : `${fileBasename}${ext}`;
334
339
 
335
- const tempDir = join(tmpdir(), `pi-interview-${sessionId}`);
336
- await mkdir(tempDir, { recursive: true });
340
+ const dir = targetDir ?? join(tmpdir(), `pi-interview-${sessionId}`);
341
+ await mkdir(dir, { recursive: true });
337
342
 
338
- const filepath = join(tempDir, filename);
343
+ const filepath = join(dir, filename);
339
344
  await writeFile(filepath, buffer);
340
345
 
341
346
  return filepath;
@@ -374,6 +379,223 @@ function ensureQuestionId(
374
379
  return { ok: true, question };
375
380
  }
376
381
 
382
+ // HTML generation for saved interviews
383
+ interface SavedFromMeta {
384
+ cwd: string;
385
+ branch: string | null;
386
+ sessionId: string;
387
+ }
388
+
389
+ interface SavedInterviewMeta {
390
+ savedAt: string;
391
+ wasSubmitted: boolean;
392
+ savedFrom: SavedFromMeta;
393
+ }
394
+
395
+ function escapeHtml(str: string): string {
396
+ return str
397
+ .replace(/&/g, "&amp;")
398
+ .replace(/</g, "&lt;")
399
+ .replace(/>/g, "&gt;")
400
+ .replace(/"/g, "&quot;");
401
+ }
402
+
403
+ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[]): string {
404
+ const answerMap = new Map(answers.map((a) => [a.id, a]));
405
+ return questionsList
406
+ .map((q, i) => {
407
+ const ans = answerMap.get(q.id);
408
+ const value = ans?.value;
409
+ const attachments = ans?.attachments ?? [];
410
+
411
+ // Format answer based on question type
412
+ let answerHtml: string;
413
+ if (!value || (Array.isArray(value) && value.length === 0)) {
414
+ answerHtml = '<div class="saved-answer empty">(no answer)</div>';
415
+ } else if (q.type === "image") {
416
+ const paths = Array.isArray(value) ? value : [value];
417
+ answerHtml = `<div class="saved-images">${paths
418
+ .map((p) => `<img src="${escapeHtml(p)}" alt="uploaded image">`)
419
+ .join("")}</div>`;
420
+ } else if (q.type === "multi") {
421
+ const items = Array.isArray(value) ? value : [value];
422
+ answerHtml = `<div class="saved-answer"><ul>${items
423
+ .map((v) => `<li>${escapeHtml(String(v))}</li>`)
424
+ .join("")}</ul></div>`;
425
+ } else {
426
+ answerHtml = `<div class="saved-answer">${escapeHtml(String(value))}</div>`;
427
+ }
428
+
429
+ // Render code block if present
430
+ const codeHtml = q.codeBlock
431
+ ? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
432
+ : "";
433
+
434
+ // Render attachments if any
435
+ const attachHtml =
436
+ attachments.length > 0
437
+ ? `<div class="saved-attachments">${attachments
438
+ .map((p) => `<img src="${escapeHtml(p)}" alt="attachment">`)
439
+ .join("")}</div>`
440
+ : "";
441
+
442
+ return `
443
+ <div class="saved-question">
444
+ <h2>${i + 1}. ${escapeHtml(q.question)}</h2>
445
+ ${codeHtml}
446
+ ${answerHtml}
447
+ ${attachHtml}
448
+ </div>
449
+ `;
450
+ })
451
+ .join("\n");
452
+ }
453
+
454
+ const SAVED_VIEW_STYLES = `
455
+ .saved-interview {
456
+ max-width: 680px;
457
+ margin: 0 auto;
458
+ padding: var(--spacing);
459
+ }
460
+ .saved-header {
461
+ margin-bottom: 24px;
462
+ padding-bottom: 16px;
463
+ border-bottom: 1px solid var(--border-muted);
464
+ }
465
+ .saved-header h1 {
466
+ margin: 0 0 8px;
467
+ font-size: 20px;
468
+ }
469
+ .saved-meta {
470
+ display: flex;
471
+ flex-wrap: wrap;
472
+ gap: 16px;
473
+ font-size: 12px;
474
+ color: var(--fg-muted);
475
+ }
476
+ .saved-status {
477
+ padding: 2px 8px;
478
+ border-radius: var(--radius);
479
+ background: var(--bg-elevated);
480
+ }
481
+ .saved-status.submitted {
482
+ color: var(--success);
483
+ border: 1px solid var(--success);
484
+ }
485
+ .saved-status.draft {
486
+ color: var(--warning);
487
+ border: 1px solid var(--warning);
488
+ }
489
+ .saved-question {
490
+ margin-bottom: 20px;
491
+ padding: 16px;
492
+ background: var(--bg-elevated);
493
+ border: 1px solid var(--border-muted);
494
+ border-radius: var(--radius);
495
+ }
496
+ .saved-question h2 {
497
+ margin: 0 0 12px;
498
+ font-size: 14px;
499
+ font-weight: 500;
500
+ }
501
+ .saved-code {
502
+ margin: 12px 0;
503
+ padding: 12px;
504
+ background: var(--bg-body);
505
+ border-radius: var(--radius);
506
+ overflow-x: auto;
507
+ font-size: 13px;
508
+ }
509
+ .saved-answer {
510
+ color: var(--fg);
511
+ padding: 8px 12px;
512
+ background: var(--bg-body);
513
+ border-radius: var(--radius);
514
+ white-space: pre-wrap;
515
+ }
516
+ .saved-answer.empty {
517
+ color: var(--fg-dim);
518
+ font-style: italic;
519
+ }
520
+ .saved-answer ul {
521
+ margin: 0;
522
+ padding-left: 20px;
523
+ }
524
+ .saved-images, .saved-attachments {
525
+ margin-top: 12px;
526
+ display: flex;
527
+ flex-wrap: wrap;
528
+ gap: 8px;
529
+ }
530
+ .saved-images img, .saved-attachments img {
531
+ max-width: 200px;
532
+ max-height: 150px;
533
+ border-radius: var(--radius);
534
+ border: 1px solid var(--border-muted);
535
+ }
536
+ `;
537
+
538
+ function generateSavedHtml(options: {
539
+ questions: QuestionsFile;
540
+ answers: ResponseItem[];
541
+ meta: SavedInterviewMeta;
542
+ baseStyles: string;
543
+ themeCss: string;
544
+ }): string {
545
+ const { questions: questionsData, answers, meta, baseStyles, themeCss } = options;
546
+ const title = questionsData.title || "Interview";
547
+
548
+ // Build the data object for embedding
549
+ const dataForEmbedding = {
550
+ title: questionsData.title,
551
+ description: questionsData.description,
552
+ questions: questionsData.questions,
553
+ savedAnswers: answers,
554
+ savedAt: meta.savedAt,
555
+ wasSubmitted: meta.wasSubmitted,
556
+ savedFrom: meta.savedFrom,
557
+ };
558
+
559
+ const embeddedJson = safeInlineJSON(dataForEmbedding);
560
+ const questionsHtml = renderQuestionsHtml(questionsData.questions, answers);
561
+ const savedDate = new Date(meta.savedAt).toLocaleString();
562
+ const statusClass = meta.wasSubmitted ? "submitted" : "draft";
563
+ const statusText = meta.wasSubmitted ? "Submitted" : "Draft";
564
+
565
+ return `<!DOCTYPE html>
566
+ <html lang="en">
567
+ <head>
568
+ <meta charset="UTF-8">
569
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
570
+ <title>${escapeHtml(title)} - Saved Interview</title>
571
+ <style>
572
+ ${baseStyles}
573
+ ${themeCss}
574
+ ${SAVED_VIEW_STYLES}
575
+ </style>
576
+ </head>
577
+ <body>
578
+ <main class="saved-interview">
579
+ <header class="saved-header">
580
+ <h1>${escapeHtml(title)}</h1>
581
+ <div class="saved-meta">
582
+ <span>Saved: ${escapeHtml(savedDate)}</span>
583
+ <span>Project: ${escapeHtml(meta.savedFrom.cwd)}</span>
584
+ ${meta.savedFrom.branch ? `<span>Branch: ${escapeHtml(meta.savedFrom.branch)}</span>` : ""}
585
+ <span class="saved-status ${statusClass}">${statusText}</span>
586
+ </div>
587
+ </header>
588
+ <div class="saved-questions">
589
+ ${questionsHtml}
590
+ </div>
591
+ </main>
592
+ <script type="application/json" id="pi-interview-data">
593
+ ${embeddedJson}
594
+ </script>
595
+ </body>
596
+ </html>`;
597
+ }
598
+
377
599
  export async function startInterviewServer(
378
600
  options: InterviewServerOptions,
379
601
  callbacks: InterviewServerCallbacks
@@ -468,6 +690,8 @@ export async function startInterviewServer(
468
690
  mode: themeMode,
469
691
  toggleHotkey: themeConfig.toggleHotkey,
470
692
  },
693
+ savedAnswers: options.savedAnswers,
694
+ autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
471
695
  });
472
696
  const html = TEMPLATE
473
697
  .replace("/* __INTERVIEW_DATA_PLACEHOLDER__ */", inlineData)
@@ -563,7 +787,8 @@ export async function startInterviewServer(
563
787
  sendJson(res, 200, { ok: true });
564
788
  return;
565
789
  }
566
- const reason = (body as { reason?: string }).reason;
790
+ const payload = body as { reason?: string; responses?: ResponseItem[] };
791
+ const reason = payload.reason;
567
792
  if (reason === "timeout" || reason === "stale") {
568
793
  const recoveryPath = saveToRecovery(questions, cwd, gitBranch, sessionId);
569
794
  const label = reason === "timeout" ? "timed out" : "stale";
@@ -572,7 +797,8 @@ export async function startInterviewServer(
572
797
  markCompleted();
573
798
  unregisterSession(sessionId);
574
799
  sendJson(res, 200, { ok: true });
575
- setImmediate(() => callbacks.onCancel(reason));
800
+ const partialResponses = Array.isArray(payload.responses) ? payload.responses : undefined;
801
+ setImmediate(() => callbacks.onCancel(reason as "timeout" | "user" | "stale" | undefined, partialResponses));
576
802
  return;
577
803
  }
578
804
 
@@ -705,6 +931,123 @@ export async function startInterviewServer(
705
931
  return;
706
932
  }
707
933
 
934
+ if (method === "POST" && url.pathname === "/save") {
935
+ const body = await parseJSONBody(req).catch((err) => {
936
+ if (err instanceof BodyTooLargeError) {
937
+ sendJson(res, err.statusCode, { ok: false, error: err.message });
938
+ return null;
939
+ }
940
+ sendJson(res, 400, { ok: false, error: err.message });
941
+ return null;
942
+ });
943
+ if (!body) return;
944
+ if (!validateTokenBody(body, sessionToken, res)) return;
945
+ // Note: don't check `completed` - allow save after submit
946
+
947
+ const payload = body as {
948
+ responses?: ResponseItem[];
949
+ images?: Array<{
950
+ id: string;
951
+ filename: string;
952
+ mimeType: string;
953
+ data: string;
954
+ isAttachment?: boolean;
955
+ }>;
956
+ submitted?: boolean;
957
+ };
958
+
959
+ const responsesInput = Array.isArray(payload.responses) ? payload.responses : [];
960
+ const imagesInput = Array.isArray(payload.images) ? payload.images : [];
961
+ const submitted = payload.submitted === true;
962
+
963
+ const snapshotBaseDir = options.snapshotDir ?? SNAPSHOTS_DIR;
964
+
965
+ // Build folder name: {title}-{project}-{branch}-{timestamp}[-submitted]
966
+ const now = new Date();
967
+ const date = now.toISOString().slice(0, 10);
968
+ const time = now.toTimeString().slice(0, 8).replace(/:/g, "");
969
+ const timestamp = `${date}-${time}`;
970
+ const project = sanitizeForFilename(basename(cwd) || "unknown");
971
+ const branch = sanitizeForFilename(gitBranch || "nogit");
972
+ const titleSlug = sanitizeForFilename(questions.title || "interview");
973
+ const suffix = submitted ? "-submitted" : "";
974
+ const folderName = `${titleSlug}-${project}-${branch}-${timestamp}${suffix}`;
975
+ const snapshotPath = join(snapshotBaseDir, folderName);
976
+ const imagesPath = join(snapshotPath, "images");
977
+
978
+ await mkdir(snapshotPath, { recursive: true });
979
+
980
+ // Process responses - make a deep copy to avoid mutating input
981
+ const savedResponses: ResponseItem[] = responsesInput.map((r) => ({
982
+ ...r,
983
+ value: Array.isArray(r.value) ? [...r.value] : r.value,
984
+ attachments: r.attachments ? [...r.attachments] : undefined,
985
+ }));
986
+
987
+ // Process uploaded images - save to images/ subfolder
988
+ if (imagesInput.length > 0) {
989
+ await mkdir(imagesPath, { recursive: true });
990
+ for (const image of imagesInput) {
991
+ if (!image || typeof image.id !== "string") continue;
992
+
993
+ try {
994
+ const absPath = await handleImageUpload(image, sessionId, imagesPath);
995
+ const relPath = "images/" + basename(absPath);
996
+
997
+ const existing = savedResponses.find((r) => r.id === image.id);
998
+ if (image.isAttachment) {
999
+ if (existing) {
1000
+ existing.attachments = existing.attachments || [];
1001
+ existing.attachments.push(relPath);
1002
+ } else {
1003
+ savedResponses.push({ id: image.id, value: "", attachments: [relPath] });
1004
+ }
1005
+ } else {
1006
+ if (existing) {
1007
+ if (Array.isArray(existing.value)) {
1008
+ existing.value.push(relPath);
1009
+ } else if (existing.value === "") {
1010
+ existing.value = relPath;
1011
+ } else {
1012
+ existing.value = [existing.value, relPath];
1013
+ }
1014
+ } else {
1015
+ savedResponses.push({ id: image.id, value: relPath });
1016
+ }
1017
+ }
1018
+ } catch (err) {
1019
+ const message = err instanceof Error ? err.message : "Image upload failed";
1020
+ sendJson(res, 400, { ok: false, error: message, field: image.id });
1021
+ return;
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ // Generate HTML with embedded data
1027
+ const meta: SavedInterviewMeta = {
1028
+ savedAt: new Date().toISOString(),
1029
+ wasSubmitted: submitted,
1030
+ savedFrom: { cwd: normalizedCwd, branch: gitBranch, sessionId },
1031
+ };
1032
+ const themeCss = themeMode === "light" ? themeLightCss : themeDarkCss;
1033
+ const html = generateSavedHtml({
1034
+ questions,
1035
+ answers: savedResponses,
1036
+ meta,
1037
+ baseStyles: STYLES,
1038
+ themeCss,
1039
+ });
1040
+
1041
+ await writeFile(join(snapshotPath, "index.html"), html);
1042
+
1043
+ sendJson(res, 200, {
1044
+ ok: true,
1045
+ path: snapshotPath,
1046
+ relativePath: normalizePath(snapshotPath),
1047
+ });
1048
+ return;
1049
+ }
1050
+
708
1051
  sendText(res, 404, "Not found");
709
1052
  } catch (err) {
710
1053
  const message = err instanceof Error ? err.message : "Server error";
package/settings.ts CHANGED
@@ -17,6 +17,8 @@ export interface InterviewSettings {
17
17
  timeout?: number;
18
18
  port?: number;
19
19
  theme?: InterviewThemeSettings;
20
+ snapshotDir?: string; // Default: ~/.pi/interview-snapshots/
21
+ autoSaveOnSubmit?: boolean; // Default: true
20
22
  }
21
23
 
22
24
  export function loadSettings(): InterviewSettings {