pi-interview 0.3.0 → 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
  }
@@ -1314,12 +1316,17 @@
1314
1316
  otherCheck.name = question.id;
1315
1317
  otherCheck.value = "__other__";
1316
1318
  otherCheck.id = `q-${question.id}-other`;
1317
- const otherInput = document.createElement("input");
1318
- otherInput.type = "text";
1319
+ const otherInput = document.createElement("textarea");
1319
1320
  otherInput.className = "other-input";
1320
1321
  otherInput.placeholder = "Other...";
1322
+ otherInput.rows = 1;
1321
1323
  otherInput.dataset.questionId = question.id;
1324
+ const autoResizeOther = () => {
1325
+ otherInput.style.height = "auto";
1326
+ otherInput.style.height = otherInput.scrollHeight + "px";
1327
+ };
1322
1328
  otherInput.addEventListener("input", () => {
1329
+ autoResizeOther();
1323
1330
  if (otherInput.value && !otherCheck.checked) {
1324
1331
  otherCheck.checked = true;
1325
1332
  if (question.type === "multi") updateDoneState(question.id);
@@ -1861,6 +1868,7 @@
1861
1868
  if (otherCheck && otherInput) {
1862
1869
  otherCheck.checked = true;
1863
1870
  otherInput.value = value;
1871
+ otherInput.dispatchEvent(new Event("input", { bubbles: true }));
1864
1872
  }
1865
1873
  }
1866
1874
  }
@@ -1893,6 +1901,7 @@
1893
1901
  if (otherCheck && otherInput) {
1894
1902
  otherCheck.checked = true;
1895
1903
  otherInput.value = otherValue;
1904
+ otherInput.dispatchEvent(new Event("input", { bubbles: true }));
1896
1905
  }
1897
1906
  }
1898
1907
  }
@@ -1961,6 +1970,65 @@
1961
1970
  }
1962
1971
  }
1963
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
+
1964
2032
  function readFileBase64(file) {
1965
2033
  return new Promise((resolve, reject) => {
1966
2034
  const reader = new FileReader();
@@ -2013,6 +2081,54 @@
2013
2081
  return { responses, images };
2014
2082
  }
2015
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
+
2016
2132
  async function submitForm(event) {
2017
2133
  event.preventDefault();
2018
2134
  clearGlobalError();
@@ -2028,18 +2144,24 @@
2028
2144
  body: JSON.stringify({ token: sessionToken, ...payload }),
2029
2145
  });
2030
2146
 
2031
- 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" }));
2032
2148
 
2033
- if (!response.ok || !data.ok) {
2034
- if (data.field) {
2035
- 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");
2036
2152
  } else {
2037
- showGlobalError(data.error || "Submission failed.");
2153
+ showGlobalError(result.error || "Submission failed.");
2038
2154
  }
2039
2155
  submitBtn.disabled = false;
2040
2156
  return;
2041
2157
  }
2042
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
+
2043
2165
  clearProgress();
2044
2166
  stopHeartbeat();
2045
2167
  stopQueuePolling();
@@ -2089,11 +2211,28 @@
2089
2211
  containerEl.appendChild(createQuestionCard(question, index));
2090
2212
  });
2091
2213
 
2092
- 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
+
2093
2222
  startHeartbeat();
2094
2223
  startQueuePolling();
2095
2224
 
2096
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
+ }
2097
2236
  if (queueToastClose) {
2098
2237
  queueToastClose.addEventListener("click", () => {
2099
2238
  queueState.dismissed = true;
package/form/styles.css CHANGED
@@ -105,6 +105,7 @@ body {
105
105
  display: flex;
106
106
  flex-direction: column;
107
107
  gap: 20px;
108
+ min-width: 0;
108
109
  }
109
110
 
110
111
  .question-card {
@@ -115,6 +116,8 @@ body {
115
116
  background: var(--bg-elevated);
116
117
  transition: border-color 150ms ease, box-shadow 150ms ease;
117
118
  outline: none;
119
+ min-width: 0;
120
+ overflow: hidden;
118
121
  }
119
122
 
120
123
  .question-card.active {
@@ -149,6 +152,7 @@ body {
149
152
  .option-list {
150
153
  display: grid;
151
154
  gap: 10px;
155
+ min-width: 0;
152
156
  }
153
157
 
154
158
  .option-item {
@@ -161,6 +165,7 @@ body {
161
165
  border: 1px solid transparent;
162
166
  cursor: pointer;
163
167
  transition: border-color 100ms ease, background 100ms ease;
168
+ min-width: 0;
164
169
  }
165
170
 
166
171
  .option-item:hover {
@@ -184,6 +189,11 @@ body {
184
189
  font-family: var(--font-ui);
185
190
  font-size: inherit;
186
191
  padding: 2px 0;
192
+ resize: none;
193
+ overflow: hidden;
194
+ min-height: 1.7em;
195
+ line-height: 1.7;
196
+ field-sizing: content;
187
197
  }
188
198
 
189
199
  .other-input::placeholder {
@@ -200,6 +210,7 @@ body {
200
210
  border-radius: var(--radius);
201
211
  color: var(--accent);
202
212
  background: var(--bg-card);
213
+ display: flex;
203
214
  align-items: center;
204
215
  justify-content: center;
205
216
  cursor: pointer;
@@ -1107,6 +1118,10 @@ button {
1107
1118
  padding: 20px;
1108
1119
  }
1109
1120
 
1121
+ .session-bar {
1122
+ margin: -20px -20px 16px -20px;
1123
+ }
1124
+
1110
1125
  .interview-header h1 {
1111
1126
  font-size: 20px;
1112
1127
  }
@@ -1256,6 +1271,7 @@ button {
1256
1271
  background: var(--bg-elevated);
1257
1272
  overflow: hidden;
1258
1273
  font-size: 0.8125rem;
1274
+ min-width: 0;
1259
1275
  }
1260
1276
 
1261
1277
  .code-block-header {
@@ -1305,7 +1321,7 @@ button {
1305
1321
 
1306
1322
  .code-block-lines-container {
1307
1323
  display: table;
1308
- width: 100%;
1324
+ min-width: 100%;
1309
1325
  }
1310
1326
 
1311
1327
  .code-block-line {
@@ -1370,6 +1386,7 @@ button {
1370
1386
  position: relative;
1371
1387
  flex-direction: column;
1372
1388
  align-items: stretch;
1389
+ min-width: 0;
1373
1390
  }
1374
1391
 
1375
1392
  .option-item.has-code > input {
@@ -1392,3 +1409,62 @@ button {
1392
1409
  margin-bottom: 1rem;
1393
1410
  }
1394
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,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.3.0",
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",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/nicobailon/pi-interview-tool.git"
9
+ "url": "git+https://github.com/nicobailon/pi-interview-tool.git"
10
10
  },
11
11
  "keywords": [
12
12
  "pi",
@@ -17,7 +17,7 @@
17
17
  "cli"
18
18
  ],
19
19
  "bin": {
20
- "pi-interview": "./bin/install.js"
20
+ "pi-interview": "bin/install.js"
21
21
  },
22
22
  "files": [
23
23
  "bin/",
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 {