pi-ui-extend 1.0.38 → 1.0.39
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/dist/app/extensions/extension-ui-controller.js +3 -0
- package/dist/app/rendering/extension-entry-renderer.js +2 -0
- package/dist/bundled-extensions/question/contract.d.ts +3 -0
- package/dist/bundled-extensions/question/contract.js +24 -0
- package/dist/bundled-extensions/question/desktop.d.ts +11 -0
- package/dist/bundled-extensions/question/desktop.js +143 -0
- package/dist/bundled-extensions/question/index.d.ts +1 -0
- package/dist/bundled-extensions/question/index.js +5 -1
- package/dist/bundled-extensions/question/render.js +6 -0
- package/dist/bundled-extensions/question/result.js +71 -9
- package/dist/bundled-extensions/question/tool-description.js +4 -3
- package/dist/bundled-extensions/question/tui.js +127 -12
- package/dist/bundled-extensions/question/types.d.ts +23 -2
- package/dist/tool-renderers/question.js +20 -1
- package/docs/desktop-markdown-media.md +77 -0
- package/docs/desktop-mvp.md +134 -0
- package/docs/desktop-task-manager.md +124 -0
- package/external/pi-tools-suite/package.json +3 -3
- package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +8 -0
- package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +41 -2
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +12 -0
- package/external/pi-tools-suite/src/async-subagents/index.ts +6 -2
- package/external/pi-tools-suite/src/async-subagents/subagent-overlay.ts +1 -1
- package/external/pi-tools-suite/src/dcp/auto-compress.ts +1 -1
- package/external/pi-tools-suite/src/dcp/compression-blocks.ts +1 -51
- package/external/pi-tools-suite/src/dcp/debug-log.ts +6 -0
- package/external/pi-tools-suite/src/dcp/index.ts +27 -125
- package/external/pi-tools-suite/src/dcp/prompts.ts +2 -2
- package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +2 -1
- package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +31 -10
- package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +6 -7
- package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +153 -111
- package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +28 -17
- package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +61 -27
- package/external/pi-tools-suite/src/dcp/pruner.ts +21 -13
- package/external/pi-tools-suite/src/dcp/state.ts +59 -0
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +4 -0
- package/external/pi-tools-suite/src/lib/rpc-session-state.ts +34 -0
- package/external/pi-tools-suite/src/todo/todo.ts +8 -3
- package/package.json +8 -4
|
@@ -171,20 +171,48 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
171
171
|
function captureCustomDraft() {
|
|
172
172
|
if (mode !== "custom")
|
|
173
173
|
return;
|
|
174
|
-
|
|
174
|
+
const question = currentQuestion();
|
|
175
|
+
const snapshot = sharedEditorSnapshot();
|
|
176
|
+
customDrafts.set(question.id, snapshot);
|
|
177
|
+
if (question.multiple) {
|
|
178
|
+
const existing = multipleSelection(question);
|
|
179
|
+
selections.set(question.id, {
|
|
180
|
+
id: question.id,
|
|
181
|
+
choiceValues: existing.choiceValues,
|
|
182
|
+
customText: snapshot.text,
|
|
183
|
+
...(snapshot.images.length > 0 ? { images: snapshot.images } : {}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function multipleSelection(question) {
|
|
188
|
+
const existing = selections.get(question.id);
|
|
189
|
+
return existing && "choiceValues" in existing ? existing : { id: question.id, choiceValues: [] };
|
|
190
|
+
}
|
|
191
|
+
function selectionIsComplete(question, selection) {
|
|
192
|
+
if (!selection)
|
|
193
|
+
return false;
|
|
194
|
+
if (!question.multiple)
|
|
195
|
+
return !("choiceValues" in selection);
|
|
196
|
+
if (!("choiceValues" in selection))
|
|
197
|
+
return false;
|
|
198
|
+
const hasCustom = selection.customText !== undefined;
|
|
199
|
+
if (hasCustom && !selection.customText?.trim() && (selection.images?.length ?? 0) === 0)
|
|
200
|
+
return false;
|
|
201
|
+
const count = selection.choiceValues.length + (hasCustom ? 1 : 0);
|
|
202
|
+
return count >= (question.minSelections ?? 1) && count <= (question.maxSelections ?? question.choices.length + 1);
|
|
175
203
|
}
|
|
176
204
|
function getCompleteSelections() {
|
|
177
205
|
const orderedSelections = [];
|
|
178
206
|
for (const question of questions) {
|
|
179
207
|
const selection = selections.get(question.id);
|
|
180
|
-
if (!selection)
|
|
208
|
+
if (!selection || !selectionIsComplete(question, selection))
|
|
181
209
|
return undefined;
|
|
182
210
|
orderedSelections.push(selection);
|
|
183
211
|
}
|
|
184
212
|
return orderedSelections;
|
|
185
213
|
}
|
|
186
214
|
function firstUnansweredIndex() {
|
|
187
|
-
return questions.findIndex((question) => !selections.
|
|
215
|
+
return questions.findIndex((question) => !selectionIsComplete(question, selections.get(question.id)));
|
|
188
216
|
}
|
|
189
217
|
function submitCompleteSelections() {
|
|
190
218
|
const completeSelections = getCompleteSelections();
|
|
@@ -200,6 +228,18 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
200
228
|
submitCompleteSelections();
|
|
201
229
|
}
|
|
202
230
|
function formatReviewAnswerLabel(question, selection) {
|
|
231
|
+
if ("choiceValues" in selection) {
|
|
232
|
+
const labels = question.choices
|
|
233
|
+
.filter((choice) => selection.choiceValues.includes(choice.value))
|
|
234
|
+
.map((choice) => choice.label);
|
|
235
|
+
if (selection.customText !== undefined) {
|
|
236
|
+
const imageCount = selection.images?.length ?? 0;
|
|
237
|
+
const text = selection.customText.trim() || formatAttachedImages(imageCount);
|
|
238
|
+
const imageSuffix = selection.customText.trim() && imageCount > 0 ? ` (+${formatAttachedImages(imageCount)})` : "";
|
|
239
|
+
labels.push(`${CUSTOM_ANSWER_LABEL}: ${text}${imageSuffix}`);
|
|
240
|
+
}
|
|
241
|
+
return labels.join(", ") || "Unanswered";
|
|
242
|
+
}
|
|
203
243
|
if ("customText" in selection) {
|
|
204
244
|
const imageCount = selection.images?.length ?? 0;
|
|
205
245
|
const text = selection.customText || formatAttachedImages(imageCount);
|
|
@@ -212,6 +252,12 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
212
252
|
const selection = selections.get(question.id);
|
|
213
253
|
if (!selection)
|
|
214
254
|
return 0;
|
|
255
|
+
if ("choiceValues" in selection) {
|
|
256
|
+
const firstSelected = question.choices.findIndex((choice) => selection.choiceValues.includes(choice.value));
|
|
257
|
+
if (firstSelected !== -1)
|
|
258
|
+
return firstSelected;
|
|
259
|
+
return selection.customText !== undefined ? customAnswerIndex(question) : 0;
|
|
260
|
+
}
|
|
215
261
|
if ("customText" in selection)
|
|
216
262
|
return customAnswerIndex(question);
|
|
217
263
|
const choiceIndex = question.choices.findIndex((choice) => choice.value === selection.choiceValue);
|
|
@@ -282,9 +328,18 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
282
328
|
function enterCustomMode() {
|
|
283
329
|
const question = currentQuestion();
|
|
284
330
|
const existing = selections.get(question.id);
|
|
285
|
-
const prefill = existing && "customText" in existing
|
|
331
|
+
const prefill = existing && "customText" in existing && existing.customText !== undefined
|
|
286
332
|
? { text: existing.customText, images: existing.images ?? [] }
|
|
287
333
|
: customDrafts.get(question.id) ?? { text: "", images: [] };
|
|
334
|
+
if (question.multiple) {
|
|
335
|
+
const multi = multipleSelection(question);
|
|
336
|
+
if (multi.customText === undefined && multi.choiceValues.length >= (question.maxSelections ?? question.choices.length + 1)) {
|
|
337
|
+
customError = `Choose at most ${question.maxSelections ?? question.choices.length + 1} answers.`;
|
|
338
|
+
refresh();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
selections.set(question.id, { ...multi, customText: prefill.text, ...(prefill.images.length > 0 ? { images: prefill.images } : {}) });
|
|
342
|
+
}
|
|
288
343
|
mode = "custom";
|
|
289
344
|
selectedChoiceIndex = customAnswerIndex(question);
|
|
290
345
|
customError = undefined;
|
|
@@ -295,14 +350,43 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
295
350
|
const question = currentQuestion();
|
|
296
351
|
const choice = question.choices[index];
|
|
297
352
|
if (choice) {
|
|
353
|
+
if (question.multiple) {
|
|
354
|
+
const existing = multipleSelection(question);
|
|
355
|
+
const selected = existing.choiceValues.includes(choice.value);
|
|
356
|
+
const selectedCount = existing.choiceValues.length + (existing.customText !== undefined ? 1 : 0);
|
|
357
|
+
if (!selected && selectedCount >= (question.maxSelections ?? question.choices.length + 1)) {
|
|
358
|
+
customError = `Choose at most ${question.maxSelections ?? question.choices.length + 1} answers.`;
|
|
359
|
+
refresh();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const choiceValues = selected
|
|
363
|
+
? existing.choiceValues.filter((value) => value !== choice.value)
|
|
364
|
+
: question.choices.filter((candidate) => candidate.value === choice.value || existing.choiceValues.includes(candidate.value)).map((candidate) => candidate.value);
|
|
365
|
+
selections.set(question.id, { ...existing, choiceValues });
|
|
366
|
+
customError = undefined;
|
|
367
|
+
refresh();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
298
370
|
selections.set(question.id, { id: question.id, choiceValue: choice.value });
|
|
299
371
|
customDrafts.delete(question.id);
|
|
300
372
|
clearSharedEditorText();
|
|
301
373
|
advanceAfterAnswer();
|
|
302
374
|
return;
|
|
303
375
|
}
|
|
304
|
-
if (index === question.choices.length)
|
|
376
|
+
if (index === question.choices.length) {
|
|
377
|
+
if (question.multiple) {
|
|
378
|
+
const existing = multipleSelection(question);
|
|
379
|
+
if (existing.customText !== undefined) {
|
|
380
|
+
customDrafts.set(question.id, { text: existing.customText, images: existing.images ?? [] });
|
|
381
|
+
const { customText: _customText, images: _images, ...withoutCustom } = existing;
|
|
382
|
+
selections.set(question.id, withoutCustom);
|
|
383
|
+
customError = undefined;
|
|
384
|
+
refresh();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
305
388
|
enterCustomMode();
|
|
389
|
+
}
|
|
306
390
|
}
|
|
307
391
|
function submitCustomAnswer() {
|
|
308
392
|
const snapshot = sharedEditorSnapshot();
|
|
@@ -313,6 +397,22 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
313
397
|
return;
|
|
314
398
|
}
|
|
315
399
|
const question = currentQuestion();
|
|
400
|
+
if (question.multiple) {
|
|
401
|
+
const existing = multipleSelection(question);
|
|
402
|
+
selections.set(question.id, {
|
|
403
|
+
id: question.id,
|
|
404
|
+
choiceValues: existing.choiceValues,
|
|
405
|
+
customText: trimmed,
|
|
406
|
+
...(snapshot.images.length > 0 ? { images: snapshot.images } : {}),
|
|
407
|
+
});
|
|
408
|
+
customDrafts.delete(question.id);
|
|
409
|
+
clearSharedEditorText();
|
|
410
|
+
mode = "choices";
|
|
411
|
+
customError = undefined;
|
|
412
|
+
syncChoiceSelection();
|
|
413
|
+
refresh();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
316
416
|
selections.set(question.id, {
|
|
317
417
|
id: question.id,
|
|
318
418
|
customText: trimmed,
|
|
@@ -458,7 +558,7 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
458
558
|
goBack();
|
|
459
559
|
return;
|
|
460
560
|
}
|
|
461
|
-
if (isKey(data, "enter")) {
|
|
561
|
+
if (isKey(data, "enter") || data === " ") {
|
|
462
562
|
selectChoice(selectedChoiceIndex);
|
|
463
563
|
return;
|
|
464
564
|
}
|
|
@@ -481,7 +581,8 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
481
581
|
const zones = [];
|
|
482
582
|
const active = activeTab();
|
|
483
583
|
for (let index = 0; index < questions.length; index += 1) {
|
|
484
|
-
const
|
|
584
|
+
const question = questions[index];
|
|
585
|
+
const answered = selectionIsComplete(question, selections.get(question.id));
|
|
485
586
|
const label = ` ${index + 1}${answered ? "✓" : "·"} `;
|
|
486
587
|
const startColumn = visibleLength(plain) + 1;
|
|
487
588
|
plain += label;
|
|
@@ -517,9 +618,10 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
517
618
|
renderSeparator(add, width);
|
|
518
619
|
questions.forEach((question, index) => {
|
|
519
620
|
const answer = selections.get(question.id);
|
|
621
|
+
const complete = selectionIsComplete(question, answer);
|
|
520
622
|
const label = answer ? formatReviewAnswerLabel(question, answer) : "Unanswered";
|
|
521
|
-
const status =
|
|
522
|
-
renderSelectableLine(add, index === selectedReviewIndex, `${status} ${index + 1}. ${question.label}: ${label}`, { kind: "review", index }, width,
|
|
623
|
+
const status = complete ? "✓" : "·";
|
|
624
|
+
renderSelectableLine(add, index === selectedReviewIndex, `${status} ${index + 1}. ${question.label}: ${label}`, { kind: "review", index }, width, complete ? "success" : "warning");
|
|
523
625
|
});
|
|
524
626
|
const isComplete = Boolean(getCompleteSelections());
|
|
525
627
|
renderSeparator(add, width);
|
|
@@ -527,16 +629,22 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
527
629
|
}
|
|
528
630
|
function renderQuestion(add, addWrapped, width) {
|
|
529
631
|
const question = currentQuestion();
|
|
632
|
+
const selection = selections.get(question.id);
|
|
633
|
+
const multiSelection = selection && "choiceValues" in selection ? selection : undefined;
|
|
530
634
|
renderHeader(add, `${questionIndex + 1}/${questions.length} ${question.label}`, width);
|
|
531
635
|
renderTabs(add, width);
|
|
532
636
|
renderSeparator(add, width);
|
|
533
637
|
addWrapped(theme.fg("info", ` ${question.prompt}`));
|
|
638
|
+
if (question.multiple)
|
|
639
|
+
addWrapped(theme.fg("muted", ` Select ${question.minSelections ?? 1} to ${question.maxSelections ?? question.choices.length + 1} answers.`));
|
|
534
640
|
question.choices.forEach((choice, index) => {
|
|
535
|
-
|
|
641
|
+
const prefix = question.multiple ? `[${multiSelection?.choiceValues.includes(choice.value) ? "x" : " "}]` : `${index + 1}.`;
|
|
642
|
+
renderSelectableLine(add, mode === "choices" && index === selectedChoiceIndex, `${prefix} ${choice.label}`, { kind: "choice", index }, width, "warning");
|
|
536
643
|
if (choice.description)
|
|
537
644
|
renderMutedLine(add, ` ${choice.description}`, width);
|
|
538
645
|
});
|
|
539
|
-
|
|
646
|
+
const customPrefix = question.multiple ? `[${multiSelection?.customText !== undefined ? "x" : " "}]` : `${question.choices.length + 1}.`;
|
|
647
|
+
renderSelectableLine(add, mode === "choices" && selectedChoiceIndex === customAnswerIndex(), `${customPrefix} ${CUSTOM_ANSWER_LABEL}`, { kind: "custom" }, width, "warning");
|
|
540
648
|
if (mode === "custom") {
|
|
541
649
|
if (!usesSharedEditor) {
|
|
542
650
|
(sharedEditorText() || " ").split("\n").forEach((line) => addWrapped(theme.fg("text", ` ${line}`)));
|
|
@@ -544,6 +652,8 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
544
652
|
if (customError && !sharedEditorText().trim())
|
|
545
653
|
addWrapped(theme.fg("warning", ` ${customError}`));
|
|
546
654
|
}
|
|
655
|
+
if (mode === "choices" && customError)
|
|
656
|
+
addWrapped(theme.fg("warning", ` ${customError}`));
|
|
547
657
|
}
|
|
548
658
|
function handleMouse(event) {
|
|
549
659
|
if (!event.released)
|
|
@@ -562,7 +672,12 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
562
672
|
selectChoice(zone.index);
|
|
563
673
|
return true;
|
|
564
674
|
case "custom":
|
|
565
|
-
|
|
675
|
+
if (mode === "custom") {
|
|
676
|
+
captureCustomDraft();
|
|
677
|
+
mode = "choices";
|
|
678
|
+
}
|
|
679
|
+
selectedChoiceIndex = customAnswerIndex();
|
|
680
|
+
selectChoice(selectedChoiceIndex);
|
|
566
681
|
return true;
|
|
567
682
|
case "review":
|
|
568
683
|
moveToQuestion(zone.index);
|
|
@@ -8,6 +8,9 @@ export interface QuestionInput {
|
|
|
8
8
|
label: string;
|
|
9
9
|
prompt: string;
|
|
10
10
|
choices: QuestionChoiceInput[];
|
|
11
|
+
multiple?: boolean;
|
|
12
|
+
minSelections?: number;
|
|
13
|
+
maxSelections?: number;
|
|
11
14
|
}
|
|
12
15
|
export interface QuestionToolInput {
|
|
13
16
|
questions: QuestionInput[];
|
|
@@ -22,6 +25,9 @@ export interface NormalizedQuestion {
|
|
|
22
25
|
label: string;
|
|
23
26
|
prompt: string;
|
|
24
27
|
choices: NormalizedQuestionChoice[];
|
|
28
|
+
multiple?: true;
|
|
29
|
+
minSelections?: number;
|
|
30
|
+
maxSelections?: number;
|
|
25
31
|
}
|
|
26
32
|
export interface PredefinedQuestionSelection {
|
|
27
33
|
id: string;
|
|
@@ -32,15 +38,29 @@ export interface CustomQuestionSelection {
|
|
|
32
38
|
customText: string;
|
|
33
39
|
images?: QuestionImageContent[];
|
|
34
40
|
}
|
|
35
|
-
export
|
|
36
|
-
export interface QuestionAnswer {
|
|
41
|
+
export interface MultipleQuestionSelection {
|
|
37
42
|
id: string;
|
|
43
|
+
choiceValues: string[];
|
|
44
|
+
customText?: string;
|
|
45
|
+
images?: QuestionImageContent[];
|
|
46
|
+
}
|
|
47
|
+
export type QuestionSelection = PredefinedQuestionSelection | CustomQuestionSelection | MultipleQuestionSelection;
|
|
48
|
+
export interface QuestionAnswerSelection {
|
|
38
49
|
value: string;
|
|
39
50
|
label: string;
|
|
40
51
|
wasCustom: boolean;
|
|
41
52
|
index?: number;
|
|
42
53
|
imageCount?: number;
|
|
43
54
|
}
|
|
55
|
+
export interface SingleQuestionAnswer extends QuestionAnswerSelection {
|
|
56
|
+
id: string;
|
|
57
|
+
}
|
|
58
|
+
export interface MultipleQuestionAnswer {
|
|
59
|
+
id: string;
|
|
60
|
+
multiple: true;
|
|
61
|
+
selections: QuestionAnswerSelection[];
|
|
62
|
+
}
|
|
63
|
+
export type QuestionAnswer = SingleQuestionAnswer | MultipleQuestionAnswer;
|
|
44
64
|
export interface SuccessfulQuestionResult {
|
|
45
65
|
answers: QuestionAnswer[];
|
|
46
66
|
canceled: false;
|
|
@@ -73,6 +93,7 @@ export interface QuestionUiContext {
|
|
|
73
93
|
hasUI?: boolean;
|
|
74
94
|
ui: {
|
|
75
95
|
custom<T>(factory: (tui: QuestionTui, theme: QuestionTheme, keybindings: unknown, done: (value: T) => void) => QuestionComponent): Promise<T | undefined>;
|
|
96
|
+
editor?(title: string, prefill?: string): Promise<string | undefined>;
|
|
76
97
|
setEditorText?(text: string): void;
|
|
77
98
|
getEditorText?(): string;
|
|
78
99
|
setEditorSnapshot?(snapshot: QuestionEditorSnapshot): void;
|
|
@@ -38,6 +38,9 @@ function formatQuestions(questions) {
|
|
|
38
38
|
return questions.map((question, index) => {
|
|
39
39
|
const title = `◇ ${index + 1}/${questions.length} ${questionLabel(question)}`;
|
|
40
40
|
const prompt = stringValue(question.prompt) ?? "(no prompt)";
|
|
41
|
+
const bounds = question.multiple === true
|
|
42
|
+
? ` Select ${integerValue(question.minSelections) ?? 1} to ${integerValue(question.maxSelections) ?? ((Array.isArray(question.choices) ? question.choices.length : 0) + 1)} answers.`
|
|
43
|
+
: undefined;
|
|
41
44
|
const choices = Array.isArray(question.choices)
|
|
42
45
|
? question.choices.filter(isPlainRecord)
|
|
43
46
|
: [];
|
|
@@ -47,7 +50,7 @@ function formatQuestions(questions) {
|
|
|
47
50
|
return description ? ` ${choiceIndex + 1}. ${label} — ${description}` : ` ${choiceIndex + 1}. ${label}`;
|
|
48
51
|
});
|
|
49
52
|
choiceLines.push(` ${choices.length + 1}. ${CUSTOM_ANSWER_LABEL} (custom answer)`);
|
|
50
|
-
return [title, ` ${prompt}`, ...choiceLines].join("\n");
|
|
53
|
+
return [title, ` ${prompt}`, ...(bounds ? [bounds] : []), ...choiceLines].join("\n");
|
|
51
54
|
}).join("\n\n");
|
|
52
55
|
}
|
|
53
56
|
function formatQuestionResult(details, questions) {
|
|
@@ -71,18 +74,34 @@ function formatQuestionResult(details, questions) {
|
|
|
71
74
|
}
|
|
72
75
|
function formatAnswer(answer, questionLabels) {
|
|
73
76
|
const label = questionLabels.get(stringValue(answer.id)) ?? stringValue(answer.id) ?? "Question";
|
|
77
|
+
if (answer.multiple === true && Array.isArray(answer.selections)) {
|
|
78
|
+
const selections = answer.selections.filter(isPlainRecord).map((selection) => formatAnswerSelection(selection));
|
|
79
|
+
return `✓ ${label}: ${selections.join(", ") || "(empty)"}`;
|
|
80
|
+
}
|
|
74
81
|
const answerLabel = stringValue(answer.label) ?? "(empty)";
|
|
75
82
|
if (answer.wasCustom === true)
|
|
76
83
|
return `✓ ${label}: ${answerLabel} (custom answer)`;
|
|
77
84
|
const index = typeof answer.index === "number" && Number.isFinite(answer.index) ? `choice ${answer.index}` : "choice";
|
|
78
85
|
return `✓ ${label}: ${answerLabel} (${index})`;
|
|
79
86
|
}
|
|
87
|
+
function formatAnswerSelection(answer) {
|
|
88
|
+
const label = stringValue(answer.label) ?? "(empty)";
|
|
89
|
+
if (answer.wasCustom === true) {
|
|
90
|
+
const imageCount = integerValue(answer.imageCount);
|
|
91
|
+
return `${label} (custom answer${imageCount ? `; ${imageCount} image${imageCount === 1 ? "" : "s"} attached` : ""})`;
|
|
92
|
+
}
|
|
93
|
+
const index = integerValue(answer.index);
|
|
94
|
+
return `${label} (${index === undefined ? "choice" : `choice ${index}`})`;
|
|
95
|
+
}
|
|
80
96
|
function questionLabel(question) {
|
|
81
97
|
return stringValue(question.label) ?? stringValue(question.id) ?? "Question";
|
|
82
98
|
}
|
|
83
99
|
function stringValue(value) {
|
|
84
100
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
85
101
|
}
|
|
102
|
+
function integerValue(value) {
|
|
103
|
+
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
|
|
104
|
+
}
|
|
86
105
|
function isPlainRecord(value) {
|
|
87
106
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
107
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Spec: Desktop Markdown media previews
|
|
2
|
+
|
|
3
|
+
## Type
|
|
4
|
+
|
|
5
|
+
Change
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Show an inline preview when transcript Markdown links to a supported local image or video, including `file://` artifacts outside the active workspace, and keep every local artifact label actionable.
|
|
10
|
+
|
|
11
|
+
## Scope
|
|
12
|
+
|
|
13
|
+
- Relative Markdown destinations that resolve inside the active workspace.
|
|
14
|
+
- Absolute local paths expressed as `file://` Markdown destinations, such as QA artifacts in the system temporary directory.
|
|
15
|
+
- Image formats: AVIF, BMP, GIF, JPEG, PNG, SVG, and WebP.
|
|
16
|
+
- Video formats: M4V, MOV, MP4, OGV, and WebM.
|
|
17
|
+
- Both ordinary Markdown links and local Markdown image syntax.
|
|
18
|
+
|
|
19
|
+
## Non-goals
|
|
20
|
+
|
|
21
|
+
- Embedding remote media.
|
|
22
|
+
- Adding codecs beyond those supported by the desktop WebView.
|
|
23
|
+
- Changing previews for non-media project files.
|
|
24
|
+
- Inline previews for non-media `file://` artifacts; those remain links and open in the operating-system default application.
|
|
25
|
+
|
|
26
|
+
## Behavior
|
|
27
|
+
|
|
28
|
+
- A supported local media link renders an inline, bounded preview with its Markdown label as a caption.
|
|
29
|
+
- A supported `file://` media link uses the same preview treatment after its URL is decoded to an absolute local path.
|
|
30
|
+
- Images are lazy-loaded and open the existing media preview dialog when clicked.
|
|
31
|
+
- Videos expose native inline playback controls; their caption opens the media preview dialog.
|
|
32
|
+
- A missing, disallowed, or unrenderable media file leaves a readable fallback and a clickable caption.
|
|
33
|
+
- Opening a supported media link uses the media path flow, not the UTF-8 source-file reader.
|
|
34
|
+
- Other relative project links continue to open the source preview dialog.
|
|
35
|
+
- Other `file://` links remain visibly clickable and open only after an explicit click.
|
|
36
|
+
|
|
37
|
+
## Contracts
|
|
38
|
+
|
|
39
|
+
- The frontend requests a resolved media file using the active workspace and normalized relative path.
|
|
40
|
+
- The backend canonicalizes both workspace and destination, rejects traversal and symlink escapes, verifies a supported extension, and grants asset-protocol access only to the resolved file.
|
|
41
|
+
- A `file://` destination is accepted only when it decodes to an absolute path. The backend canonicalizes it, requires an existing regular file, and grants asset-protocol access only for supported image/video previews.
|
|
42
|
+
- Non-media local files are passed to the OS opener only in response to a user click.
|
|
43
|
+
- Markdown output continues to escape labels, paths, and attributes before insertion through `{@html}`.
|
|
44
|
+
|
|
45
|
+
## Edge cases
|
|
46
|
+
|
|
47
|
+
- URL-like, absolute, control-character, and parent-traversing destinations are not treated as project media.
|
|
48
|
+
- URL schemes other than `file://`, malformed file URLs, relative `file:` forms, and decoded control characters are not treated as local files.
|
|
49
|
+
- Query strings and fragments are removed by the existing project-path normalization.
|
|
50
|
+
- Media loading failures do not replace the transcript with a global error.
|
|
51
|
+
- Clicking a failed media caption still reports the underlying file-resolution error through the normal project-file action.
|
|
52
|
+
|
|
53
|
+
## Related files
|
|
54
|
+
|
|
55
|
+
- `desktop/src/lib/markdown.ts`
|
|
56
|
+
- `desktop/src/components/MarkdownText.svelte`
|
|
57
|
+
- `desktop/src/App.svelte`
|
|
58
|
+
- `desktop/src-tauri/src/lib.rs`
|
|
59
|
+
|
|
60
|
+
## Verification
|
|
61
|
+
|
|
62
|
+
- Markdown unit tests cover workspace and `file://` image/video links, image syntax, escaping, and rejected destinations.
|
|
63
|
+
- Rust unit tests cover supported workspace/absolute media, unsupported binary files, relative local paths, traversal, and workspace containment.
|
|
64
|
+
- `npm run test`, `npm run check`, and `npm run build:web` pass in `desktop/`.
|
|
65
|
+
- `cargo test` passes in `desktop/src-tauri/`.
|
|
66
|
+
|
|
67
|
+
## Risks / unknowns
|
|
68
|
+
|
|
69
|
+
- Actual video playback remains codec-dependent in the operating-system WebView; load failures use the fallback state.
|
|
70
|
+
- A transcript can request an inline view of any readable local image/video via an explicit `file://` URL. The bytes remain local and are exposed only to the app's scoped asset protocol.
|
|
71
|
+
|
|
72
|
+
## Evidence
|
|
73
|
+
|
|
74
|
+
- Confirmed by code: relative links currently call `read_project_file`, which always decodes bytes as UTF-8.
|
|
75
|
+
- Confirmed by code: attachment previews already use Tauri's scoped asset protocol and the existing media dialog.
|
|
76
|
+
- Confirmed by the reported session: QA artifact links use `file:///tmp/...`, which the project-path normalizer intentionally rejects and previously reduced to plain labels.
|
|
77
|
+
- Confirmed by tests: project paths are normalized and constrained to the active workspace.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Spec: Pix Desktop MVP
|
|
2
|
+
|
|
3
|
+
## Type
|
|
4
|
+
|
|
5
|
+
Change
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Provide a lightweight desktop client for Pix using Tauri 2 and Svelte 5 +
|
|
10
|
+
TypeScript. The first vertical slice must let a user select a workspace, open
|
|
11
|
+
or create a pi session, send a prompt, observe streamed assistant/thought/tool
|
|
12
|
+
updates, cancel a running turn, and answer extension elicitations.
|
|
13
|
+
|
|
14
|
+
## Scope
|
|
15
|
+
|
|
16
|
+
- A standalone `desktop/` package; the existing terminal UI remains unchanged.
|
|
17
|
+
- A Tauri process host for the existing `acp/dist/main.js` adapter.
|
|
18
|
+
- An ACP 1.4 JSON-RPC client in the Svelte application.
|
|
19
|
+
- Workspace selection, recent sessions, transcript replay, model/thinking
|
|
20
|
+
selectors, prompt composition, cancellation, and one-field form
|
|
21
|
+
elicitations.
|
|
22
|
+
- Development commands and a documented local setup path.
|
|
23
|
+
|
|
24
|
+
## Non-goals
|
|
25
|
+
|
|
26
|
+
- Feature parity with `PiUiExtendApp`.
|
|
27
|
+
- Voice input, an embedded terminal, custom widgets, image attachments,
|
|
28
|
+
markdown/diff rendering, or authentication management.
|
|
29
|
+
- Bundling Node or a platform-specific standalone `pix-acp` executable in this
|
|
30
|
+
first slice. Development uses Node plus pix-acp's pinned pi npm dependency;
|
|
31
|
+
distributable sidecar packaging is a follow-up milestone.
|
|
32
|
+
- Reimplementing the pi runtime in Rust or the webview.
|
|
33
|
+
|
|
34
|
+
## Behavior
|
|
35
|
+
|
|
36
|
+
1. Tauri starts one isolated `pix-acp` child and exposes only start, line-send,
|
|
37
|
+
and stop commands to the webview. Arbitrary shell execution is not exposed.
|
|
38
|
+
2. The client initializes ACP with form-elicitation support before creating or
|
|
39
|
+
loading a session.
|
|
40
|
+
3. Selecting a workspace lists its persisted sessions. The user can create a
|
|
41
|
+
session or load one; loading replays its transcript. Clicking the active
|
|
42
|
+
session tab opens a searchable session selector below the tabs, matching the
|
|
43
|
+
terminal UI's active-tab behavior. Each visible tab also has a close button;
|
|
44
|
+
closing a tab does not delete its persisted session, so it remains available
|
|
45
|
+
from the selector.
|
|
46
|
+
4. A submitted prompt appears immediately as a user message. ACP
|
|
47
|
+
`session/update` notifications incrementally update assistant text,
|
|
48
|
+
thoughts, and tool activity.
|
|
49
|
+
5. While a prompt request is pending, duplicate submission is disabled and a
|
|
50
|
+
stop action sends `session/cancel`. Final updates remain accepted until the
|
|
51
|
+
prompt response settles.
|
|
52
|
+
6. Model and thought-level controls reflect ACP `configOptions` and use
|
|
53
|
+
`session/set_config_option` for changes.
|
|
54
|
+
7. A form `elicitation/create` request blocks in a modal until the user accepts
|
|
55
|
+
or cancels. String/select/boolean fields emitted by `pix-acp` are supported.
|
|
56
|
+
8. Adapter exits, malformed protocol messages, and request failures are shown
|
|
57
|
+
as recoverable UI errors; all pending requests reject when the adapter exits.
|
|
58
|
+
|
|
59
|
+
## Contracts
|
|
60
|
+
|
|
61
|
+
- Webview → Rust commands:
|
|
62
|
+
- `acp_start()` returning the child generation number
|
|
63
|
+
- `acp_send(generation, line)` where `line` is one newline-free JSON-RPC
|
|
64
|
+
object and the generation must still own the child
|
|
65
|
+
- `acp_stop(generation)`; stale generations cannot stop a replacement child
|
|
66
|
+
- Rust → webview events:
|
|
67
|
+
- `acp://stdout` with `{ generation, line }` for one complete stdout line
|
|
68
|
+
- `acp://stderr` with `{ generation, line }` for one diagnostic line
|
|
69
|
+
- `acp://exit` with the generation and child exit status
|
|
70
|
+
- Rust starts `node ../../acp/dist/main.js` by default. `PIX_ACP_NODE_BINARY`
|
|
71
|
+
and `PIX_ACP_ENTRY` may override executable and entry path for development.
|
|
72
|
+
- Webview ↔ adapter payloads follow `@agentclientprotocol/sdk` 1.4.0 types.
|
|
73
|
+
|
|
74
|
+
## Invariants
|
|
75
|
+
|
|
76
|
+
- ACP stdout is protocol-only; diagnostics are never parsed as JSON-RPC.
|
|
77
|
+
- At most one adapter child is owned by an application window.
|
|
78
|
+
- At most one active prompt exists per ACP session.
|
|
79
|
+
- A workspace path must be non-empty and absolute before `session/new` or
|
|
80
|
+
`session/load`.
|
|
81
|
+
- Pending JSON-RPC calls have deterministic cleanup on response, send failure,
|
|
82
|
+
adapter exit, and client disposal.
|
|
83
|
+
- Reconnect clears session-local UI state and ignores events from older child
|
|
84
|
+
generations.
|
|
85
|
+
|
|
86
|
+
## Edge cases
|
|
87
|
+
|
|
88
|
+
- Repeated start is idempotent; repeated stop succeeds.
|
|
89
|
+
- Stop closes adapter stdin first so pix-acp can dispose nested pi processes,
|
|
90
|
+
then forces termination after a bounded grace period.
|
|
91
|
+
- A stale persisted session may fail to load; the UI remains usable for a new
|
|
92
|
+
session.
|
|
93
|
+
- Cancellation is a notification, so the UI waits for the original prompt
|
|
94
|
+
response rather than assuming immediate completion.
|
|
95
|
+
- Unknown ACP updates are ignored rather than crashing the transcript.
|
|
96
|
+
- Unsupported or malformed elicitation schemas are cancelled.
|
|
97
|
+
|
|
98
|
+
## Related files
|
|
99
|
+
|
|
100
|
+
- `desktop/`
|
|
101
|
+
- `acp/src/main.ts`
|
|
102
|
+
- `acp/src/acp/pix-acp-agent.ts`
|
|
103
|
+
- `acp/src/acp/event-translator.ts`
|
|
104
|
+
- `acp/src/acp/ui-request-bridge.ts`
|
|
105
|
+
|
|
106
|
+
## Verification
|
|
107
|
+
|
|
108
|
+
- `npm --prefix desktop run check`
|
|
109
|
+
- `npm --prefix desktop test`
|
|
110
|
+
- `npm run check:acp`
|
|
111
|
+
- `npm run check`
|
|
112
|
+
- `cargo check --manifest-path desktop/src-tauri/Cargo.toml`
|
|
113
|
+
|
|
114
|
+
## Risks / unknowns
|
|
115
|
+
|
|
116
|
+
- Production bundles still need a platform-specific strategy for shipping
|
|
117
|
+
Node/pi/pix-acp or a compiled standalone sidecar.
|
|
118
|
+
- Tauri/Rust verification requires a local Rust toolchain; frontend tests
|
|
119
|
+
remain runnable independently.
|
|
120
|
+
- ACP 1.4 live message chunks from `pix-acp` may omit `messageId`; the desktop
|
|
121
|
+
reducer therefore coalesces adjacent chunks by role when no id is present.
|
|
122
|
+
|
|
123
|
+
## Evidence
|
|
124
|
+
|
|
125
|
+
- Confirmed by code: `pix-acp` supports the required session, prompt, cancel,
|
|
126
|
+
config, streamed update, replay, and elicitation surfaces.
|
|
127
|
+
- Confirmed by tests: `acp/test/agent.test.ts` and
|
|
128
|
+
`acp/test/event-translator.test.ts` cover those adapter contracts.
|
|
129
|
+
- Confirmed by docs: Tauri 2 supports a Vite frontend and child-process event
|
|
130
|
+
bridging; Svelte 5 supports typed components and rune-based state.
|
|
131
|
+
- Inferred: a narrow Rust process host is safer and easier to package later
|
|
132
|
+
than exposing a general shell plugin to the webview.
|
|
133
|
+
- Unknown: final production sidecar packaging format and signing/notarization
|
|
134
|
+
workflow.
|