pi-soly 1.9.3 → 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 (104) hide show
  1. package/ask/index.ts +12 -11
  2. package/ask/picker.ts +356 -76
  3. package/ask/prompt.ts +6 -2
  4. package/ask/tests/picker.test.ts +273 -82
  5. package/codemap.ts +276 -0
  6. package/hotreload.ts +239 -0
  7. package/init.ts +302 -0
  8. package/mcp/CHANGELOG.md +384 -0
  9. package/mcp/LICENSE +21 -0
  10. package/mcp/OAUTH.md +355 -0
  11. package/mcp/README.md +410 -0
  12. package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
  13. package/mcp/__tests__/cli.upstream-test.ts +97 -0
  14. package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
  15. package/mcp/__tests__/config.upstream-test.ts +303 -0
  16. package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
  17. package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
  18. package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
  19. package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
  20. package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
  21. package/mcp/__tests__/errors.upstream-test.ts +218 -0
  22. package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
  23. package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
  24. package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
  25. package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
  26. package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
  27. package/mcp/__tests__/logger.upstream-test.ts +175 -0
  28. package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
  29. package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
  30. package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
  31. package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
  32. package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
  33. package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
  34. package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
  35. package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
  36. package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
  37. package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
  38. package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
  39. package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
  40. package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
  41. package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
  42. package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
  43. package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
  44. package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
  45. package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
  46. package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
  47. package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
  48. package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
  49. package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
  50. package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
  51. package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
  52. package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
  53. package/mcp/agent-dir.ts +20 -0
  54. package/mcp/app-bridge.bundle.js +67 -0
  55. package/mcp/cli.js +184 -0
  56. package/mcp/commands.ts +422 -0
  57. package/mcp/config.ts +666 -0
  58. package/mcp/consent-manager.ts +64 -0
  59. package/mcp/direct-tools.ts +439 -0
  60. package/mcp/elicitation-handler.ts +347 -0
  61. package/mcp/errors.ts +219 -0
  62. package/mcp/glimpse-ui.ts +80 -0
  63. package/mcp/host-html-template.ts +427 -0
  64. package/mcp/index.ts +362 -0
  65. package/mcp/init.ts +362 -0
  66. package/mcp/lifecycle.ts +93 -0
  67. package/mcp/logger.ts +169 -0
  68. package/mcp/mcp-auth-flow.ts +559 -0
  69. package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
  70. package/mcp/mcp-auth.ts +302 -0
  71. package/mcp/mcp-auth.upstream-test.ts +373 -0
  72. package/mcp/mcp-callback-server.ts +372 -0
  73. package/mcp/mcp-callback-server.upstream-test.ts +416 -0
  74. package/mcp/mcp-oauth-provider.ts +369 -0
  75. package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
  76. package/mcp/mcp-panel.ts +829 -0
  77. package/mcp/mcp-setup-panel.ts +580 -0
  78. package/mcp/metadata-cache.ts +201 -0
  79. package/mcp/notify.ts +111 -0
  80. package/mcp/npx-resolver.ts +424 -0
  81. package/mcp/oauth-handler.ts +57 -0
  82. package/mcp/onboarding-state.ts +68 -0
  83. package/mcp/package.json +106 -0
  84. package/mcp/panel-keys.ts +37 -0
  85. package/mcp/proxy-modes.ts +949 -0
  86. package/mcp/resource-tools.ts +17 -0
  87. package/mcp/sampling-handler.ts +268 -0
  88. package/mcp/server-manager.ts +545 -0
  89. package/mcp/state.ts +41 -0
  90. package/mcp/tool-metadata.ts +216 -0
  91. package/mcp/tool-registrar.ts +46 -0
  92. package/mcp/tool-result-renderer.ts +161 -0
  93. package/mcp/types.ts +448 -0
  94. package/mcp/ui-resource-handler.ts +146 -0
  95. package/mcp/ui-server.ts +623 -0
  96. package/mcp/ui-session.ts +386 -0
  97. package/mcp/ui-stream-types.ts +89 -0
  98. package/mcp/utils.ts +129 -0
  99. package/mcp/vitest.config.ts +14 -0
  100. package/migrate.ts +258 -0
  101. package/notification.ts +218 -0
  102. package/notifications-log.ts +83 -0
  103. package/package.json +20 -3
  104. package/status.ts +140 -0
package/ask/index.ts CHANGED
@@ -159,12 +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
162
  title: `pi-ask — ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
169
163
  });
170
164
  },
@@ -184,14 +178,16 @@ export default function piAskExtension(pi: ExtensionAPI) {
184
178
  }
185
179
 
186
180
  const answers = result.answers ?? {};
181
+ const notes = result.notes ?? {};
187
182
  // Pretty-print for the LLM
188
183
  const out: string[] = ["User answers:"];
189
184
  for (let i = 0; i < params.questions.length; i++) {
190
185
  const q = params.questions[i];
191
186
  if (!q) continue;
192
187
  const a = answers[i];
188
+ let line: string;
193
189
  if (a === undefined) {
194
- out.push(` Q${i + 1} (${q.header}): (no answer)`);
190
+ line = ` Q${i + 1} (${q.header}): (no answer)`;
195
191
  } else if (Array.isArray(a)) {
196
192
  const parts: string[] = [];
197
193
  for (const item of a) {
@@ -201,17 +197,22 @@ export default function piAskExtension(pi: ExtensionAPI) {
201
197
  parts.push(`"${item}"`);
202
198
  }
203
199
  }
204
- out.push(` Q${i + 1} (${q.header}) [multi]: ${parts.join(", ")}`);
200
+ line = ` Q${i + 1} (${q.header}) [multi]: ${parts.join(", ")}`;
205
201
  } else if (typeof a === "number") {
206
- out.push(` Q${i + 1} (${q.header}): ${q.options[a]?.label ?? `?${a}`}`);
202
+ line = ` Q${i + 1} (${q.header}): ${q.options[a]?.label ?? `?${a}`}`;
207
203
  } else {
208
- out.push(` Q${i + 1} (${q.header}) [Other]: "${a}"`);
204
+ line = ` Q${i + 1} (${q.header}) [Other]: "${a}"`;
205
+ }
206
+ // Append note if present
207
+ if (notes[i]) {
208
+ line += ` // note: "${notes[i]}"`;
209
209
  }
210
+ out.push(line);
210
211
  }
211
212
 
212
213
  return {
213
214
  content: [{ type: "text", text: out.join("\n") }],
214
- details: { answers },
215
+ details: { answers, notes: Object.keys(notes).length > 0 ? notes : undefined },
215
216
  };
216
217
  },
217
218
  });
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";
@@ -31,6 +33,13 @@ export interface AskOption {
31
33
  label: string;
32
34
  description?: string;
33
35
  recommended?: boolean;
36
+ /** Optional preview content shown in a side panel when this option is
37
+ * focused. Use markdown or plain text to show code snippets, structure
38
+ * examples, or elaboration of what the option entails.
39
+ *
40
+ * Example:
41
+ * preview: "```ts\nclass Auth {\n token: string\n}\n```" */
42
+ preview?: string;
34
43
  }
35
44
 
36
45
  export interface AskQuestion {
@@ -59,13 +68,10 @@ export interface AskProResult {
59
68
  cancelled?: boolean;
60
69
  /** Map of question index → answer. Single: number | string. Multi: (number | string)[] */
61
70
  answers?: Record<number, AskAnswer | AskMultiAnswer>;
62
- }
63
-
64
- /** Options for the text-input dialog opened when "Other…" is picked. */
65
- export interface AskProInputRequest {
66
- title: string;
67
- prompt: string;
68
- placeholder?: string;
71
+ /** Optional free-text notes the user added to specific questions.
72
+ * Keyed by question index. Added when the user pressed `n` after
73
+ * picking an option and typed a note. */
74
+ notes?: Record<number, string>;
69
75
  }
70
76
 
71
77
  interface AskProComponentDeps {
@@ -75,11 +81,6 @@ interface AskProComponentDeps {
75
81
  done: (result: AskProResult) => void;
76
82
  /** Optional title shown above the tabs. */
77
83
  title?: string;
78
- /** Open a text-input dialog for the "Other…" option. Returns the typed
79
- * text, or undefined if the user cancelled. If omitted, the "Other…"
80
- * option is hidden even when `allowOther: true` (caller should ensure
81
- * the dependency is present if it advertises allowOther). */
82
- onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
83
84
  }
84
85
 
85
86
  // ---------------------------------------------------------------------------
@@ -99,6 +100,18 @@ const KEY_LEFT = "\x1b[D";
99
100
  const KEY_SHIFT_TAB = "\x1b[Z";
100
101
  const KEY_BACKSPACE = "\x7f";
101
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
+
102
115
  /** A standalone picker component. Extends Container so it composes in the
103
116
  * editor area like any other TUI widget. */
104
117
  export class AskProComponent extends Container {
@@ -106,17 +119,22 @@ export class AskProComponent extends Container {
106
119
  private theme: AskProTheme;
107
120
  private keybindings: KeybindingsManager;
108
121
  private done: (result: AskProResult) => void;
109
- private onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
110
122
  private title: string;
111
123
 
112
124
  private currentIndex = 0;
113
125
  private selectedIndex = 0;
114
126
  /** answers[questionIdx] = AskAnswer (single) or AskMultiAnswer (multi). */
115
127
  private answers = new Map<number, AskAnswer | AskMultiAnswer>();
128
+ /** notes[questionIdx] = free-text note added by user (via `n` key). */
129
+ private notes = new Map<number, string>();
116
130
  /** Set true once `done` is called — further input is ignored. */
117
131
  private completed = false;
118
- /** Set while a text-input dialog is awaiting the user's reply. */
119
- 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;
120
138
 
121
139
  private tabsText!: Text;
122
140
  private bodyContainer!: Container;
@@ -128,7 +146,6 @@ export class AskProComponent extends Container {
128
146
  this.theme = deps.theme;
129
147
  this.keybindings = deps.keybindings;
130
148
  this.done = deps.done;
131
- this.onRequestInput = deps.onRequestInput;
132
149
  this.title = deps.title ?? "pi-ask";
133
150
 
134
151
  const titleText = new Text(this.theme.fg("accent", this.theme.bold(this.title)), 1, 0);
@@ -194,6 +211,11 @@ export class AskProComponent extends Container {
194
211
  const q = this.questions[this.currentIndex];
195
212
  if (!q) return;
196
213
 
214
+ // Compute the current preview (from the option under the cursor).
215
+ // Shown side-by-side with the option list via pad-right.
216
+ const currentPreview = this.currentPreviewLines();
217
+ const hasPreview = currentPreview.length > 0;
218
+
197
219
  // Question line: "Q1 of 3: <question>"
198
220
  this.bodyContainer.addChild(
199
221
  new Text(
@@ -247,8 +269,9 @@ export class AskProComponent extends Container {
247
269
  }
248
270
  }
249
271
 
250
- // Synthetic "Other…" option (when allowOther=true)
251
- 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) {
252
275
  const otherIndex = q.options.length;
253
276
  const isOtherSelected = this.selectedIndex === otherIndex;
254
277
  const customStr = this.getCustomString(currentAns);
@@ -320,6 +343,30 @@ export class AskProComponent extends Container {
320
343
  ),
321
344
  );
322
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
+ }
323
370
  }
324
371
 
325
372
  // -------------------------------------------------------------------------
@@ -354,7 +401,7 @@ export class AskProComponent extends Container {
354
401
  const q = this.questions[this.currentIndex];
355
402
  if (!q) return 0;
356
403
  const allowOther = q.allowOther ?? false;
357
- return q.options.length + (allowOther && this.onRequestInput ? 1 : 0);
404
+ return q.options.length + (allowOther ? 1 : 0);
358
405
  }
359
406
 
360
407
  // -------------------------------------------------------------------------
@@ -371,14 +418,25 @@ export class AskProComponent extends Container {
371
418
  const totalOptions = this.totalOptionsForCurrent();
372
419
 
373
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
+
374
430
  parts.push(this.theme.fg("dim", "↑↓ navigate"));
375
431
  parts.push(this.theme.fg("dim", `1-${totalOptions} pick`));
376
432
  if (this.currentIndex > 0) parts.push(this.theme.fg("dim", "tab/← prev"));
377
433
  if (this.currentIndex < this.questions.length - 1) {
378
434
  parts.push(this.theme.fg("dim", "tab/→ next"));
379
435
  }
380
- // "Other…" hint: single-select uses Enter, multi-select uses Space
381
- 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) {
382
440
  parts.push(this.theme.fg("accent", isMulti ? "␣ type" : "⏎ type"));
383
441
  } else if (isMulti) {
384
442
  // Multi-select: Space toggles, Enter advances/submits
@@ -397,6 +455,11 @@ export class AskProComponent extends Container {
397
455
  // Single-select: Enter is the action key
398
456
  parts.push(this.theme.fg("accent", isLast ? "⏎ submit" : "⏎ next"));
399
457
  }
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));
400
463
  parts.push(this.theme.fg("dim", "esc cancel"));
401
464
  return parts.join(" ");
402
465
  }
@@ -420,9 +483,17 @@ export class AskProComponent extends Container {
420
483
  // -------------------------------------------------------------------------
421
484
 
422
485
  handleInput(keyData: string): void {
423
- 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
+ }
424
495
 
425
- // Esc — cancel
496
+ // Esc — cancel the whole picker
426
497
  if (keyData === KEY_ESC) {
427
498
  this.completed = true;
428
499
  this.done({ cancelled: true });
@@ -494,13 +565,13 @@ export class AskProComponent extends Container {
494
565
  }
495
566
 
496
567
  // Space — toggle in multi-select.
497
- // On "Other…", opens the input dialog (or toggles existing custom string).
568
+ // On "Other…", opens the inline text field (or toggles existing custom string).
498
569
  // In single-select, Space is a no-op (Enter is the action key there).
499
570
  if (keyData === KEY_SPACE) {
500
571
  if (!isMulti) return;
501
- // On Other… → open input dialog (or re-toggle existing custom string)
502
- if (allowOther && this.onRequestInput && this.selectedIndex === otherIndex) {
503
- void this.requestOtherInput();
572
+ // On Other… → open inline field (or re-toggle existing custom string)
573
+ if (allowOther && this.selectedIndex === otherIndex) {
574
+ this.openOtherInput();
504
575
  return;
505
576
  }
506
577
  const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
@@ -521,16 +592,11 @@ export class AskProComponent extends Container {
521
592
  keyData === KEY_ENTER ||
522
593
  keyData === KEY_ENTER_CR
523
594
  ) {
524
- // If "Other…" is the selected option in single-select, open
525
- // the input dialog. In multi-select, Enter on Other… just
526
- // advances (use Space to toggle/type a custom answer).
527
- if (
528
- allowOther &&
529
- this.onRequestInput &&
530
- this.selectedIndex === otherIndex &&
531
- !isMulti
532
- ) {
533
- 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();
534
600
  return;
535
601
  }
536
602
 
@@ -565,6 +631,13 @@ export class AskProComponent extends Container {
565
631
  }
566
632
  return;
567
633
  }
634
+
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();
639
+ return;
640
+ }
568
641
  }
569
642
 
570
643
  private handlePick(optionIdx: number): void {
@@ -574,9 +647,9 @@ export class AskProComponent extends Container {
574
647
  const allowOther = q.allowOther ?? false;
575
648
  const otherIndex = allowOther ? q.options.length : -1;
576
649
 
577
- // "Other…" picked via number key
578
- if (allowOther && this.onRequestInput && optionIdx === otherIndex) {
579
- void this.requestOtherInput();
650
+ // "Other…" picked via number key → open inline field
651
+ if (allowOther && optionIdx === otherIndex) {
652
+ this.openOtherInput();
580
653
  return;
581
654
  }
582
655
 
@@ -604,57 +677,118 @@ export class AskProComponent extends Container {
604
677
  }
605
678
  }
606
679
 
607
- /**
608
- * Open the text-input dialog for the "Other…" option. Awaits the user's
609
- * reply asynchronously. While awaiting, the picker ignores further input.
610
- * If the user types text, the answer is stored (string for single, pushed
611
- * to the multi-select array for multi). If the user cancels, the answer
612
- * is unchanged.
613
- */
614
- private async requestOtherInput(): Promise<void> {
615
- 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 {
616
695
  const q = this.questions[this.currentIndex];
617
696
  if (!q) return;
618
- this.awaitingInput = true;
619
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
+ }
703
+
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
+ }
620
713
 
621
- const text = await this.onRequestInput({
622
- title: q.header,
623
- prompt: `Custom answer for: ${q.question}`,
624
- placeholder: "Type your answer…",
625
- });
626
- this.awaitingInput = false;
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;
627
720
 
628
- if (text === undefined) {
629
- // User cancelled leave answer as-is, just redraw
630
- this.repaint();
721
+ // Esc cancel inline input, return to option navigation
722
+ if (keyData === KEY_ESC) {
723
+ this.closeInput(false);
631
724
  return;
632
725
  }
633
- const trimmed = text.trim();
634
- if (trimmed === "") {
635
- 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);
636
733
  return;
637
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
+ }
638
740
 
639
- if (isMulti) {
640
- const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
641
- // Replace existing custom string (if any) so user can edit
642
- const existingIdx = cur.findIndex((a) => typeof a === "string");
643
- if (existingIdx >= 0) cur[existingIdx] = trimmed;
644
- else cur.push(trimmed);
645
- 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
+ }
646
759
  this.repaint();
647
- } else {
648
- // Single-select: set custom string, advance or submit
649
- 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 !== "") {
650
786
  if (this.currentIndex < this.questions.length - 1) {
651
787
  this.currentIndex++;
652
788
  this.selectedIndex = 0;
653
789
  this.repaint();
654
790
  } else if (this.allAnswered()) {
655
791
  this.submit();
656
- } else {
657
- this.repaint();
658
792
  }
659
793
  }
660
794
  }
@@ -665,8 +799,18 @@ export class AskProComponent extends Container {
665
799
  for (let i = 0; i < this.questions.length; i++) {
666
800
  answers[i] = this.answers.get(i) as AskAnswer | AskMultiAnswer;
667
801
  }
802
+ // Include notes only if at least one was added
803
+ const notes: Record<number, string> = {};
804
+ let hasNotes = false;
805
+ for (let i = 0; i < this.questions.length; i++) {
806
+ const n = this.notes.get(i);
807
+ if (n) {
808
+ notes[i] = n;
809
+ hasNotes = true;
810
+ }
811
+ }
668
812
  this.completed = true;
669
- this.done({ answers });
813
+ this.done(hasNotes ? { answers, notes } : { answers });
670
814
  }
671
815
 
672
816
  // -------------------------------------------------------------------------
@@ -676,7 +820,143 @@ export class AskProComponent extends Container {
676
820
 
677
821
  dispose(): void {
678
822
  this.completed = true;
679
- this.awaitingInput = false;
823
+ this.inputMode = null;
824
+ this.noteInput = null;
825
+ }
826
+
827
+ // -----------------------------------------------------------------------
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.
837
+ // -----------------------------------------------------------------------
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
841
+
842
+ /** Lines of preview content for the option currently under the cursor.
843
+ * Returns [] when no option is focused or the option has no preview. */
844
+ private currentPreviewLines(): string[] {
845
+ const q = this.questions[this.currentIndex];
846
+ if (!q) return [];
847
+ // Only real options carry previews (not "Other…")
848
+ if (this.selectedIndex < 0 || this.selectedIndex >= q.options.length) return [];
849
+ const opt = q.options[this.selectedIndex];
850
+ if (!opt?.preview) return [];
851
+ // Trim and split on newlines; drop leading/trailing blank lines.
852
+ const lines = opt.preview
853
+ .replace(/\r\n/g, "\n")
854
+ .split("\n")
855
+ .map((l) => l.trimEnd());
856
+ while (lines.length > 0 && lines[0]?.trim() === "") lines.shift();
857
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") lines.pop();
858
+ return lines;
859
+ }
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
+
886
+ /** Override render to produce a side-by-side layout when a preview is
887
+ * present. Falls back to default Container render otherwise. Every
888
+ * returned line is guaranteed ≤ `width` visible columns. */
889
+ render(width: number): string[] {
890
+ const superLines = super.render(width);
891
+ const previewLines = this.currentPreviewLines();
892
+ if (previewLines.length === 0) return superLines;
893
+
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
+ }
908
+
909
+ // Wrap each preview source line to previewWidth (a long line may
910
+ // produce several wrapped rows).
911
+ const wrapped: string[] = [];
912
+ for (const line of previewLines) {
913
+ for (const w of this.wrapPreviewLine(line, previewWidth)) wrapped.push(w);
914
+ }
915
+
916
+ const result: string[] = [];
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++) {
928
+ const superLine = superLines[i] ?? "";
929
+ const pLine = wrapped[i];
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));
937
+ }
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));
957
+ }
958
+ }
959
+ return result;
680
960
  }
681
961
  }
682
962