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.
Files changed (40) hide show
  1. package/dist/app/extensions/extension-ui-controller.js +3 -0
  2. package/dist/app/rendering/extension-entry-renderer.js +2 -0
  3. package/dist/bundled-extensions/question/contract.d.ts +3 -0
  4. package/dist/bundled-extensions/question/contract.js +24 -0
  5. package/dist/bundled-extensions/question/desktop.d.ts +11 -0
  6. package/dist/bundled-extensions/question/desktop.js +143 -0
  7. package/dist/bundled-extensions/question/index.d.ts +1 -0
  8. package/dist/bundled-extensions/question/index.js +5 -1
  9. package/dist/bundled-extensions/question/render.js +6 -0
  10. package/dist/bundled-extensions/question/result.js +71 -9
  11. package/dist/bundled-extensions/question/tool-description.js +4 -3
  12. package/dist/bundled-extensions/question/tui.js +127 -12
  13. package/dist/bundled-extensions/question/types.d.ts +23 -2
  14. package/dist/tool-renderers/question.js +20 -1
  15. package/docs/desktop-markdown-media.md +77 -0
  16. package/docs/desktop-mvp.md +134 -0
  17. package/docs/desktop-task-manager.md +124 -0
  18. package/external/pi-tools-suite/package.json +3 -3
  19. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +8 -0
  20. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +41 -2
  21. package/external/pi-tools-suite/src/async-subagents/core/config.ts +12 -0
  22. package/external/pi-tools-suite/src/async-subagents/index.ts +6 -2
  23. package/external/pi-tools-suite/src/async-subagents/subagent-overlay.ts +1 -1
  24. package/external/pi-tools-suite/src/dcp/auto-compress.ts +1 -1
  25. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +1 -51
  26. package/external/pi-tools-suite/src/dcp/debug-log.ts +6 -0
  27. package/external/pi-tools-suite/src/dcp/index.ts +27 -125
  28. package/external/pi-tools-suite/src/dcp/prompts.ts +2 -2
  29. package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +2 -1
  30. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +31 -10
  31. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +6 -7
  32. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +153 -111
  33. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +28 -17
  34. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +61 -27
  35. package/external/pi-tools-suite/src/dcp/pruner.ts +21 -13
  36. package/external/pi-tools-suite/src/dcp/state.ts +59 -0
  37. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +4 -0
  38. package/external/pi-tools-suite/src/lib/rpc-session-state.ts +34 -0
  39. package/external/pi-tools-suite/src/todo/todo.ts +8 -3
  40. 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
- customDrafts.set(currentQuestion().id, sharedEditorSnapshot());
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.has(question.id));
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 answered = selections.has(questions[index].id);
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 = answer ? "✓" : "·";
522
- renderSelectableLine(add, index === selectedReviewIndex, `${status} ${index + 1}. ${question.label}: ${label}`, { kind: "review", index }, width, answer ? "success" : "warning");
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
- renderSelectableLine(add, mode === "choices" && index === selectedChoiceIndex, `${index + 1}. ${choice.label}`, { kind: "choice", index }, width, "warning");
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
- renderSelectableLine(add, mode === "choices" && selectedChoiceIndex === customAnswerIndex(), `${question.choices.length + 1}. ${CUSTOM_ANSWER_LABEL}`, { kind: "custom" }, width, "warning");
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
- enterCustomMode();
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 type QuestionSelection = PredefinedQuestionSelection | CustomQuestionSelection;
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.