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