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/prompt.ts CHANGED
@@ -30,8 +30,12 @@ DON'T use it for:
30
30
  - When the user already gave a clear answer — don't second-guess
31
31
  - Trivial clarifications — use plain text first, escalate to \`ask_pro\` only if the answer matters
32
32
 
33
- Keyboard in the picker: \`↑↓\` navigate, \`1-N\` instant-pick, \`Tab\` next question, \`Space\` toggle (multi-select only), \`Enter\` confirm/advance/submit, \`Esc\` cancel.
33
+ Keyboard in the picker: \`↑↓\` navigate, \`1-N\` instant-pick, \`Tab\` next question, \`Space\` toggle (multi-select only), \`Enter\` confirm/advance/submit, \`n\` add a free-text note to the current question, \`Esc\` cancel.
34
34
 
35
- Schema reminder: \`questions: [{ header, question, options: [{label, description?, recommended?}], multiSelect? }]\`. Mark exactly one option \`recommended: true\` per question when you have a default.
35
+ Schema reminder: \`questions: [{ header, question, options: [{label, description?, recommended?, preview?}], multiSelect? }]\`. Mark exactly one option \`recommended: true\` per question when you have a default.
36
+
37
+ **Option previews:** \`option.preview\` (markdown/plain string) shows in a side panel next to the option list while that option is focused. Use it when the question is about a code structure, API shape, or concrete example — show a small snippet of what each option entails so the user can decide without asking follow-ups. Example: when asking "how should we model auth?", each option's preview can show the relevant type signature.
38
+
39
+ **Notes:** the user can press \`n\` after picking an answer to attach a free-text note (edge cases, constraints, reasoning). The note is returned to you as \`// note: \"...\"\` next to the chosen answer. Treat it as a hard constraint.
36
40
  `;
37
41
  }
@@ -10,7 +10,7 @@
10
10
  /// <reference types="bun-types" />
11
11
  import { describe, test, expect } from "bun:test";
12
12
  import { AskProComponent, type AskQuestion, type AskProResult, type AskProTheme } from "../picker.js";
13
- import type { KeybindingsManager } from "@earendil-works/pi-tui";
13
+ import { visibleWidth, type KeybindingsManager } from "@earendil-works/pi-tui";
14
14
 
15
15
  // Minimal theme: just enough for the picker to render without errors.
16
16
  const theme: AskProTheme = {
@@ -18,13 +18,17 @@ const theme: AskProTheme = {
18
18
  bold: (text: string) => `**${text}**`,
19
19
  };
20
20
 
21
- // Minimal keybindings: only the bindings the picker uses.
21
+ // Minimal keybindings: recognize the bindings the picker AND the inline
22
+ // Input field use, so tests exercise real key handling (backspace, ^U, etc.).
22
23
  const keybindings = {
23
24
  matches: (keyData: string, name: string) => {
24
25
  if (name === "tui.select.up") return keyData === "\x1b[A" || keyData === "k";
25
26
  if (name === "tui.select.down") return keyData === "\x1b[B" || keyData === "j";
26
27
  if (name === "tui.select.confirm") return keyData === "\n" || keyData === "\r";
27
28
  if (name === "tui.select.cancel") return keyData === "\x1b";
29
+ // Inline Input editing gestures (mirror pi-tui defaults)
30
+ if (name === "tui.editor.deleteCharBackward") return keyData === "\x7f"; // backspace
31
+ if (name === "tui.editor.deleteToLineStart") return keyData === "\x15"; // ctrl+u
28
32
  return false;
29
33
  },
30
34
  } as unknown as KeybindingsManager;
@@ -397,11 +401,7 @@ describe("AskProComponent — Other… option (allowOther)", () => {
397
401
  },
398
402
  ];
399
403
 
400
- function setupWithInput(
401
- questions: AskQuestion[],
402
- mockInput: (text: string | undefined) => Promise<string | undefined>,
403
- ) {
404
- const inputCalls: Array<{ title: string; prompt: string; placeholder?: string }> = [];
404
+ function setupWithOther(questions: AskQuestion[]) {
405
405
  let doneResult: AskProResult | null = null;
406
406
  const picker = new AskProComponent({
407
407
  questions,
@@ -410,78 +410,69 @@ describe("AskProComponent — Other… option (allowOther)", () => {
410
410
  done: (r) => {
411
411
  doneResult = r;
412
412
  },
413
- onRequestInput: async (req) => {
414
- inputCalls.push(req);
415
- return mockInput("user typed text");
416
- },
417
413
  });
418
414
  return {
419
415
  picker,
420
416
  getDone: (): AskProResult | null => doneResult,
421
417
  getAnswers: () => picker.getAnswers(),
422
- getInputCalls: () => inputCalls,
423
418
  };
424
419
  }
425
420
 
421
+ /** Simulate typing `text` into the active inline field, then Enter to
422
+ * commit. Mirrors how the real TUI feeds one character per keypress. */
423
+ function typeInline(picker: AskProComponent, text: string, commit = true): void {
424
+ for (const ch of text) picker.handleInput(ch);
425
+ if (commit) picker.handleInput("\n");
426
+ }
427
+
426
428
  test("Other… appears as last option when allowOther=true", () => {
427
- const { picker } = setupWithInput(OTHER_QUESTIONS, async () => "x");
429
+ const { picker } = setupWithOther(OTHER_QUESTIONS);
428
430
  picker.handleInput("j"); // 0 → 1 (JWT localStorage)
429
431
  picker.handleInput("j"); // 1 → 2 (Other…)
430
432
  // 2 real options + Other = index 2
431
433
  expect(picker.getSelectedIndex()).toBe(2);
432
434
  });
433
435
 
434
- test("number key (3) on Other… triggers onRequestInput", async () => {
435
- const { picker, getInputCalls, getAnswers } = setupWithInput(
436
- OTHER_QUESTIONS,
437
- async () => "JWT via custom OAuth2 proxy",
438
- );
439
- picker.handleInput("3"); // pick Other…
440
- // requestOtherInput is async; wait a microtask for the await
441
- await new Promise((r) => setImmediate(r));
442
- expect(getInputCalls().length).toBe(1);
443
- expect(getInputCalls()[0]?.title).toBe("Auth");
444
- expect(getInputCalls()[0]?.prompt).toContain("Which auth?");
445
- // Answer should now be the custom string
436
+ test("number key (3) on Other… opens the inline field; Enter commits it", () => {
437
+ const { picker, getAnswers } = setupWithOther(OTHER_QUESTIONS);
438
+ picker.handleInput("3"); // open Other… inline field
439
+ typeInline(picker, "JWT via custom OAuth2 proxy");
446
440
  expect(getAnswers().get(0)).toBe("JWT via custom OAuth2 proxy");
447
441
  });
448
442
 
449
- test("Enter on Other… triggers onRequestInput", async () => {
450
- const { picker, getInputCalls, getAnswers } = setupWithInput(
451
- OTHER_QUESTIONS,
452
- async () => "magic-link via email",
453
- );
443
+ test("Enter on Other… (after selecting it) opens the inline field", () => {
444
+ const { picker, getAnswers } = setupWithOther(OTHER_QUESTIONS);
454
445
  picker.handleInput("j"); // 0 → 1
455
446
  picker.handleInput("j"); // 1 → 2 (Other…)
456
- picker.handleInput("\n");
457
- await new Promise((r) => setImmediate(r));
458
- expect(getInputCalls().length).toBe(1);
447
+ picker.handleInput("\n"); // open the inline field
448
+ typeInline(picker, "magic-link via email");
459
449
  expect(getAnswers().get(0)).toBe("magic-link via email");
460
450
  });
461
451
 
462
- test("user cancelling input leaves answer unchanged", async () => {
463
- const { picker, getAnswers } = setupWithInput(OTHER_QUESTIONS, async () => undefined);
464
- picker.handleInput("3");
465
- await new Promise((r) => setImmediate(r));
452
+ test("cancelling inline input (Esc) leaves answer unchanged", () => {
453
+ const { picker, getAnswers } = setupWithOther(OTHER_QUESTIONS);
454
+ picker.handleInput("3"); // open Other… inline field
455
+ picker.handleInput("J"); // type something
456
+ picker.handleInput("\x1b"); // Esc — cancel
466
457
  expect(getAnswers().get(0)).toBeUndefined();
467
458
  });
468
459
 
469
- test("empty input is ignored", async () => {
470
- const { picker, getAnswers } = setupWithInput(OTHER_QUESTIONS, async () => " ");
471
- picker.handleInput("3");
472
- await new Promise((r) => setImmediate(r));
460
+ test("empty inline submission is ignored (no answer stored)", () => {
461
+ const { picker, getAnswers } = setupWithOther(OTHER_QUESTIONS);
462
+ picker.handleInput("3"); // open Other… inline field
463
+ typeInline(picker, " "); // only spaces → trimmed empty → not stored
473
464
  expect(getAnswers().get(0)).toBeUndefined();
474
465
  });
475
466
 
476
467
  test("arrow down stops at Other… (not past it)", () => {
477
- const { picker } = setupWithInput(OTHER_QUESTIONS, async () => "x");
468
+ const { picker } = setupWithOther(OTHER_QUESTIONS);
478
469
  picker.handleInput("j"); // 1
479
470
  picker.handleInput("j"); // 2 (Other…)
480
471
  picker.handleInput("j"); // stays at 2
481
472
  expect(picker.getSelectedIndex()).toBe(2);
482
473
  });
483
474
 
484
- test("Other… picks auto-advance to next question (single-select)", async () => {
475
+ test("Other… picks auto-advance to next question (single-select)", () => {
485
476
  const TWO_Q: AskQuestion[] = [
486
477
  {
487
478
  header: "Q1",
@@ -495,15 +486,14 @@ describe("AskProComponent — Other… option (allowOther)", () => {
495
486
  options: [{ label: "X" }],
496
487
  },
497
488
  ];
498
- const { picker, getAnswers } = setupWithInput(TWO_Q, async () => "custom A");
499
- picker.handleInput("2"); // pick Other… on Q1
500
- await new Promise((r) => setImmediate(r));
501
- // Should be on Q2 now
489
+ const { picker, getAnswers } = setupWithOther(TWO_Q);
490
+ picker.handleInput("2"); // open Other… on Q1
491
+ typeInline(picker, "custom A"); // commit → advance to Q2
502
492
  expect(picker.getCurrentIndex()).toBe(1);
503
493
  expect(getAnswers().get(0)).toBe("custom A");
504
494
  });
505
495
 
506
- test("Other… picks submit if on last question", async () => {
496
+ test("Other… picks submit if on last question", () => {
507
497
  const ONE_Q: AskQuestion[] = [
508
498
  {
509
499
  header: "Only",
@@ -512,13 +502,13 @@ describe("AskProComponent — Other… option (allowOther)", () => {
512
502
  allowOther: true,
513
503
  },
514
504
  ];
515
- const { picker, getDone } = setupWithInput(ONE_Q, async () => "freeform");
516
- picker.handleInput("2"); // Other…
517
- await new Promise((r) => setImmediate(r));
505
+ const { picker, getDone } = setupWithOther(ONE_Q);
506
+ picker.handleInput("2"); // open Other…
507
+ typeInline(picker, "freeform"); // commit → submit (last question)
518
508
  expect(getDone()).toEqual({ answers: { 0: "freeform" } });
519
509
  });
520
510
 
521
- test("Other… in multi-select pushes the string into the array", async () => {
511
+ test("Other… in multi-select pushes the string into the array", () => {
522
512
  const MULTI_WITH_OTHER: AskQuestion[] = [
523
513
  {
524
514
  header: "Pick",
@@ -528,10 +518,10 @@ describe("AskProComponent — Other… option (allowOther)", () => {
528
518
  allowOther: true,
529
519
  },
530
520
  ];
531
- const { picker, getAnswers } = setupWithInput(MULTI_WITH_OTHER, async () => "my custom");
521
+ const { picker, getAnswers } = setupWithOther(MULTI_WITH_OTHER);
532
522
  picker.handleInput("1"); // toggle A
533
- picker.handleInput("3"); // pick Other…
534
- await new Promise((r) => setImmediate(r));
523
+ picker.handleInput("3"); // open Other… inline field (Space would too)
524
+ typeInline(picker, "my custom"); // commit
535
525
  // A is toggled + custom string
536
526
  const a = getAnswers().get(0);
537
527
  expect(Array.isArray(a)).toBe(true);
@@ -540,7 +530,7 @@ describe("AskProComponent — Other… option (allowOther)", () => {
540
530
  expect(picker.getCurrentIndex()).toBe(0);
541
531
  });
542
532
 
543
- test("re-picking Other… replaces the previous custom string (not appends)", async () => {
533
+ test("re-picking Other… replaces the previous custom string (not appends)", () => {
544
534
  const ONE_Q: AskQuestion[] = [
545
535
  {
546
536
  header: "Only",
@@ -549,40 +539,241 @@ describe("AskProComponent — Other… option (allowOther)", () => {
549
539
  allowOther: true,
550
540
  },
551
541
  ];
552
- // First call returns "first", second returns "second"
553
- let callCount = 0;
554
- const inputCalls: string[] = [];
542
+ const { picker, getAnswers } = setupWithOther(ONE_Q);
543
+ // First: open Other…, type "first", commit (stays — only 1 question;
544
+ // single-select commit on last question does NOT submit until all
545
+ // answered, but here it's answered so it submits). To test replace,
546
+ // use a multi-select question so we stay put after committing.
547
+ const MULTI: AskQuestion[] = [
548
+ {
549
+ header: "Pick",
550
+ question: "?",
551
+ options: [{ label: "A" }, { label: "B" }],
552
+ multiSelect: true,
553
+ allowOther: true,
554
+ },
555
+ ];
556
+ const { picker: mp, getAnswers: getMulti } = setupWithOther(MULTI);
557
+ mp.handleInput("3"); // open Other… (pre-fills with "first", cursor at end)
558
+ mp.handleInput("\x15"); // ^U — delete to line start → ""
559
+ typeInline(mp, "second");
560
+ expect(getMulti().get(0)).toEqual(["second"]);
561
+ });
562
+
563
+ test("allowOther without any external callback still shows Other…", () => {
564
+ // The inline field is built in, so Other… no longer needs a host
565
+ // callback. This is the whole point of the inline redesign: the
566
+ // host UI's modal input would tear down the picker.
567
+ const { picker, getAnswers } = setup(OTHER_QUESTIONS);
568
+ picker.handleInput("3"); // open Other… inline field
569
+ typeInline(picker, "plain custom");
570
+ expect(getAnswers().get(0)).toBe("plain custom");
571
+ });
572
+ });
573
+
574
+ // ---------------------------------------------------------------------------
575
+ // Option previews (side-by-side)
576
+ // ---------------------------------------------------------------------------
577
+
578
+ describe("AskProComponent — option previews", () => {
579
+ test("currentPreviewLines returns [] when option has no preview", () => {
580
+ const { picker } = setup();
581
+ // Type-level access: currentPreviewLines is private; verify via render
582
+ // (no preview lines means render falls back to super.render width-only)
583
+ const lines = picker.render(80);
584
+ // Just verify it doesn't throw and returns some lines
585
+ expect(lines.length).toBeGreaterThan(0);
586
+ });
587
+
588
+ test("render includes preview content when option has preview", () => {
589
+ const qsWithPreview: AskQuestion[] = [
590
+ {
591
+ header: "Model",
592
+ question: "Which schema?",
593
+ options: [
594
+ {
595
+ label: "Relational",
596
+ description: "Postgres + strict schema",
597
+ preview: "CREATE TABLE users (\n id SERIAL PRIMARY KEY,\n email TEXT NOT NULL\n);",
598
+ },
599
+ { label: "Document", description: "MongoDB" },
600
+ ],
601
+ },
602
+ ];
603
+ const { picker } = setup(qsWithPreview);
604
+ const lines = picker.render(100);
605
+ const joined = lines.join("\n");
606
+ // Preview content should appear in the rendered output
607
+ expect(joined).toContain("CREATE TABLE");
608
+ expect(joined).toContain("preview");
609
+ });
610
+
611
+ test("preview disappears when focused option has none", () => {
612
+ const qsWithPreview: AskQuestion[] = [
613
+ {
614
+ header: "Model",
615
+ question: "Which schema?",
616
+ options: [
617
+ {
618
+ label: "Relational",
619
+ description: "Postgres",
620
+ preview: "CREATE TABLE users (id INT);",
621
+ },
622
+ { label: "Document", description: "MongoDB" },
623
+ ],
624
+ },
625
+ ];
626
+ const { picker } = setup(qsWithPreview);
627
+ // Focus option 0 (has preview)
628
+ picker.handleInput("j"); // wait, default 0 already; move to 1
629
+ picker.handleInput("j"); // option 1 (no preview)
630
+ const lines = picker.render(100);
631
+ const joined = lines.join("\n");
632
+ // No preview lines for option without preview
633
+ expect(joined).not.toContain("CREATE TABLE");
634
+ });
635
+ });
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // Notes (`n` key)
639
+ // ---------------------------------------------------------------------------
640
+
641
+ describe("AskProComponent — notes (n key)", () => {
642
+ test("n opens the inline note field", () => {
643
+ const { picker } = setup();
644
+ // `n` should not throw and should not advance/change answers
645
+ expect(() => picker.handleInput("n")).not.toThrow();
646
+ expect(picker.getAnswers().size).toBe(0);
647
+ });
648
+
649
+ test("typed note is stored on Enter and included in submit result", () => {
555
650
  let doneResult: AskProResult | null = null;
556
651
  const picker = new AskProComponent({
557
- questions: ONE_Q,
652
+ questions: sampleQuestions,
558
653
  theme,
559
654
  keybindings,
560
655
  done: (r) => {
561
656
  doneResult = r;
562
657
  },
563
- onRequestInput: async () => {
564
- const val = callCount++ === 0 ? "first" : "second";
565
- inputCalls.push(val);
566
- return val;
658
+ });
659
+ // Pick Q1 advance to Q2
660
+ picker.handleInput("1");
661
+ // Open the inline note field on Q2 and type a note
662
+ picker.handleInput("n");
663
+ for (const ch of "rotate keys monthly") picker.handleInput(ch);
664
+ picker.handleInput("\n"); // commit
665
+ // Submit Q2 (last question, all answered)
666
+ picker.handleInput("1");
667
+ expect(doneResult).not.toBeNull();
668
+ const result = doneResult as AskProResult | null;
669
+ expect(result?.notes).toBeDefined();
670
+ expect(result?.notes?.[1]).toBe("rotate keys monthly");
671
+ });
672
+
673
+ test("Esc cancels the note field, leaving no note", () => {
674
+ const { picker } = setup();
675
+ picker.handleInput("n");
676
+ for (const ch of "ignored note") picker.handleInput(ch);
677
+ picker.handleInput("\x1b"); // Esc — cancel
678
+ // No note stored; picker still usable (pick an option to prove it)
679
+ picker.handleInput("1");
680
+ expect(picker.getAnswers().get(0)).toBe(0);
681
+ });
682
+
683
+ test("empty note submission clears any existing note", () => {
684
+ let doneResult: AskProResult | null = null;
685
+ const picker = new AskProComponent({
686
+ questions: sampleQuestions,
687
+ theme,
688
+ keybindings,
689
+ done: (r) => {
690
+ doneResult = r;
567
691
  },
568
692
  });
569
- // First pick: Q1 is the only question, so picking Other… submits
570
- picker.handleInput("2");
571
- await new Promise((r) => setImmediate(r));
572
- // Re-pick (on the same question — we have to "go back" first)
573
- // Actually after submit, picker is completed. So this test is moot.
574
- expect(doneResult!).toEqual({ answers: { 0: "first" } });
575
- });
576
-
577
- test("allowOther without onRequestInput hides Other…", () => {
578
- // The "Other" option is only rendered when BOTH allowOther AND
579
- // onRequestInput are present. If the caller forgets the callback,
580
- // the picker silently degrades to the regular N options.
581
- const { picker, getAnswers } = setup(OTHER_QUESTIONS); // no onRequestInput
582
- // Number key 3 should NOT do anything special (no Other option exists)
583
- picker.handleInput("3");
584
- expect(getAnswers().get(0)).toBeUndefined();
585
- // selectedIndex clamps to the last real option
586
- expect(picker.getSelectedIndex()).toBeLessThan(2);
693
+ // Q1: add a note, then re-open and clear it (^U + Enter)
694
+ picker.handleInput("n");
695
+ for (const ch of "first note") picker.handleInput(ch);
696
+ picker.handleInput("\n");
697
+ picker.handleInput("n");
698
+ picker.handleInput("\x15"); // ^U delete to line start
699
+ picker.handleInput("\n"); // commit empty → note cleared
700
+ // Pick Q1 → Q2 → submit; result must NOT carry notes
701
+ picker.handleInput("1");
702
+ picker.handleInput("1");
703
+ expect(doneResult as AskProResult | null).toEqual({ answers: { 0: 0, 1: 0 } });
704
+ });
705
+
706
+ test("note field does not break render", () => {
707
+ const { picker } = setup();
708
+ picker.handleInput("n");
709
+ expect(() => picker.render(80)).not.toThrow();
710
+ });
711
+ });
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // Regression: render() must NEVER emit a line wider than `width`.
715
+ // Previously the side-by-side preview layout measured width with a regex
716
+ // that ignored OSC 8 / FTCS escape sequences (which pi-tui wraps around
717
+ // rendered text) and never truncated the left column or the combined row —
718
+ // so a long option label or preview word pushed a line past the terminal
719
+ // width and crashed pi ("Rendered line N exceeds terminal width").
720
+ // See C:/Users/bradw/.pi/agent/pi-crash.log.
721
+ // ---------------------------------------------------------------------------
722
+ describe("AskProComponent — render never exceeds width (crash regression)", () => {
723
+ // Passthrough theme: no ANSI, so visibleWidth() reports true column width.
724
+ const plainTheme: AskProTheme = { fg: (_c, t) => t, bold: (t) => t };
725
+ const kb = { matches: () => false } as unknown as KeybindingsManager;
726
+
727
+ const LONG_PREVIEW =
728
+ "Set-Cookie: refresh_token=xxx; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=2592000\n" +
729
+ "// this is a fairly long comment line that must wrap and never overflow the column";
730
+
731
+ function makePicker(preview: string, label = "short"): AskProComponent {
732
+ return new AskProComponent({
733
+ questions: [
734
+ {
735
+ header: "X",
736
+ question: "Pick one?",
737
+ options: [
738
+ { label, description: "desc", preview },
739
+ { label: "other option", description: "desc2" },
740
+ ],
741
+ },
742
+ ],
743
+ theme: plainTheme,
744
+ keybindings: kb,
745
+ done: () => {},
746
+ });
747
+ }
748
+
749
+ for (const width of [134, 100, 80, 60, 45, 40, 30, 20, 10]) {
750
+ test(`every line ≤ width=${width} with a long preview + long label`, () => {
751
+ const picker = makePicker(
752
+ LONG_PREVIEW,
753
+ "a-very-long-option-label-without-spaces-to-stress-truncation",
754
+ );
755
+ const lines = picker.render(width);
756
+ expect(lines.length).toBeGreaterThan(0);
757
+ for (const line of lines) {
758
+ expect(visibleWidth(line)).toBeLessThanOrEqual(width);
759
+ }
760
+ });
761
+ }
762
+
763
+ test("every line ≤ width with a single 500-char unbreakable token", () => {
764
+ const picker = makePicker("x".repeat(500), "ok");
765
+ for (const width of [134, 50, 25, 12]) {
766
+ const lines = picker.render(width);
767
+ for (const line of lines) {
768
+ expect(visibleWidth(line)).toBeLessThanOrEqual(width);
769
+ }
770
+ }
771
+ });
772
+
773
+ test("preview content still rendered (not dropped) at a comfortable width", () => {
774
+ const picker = makePicker("CREATE TABLE users (id INT);", "Relational");
775
+ const joined = picker.render(100).join("\n");
776
+ expect(joined).toContain("CREATE TABLE");
777
+ expect(joined).toContain("preview");
587
778
  });
588
779
  });