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.
- package/ask/index.ts +12 -11
- package/ask/picker.ts +356 -76
- package/ask/prompt.ts +6 -2
- package/ask/tests/picker.test.ts +273 -82
- package/codemap.ts +276 -0
- package/hotreload.ts +239 -0
- package/init.ts +302 -0
- package/mcp/CHANGELOG.md +384 -0
- package/mcp/LICENSE +21 -0
- package/mcp/OAUTH.md +355 -0
- package/mcp/README.md +410 -0
- package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
- package/mcp/__tests__/cli.upstream-test.ts +97 -0
- package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
- package/mcp/__tests__/config.upstream-test.ts +303 -0
- package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
- package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
- package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
- package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
- package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
- package/mcp/__tests__/errors.upstream-test.ts +218 -0
- package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
- package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
- package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
- package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
- package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
- package/mcp/__tests__/logger.upstream-test.ts +175 -0
- package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
- package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
- package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
- package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
- package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
- package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
- package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
- package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
- package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
- package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
- package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
- package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
- package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
- package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
- package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
- package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
- package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
- package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
- package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
- package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
- package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
- package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
- package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
- package/mcp/agent-dir.ts +20 -0
- package/mcp/app-bridge.bundle.js +67 -0
- package/mcp/cli.js +184 -0
- package/mcp/commands.ts +422 -0
- package/mcp/config.ts +666 -0
- package/mcp/consent-manager.ts +64 -0
- package/mcp/direct-tools.ts +439 -0
- package/mcp/elicitation-handler.ts +347 -0
- package/mcp/errors.ts +219 -0
- package/mcp/glimpse-ui.ts +80 -0
- package/mcp/host-html-template.ts +427 -0
- package/mcp/index.ts +362 -0
- package/mcp/init.ts +362 -0
- package/mcp/lifecycle.ts +93 -0
- package/mcp/logger.ts +169 -0
- package/mcp/mcp-auth-flow.ts +559 -0
- package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
- package/mcp/mcp-auth.ts +302 -0
- package/mcp/mcp-auth.upstream-test.ts +373 -0
- package/mcp/mcp-callback-server.ts +372 -0
- package/mcp/mcp-callback-server.upstream-test.ts +416 -0
- package/mcp/mcp-oauth-provider.ts +369 -0
- package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
- package/mcp/mcp-panel.ts +829 -0
- package/mcp/mcp-setup-panel.ts +580 -0
- package/mcp/metadata-cache.ts +201 -0
- package/mcp/notify.ts +111 -0
- package/mcp/npx-resolver.ts +424 -0
- package/mcp/oauth-handler.ts +57 -0
- package/mcp/onboarding-state.ts +68 -0
- package/mcp/package.json +106 -0
- package/mcp/panel-keys.ts +37 -0
- package/mcp/proxy-modes.ts +949 -0
- package/mcp/resource-tools.ts +17 -0
- package/mcp/sampling-handler.ts +268 -0
- package/mcp/server-manager.ts +545 -0
- package/mcp/state.ts +41 -0
- package/mcp/tool-metadata.ts +216 -0
- package/mcp/tool-registrar.ts +46 -0
- package/mcp/tool-result-renderer.ts +161 -0
- package/mcp/types.ts +448 -0
- package/mcp/ui-resource-handler.ts +146 -0
- package/mcp/ui-server.ts +623 -0
- package/mcp/ui-session.ts +386 -0
- package/mcp/ui-stream-types.ts +89 -0
- package/mcp/utils.ts +129 -0
- package/mcp/vitest.config.ts +14 -0
- package/migrate.ts +258 -0
- package/notification.ts +218 -0
- package/notifications-log.ts +83 -0
- package/package.json +20 -3
- 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
|
}
|
package/ask/tests/picker.test.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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 } =
|
|
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…
|
|
435
|
-
const { picker,
|
|
436
|
-
|
|
437
|
-
|
|
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…
|
|
450
|
-
const { picker,
|
|
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
|
-
|
|
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("
|
|
463
|
-
const { picker, getAnswers } =
|
|
464
|
-
picker.handleInput("3");
|
|
465
|
-
|
|
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
|
|
470
|
-
const { picker, getAnswers } =
|
|
471
|
-
picker.handleInput("3");
|
|
472
|
-
|
|
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 } =
|
|
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)",
|
|
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 } =
|
|
499
|
-
picker.handleInput("2"); //
|
|
500
|
-
|
|
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",
|
|
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 } =
|
|
516
|
-
picker.handleInput("2"); // Other…
|
|
517
|
-
|
|
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",
|
|
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 } =
|
|
521
|
+
const { picker, getAnswers } = setupWithOther(MULTI_WITH_OTHER);
|
|
532
522
|
picker.handleInput("1"); // toggle A
|
|
533
|
-
picker.handleInput("3"); //
|
|
534
|
-
|
|
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)",
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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:
|
|
652
|
+
questions: sampleQuestions,
|
|
558
653
|
theme,
|
|
559
654
|
keybindings,
|
|
560
655
|
done: (r) => {
|
|
561
656
|
doneResult = r;
|
|
562
657
|
},
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
//
|
|
570
|
-
picker.handleInput("
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
picker
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
});
|