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 +46 -0
- package/form/index.html +4 -0
- package/form/script.js +149 -10
- package/form/styles.css +77 -1
- package/index.ts +154 -12
- package/package.json +3 -3
- package/server.ts +352 -9
- package/settings.ts +2 -0
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
|
|
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("
|
|
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
|
|
2147
|
+
const result = await response.json().catch(() => ({ ok: false, error: "Invalid server response" }));
|
|
2032
2148
|
|
|
2033
|
-
if (!response.ok || !
|
|
2034
|
-
if (
|
|
2035
|
-
setFieldError(
|
|
2149
|
+
if (!response.ok || !result.ok) {
|
|
2150
|
+
if (result.field) {
|
|
2151
|
+
setFieldError(result.field, result.error || "Invalid input");
|
|
2036
2152
|
} else {
|
|
2037
|
-
showGlobalError(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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):
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
+
"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": "
|
|
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
|
|
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 =
|
|
338
|
+
const filename = fileBasename.includes(".") ? fileBasename : `${fileBasename}${ext}`;
|
|
334
339
|
|
|
335
|
-
const
|
|
336
|
-
await mkdir(
|
|
340
|
+
const dir = targetDir ?? join(tmpdir(), `pi-interview-${sessionId}`);
|
|
341
|
+
await mkdir(dir, { recursive: true });
|
|
337
342
|
|
|
338
|
-
const filepath = join(
|
|
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, "&")
|
|
398
|
+
.replace(/</g, "<")
|
|
399
|
+
.replace(/>/g, ">")
|
|
400
|
+
.replace(/"/g, """);
|
|
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
|
|
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
|
-
|
|
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 {
|