pi-soly 1.10.0 → 1.11.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.
Files changed (103) hide show
  1. package/ask/index.ts +0 -11
  2. package/ask/picker.ts +281 -176
  3. package/ask/tests/picker.test.ts +177 -148
  4. package/codemap.ts +276 -0
  5. package/hotreload.ts +239 -0
  6. package/init.ts +302 -0
  7. package/mcp/CHANGELOG.md +384 -0
  8. package/mcp/LICENSE +21 -0
  9. package/mcp/OAUTH.md +355 -0
  10. package/mcp/README.md +410 -0
  11. package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
  12. package/mcp/__tests__/cli.upstream-test.ts +97 -0
  13. package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
  14. package/mcp/__tests__/config.upstream-test.ts +303 -0
  15. package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
  16. package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
  17. package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
  18. package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
  19. package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
  20. package/mcp/__tests__/errors.upstream-test.ts +218 -0
  21. package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
  22. package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
  23. package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
  24. package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
  25. package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
  26. package/mcp/__tests__/logger.upstream-test.ts +175 -0
  27. package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
  28. package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
  29. package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
  30. package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
  31. package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
  32. package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
  33. package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
  34. package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
  35. package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
  36. package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
  37. package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
  38. package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
  39. package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
  40. package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
  41. package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
  42. package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
  43. package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
  44. package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
  45. package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
  46. package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
  47. package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
  48. package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
  49. package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
  50. package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
  51. package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
  52. package/mcp/agent-dir.ts +20 -0
  53. package/mcp/app-bridge.bundle.js +67 -0
  54. package/mcp/cli.js +184 -0
  55. package/mcp/commands.ts +422 -0
  56. package/mcp/config.ts +666 -0
  57. package/mcp/consent-manager.ts +64 -0
  58. package/mcp/direct-tools.ts +439 -0
  59. package/mcp/elicitation-handler.ts +347 -0
  60. package/mcp/errors.ts +219 -0
  61. package/mcp/glimpse-ui.ts +80 -0
  62. package/mcp/host-html-template.ts +427 -0
  63. package/mcp/index.ts +362 -0
  64. package/mcp/init.ts +362 -0
  65. package/mcp/lifecycle.ts +93 -0
  66. package/mcp/logger.ts +169 -0
  67. package/mcp/mcp-auth-flow.ts +559 -0
  68. package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
  69. package/mcp/mcp-auth.ts +302 -0
  70. package/mcp/mcp-auth.upstream-test.ts +373 -0
  71. package/mcp/mcp-callback-server.ts +372 -0
  72. package/mcp/mcp-callback-server.upstream-test.ts +416 -0
  73. package/mcp/mcp-oauth-provider.ts +369 -0
  74. package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
  75. package/mcp/mcp-panel.ts +829 -0
  76. package/mcp/mcp-setup-panel.ts +580 -0
  77. package/mcp/metadata-cache.ts +201 -0
  78. package/mcp/notify.ts +111 -0
  79. package/mcp/npx-resolver.ts +424 -0
  80. package/mcp/oauth-handler.ts +57 -0
  81. package/mcp/onboarding-state.ts +68 -0
  82. package/mcp/package.json +106 -0
  83. package/mcp/panel-keys.ts +37 -0
  84. package/mcp/proxy-modes.ts +949 -0
  85. package/mcp/resource-tools.ts +17 -0
  86. package/mcp/sampling-handler.ts +268 -0
  87. package/mcp/server-manager.ts +545 -0
  88. package/mcp/state.ts +41 -0
  89. package/mcp/tool-metadata.ts +216 -0
  90. package/mcp/tool-registrar.ts +46 -0
  91. package/mcp/tool-result-renderer.ts +161 -0
  92. package/mcp/types.ts +448 -0
  93. package/mcp/ui-resource-handler.ts +146 -0
  94. package/mcp/ui-server.ts +623 -0
  95. package/mcp/ui-session.ts +386 -0
  96. package/mcp/ui-stream-types.ts +89 -0
  97. package/mcp/utils.ts +129 -0
  98. package/mcp/vitest.config.ts +14 -0
  99. package/migrate.ts +258 -0
  100. package/notification.ts +218 -0
  101. package/notifications-log.ts +83 -0
  102. package/package.json +20 -3
  103. package/status.ts +140 -0
package/ask/index.ts CHANGED
@@ -159,17 +159,6 @@ export default function piAskExtension(pi: ExtensionAPI) {
159
159
  theme: askTheme,
160
160
  keybindings,
161
161
  done,
162
- // Bridge to the parent's UI for the "Other…" text input.
163
- // The picker stays decoupled from ExtensionContext.
164
- onRequestInput: async (req) => {
165
- if (!ctx.hasUI) return undefined;
166
- return (await ctx.ui.input(req.title, req.placeholder)) ?? undefined;
167
- },
168
- // Bridge for the `n` note dialog. Reuses the same text-input UI.
169
- onRequestNote: async (req) => {
170
- if (!ctx.hasUI) return undefined;
171
- return (await ctx.ui.input(req.title, req.placeholder)) ?? undefined;
172
- },
173
162
  title: `pi-ask — ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
174
163
  });
175
164
  },
package/ask/picker.ts CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  Container,
18
18
  Text,
19
19
  Spacer,
20
+ Input,
21
+ truncateToWidth,
20
22
  type Component,
21
23
  type KeybindingsManager,
22
24
  } from "@earendil-works/pi-tui";
@@ -72,13 +74,6 @@ export interface AskProResult {
72
74
  notes?: Record<number, string>;
73
75
  }
74
76
 
75
- /** Options for the text-input dialog opened when "Other…" is picked. */
76
- export interface AskProInputRequest {
77
- title: string;
78
- prompt: string;
79
- placeholder?: string;
80
- }
81
-
82
77
  interface AskProComponentDeps {
83
78
  questions: AskQuestion[];
84
79
  theme: AskProTheme;
@@ -86,15 +81,6 @@ interface AskProComponentDeps {
86
81
  done: (result: AskProResult) => void;
87
82
  /** Optional title shown above the tabs. */
88
83
  title?: string;
89
- /** Open a text-input dialog for the "Other…" option. Returns the typed
90
- * text, or undefined if the user cancelled. If omitted, the "Other…"
91
- * option is hidden even when `allowOther: true` (caller should ensure
92
- * the dependency is present if it advertises allowOther). */
93
- onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
94
- /** Open a text-input dialog for adding a note to the current question
95
- * (triggered by pressing `n`). Returns the typed text, or undefined
96
- * if cancelled. If omitted, the `n` shortcut is a no-op. */
97
- onRequestNote?: (req: AskProInputRequest) => Promise<string | undefined>;
98
84
  }
99
85
 
100
86
  // ---------------------------------------------------------------------------
@@ -114,6 +100,18 @@ const KEY_LEFT = "\x1b[D";
114
100
  const KEY_SHIFT_TAB = "\x1b[Z";
115
101
  const KEY_BACKSPACE = "\x7f";
116
102
 
103
+ /** Active inline text-input mode. When non-null, the picker renders an
104
+ * embedded single-line `Input` field below the option list and routes
105
+ * keystrokes to it until Enter (commit) or Esc (cancel).
106
+ *
107
+ * Why inline: the previous design called out to the host UI's modal input
108
+ * dialog (`ctx.ui.input()`), which clears `editorContainer` to show itself
109
+ * and restores the *default* editor on close — destroying the live picker.
110
+ * Inline input keeps the picker alive and self-contained. */
111
+ type InputMode =
112
+ | { kind: "note" }
113
+ | { kind: "other"; isMulti: boolean; input: Input };
114
+
117
115
  /** A standalone picker component. Extends Container so it composes in the
118
116
  * editor area like any other TUI widget. */
119
117
  export class AskProComponent extends Container {
@@ -121,8 +119,6 @@ export class AskProComponent extends Container {
121
119
  private theme: AskProTheme;
122
120
  private keybindings: KeybindingsManager;
123
121
  private done: (result: AskProResult) => void;
124
- private onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
125
- private onRequestNote?: (req: AskProInputRequest) => Promise<string | undefined>;
126
122
  private title: string;
127
123
 
128
124
  private currentIndex = 0;
@@ -133,12 +129,15 @@ export class AskProComponent extends Container {
133
129
  private notes = new Map<number, string>();
134
130
  /** Set true once `done` is called — further input is ignored. */
135
131
  private completed = false;
136
- /** Set while a text-input dialog is awaiting the user's reply. */
137
- private awaitingInput = false;
132
+ /** Active inline text-input mode (note or Other…). When set, all keys
133
+ * except Enter/Esc are routed to the embedded Input. */
134
+ private inputMode: InputMode | null = null;
135
+ /** Note Input used while inputMode.kind === "note". Kept on the instance
136
+ * so the `n`-flow can reuse a single field across open/close cycles. */
137
+ private noteInput: Input | null = null;
138
138
 
139
139
  private tabsText!: Text;
140
140
  private bodyContainer!: Container;
141
- private previewText!: Text;
142
141
  private footerText!: Text;
143
142
 
144
143
  constructor(deps: AskProComponentDeps) {
@@ -147,8 +146,6 @@ export class AskProComponent extends Container {
147
146
  this.theme = deps.theme;
148
147
  this.keybindings = deps.keybindings;
149
148
  this.done = deps.done;
150
- this.onRequestInput = deps.onRequestInput;
151
- this.onRequestNote = deps.onRequestNote;
152
149
  this.title = deps.title ?? "pi-ask";
153
150
 
154
151
  const titleText = new Text(this.theme.fg("accent", this.theme.bold(this.title)), 1, 0);
@@ -272,8 +269,9 @@ export class AskProComponent extends Container {
272
269
  }
273
270
  }
274
271
 
275
- // Synthetic "Other…" option (when allowOther=true)
276
- if (allowOther && this.onRequestInput) {
272
+ // Synthetic "Other…" option (when allowOther=true). The inline text
273
+ // field is built in, so this no longer depends on any external callback.
274
+ if (allowOther) {
277
275
  const otherIndex = q.options.length;
278
276
  const isOtherSelected = this.selectedIndex === otherIndex;
279
277
  const customStr = this.getCustomString(currentAns);
@@ -345,6 +343,30 @@ export class AskProComponent extends Container {
345
343
  ),
346
344
  );
347
345
  }
346
+
347
+ // Inline text field for note / Other…. Shown below the option list when
348
+ // inputMode is active; owns the keyboard (Enter commits, Esc cancels).
349
+ this.renderInlineField();
350
+ }
351
+
352
+ /** Append the active inline text field to bodyContainer. No-op when no
353
+ * inline mode is active. */
354
+ private renderInlineField(): void {
355
+ const mode = this.inputMode;
356
+ if (!mode) return;
357
+ const q = this.questions[this.currentIndex];
358
+ const label =
359
+ mode.kind === "note"
360
+ ? this.theme.fg("dim", "Note:")
361
+ : this.theme.fg("dim", "Custom answer:");
362
+ this.bodyContainer.addChild(new Spacer(1));
363
+ this.bodyContainer.addChild(new Text(label + " " + this.theme.fg("dim", "(enter ⏎ confirm · esc cancel)"), 1, 0));
364
+ this.bodyContainer.addChild(new Spacer(1));
365
+ if (mode.kind === "other") {
366
+ this.bodyContainer.addChild(mode.input);
367
+ } else if (this.noteInput) {
368
+ this.bodyContainer.addChild(this.noteInput);
369
+ }
348
370
  }
349
371
 
350
372
  // -------------------------------------------------------------------------
@@ -379,7 +401,7 @@ export class AskProComponent extends Container {
379
401
  const q = this.questions[this.currentIndex];
380
402
  if (!q) return 0;
381
403
  const allowOther = q.allowOther ?? false;
382
- return q.options.length + (allowOther && this.onRequestInput ? 1 : 0);
404
+ return q.options.length + (allowOther ? 1 : 0);
383
405
  }
384
406
 
385
407
  // -------------------------------------------------------------------------
@@ -396,14 +418,25 @@ export class AskProComponent extends Container {
396
418
  const totalOptions = this.totalOptionsForCurrent();
397
419
 
398
420
  const parts: string[] = [];
421
+
422
+ // When the inline text field is open, it owns the keyboard — show
423
+ // only its affordances, not the navigation hints.
424
+ if (this.inputMode !== null) {
425
+ parts.push(this.theme.fg("accent", "⏎ confirm"));
426
+ parts.push(this.theme.fg("dim", "esc cancel"));
427
+ return parts.join(" ");
428
+ }
429
+
399
430
  parts.push(this.theme.fg("dim", "↑↓ navigate"));
400
431
  parts.push(this.theme.fg("dim", `1-${totalOptions} pick`));
401
432
  if (this.currentIndex > 0) parts.push(this.theme.fg("dim", "tab/← prev"));
402
433
  if (this.currentIndex < this.questions.length - 1) {
403
434
  parts.push(this.theme.fg("dim", "tab/→ next"));
404
435
  }
405
- // "Other…" hint: single-select uses Enter, multi-select uses Space
406
- if (allowOther && this.onRequestInput && this.selectedIndex === otherIndex) {
436
+ // "Other…" hint: single-select uses Enter to open inline input,
437
+ // multi-select uses Space. (The inline field is always available now;
438
+ // no external dependency required.)
439
+ if (allowOther && this.selectedIndex === otherIndex) {
407
440
  parts.push(this.theme.fg("accent", isMulti ? "␣ type" : "⏎ type"));
408
441
  } else if (isMulti) {
409
442
  // Multi-select: Space toggles, Enter advances/submits
@@ -422,12 +455,11 @@ export class AskProComponent extends Container {
422
455
  // Single-select: Enter is the action key
423
456
  parts.push(this.theme.fg("accent", isLast ? "⏎ submit" : "⏎ next"));
424
457
  }
425
- // `n` hint: add/edit note (only if dep is wired)
426
- if (this.onRequestNote) {
427
- const hasNote = this.notes.has(this.currentIndex);
428
- const hint = hasNote ? "n ✓note" : "n note";
429
- parts.push(this.theme.fg(hasNote ? "success" : "dim", hint));
430
- }
458
+ // `n` hint: add/edit an inline note. Always available now (inline
459
+ // field, no external dependency).
460
+ const hasNote = this.notes.has(this.currentIndex);
461
+ const noteHint = hasNote ? "n ✓note" : "n note";
462
+ parts.push(this.theme.fg(hasNote ? "success" : "dim", noteHint));
431
463
  parts.push(this.theme.fg("dim", "esc cancel"));
432
464
  return parts.join(" ");
433
465
  }
@@ -451,9 +483,17 @@ export class AskProComponent extends Container {
451
483
  // -------------------------------------------------------------------------
452
484
 
453
485
  handleInput(keyData: string): void {
454
- if (this.completed || this.awaitingInput) return;
486
+ if (this.completed) return;
487
+
488
+ // --- Inline text-input mode (note / Other…) --------------------------
489
+ // When active, all keys route to the embedded Input except the
490
+ // confirm/cancel gestures, which we intercept to commit or abort.
491
+ if (this.inputMode !== null) {
492
+ this.handleInputModeKey(keyData);
493
+ return;
494
+ }
455
495
 
456
- // Esc — cancel
496
+ // Esc — cancel the whole picker
457
497
  if (keyData === KEY_ESC) {
458
498
  this.completed = true;
459
499
  this.done({ cancelled: true });
@@ -525,13 +565,13 @@ export class AskProComponent extends Container {
525
565
  }
526
566
 
527
567
  // Space — toggle in multi-select.
528
- // On "Other…", opens the input dialog (or toggles existing custom string).
568
+ // On "Other…", opens the inline text field (or toggles existing custom string).
529
569
  // In single-select, Space is a no-op (Enter is the action key there).
530
570
  if (keyData === KEY_SPACE) {
531
571
  if (!isMulti) return;
532
- // On Other… → open input dialog (or re-toggle existing custom string)
533
- if (allowOther && this.onRequestInput && this.selectedIndex === otherIndex) {
534
- void this.requestOtherInput();
572
+ // On Other… → open inline field (or re-toggle existing custom string)
573
+ if (allowOther && this.selectedIndex === otherIndex) {
574
+ this.openOtherInput();
535
575
  return;
536
576
  }
537
577
  const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
@@ -552,16 +592,11 @@ export class AskProComponent extends Container {
552
592
  keyData === KEY_ENTER ||
553
593
  keyData === KEY_ENTER_CR
554
594
  ) {
555
- // If "Other…" is the selected option in single-select, open
556
- // the input dialog. In multi-select, Enter on Other… just
557
- // advances (use Space to toggle/type a custom answer).
558
- if (
559
- allowOther &&
560
- this.onRequestInput &&
561
- this.selectedIndex === otherIndex &&
562
- !isMulti
563
- ) {
564
- void this.requestOtherInput();
595
+ // If "Other…" is the selected option in single-select, open the
596
+ // inline field. In multi-select, Enter on Other… just advances
597
+ // (use Space to toggle/type a custom answer).
598
+ if (allowOther && this.selectedIndex === otherIndex && !isMulti) {
599
+ this.openOtherInput();
565
600
  return;
566
601
  }
567
602
 
@@ -597,10 +632,10 @@ export class AskProComponent extends Container {
597
632
  return;
598
633
  }
599
634
 
600
- // `n` — add/edit a free-text note for the current question.
601
- // Requires onRequestNote dep; otherwise ignored.
602
- if (keyData === "n" && this.onRequestNote) {
603
- void this.requestNoteInput();
635
+ // `n` — add/edit a free-text note for the current question via the
636
+ // inline field. Always available (no external dependency).
637
+ if (keyData === "n") {
638
+ this.openNoteInput();
604
639
  return;
605
640
  }
606
641
  }
@@ -612,9 +647,9 @@ export class AskProComponent extends Container {
612
647
  const allowOther = q.allowOther ?? false;
613
648
  const otherIndex = allowOther ? q.options.length : -1;
614
649
 
615
- // "Other…" picked via number key
616
- if (allowOther && this.onRequestInput && optionIdx === otherIndex) {
617
- void this.requestOtherInput();
650
+ // "Other…" picked via number key → open inline field
651
+ if (allowOther && optionIdx === otherIndex) {
652
+ this.openOtherInput();
618
653
  return;
619
654
  }
620
655
 
@@ -642,91 +677,122 @@ export class AskProComponent extends Container {
642
677
  }
643
678
  }
644
679
 
645
- /**
646
- * Open the text-input dialog for the "Other…" option. Awaits the user's
647
- * reply asynchronously. While awaiting, the picker ignores further input.
648
- * If the user types text, the answer is stored (string for single, pushed
649
- * to the multi-select array for multi). If the user cancels, the answer
650
- * is unchanged.
651
- */
652
- private async requestOtherInput(): Promise<void> {
653
- if (!this.onRequestInput) return;
680
+ /** Create a fresh inline Input pre-filled with `value`, cursor at the
681
+ * end so the user can immediately extend or backspace-edit it.
682
+ * (Input.setValue leaves the cursor at min(prevCursor, len) which is 0
683
+ * for a fresh field typing the prefill char-by-char puts the cursor
684
+ * at the end naturally.) */
685
+ private makeInput(value: string): Input {
686
+ const input = new Input();
687
+ for (const ch of value) input.handleInput(ch);
688
+ return input;
689
+ }
690
+
691
+ /** Open the inline field for the "Other…" option on the current
692
+ * question. Pre-fills with any existing custom string so it can be
693
+ * edited. While open, all keys route to the field except Enter/Esc. */
694
+ private openOtherInput(): void {
654
695
  const q = this.questions[this.currentIndex];
655
696
  if (!q) return;
656
- this.awaitingInput = true;
657
697
  const isMulti = q.multiSelect ?? false;
698
+ const currentAns = this.answers.get(this.currentIndex);
699
+ const existing = this.getCustomString(currentAns);
700
+ this.inputMode = { kind: "other", isMulti, input: this.makeInput(existing) };
701
+ this.repaint();
702
+ }
658
703
 
659
- const text = await this.onRequestInput({
660
- title: q.header,
661
- prompt: `Custom answer for: ${q.question}`,
662
- placeholder: "Type your answer…",
663
- });
664
- this.awaitingInput = false;
704
+ /** Open the inline field for a note on the current question. Pre-fills
705
+ * with any existing note so it can be edited. */
706
+ private openNoteInput(): void {
707
+ if (!this.questions[this.currentIndex]) return;
708
+ const existing = this.notes.get(this.currentIndex) ?? "";
709
+ this.noteInput = this.makeInput(existing);
710
+ this.inputMode = { kind: "note" };
711
+ this.repaint();
712
+ }
665
713
 
666
- if (text === undefined) {
667
- // User cancelled leave answer as-is, just redraw
668
- this.repaint();
714
+ /** Route a keystroke to the active inline field, intercepting Enter
715
+ * (commit) and Esc (cancel). Mirrors the Enter-on-last / advance /
716
+ * submit flow that the old async path had. */
717
+ private handleInputModeKey(keyData: string): void {
718
+ const mode = this.inputMode;
719
+ if (!mode) return;
720
+
721
+ // Esc — cancel inline input, return to option navigation
722
+ if (keyData === KEY_ESC) {
723
+ this.closeInput(false);
669
724
  return;
670
725
  }
671
- const trimmed = text.trim();
672
- if (trimmed === "") {
673
- this.repaint();
726
+ // Enter commit
727
+ if (
728
+ this.keybindings.matches(keyData, "tui.select.confirm") ||
729
+ keyData === KEY_ENTER ||
730
+ keyData === KEY_ENTER_CR
731
+ ) {
732
+ this.closeInput(true);
674
733
  return;
675
734
  }
735
+ // Everything else (typing, arrows, backspace, etc.) → the field
736
+ const input = mode.kind === "other" ? mode.input : this.noteInput!;
737
+ input.handleInput(keyData);
738
+ this.repaint();
739
+ }
676
740
 
677
- if (isMulti) {
678
- const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
679
- // Replace existing custom string (if any) so user can edit
680
- const existingIdx = cur.findIndex((a) => typeof a === "string");
681
- if (existingIdx >= 0) cur[existingIdx] = trimmed;
682
- else cur.push(trimmed);
683
- this.answers.set(this.currentIndex, cur);
741
+ /** Commit (commit=true) or discard the active inline field, then return
742
+ * to normal option navigation. */
743
+ private closeInput(commit: boolean): void {
744
+ const mode = this.inputMode;
745
+ if (!mode) return;
746
+ this.inputMode = null;
747
+
748
+ if (mode.kind === "note") {
749
+ const text = this.noteInput?.getValue() ?? "";
750
+ this.noteInput = null;
751
+ if (commit) {
752
+ const trimmed = text.trim();
753
+ if (trimmed === "") {
754
+ this.notes.delete(this.currentIndex);
755
+ } else {
756
+ this.notes.set(this.currentIndex, trimmed);
757
+ }
758
+ }
684
759
  this.repaint();
685
- } else {
686
- // Single-select: set custom string, advance or submit
687
- this.answers.set(this.currentIndex, trimmed);
760
+ return;
761
+ }
762
+
763
+ // kind === "other"
764
+ const isMulti = mode.isMulti;
765
+ const text = mode.input.getValue();
766
+ const trimmed = commit ? text.trim() : "";
767
+ if (commit && trimmed !== "") {
768
+ if (isMulti) {
769
+ const cur =
770
+ (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
771
+ // Replace existing custom string (if any) so user can edit
772
+ const existingIdx = cur.findIndex((a) => typeof a === "string");
773
+ if (existingIdx >= 0) cur[existingIdx] = trimmed;
774
+ else cur.push(trimmed);
775
+ this.answers.set(this.currentIndex, cur);
776
+ } else {
777
+ this.answers.set(this.currentIndex, trimmed);
778
+ }
779
+ }
780
+ this.repaint();
781
+
782
+ // Single-select: a committed custom answer advances or submits,
783
+ // matching the old async behavior. Multi-select stays put (user
784
+ // may toggle more options). Cancellation never advances.
785
+ if (commit && !isMulti && trimmed !== "") {
688
786
  if (this.currentIndex < this.questions.length - 1) {
689
787
  this.currentIndex++;
690
788
  this.selectedIndex = 0;
691
789
  this.repaint();
692
790
  } else if (this.allAnswered()) {
693
791
  this.submit();
694
- } else {
695
- this.repaint();
696
792
  }
697
793
  }
698
794
  }
699
795
 
700
- /** Open a text-input dialog to add/edit a note for the current question.
701
- * Triggered by the `n` key. Pre-fills with any existing note so the
702
- * user can edit it. An empty submission clears the note. */
703
- private async requestNoteInput(): Promise<void> {
704
- if (!this.onRequestNote) return;
705
- const q = this.questions[this.currentIndex];
706
- if (!q) return;
707
- this.awaitingInput = true;
708
- const existing = this.notes.get(this.currentIndex) ?? "";
709
- const text = await this.onRequestNote({
710
- title: q.header,
711
- prompt: `Add a note to your answer for: ${q.question}`,
712
- placeholder: existing || "Add context, edge cases, or reasoning…",
713
- });
714
- this.awaitingInput = false;
715
- if (text === undefined) {
716
- // Cancelled — keep existing note, just redraw
717
- this.repaint();
718
- return;
719
- }
720
- const trimmed = text.trim();
721
- if (trimmed === "") {
722
- // Empty submission clears the note
723
- this.notes.delete(this.currentIndex);
724
- } else {
725
- this.notes.set(this.currentIndex, trimmed);
726
- }
727
- this.repaint();
728
- }
729
-
730
796
  private submit(): void {
731
797
  if (!this.allAnswered()) return;
732
798
  const answers: Record<number, AskAnswer | AskMultiAnswer> = {};
@@ -754,17 +820,24 @@ export class AskProComponent extends Container {
754
820
 
755
821
  dispose(): void {
756
822
  this.completed = true;
757
- this.awaitingInput = false;
823
+ this.inputMode = null;
824
+ this.noteInput = null;
758
825
  }
759
826
 
760
827
  // -----------------------------------------------------------------------
761
- // Side-by-side render: stack picker column (left) with preview column
762
- // (right) row-by-row. Each visible body row gets padded to SPLIT_COL and
763
- // the matching preview row is appended. If no preview is set, the left
764
- // column takes the full width (no padding).
828
+ // Side-by-side render: picker column (left) + preview column (right).
829
+ //
830
+ // HARD GUARANTEE: every emitted line is capped to `width` visible columns
831
+ // via truncateToWidth(). The previous implementation measured width with a
832
+ // hand-rolled regex that ignored OSC 8 hyperlinks / FTCS marks (which pi-tui
833
+ // wraps around rendered text) and never truncated the left column or the
834
+ // combined row — so a long option label or preview word pushed a line past
835
+ // the terminal width and crashed pi ("Rendered line N exceeds terminal
836
+ // width"). See C:/Users/bradw/.pi/agent/pi-crash.log.
765
837
  // -----------------------------------------------------------------------
766
- private static readonly SPLIT_COL = 60; // picker column width when preview is present
767
- private static readonly PREVIEW_COL = 60; // preview column width
838
+ private static readonly SPLIT_COL = 60; // max picker column width when a preview is present
839
+ private static readonly SEP = " │ "; // 3-col gutter between the two columns
840
+ private static readonly MIN_COL = 16; // minimum useful width for either column
768
841
 
769
842
  /** Lines of preview content for the option currently under the cursor.
770
843
  * Returns [] when no option is focused or the option has no preview. */
@@ -785,70 +858,102 @@ export class AskProComponent extends Container {
785
858
  return lines;
786
859
  }
787
860
 
861
+ /** Greedy word-wrap one source line to `maxWidth`, hard-truncating any
862
+ * single word longer than `maxWidth`. Returns 1+ lines, each ≤ maxWidth
863
+ * visible columns. Uses .length here only as a fast upper bound — the
864
+ * caller still hard-caps the final row, so a miscount can never overflow. */
865
+ private wrapPreviewLine(line: string, maxWidth: number): string[] {
866
+ if (maxWidth <= 0) return [""];
867
+ if (line.length === 0) return [""];
868
+ const words = line.split(" ");
869
+ const out: string[] = [];
870
+ let cur = "";
871
+ for (const w of words) {
872
+ const word = w.length > maxWidth ? truncateToWidth(w, maxWidth, "") : w;
873
+ if (cur.length === 0) {
874
+ cur = word;
875
+ } else if (cur.length + 1 + word.length <= maxWidth) {
876
+ cur += " " + word;
877
+ } else {
878
+ out.push(cur);
879
+ cur = word;
880
+ }
881
+ }
882
+ out.push(cur);
883
+ return out;
884
+ }
885
+
788
886
  /** Override render to produce a side-by-side layout when a preview is
789
- * present. Falls back to default Container render otherwise. */
887
+ * present. Falls back to default Container render otherwise. Every
888
+ * returned line is guaranteed ≤ `width` visible columns. */
790
889
  render(width: number): string[] {
791
890
  const superLines = super.render(width);
792
891
  const previewLines = this.currentPreviewLines();
793
892
  if (previewLines.length === 0) return superLines;
794
893
 
795
- // We need to find which body lines belong to the option list and
796
- // merge the preview next to them. Simpler approach: append a preview
797
- // block below the picker body, styled as a framed right-aligned panel.
798
- // True side-by-side would require row index tracking which Container
799
- // doesn't expose; the framed block is visually distinct and avoids
800
- // fragile index math.
801
- const splitCol = Math.min(AskProComponent.SPLIT_COL, Math.floor(width * 0.6));
802
- const previewWidth = Math.max(30, width - splitCol - 3);
803
894
  const border = this.theme.fg("dim", "│");
895
+ const sepLen = AskProComponent.SEP.length; // 3
896
+
897
+ // Column allocation. Picker gets ~55% (capped at SPLIT_COL); the rest
898
+ // goes to preview. If there isn't room for two usable columns, fall
899
+ // back to a stacked layout (picker full-width, preview underneath).
900
+ const splitCol = Math.min(
901
+ AskProComponent.SPLIT_COL,
902
+ Math.max(AskProComponent.MIN_COL, Math.floor(width * 0.55)),
903
+ );
904
+ const previewWidth = width - splitCol - sepLen;
905
+ if (previewWidth < AskProComponent.MIN_COL) {
906
+ return this.renderPreviewStacked(width, superLines, previewLines, border);
907
+ }
804
908
 
805
- // Wrap preview lines to previewWidth
909
+ // Wrap each preview source line to previewWidth (a long line may
910
+ // produce several wrapped rows).
806
911
  const wrapped: string[] = [];
807
912
  for (const line of previewLines) {
808
- if (line.length === 0) {
809
- wrapped.push("");
810
- continue;
811
- }
812
- // Simple greedy word wrap
813
- const words = line.split(" ");
814
- let cur = "";
815
- for (const w of words) {
816
- if (cur.length === 0) {
817
- cur = w;
818
- } else if (cur.length + 1 + w.length <= previewWidth) {
819
- cur += " " + w;
820
- } else {
821
- wrapped.push(cur);
822
- cur = w;
823
- }
824
- }
825
- if (cur) wrapped.push(cur);
913
+ for (const w of this.wrapPreviewLine(line, previewWidth)) wrapped.push(w);
826
914
  }
827
915
 
828
- // Insert preview as a side panel: pad each picker line to splitCol,
829
- // then add a vertical border and the matching preview line.
830
- // We do this for ALL super lines so the preview spans the picker's
831
- // full height.
832
916
  const result: string[] = [];
833
- // Header row above the preview content
834
- result.push("".padEnd(splitCol) + " " + border + " " + this.theme.fg("dim", "— preview —"));
835
- for (let i = 0; i < superLines.length; i++) {
917
+ // Header row for the preview column (aligned under it), hard-capped.
918
+ result.push(
919
+ truncateToWidth(
920
+ "".padEnd(splitCol) + " " + border + " " + this.theme.fg("dim", "— preview —"),
921
+ width,
922
+ "",
923
+ false,
924
+ ),
925
+ );
926
+ const rows = Math.max(superLines.length, wrapped.length);
927
+ for (let i = 0; i < rows; i++) {
836
928
  const superLine = superLines[i] ?? "";
837
- // Strip ANSI for width measurement
838
- const visibleLen = superLine.replace(/\x1b\[[0-9;]*m/g, "").length;
839
- const pad = Math.max(1, splitCol - visibleLen);
840
- let row = superLine + " ".repeat(pad) + border + " ";
841
929
  const pLine = wrapped[i];
842
- if (pLine !== undefined) {
843
- row += this.theme.fg("text", pLine);
844
- }
845
- result.push(row);
930
+ // Left: exactly splitCol wide — truncate long option lines and pad
931
+ // short ones. truncateToWidth preserves ANSI/OSC styling.
932
+ const left = truncateToWidth(superLine, splitCol, "", true);
933
+ let row = left + " " + border + " ";
934
+ if (pLine !== undefined) row += this.theme.fg("text", pLine);
935
+ // Hard cap — the guarantee that prevents the terminal-width crash.
936
+ result.push(truncateToWidth(row, width, "", false));
846
937
  }
847
- // If preview has more lines than the picker, append them below
848
- if (wrapped.length > superLines.length) {
849
- for (let i = superLines.length; i < wrapped.length; i++) {
850
- const pLine = wrapped[i] ?? "";
851
- result.push(" ".repeat(splitCol + 1) + border + " " + this.theme.fg("text", pLine));
938
+ return result;
939
+ }
940
+
941
+ /** Stacked fallback for narrow terminals: picker lines at full width,
942
+ * then a framed preview block below. Every line width. */
943
+ private renderPreviewStacked(
944
+ width: number,
945
+ superLines: string[],
946
+ previewLines: string[],
947
+ border: string,
948
+ ): string[] {
949
+ const result: string[] = [];
950
+ for (const l of superLines) result.push(truncateToWidth(l, width, "", false));
951
+ result.push(
952
+ truncateToWidth(this.theme.fg("dim", `${border} — preview —`), width, "", false),
953
+ );
954
+ for (const line of previewLines) {
955
+ for (const w of this.wrapPreviewLine(line, width)) {
956
+ result.push(truncateToWidth(this.theme.fg("text", w), width, "", false));
852
957
  }
853
958
  }
854
959
  return result;