@danypops/pi-papyrus 0.45.1 → 0.45.2

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.
@@ -1,15 +1,17 @@
1
1
  /**
2
- * discuss-ask-view.ts — Discuss's own live:true synchronous ask UI: searchable single-select
3
- * with a split-pane description preview, a checkbox multi-select, an integrated freeform
4
- * editor, an optional post-selection comment, docked in the real input editor (never a floating
5
- * overlay), and an auto-dismiss timeout. Owned end-to-end by Papyrus/Discuss -- no runtime
6
- * dependency on or delegation to another package's registered tool.
2
+ * Discuss's live:true prompt orchestration: searchable single-select, checkbox multi-select,
3
+ * freeform replies, and optional comments docked in Pi's input editor. Multi-select state and
4
+ * viewport behavior come from Vehicle's Pi binding of Malevich; this module owns only
5
+ * Discussion-specific modes and response mapping.
7
6
  *
8
- * Substantially adapted from pi-ask-user's index.ts (MIT, Copyright (c) 2026 Enzo Lucchesi --
9
- * full notice in THIRD_PARTY_LICENSES.md), with the standalone-tool plumbing (schema, tool
10
- * registration, its own event emission, malformed-options recovery for other tools' schemas)
11
- * removed since Discuss already owns its schema, persistence, and rendering.
7
+ * The single-select/editor flow is adapted from pi-ask-user (MIT, Copyright (c) 2026 Enzo
8
+ * Lucchesi; full notice in THIRD_PARTY_LICENSES.md).
12
9
  */
10
+ import {
11
+ createMultiSelectList,
12
+ type MultiSelectListItem,
13
+ type MultiSelectList as SharedMultiSelectList,
14
+ } from "@danypops/vehicle-client-pi/multi-select-list";
13
15
  import type { AgentToolUpdateCallback, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
14
16
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
15
17
  import {
@@ -283,185 +285,98 @@ function matchesSelectDown(data: string, keybindings: KeybindingsManager): boole
283
285
  return keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab) || matchesKey(data, VIM_SELECT_DOWN_KEY);
284
286
  }
285
287
 
286
- class MultiSelectList implements Component {
287
- private selectedIndex = 0;
288
- private checked = new Set<number>();
288
+ type DiscussionMultiSelectChoice =
289
+ | { readonly kind: "option"; readonly option: AskOption }
290
+ | { readonly kind: "comment" }
291
+ | { readonly kind: "freeform" };
292
+
293
+ class DiscussionMultiSelectList implements Component {
294
+ private readonly list: SharedMultiSelectList<DiscussionMultiSelectChoice>;
295
+ private readonly commentIndex: number | undefined;
289
296
  private commentEnabled = false;
290
- private cachedWidth?: number;
291
- private cachedLines?: string[];
292
297
 
293
298
  public onCancel?: () => void;
294
299
  public onSubmit?: (result: string[]) => void;
295
300
  public onEnterFreeform?: () => void;
296
301
 
297
302
  constructor(
298
- private options: AskOption[],
299
- private allowFreeform: boolean,
300
- private allowComment: boolean,
301
- private theme: Theme,
302
- private keybindings: KeybindingsManager,
303
- private commentToggle: ResolvedShortcut,
304
- ) {}
303
+ options: AskOption[],
304
+ allowFreeform: boolean,
305
+ allowComment: boolean,
306
+ theme: Theme,
307
+ keybindings: KeybindingsManager,
308
+ private readonly commentToggle: ResolvedShortcut,
309
+ ) {
310
+ const items: Array<MultiSelectListItem<DiscussionMultiSelectChoice>> = options.map((option, index) => ({
311
+ value: { kind: "option", option },
312
+ label: option.title,
313
+ ...(option.description ? { description: option.description } : {}),
314
+ ...(index < 9 ? { shortcut: String(index + 1) } : {}),
315
+ numberLabel: String(index + 1),
316
+ }));
317
+ if (allowComment) {
318
+ this.commentIndex = items.length;
319
+ items.push({
320
+ value: { kind: "comment" },
321
+ label: COMMENT_TOGGLE_LABEL,
322
+ includeInSelection: false,
323
+ confirmAction: "toggle",
324
+ numberLabel: false,
325
+ });
326
+ }
327
+ if (allowFreeform) {
328
+ items.push({
329
+ value: { kind: "freeform" },
330
+ label: "Type something.",
331
+ description: "Enter a custom response",
332
+ toggleable: false,
333
+ confirmAction: "activate",
334
+ numberLabel: false,
335
+ });
336
+ }
337
+ this.list = createMultiSelectList<DiscussionMultiSelectChoice>({
338
+ items,
339
+ theme,
340
+ keybindings,
341
+ onCancel: () => this.onCancel?.(),
342
+ onToggle: (item: MultiSelectListItem<DiscussionMultiSelectChoice>, checked: boolean) => {
343
+ if (item.value.kind === "comment") this.commentEnabled = checked;
344
+ },
345
+ onActivate: (item: MultiSelectListItem<DiscussionMultiSelectChoice>) => {
346
+ if (item.value.kind === "freeform") this.onEnterFreeform?.();
347
+ },
348
+ onSubmit: (choices: DiscussionMultiSelectChoice[]) => {
349
+ const titles = choices
350
+ .filter((choice): choice is Extract<DiscussionMultiSelectChoice, { kind: "option" }> => choice.kind === "option")
351
+ .map((choice) => choice.option.title);
352
+ if (titles.length > 0) this.onSubmit?.(titles);
353
+ else this.onCancel?.();
354
+ },
355
+ });
356
+ }
305
357
 
306
358
  public isCommentEnabled(): boolean {
307
359
  return this.commentEnabled;
308
360
  }
309
- invalidate(): void {
310
- this.cachedWidth = undefined;
311
- this.cachedLines = undefined;
312
- }
313
361
 
314
- private getItemCount(): number {
315
- return this.options.length + (this.allowComment ? 1 : 0) + (this.allowFreeform ? 1 : 0);
316
- }
317
- private getCommentToggleIndex(): number | null {
318
- return this.allowComment ? this.options.length : null;
319
- }
320
- private getFreeformIndex(): number {
321
- return this.options.length + (this.allowComment ? 1 : 0);
322
- }
323
- private isCommentToggleRow(index: number): boolean {
324
- const i = this.getCommentToggleIndex();
325
- return i !== null && index === i;
326
- }
327
- private isFreeformRow(index: number): boolean {
328
- return this.allowFreeform && index === this.getFreeformIndex();
329
- }
330
-
331
- private toggle(index: number): void {
332
- if (index < 0 || index >= this.options.length) return;
333
- if (this.checked.has(index)) this.checked.delete(index);
334
- else this.checked.add(index);
362
+ setMaxVisibleRows(rows: number): void {
363
+ this.list.setMaxVisibleRows(rows);
335
364
  }
336
365
 
337
- private toggleComment(): void {
338
- if (!this.allowComment) return;
339
- this.commentEnabled = !this.commentEnabled;
340
- this.invalidate();
366
+ invalidate(): void {
367
+ this.list.invalidate();
341
368
  }
342
369
 
343
370
  handleInput(data: string): void {
344
- if (this.keybindings.matches(data, "tui.select.cancel")) {
345
- this.onCancel?.();
346
- return;
347
- }
348
- const count = this.getItemCount();
349
- if (count === 0) {
350
- this.onCancel?.();
351
- return;
352
- }
353
- if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
354
- this.toggleComment();
355
- return;
356
- }
357
-
358
- if (matchesSelectUp(data, this.keybindings)) {
359
- this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
360
- this.invalidate();
361
- return;
362
- }
363
- if (matchesSelectDown(data, this.keybindings)) {
364
- this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
365
- this.invalidate();
366
- return;
367
- }
368
-
369
- const numMatch = data.match(/^[1-9]$/);
370
- if (numMatch) {
371
- const idx = Number.parseInt(numMatch[0], 10) - 1;
372
- if (idx >= 0 && idx < this.options.length) {
373
- this.toggle(idx);
374
- this.selectedIndex = Math.min(idx, count - 1);
375
- this.invalidate();
376
- }
377
- return;
378
- }
379
-
380
- if (matchesKey(data, Key.space)) {
381
- if (this.isCommentToggleRow(this.selectedIndex)) {
382
- this.toggleComment();
383
- return;
384
- }
385
- if (this.isFreeformRow(this.selectedIndex)) {
386
- this.onEnterFreeform?.();
387
- return;
388
- }
389
- this.toggle(this.selectedIndex);
390
- this.invalidate();
371
+ if (this.commentIndex !== undefined && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
372
+ this.list.setChecked(this.commentIndex, !this.commentEnabled);
391
373
  return;
392
374
  }
393
-
394
- if (this.keybindings.matches(data, "tui.select.confirm")) {
395
- if (this.isCommentToggleRow(this.selectedIndex)) {
396
- this.toggleComment();
397
- return;
398
- }
399
- if (this.isFreeformRow(this.selectedIndex)) {
400
- this.onEnterFreeform?.();
401
- return;
402
- }
403
- const selectedTitles = [...this.checked]
404
- .sort((a, b) => a - b)
405
- .map((i) => this.options[i]?.title)
406
- .filter((t): t is string => !!t);
407
- const fallback = this.options[this.selectedIndex]?.title;
408
- const result = selectedTitles.length > 0 ? selectedTitles : fallback ? [fallback] : [];
409
- if (result.length > 0) this.onSubmit?.(result);
410
- else this.onCancel?.();
411
- }
375
+ this.list.handleInput(data);
412
376
  }
413
377
 
414
378
  render(width: number): string[] {
415
- if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
416
- const theme = this.theme;
417
- const count = this.getItemCount();
418
- const maxVisible = Math.min(count, 10);
419
- if (count === 0) {
420
- this.cachedLines = [theme.fg("warning", "No options")];
421
- this.cachedWidth = width;
422
- return this.cachedLines;
423
- }
424
-
425
- const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), count - maxVisible));
426
- const endIndex = Math.min(startIndex + maxVisible, count);
427
- const lines: string[] = [];
428
-
429
- for (let i = startIndex; i < endIndex; i++) {
430
- const isSelected = i === this.selectedIndex;
431
- const prefix = isSelected ? theme.fg("accent", "→") : " ";
432
-
433
- if (this.isCommentToggleRow(i)) {
434
- const checkbox = this.commentEnabled ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
435
- const label = isSelected
436
- ? theme.fg("accent", theme.bold(COMMENT_TOGGLE_LABEL))
437
- : theme.fg("text", theme.bold(COMMENT_TOGGLE_LABEL));
438
- lines.push(truncateToWidth(`${prefix} ${checkbox} ${label}`, width, ""));
439
- continue;
440
- }
441
- if (this.isFreeformRow(i)) {
442
- const label = theme.fg("text", theme.bold("Type something."));
443
- const desc = theme.fg("muted", "Enter a custom response");
444
- lines.push(truncateToWidth(`${prefix} ${label} ${theme.fg("dim", "—")} ${desc}`, width, ""));
445
- continue;
446
- }
447
- const option = this.options[i];
448
- if (!option) continue;
449
- const checkbox = this.checked.has(i) ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
450
- const num = theme.fg("dim", `${i + 1}.`);
451
- const title = isSelected ? theme.fg("accent", theme.bold(option.title)) : theme.fg("text", theme.bold(option.title));
452
- lines.push(truncateToWidth(`${prefix} ${num} ${checkbox} ${title}`, width, ""));
453
- if (option.description) {
454
- const indent = " ";
455
- for (const w of wrapTextWithAnsi(option.description, Math.max(10, width - indent.length)))
456
- lines.push(truncateToWidth(indent + theme.fg("muted", w), width, ""));
457
- }
458
- }
459
-
460
- if (startIndex > 0 || endIndex < count)
461
- lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
462
- this.cachedWidth = width;
463
- this.cachedLines = lines;
464
- return lines;
379
+ return this.list.render(width);
465
380
  }
466
381
  }
467
382
 
@@ -740,7 +655,7 @@ class AskComponent extends Container {
740
655
  private helpText: Text;
741
656
 
742
657
  private singleSelectList?: WrappedSingleSelectList;
743
- private multiSelectList?: MultiSelectList;
658
+ private multiSelectList?: DiscussionMultiSelectList;
744
659
  private editor?: Editor;
745
660
 
746
661
  private _focused = false;
@@ -906,7 +821,8 @@ class AskComponent extends Container {
906
821
  const safeBudget = Math.max(0, Math.floor(budget));
907
822
  if (safeBudget <= 0) return [];
908
823
  if (this.mode === "select") {
909
- if (!this.allowMultiple) this.ensureSingleSelectList().setMaxVisibleRows(Math.max(1, safeBudget));
824
+ if (this.allowMultiple) this.ensureMultiSelectList().setMaxVisibleRows(Math.max(1, safeBudget));
825
+ else this.ensureSingleSelectList().setMaxVisibleRows(Math.max(1, safeBudget));
910
826
  return this.limitLines(this.modeContainer.render(width), safeBudget, width, true);
911
827
  }
912
828
  return this.renderEditorModeLines(width, safeBudget);
@@ -1119,9 +1035,9 @@ class AskComponent extends Container {
1119
1035
  return list;
1120
1036
  }
1121
1037
 
1122
- private ensureMultiSelectList(): MultiSelectList {
1038
+ private ensureMultiSelectList(): DiscussionMultiSelectList {
1123
1039
  if (this.multiSelectList) return this.multiSelectList;
1124
- const list = new MultiSelectList(
1040
+ const list = new DiscussionMultiSelectList(
1125
1041
  this.options,
1126
1042
  this.allowFreeform,
1127
1043
  this.allowComment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.45.1",
3
+ "version": "0.45.2",
4
4
  "description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -20,13 +20,14 @@
20
20
  "@danypops/jittor": "^0.14.0",
21
21
  "@danypops/papyrus": "^0.44.0",
22
22
  "@danypops/vehicle-client": "^0.5.0",
23
- "@danypops/vehicle-client-pi": "^0.15.0",
23
+ "@danypops/vehicle-client-pi": "^0.16.1",
24
24
  "@danypops/vehicle-core": "^0.10.0",
25
25
  "@danypops/vehicle-server": "^0.13.0",
26
26
  "beautiful-mermaid": "1.1.3",
27
- "malevich-tui-components": "^0.20.2"
27
+ "malevich-tui-components": "^0.20.3"
28
28
  },
29
29
  "devDependencies": {
30
+ "@danypops/pi-tui-harness": "^0.0.2",
30
31
  "@earendil-works/pi-coding-agent": "^0.80.10",
31
32
  "bun-types": "latest",
32
33
  "typebox": "^1.3.6",