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
@@ -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,41 +539,35 @@ 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[] = [];
555
- let doneResult: AskProResult | null = null;
556
- const picker = new AskProComponent({
557
- questions: ONE_Q,
558
- theme,
559
- keybindings,
560
- done: (r) => {
561
- doneResult = r;
562
- },
563
- onRequestInput: async () => {
564
- const val = callCount++ === 0 ? "first" : "second";
565
- inputCalls.push(val);
566
- return val;
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,
567
554
  },
568
- });
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);
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");
587
571
  });
588
572
  });
589
573
 
@@ -655,41 +639,15 @@ describe("AskProComponent — option previews", () => {
655
639
  // ---------------------------------------------------------------------------
656
640
 
657
641
  describe("AskProComponent — notes (n key)", () => {
658
- test("n is a no-op when onRequestNote is not provided", () => {
642
+ test("n opens the inline note field", () => {
659
643
  const { picker } = setup();
660
- // Should not throw, no effect
644
+ // `n` should not throw and should not advance/change answers
661
645
  expect(() => picker.handleInput("n")).not.toThrow();
646
+ expect(picker.getAnswers().size).toBe(0);
662
647
  });
663
648
 
664
- test("n opens note dialog when onRequestNote is provided", async () => {
665
- let noteRequestCount = 0;
666
- let resolveNote: (value: string | undefined) => void = () => {};
667
- const picker = new AskProComponent({
668
- questions: sampleQuestions,
669
- theme,
670
- keybindings,
671
- done: () => {},
672
- onRequestNote: async () => {
673
- noteRequestCount++;
674
- return new Promise<string | undefined>((r) => {
675
- resolveNote = r;
676
- });
677
- },
678
- });
679
- // `n` should trigger note request
680
- expect(noteRequestCount).toBe(0);
681
- picker.handleInput("n");
682
- // Microtask to let async start
683
- await Promise.resolve();
684
- expect(noteRequestCount).toBe(1);
685
- // Resolve with a note
686
- resolveNote("only for prod, use TLS");
687
- await Promise.resolve();
688
- });
689
-
690
- test("note is included in submit result", async () => {
649
+ test("typed note is stored on Enter and included in submit result", () => {
691
650
  let doneResult: AskProResult | null = null;
692
- const pendingNotes: Array<(v: string | undefined) => void> = [];
693
651
  const picker = new AskProComponent({
694
652
  questions: sampleQuestions,
695
653
  theme,
@@ -697,24 +655,13 @@ describe("AskProComponent — notes (n key)", () => {
697
655
  done: (r) => {
698
656
  doneResult = r;
699
657
  },
700
- onRequestNote: async () => {
701
- return new Promise<string | undefined>((resolve) => {
702
- pendingNotes.push(resolve);
703
- });
704
- },
705
658
  });
706
659
  // Pick Q1 → advance to Q2
707
660
  picker.handleInput("1");
708
- // Add note to Q2
661
+ // Open the inline note field on Q2 and type a note
709
662
  picker.handleInput("n");
710
- // Wait for the promise to register
711
- await Promise.resolve();
712
- await Promise.resolve();
713
- expect(pendingNotes.length).toBe(1);
714
- pendingNotes[0]!("rotate keys monthly");
715
- // Wait for the dialog to settle
716
- await Promise.resolve();
717
- await Promise.resolve();
663
+ for (const ch of "rotate keys monthly") picker.handleInput(ch);
664
+ picker.handleInput("\n"); // commit
718
665
  // Submit Q2 (last question, all answered)
719
666
  picker.handleInput("1");
720
667
  expect(doneResult).not.toBeNull();
@@ -723,28 +670,110 @@ describe("AskProComponent — notes (n key)", () => {
723
670
  expect(result?.notes?.[1]).toBe("rotate keys monthly");
724
671
  });
725
672
 
726
- test("empty note submission clears existing note", async () => {
727
- let resolveNote: (value: string | undefined) => void = () => {};
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;
728
685
  const picker = new AskProComponent({
729
686
  questions: sampleQuestions,
730
687
  theme,
731
688
  keybindings,
732
- done: () => {},
733
- onRequestNote: async () => {
734
- return new Promise<string | undefined>((r) => {
735
- resolveNote = r;
736
- });
689
+ done: (r) => {
690
+ doneResult = r;
737
691
  },
738
692
  });
739
- // Add a note
693
+ // Q1: add a note, then re-open and clear it (^U + Enter)
740
694
  picker.handleInput("n");
741
- resolveNote("first note");
742
- await Promise.resolve();
743
- // Clear it with empty
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();
744
708
  picker.handleInput("n");
745
- resolveNote(" ");
746
- await Promise.resolve();
747
- // No crash — note cleared
748
709
  expect(() => picker.render(80)).not.toThrow();
749
710
  });
750
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");
778
+ });
779
+ });