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/index.ts
CHANGED
|
@@ -159,12 +159,6 @@ export default function piAskExtension(pi: ExtensionAPI) {
|
|
|
159
159
|
theme: askTheme,
|
|
160
160
|
keybindings,
|
|
161
161
|
done,
|
|
162
|
-
// Bridge to the parent's UI for the "Other…" text input.
|
|
163
|
-
// The picker stays decoupled from ExtensionContext.
|
|
164
|
-
onRequestInput: async (req) => {
|
|
165
|
-
if (!ctx.hasUI) return undefined;
|
|
166
|
-
return (await ctx.ui.input(req.title, req.placeholder)) ?? undefined;
|
|
167
|
-
},
|
|
168
162
|
title: `pi-ask — ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
|
169
163
|
});
|
|
170
164
|
},
|
|
@@ -184,14 +178,16 @@ export default function piAskExtension(pi: ExtensionAPI) {
|
|
|
184
178
|
}
|
|
185
179
|
|
|
186
180
|
const answers = result.answers ?? {};
|
|
181
|
+
const notes = result.notes ?? {};
|
|
187
182
|
// Pretty-print for the LLM
|
|
188
183
|
const out: string[] = ["User answers:"];
|
|
189
184
|
for (let i = 0; i < params.questions.length; i++) {
|
|
190
185
|
const q = params.questions[i];
|
|
191
186
|
if (!q) continue;
|
|
192
187
|
const a = answers[i];
|
|
188
|
+
let line: string;
|
|
193
189
|
if (a === undefined) {
|
|
194
|
-
|
|
190
|
+
line = ` Q${i + 1} (${q.header}): (no answer)`;
|
|
195
191
|
} else if (Array.isArray(a)) {
|
|
196
192
|
const parts: string[] = [];
|
|
197
193
|
for (const item of a) {
|
|
@@ -201,17 +197,22 @@ export default function piAskExtension(pi: ExtensionAPI) {
|
|
|
201
197
|
parts.push(`"${item}"`);
|
|
202
198
|
}
|
|
203
199
|
}
|
|
204
|
-
|
|
200
|
+
line = ` Q${i + 1} (${q.header}) [multi]: ${parts.join(", ")}`;
|
|
205
201
|
} else if (typeof a === "number") {
|
|
206
|
-
|
|
202
|
+
line = ` Q${i + 1} (${q.header}): ${q.options[a]?.label ?? `?${a}`}`;
|
|
207
203
|
} else {
|
|
208
|
-
|
|
204
|
+
line = ` Q${i + 1} (${q.header}) [Other]: "${a}"`;
|
|
205
|
+
}
|
|
206
|
+
// Append note if present
|
|
207
|
+
if (notes[i]) {
|
|
208
|
+
line += ` // note: "${notes[i]}"`;
|
|
209
209
|
}
|
|
210
|
+
out.push(line);
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
return {
|
|
213
214
|
content: [{ type: "text", text: out.join("\n") }],
|
|
214
|
-
details: { answers },
|
|
215
|
+
details: { answers, notes: Object.keys(notes).length > 0 ? notes : undefined },
|
|
215
216
|
};
|
|
216
217
|
},
|
|
217
218
|
});
|
package/ask/picker.ts
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
Container,
|
|
18
18
|
Text,
|
|
19
19
|
Spacer,
|
|
20
|
+
Input,
|
|
21
|
+
truncateToWidth,
|
|
20
22
|
type Component,
|
|
21
23
|
type KeybindingsManager,
|
|
22
24
|
} from "@earendil-works/pi-tui";
|
|
@@ -31,6 +33,13 @@ export interface AskOption {
|
|
|
31
33
|
label: string;
|
|
32
34
|
description?: string;
|
|
33
35
|
recommended?: boolean;
|
|
36
|
+
/** Optional preview content shown in a side panel when this option is
|
|
37
|
+
* focused. Use markdown or plain text to show code snippets, structure
|
|
38
|
+
* examples, or elaboration of what the option entails.
|
|
39
|
+
*
|
|
40
|
+
* Example:
|
|
41
|
+
* preview: "```ts\nclass Auth {\n token: string\n}\n```" */
|
|
42
|
+
preview?: string;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export interface AskQuestion {
|
|
@@ -59,13 +68,10 @@ export interface AskProResult {
|
|
|
59
68
|
cancelled?: boolean;
|
|
60
69
|
/** Map of question index → answer. Single: number | string. Multi: (number | string)[] */
|
|
61
70
|
answers?: Record<number, AskAnswer | AskMultiAnswer>;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
title: string;
|
|
67
|
-
prompt: string;
|
|
68
|
-
placeholder?: string;
|
|
71
|
+
/** Optional free-text notes the user added to specific questions.
|
|
72
|
+
* Keyed by question index. Added when the user pressed `n` after
|
|
73
|
+
* picking an option and typed a note. */
|
|
74
|
+
notes?: Record<number, string>;
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
interface AskProComponentDeps {
|
|
@@ -75,11 +81,6 @@ interface AskProComponentDeps {
|
|
|
75
81
|
done: (result: AskProResult) => void;
|
|
76
82
|
/** Optional title shown above the tabs. */
|
|
77
83
|
title?: string;
|
|
78
|
-
/** Open a text-input dialog for the "Other…" option. Returns the typed
|
|
79
|
-
* text, or undefined if the user cancelled. If omitted, the "Other…"
|
|
80
|
-
* option is hidden even when `allowOther: true` (caller should ensure
|
|
81
|
-
* the dependency is present if it advertises allowOther). */
|
|
82
|
-
onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// ---------------------------------------------------------------------------
|
|
@@ -99,6 +100,18 @@ const KEY_LEFT = "\x1b[D";
|
|
|
99
100
|
const KEY_SHIFT_TAB = "\x1b[Z";
|
|
100
101
|
const KEY_BACKSPACE = "\x7f";
|
|
101
102
|
|
|
103
|
+
/** Active inline text-input mode. When non-null, the picker renders an
|
|
104
|
+
* embedded single-line `Input` field below the option list and routes
|
|
105
|
+
* keystrokes to it until Enter (commit) or Esc (cancel).
|
|
106
|
+
*
|
|
107
|
+
* Why inline: the previous design called out to the host UI's modal input
|
|
108
|
+
* dialog (`ctx.ui.input()`), which clears `editorContainer` to show itself
|
|
109
|
+
* and restores the *default* editor on close — destroying the live picker.
|
|
110
|
+
* Inline input keeps the picker alive and self-contained. */
|
|
111
|
+
type InputMode =
|
|
112
|
+
| { kind: "note" }
|
|
113
|
+
| { kind: "other"; isMulti: boolean; input: Input };
|
|
114
|
+
|
|
102
115
|
/** A standalone picker component. Extends Container so it composes in the
|
|
103
116
|
* editor area like any other TUI widget. */
|
|
104
117
|
export class AskProComponent extends Container {
|
|
@@ -106,17 +119,22 @@ export class AskProComponent extends Container {
|
|
|
106
119
|
private theme: AskProTheme;
|
|
107
120
|
private keybindings: KeybindingsManager;
|
|
108
121
|
private done: (result: AskProResult) => void;
|
|
109
|
-
private onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
|
|
110
122
|
private title: string;
|
|
111
123
|
|
|
112
124
|
private currentIndex = 0;
|
|
113
125
|
private selectedIndex = 0;
|
|
114
126
|
/** answers[questionIdx] = AskAnswer (single) or AskMultiAnswer (multi). */
|
|
115
127
|
private answers = new Map<number, AskAnswer | AskMultiAnswer>();
|
|
128
|
+
/** notes[questionIdx] = free-text note added by user (via `n` key). */
|
|
129
|
+
private notes = new Map<number, string>();
|
|
116
130
|
/** Set true once `done` is called — further input is ignored. */
|
|
117
131
|
private completed = false;
|
|
118
|
-
/**
|
|
119
|
-
|
|
132
|
+
/** Active inline text-input mode (note or Other…). When set, all keys
|
|
133
|
+
* except Enter/Esc are routed to the embedded Input. */
|
|
134
|
+
private inputMode: InputMode | null = null;
|
|
135
|
+
/** Note Input used while inputMode.kind === "note". Kept on the instance
|
|
136
|
+
* so the `n`-flow can reuse a single field across open/close cycles. */
|
|
137
|
+
private noteInput: Input | null = null;
|
|
120
138
|
|
|
121
139
|
private tabsText!: Text;
|
|
122
140
|
private bodyContainer!: Container;
|
|
@@ -128,7 +146,6 @@ export class AskProComponent extends Container {
|
|
|
128
146
|
this.theme = deps.theme;
|
|
129
147
|
this.keybindings = deps.keybindings;
|
|
130
148
|
this.done = deps.done;
|
|
131
|
-
this.onRequestInput = deps.onRequestInput;
|
|
132
149
|
this.title = deps.title ?? "pi-ask";
|
|
133
150
|
|
|
134
151
|
const titleText = new Text(this.theme.fg("accent", this.theme.bold(this.title)), 1, 0);
|
|
@@ -194,6 +211,11 @@ export class AskProComponent extends Container {
|
|
|
194
211
|
const q = this.questions[this.currentIndex];
|
|
195
212
|
if (!q) return;
|
|
196
213
|
|
|
214
|
+
// Compute the current preview (from the option under the cursor).
|
|
215
|
+
// Shown side-by-side with the option list via pad-right.
|
|
216
|
+
const currentPreview = this.currentPreviewLines();
|
|
217
|
+
const hasPreview = currentPreview.length > 0;
|
|
218
|
+
|
|
197
219
|
// Question line: "Q1 of 3: <question>"
|
|
198
220
|
this.bodyContainer.addChild(
|
|
199
221
|
new Text(
|
|
@@ -247,8 +269,9 @@ export class AskProComponent extends Container {
|
|
|
247
269
|
}
|
|
248
270
|
}
|
|
249
271
|
|
|
250
|
-
// Synthetic "Other…" option (when allowOther=true)
|
|
251
|
-
|
|
272
|
+
// Synthetic "Other…" option (when allowOther=true). The inline text
|
|
273
|
+
// field is built in, so this no longer depends on any external callback.
|
|
274
|
+
if (allowOther) {
|
|
252
275
|
const otherIndex = q.options.length;
|
|
253
276
|
const isOtherSelected = this.selectedIndex === otherIndex;
|
|
254
277
|
const customStr = this.getCustomString(currentAns);
|
|
@@ -320,6 +343,30 @@ export class AskProComponent extends Container {
|
|
|
320
343
|
),
|
|
321
344
|
);
|
|
322
345
|
}
|
|
346
|
+
|
|
347
|
+
// Inline text field for note / Other…. Shown below the option list when
|
|
348
|
+
// inputMode is active; owns the keyboard (Enter commits, Esc cancels).
|
|
349
|
+
this.renderInlineField();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Append the active inline text field to bodyContainer. No-op when no
|
|
353
|
+
* inline mode is active. */
|
|
354
|
+
private renderInlineField(): void {
|
|
355
|
+
const mode = this.inputMode;
|
|
356
|
+
if (!mode) return;
|
|
357
|
+
const q = this.questions[this.currentIndex];
|
|
358
|
+
const label =
|
|
359
|
+
mode.kind === "note"
|
|
360
|
+
? this.theme.fg("dim", "Note:")
|
|
361
|
+
: this.theme.fg("dim", "Custom answer:");
|
|
362
|
+
this.bodyContainer.addChild(new Spacer(1));
|
|
363
|
+
this.bodyContainer.addChild(new Text(label + " " + this.theme.fg("dim", "(enter ⏎ confirm · esc cancel)"), 1, 0));
|
|
364
|
+
this.bodyContainer.addChild(new Spacer(1));
|
|
365
|
+
if (mode.kind === "other") {
|
|
366
|
+
this.bodyContainer.addChild(mode.input);
|
|
367
|
+
} else if (this.noteInput) {
|
|
368
|
+
this.bodyContainer.addChild(this.noteInput);
|
|
369
|
+
}
|
|
323
370
|
}
|
|
324
371
|
|
|
325
372
|
// -------------------------------------------------------------------------
|
|
@@ -354,7 +401,7 @@ export class AskProComponent extends Container {
|
|
|
354
401
|
const q = this.questions[this.currentIndex];
|
|
355
402
|
if (!q) return 0;
|
|
356
403
|
const allowOther = q.allowOther ?? false;
|
|
357
|
-
return q.options.length + (allowOther
|
|
404
|
+
return q.options.length + (allowOther ? 1 : 0);
|
|
358
405
|
}
|
|
359
406
|
|
|
360
407
|
// -------------------------------------------------------------------------
|
|
@@ -371,14 +418,25 @@ export class AskProComponent extends Container {
|
|
|
371
418
|
const totalOptions = this.totalOptionsForCurrent();
|
|
372
419
|
|
|
373
420
|
const parts: string[] = [];
|
|
421
|
+
|
|
422
|
+
// When the inline text field is open, it owns the keyboard — show
|
|
423
|
+
// only its affordances, not the navigation hints.
|
|
424
|
+
if (this.inputMode !== null) {
|
|
425
|
+
parts.push(this.theme.fg("accent", "⏎ confirm"));
|
|
426
|
+
parts.push(this.theme.fg("dim", "esc cancel"));
|
|
427
|
+
return parts.join(" ");
|
|
428
|
+
}
|
|
429
|
+
|
|
374
430
|
parts.push(this.theme.fg("dim", "↑↓ navigate"));
|
|
375
431
|
parts.push(this.theme.fg("dim", `1-${totalOptions} pick`));
|
|
376
432
|
if (this.currentIndex > 0) parts.push(this.theme.fg("dim", "tab/← prev"));
|
|
377
433
|
if (this.currentIndex < this.questions.length - 1) {
|
|
378
434
|
parts.push(this.theme.fg("dim", "tab/→ next"));
|
|
379
435
|
}
|
|
380
|
-
// "Other…" hint: single-select uses Enter
|
|
381
|
-
|
|
436
|
+
// "Other…" hint: single-select uses Enter to open inline input,
|
|
437
|
+
// multi-select uses Space. (The inline field is always available now;
|
|
438
|
+
// no external dependency required.)
|
|
439
|
+
if (allowOther && this.selectedIndex === otherIndex) {
|
|
382
440
|
parts.push(this.theme.fg("accent", isMulti ? "␣ type" : "⏎ type"));
|
|
383
441
|
} else if (isMulti) {
|
|
384
442
|
// Multi-select: Space toggles, Enter advances/submits
|
|
@@ -397,6 +455,11 @@ export class AskProComponent extends Container {
|
|
|
397
455
|
// Single-select: Enter is the action key
|
|
398
456
|
parts.push(this.theme.fg("accent", isLast ? "⏎ submit" : "⏎ next"));
|
|
399
457
|
}
|
|
458
|
+
// `n` hint: add/edit an inline note. Always available now (inline
|
|
459
|
+
// field, no external dependency).
|
|
460
|
+
const hasNote = this.notes.has(this.currentIndex);
|
|
461
|
+
const noteHint = hasNote ? "n ✓note" : "n note";
|
|
462
|
+
parts.push(this.theme.fg(hasNote ? "success" : "dim", noteHint));
|
|
400
463
|
parts.push(this.theme.fg("dim", "esc cancel"));
|
|
401
464
|
return parts.join(" ");
|
|
402
465
|
}
|
|
@@ -420,9 +483,17 @@ export class AskProComponent extends Container {
|
|
|
420
483
|
// -------------------------------------------------------------------------
|
|
421
484
|
|
|
422
485
|
handleInput(keyData: string): void {
|
|
423
|
-
if (this.completed
|
|
486
|
+
if (this.completed) return;
|
|
487
|
+
|
|
488
|
+
// --- Inline text-input mode (note / Other…) --------------------------
|
|
489
|
+
// When active, all keys route to the embedded Input except the
|
|
490
|
+
// confirm/cancel gestures, which we intercept to commit or abort.
|
|
491
|
+
if (this.inputMode !== null) {
|
|
492
|
+
this.handleInputModeKey(keyData);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
424
495
|
|
|
425
|
-
// Esc — cancel
|
|
496
|
+
// Esc — cancel the whole picker
|
|
426
497
|
if (keyData === KEY_ESC) {
|
|
427
498
|
this.completed = true;
|
|
428
499
|
this.done({ cancelled: true });
|
|
@@ -494,13 +565,13 @@ export class AskProComponent extends Container {
|
|
|
494
565
|
}
|
|
495
566
|
|
|
496
567
|
// Space — toggle in multi-select.
|
|
497
|
-
// On "Other…", opens the
|
|
568
|
+
// On "Other…", opens the inline text field (or toggles existing custom string).
|
|
498
569
|
// In single-select, Space is a no-op (Enter is the action key there).
|
|
499
570
|
if (keyData === KEY_SPACE) {
|
|
500
571
|
if (!isMulti) return;
|
|
501
|
-
// On Other… → open
|
|
502
|
-
if (allowOther && this.
|
|
503
|
-
|
|
572
|
+
// On Other… → open inline field (or re-toggle existing custom string)
|
|
573
|
+
if (allowOther && this.selectedIndex === otherIndex) {
|
|
574
|
+
this.openOtherInput();
|
|
504
575
|
return;
|
|
505
576
|
}
|
|
506
577
|
const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
|
|
@@ -521,16 +592,11 @@ export class AskProComponent extends Container {
|
|
|
521
592
|
keyData === KEY_ENTER ||
|
|
522
593
|
keyData === KEY_ENTER_CR
|
|
523
594
|
) {
|
|
524
|
-
// If "Other…" is the selected option in single-select, open
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
if (
|
|
528
|
-
|
|
529
|
-
this.onRequestInput &&
|
|
530
|
-
this.selectedIndex === otherIndex &&
|
|
531
|
-
!isMulti
|
|
532
|
-
) {
|
|
533
|
-
void this.requestOtherInput();
|
|
595
|
+
// If "Other…" is the selected option in single-select, open the
|
|
596
|
+
// inline field. In multi-select, Enter on Other… just advances
|
|
597
|
+
// (use Space to toggle/type a custom answer).
|
|
598
|
+
if (allowOther && this.selectedIndex === otherIndex && !isMulti) {
|
|
599
|
+
this.openOtherInput();
|
|
534
600
|
return;
|
|
535
601
|
}
|
|
536
602
|
|
|
@@ -565,6 +631,13 @@ export class AskProComponent extends Container {
|
|
|
565
631
|
}
|
|
566
632
|
return;
|
|
567
633
|
}
|
|
634
|
+
|
|
635
|
+
// `n` — add/edit a free-text note for the current question via the
|
|
636
|
+
// inline field. Always available (no external dependency).
|
|
637
|
+
if (keyData === "n") {
|
|
638
|
+
this.openNoteInput();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
568
641
|
}
|
|
569
642
|
|
|
570
643
|
private handlePick(optionIdx: number): void {
|
|
@@ -574,9 +647,9 @@ export class AskProComponent extends Container {
|
|
|
574
647
|
const allowOther = q.allowOther ?? false;
|
|
575
648
|
const otherIndex = allowOther ? q.options.length : -1;
|
|
576
649
|
|
|
577
|
-
// "Other…" picked via number key
|
|
578
|
-
if (allowOther &&
|
|
579
|
-
|
|
650
|
+
// "Other…" picked via number key → open inline field
|
|
651
|
+
if (allowOther && optionIdx === otherIndex) {
|
|
652
|
+
this.openOtherInput();
|
|
580
653
|
return;
|
|
581
654
|
}
|
|
582
655
|
|
|
@@ -604,57 +677,118 @@ export class AskProComponent extends Container {
|
|
|
604
677
|
}
|
|
605
678
|
}
|
|
606
679
|
|
|
607
|
-
/**
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
680
|
+
/** Create a fresh inline Input pre-filled with `value`, cursor at the
|
|
681
|
+
* end so the user can immediately extend or backspace-edit it.
|
|
682
|
+
* (Input.setValue leaves the cursor at min(prevCursor, len) which is 0
|
|
683
|
+
* for a fresh field — typing the prefill char-by-char puts the cursor
|
|
684
|
+
* at the end naturally.) */
|
|
685
|
+
private makeInput(value: string): Input {
|
|
686
|
+
const input = new Input();
|
|
687
|
+
for (const ch of value) input.handleInput(ch);
|
|
688
|
+
return input;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Open the inline field for the "Other…" option on the current
|
|
692
|
+
* question. Pre-fills with any existing custom string so it can be
|
|
693
|
+
* edited. While open, all keys route to the field except Enter/Esc. */
|
|
694
|
+
private openOtherInput(): void {
|
|
616
695
|
const q = this.questions[this.currentIndex];
|
|
617
696
|
if (!q) return;
|
|
618
|
-
this.awaitingInput = true;
|
|
619
697
|
const isMulti = q.multiSelect ?? false;
|
|
698
|
+
const currentAns = this.answers.get(this.currentIndex);
|
|
699
|
+
const existing = this.getCustomString(currentAns);
|
|
700
|
+
this.inputMode = { kind: "other", isMulti, input: this.makeInput(existing) };
|
|
701
|
+
this.repaint();
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Open the inline field for a note on the current question. Pre-fills
|
|
705
|
+
* with any existing note so it can be edited. */
|
|
706
|
+
private openNoteInput(): void {
|
|
707
|
+
if (!this.questions[this.currentIndex]) return;
|
|
708
|
+
const existing = this.notes.get(this.currentIndex) ?? "";
|
|
709
|
+
this.noteInput = this.makeInput(existing);
|
|
710
|
+
this.inputMode = { kind: "note" };
|
|
711
|
+
this.repaint();
|
|
712
|
+
}
|
|
620
713
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
714
|
+
/** Route a keystroke to the active inline field, intercepting Enter
|
|
715
|
+
* (commit) and Esc (cancel). Mirrors the Enter-on-last / advance /
|
|
716
|
+
* submit flow that the old async path had. */
|
|
717
|
+
private handleInputModeKey(keyData: string): void {
|
|
718
|
+
const mode = this.inputMode;
|
|
719
|
+
if (!mode) return;
|
|
627
720
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
this.
|
|
721
|
+
// Esc — cancel inline input, return to option navigation
|
|
722
|
+
if (keyData === KEY_ESC) {
|
|
723
|
+
this.closeInput(false);
|
|
631
724
|
return;
|
|
632
725
|
}
|
|
633
|
-
|
|
634
|
-
if (
|
|
635
|
-
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);
|
|
636
733
|
return;
|
|
637
734
|
}
|
|
735
|
+
// Everything else (typing, arrows, backspace, etc.) → the field
|
|
736
|
+
const input = mode.kind === "other" ? mode.input : this.noteInput!;
|
|
737
|
+
input.handleInput(keyData);
|
|
738
|
+
this.repaint();
|
|
739
|
+
}
|
|
638
740
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
741
|
+
/** Commit (commit=true) or discard the active inline field, then return
|
|
742
|
+
* to normal option navigation. */
|
|
743
|
+
private closeInput(commit: boolean): void {
|
|
744
|
+
const mode = this.inputMode;
|
|
745
|
+
if (!mode) return;
|
|
746
|
+
this.inputMode = null;
|
|
747
|
+
|
|
748
|
+
if (mode.kind === "note") {
|
|
749
|
+
const text = this.noteInput?.getValue() ?? "";
|
|
750
|
+
this.noteInput = null;
|
|
751
|
+
if (commit) {
|
|
752
|
+
const trimmed = text.trim();
|
|
753
|
+
if (trimmed === "") {
|
|
754
|
+
this.notes.delete(this.currentIndex);
|
|
755
|
+
} else {
|
|
756
|
+
this.notes.set(this.currentIndex, trimmed);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
646
759
|
this.repaint();
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// kind === "other"
|
|
764
|
+
const isMulti = mode.isMulti;
|
|
765
|
+
const text = mode.input.getValue();
|
|
766
|
+
const trimmed = commit ? text.trim() : "";
|
|
767
|
+
if (commit && trimmed !== "") {
|
|
768
|
+
if (isMulti) {
|
|
769
|
+
const cur =
|
|
770
|
+
(this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
|
|
771
|
+
// Replace existing custom string (if any) so user can edit
|
|
772
|
+
const existingIdx = cur.findIndex((a) => typeof a === "string");
|
|
773
|
+
if (existingIdx >= 0) cur[existingIdx] = trimmed;
|
|
774
|
+
else cur.push(trimmed);
|
|
775
|
+
this.answers.set(this.currentIndex, cur);
|
|
776
|
+
} else {
|
|
777
|
+
this.answers.set(this.currentIndex, trimmed);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
this.repaint();
|
|
781
|
+
|
|
782
|
+
// Single-select: a committed custom answer advances or submits,
|
|
783
|
+
// matching the old async behavior. Multi-select stays put (user
|
|
784
|
+
// may toggle more options). Cancellation never advances.
|
|
785
|
+
if (commit && !isMulti && trimmed !== "") {
|
|
650
786
|
if (this.currentIndex < this.questions.length - 1) {
|
|
651
787
|
this.currentIndex++;
|
|
652
788
|
this.selectedIndex = 0;
|
|
653
789
|
this.repaint();
|
|
654
790
|
} else if (this.allAnswered()) {
|
|
655
791
|
this.submit();
|
|
656
|
-
} else {
|
|
657
|
-
this.repaint();
|
|
658
792
|
}
|
|
659
793
|
}
|
|
660
794
|
}
|
|
@@ -665,8 +799,18 @@ export class AskProComponent extends Container {
|
|
|
665
799
|
for (let i = 0; i < this.questions.length; i++) {
|
|
666
800
|
answers[i] = this.answers.get(i) as AskAnswer | AskMultiAnswer;
|
|
667
801
|
}
|
|
802
|
+
// Include notes only if at least one was added
|
|
803
|
+
const notes: Record<number, string> = {};
|
|
804
|
+
let hasNotes = false;
|
|
805
|
+
for (let i = 0; i < this.questions.length; i++) {
|
|
806
|
+
const n = this.notes.get(i);
|
|
807
|
+
if (n) {
|
|
808
|
+
notes[i] = n;
|
|
809
|
+
hasNotes = true;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
668
812
|
this.completed = true;
|
|
669
|
-
this.done({ answers });
|
|
813
|
+
this.done(hasNotes ? { answers, notes } : { answers });
|
|
670
814
|
}
|
|
671
815
|
|
|
672
816
|
// -------------------------------------------------------------------------
|
|
@@ -676,7 +820,143 @@ export class AskProComponent extends Container {
|
|
|
676
820
|
|
|
677
821
|
dispose(): void {
|
|
678
822
|
this.completed = true;
|
|
679
|
-
this.
|
|
823
|
+
this.inputMode = null;
|
|
824
|
+
this.noteInput = null;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// -----------------------------------------------------------------------
|
|
828
|
+
// Side-by-side render: picker column (left) + preview column (right).
|
|
829
|
+
//
|
|
830
|
+
// HARD GUARANTEE: every emitted line is capped to `width` visible columns
|
|
831
|
+
// via truncateToWidth(). The previous implementation measured width with a
|
|
832
|
+
// hand-rolled regex that ignored OSC 8 hyperlinks / FTCS marks (which pi-tui
|
|
833
|
+
// wraps around rendered text) and never truncated the left column or the
|
|
834
|
+
// combined row — so a long option label or preview word pushed a line past
|
|
835
|
+
// the terminal width and crashed pi ("Rendered line N exceeds terminal
|
|
836
|
+
// width"). See C:/Users/bradw/.pi/agent/pi-crash.log.
|
|
837
|
+
// -----------------------------------------------------------------------
|
|
838
|
+
private static readonly SPLIT_COL = 60; // max picker column width when a preview is present
|
|
839
|
+
private static readonly SEP = " │ "; // 3-col gutter between the two columns
|
|
840
|
+
private static readonly MIN_COL = 16; // minimum useful width for either column
|
|
841
|
+
|
|
842
|
+
/** Lines of preview content for the option currently under the cursor.
|
|
843
|
+
* Returns [] when no option is focused or the option has no preview. */
|
|
844
|
+
private currentPreviewLines(): string[] {
|
|
845
|
+
const q = this.questions[this.currentIndex];
|
|
846
|
+
if (!q) return [];
|
|
847
|
+
// Only real options carry previews (not "Other…")
|
|
848
|
+
if (this.selectedIndex < 0 || this.selectedIndex >= q.options.length) return [];
|
|
849
|
+
const opt = q.options[this.selectedIndex];
|
|
850
|
+
if (!opt?.preview) return [];
|
|
851
|
+
// Trim and split on newlines; drop leading/trailing blank lines.
|
|
852
|
+
const lines = opt.preview
|
|
853
|
+
.replace(/\r\n/g, "\n")
|
|
854
|
+
.split("\n")
|
|
855
|
+
.map((l) => l.trimEnd());
|
|
856
|
+
while (lines.length > 0 && lines[0]?.trim() === "") lines.shift();
|
|
857
|
+
while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") lines.pop();
|
|
858
|
+
return lines;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Greedy word-wrap one source line to `maxWidth`, hard-truncating any
|
|
862
|
+
* single word longer than `maxWidth`. Returns 1+ lines, each ≤ maxWidth
|
|
863
|
+
* visible columns. Uses .length here only as a fast upper bound — the
|
|
864
|
+
* caller still hard-caps the final row, so a miscount can never overflow. */
|
|
865
|
+
private wrapPreviewLine(line: string, maxWidth: number): string[] {
|
|
866
|
+
if (maxWidth <= 0) return [""];
|
|
867
|
+
if (line.length === 0) return [""];
|
|
868
|
+
const words = line.split(" ");
|
|
869
|
+
const out: string[] = [];
|
|
870
|
+
let cur = "";
|
|
871
|
+
for (const w of words) {
|
|
872
|
+
const word = w.length > maxWidth ? truncateToWidth(w, maxWidth, "") : w;
|
|
873
|
+
if (cur.length === 0) {
|
|
874
|
+
cur = word;
|
|
875
|
+
} else if (cur.length + 1 + word.length <= maxWidth) {
|
|
876
|
+
cur += " " + word;
|
|
877
|
+
} else {
|
|
878
|
+
out.push(cur);
|
|
879
|
+
cur = word;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
out.push(cur);
|
|
883
|
+
return out;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Override render to produce a side-by-side layout when a preview is
|
|
887
|
+
* present. Falls back to default Container render otherwise. Every
|
|
888
|
+
* returned line is guaranteed ≤ `width` visible columns. */
|
|
889
|
+
render(width: number): string[] {
|
|
890
|
+
const superLines = super.render(width);
|
|
891
|
+
const previewLines = this.currentPreviewLines();
|
|
892
|
+
if (previewLines.length === 0) return superLines;
|
|
893
|
+
|
|
894
|
+
const border = this.theme.fg("dim", "│");
|
|
895
|
+
const sepLen = AskProComponent.SEP.length; // 3
|
|
896
|
+
|
|
897
|
+
// Column allocation. Picker gets ~55% (capped at SPLIT_COL); the rest
|
|
898
|
+
// goes to preview. If there isn't room for two usable columns, fall
|
|
899
|
+
// back to a stacked layout (picker full-width, preview underneath).
|
|
900
|
+
const splitCol = Math.min(
|
|
901
|
+
AskProComponent.SPLIT_COL,
|
|
902
|
+
Math.max(AskProComponent.MIN_COL, Math.floor(width * 0.55)),
|
|
903
|
+
);
|
|
904
|
+
const previewWidth = width - splitCol - sepLen;
|
|
905
|
+
if (previewWidth < AskProComponent.MIN_COL) {
|
|
906
|
+
return this.renderPreviewStacked(width, superLines, previewLines, border);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Wrap each preview source line to previewWidth (a long line may
|
|
910
|
+
// produce several wrapped rows).
|
|
911
|
+
const wrapped: string[] = [];
|
|
912
|
+
for (const line of previewLines) {
|
|
913
|
+
for (const w of this.wrapPreviewLine(line, previewWidth)) wrapped.push(w);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const result: string[] = [];
|
|
917
|
+
// Header row for the preview column (aligned under it), hard-capped.
|
|
918
|
+
result.push(
|
|
919
|
+
truncateToWidth(
|
|
920
|
+
"".padEnd(splitCol) + " " + border + " " + this.theme.fg("dim", "— preview —"),
|
|
921
|
+
width,
|
|
922
|
+
"",
|
|
923
|
+
false,
|
|
924
|
+
),
|
|
925
|
+
);
|
|
926
|
+
const rows = Math.max(superLines.length, wrapped.length);
|
|
927
|
+
for (let i = 0; i < rows; i++) {
|
|
928
|
+
const superLine = superLines[i] ?? "";
|
|
929
|
+
const pLine = wrapped[i];
|
|
930
|
+
// Left: exactly splitCol wide — truncate long option lines and pad
|
|
931
|
+
// short ones. truncateToWidth preserves ANSI/OSC styling.
|
|
932
|
+
const left = truncateToWidth(superLine, splitCol, "", true);
|
|
933
|
+
let row = left + " " + border + " ";
|
|
934
|
+
if (pLine !== undefined) row += this.theme.fg("text", pLine);
|
|
935
|
+
// Hard cap — the guarantee that prevents the terminal-width crash.
|
|
936
|
+
result.push(truncateToWidth(row, width, "", false));
|
|
937
|
+
}
|
|
938
|
+
return result;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Stacked fallback for narrow terminals: picker lines at full width,
|
|
942
|
+
* then a framed preview block below. Every line ≤ width. */
|
|
943
|
+
private renderPreviewStacked(
|
|
944
|
+
width: number,
|
|
945
|
+
superLines: string[],
|
|
946
|
+
previewLines: string[],
|
|
947
|
+
border: string,
|
|
948
|
+
): string[] {
|
|
949
|
+
const result: string[] = [];
|
|
950
|
+
for (const l of superLines) result.push(truncateToWidth(l, width, "", false));
|
|
951
|
+
result.push(
|
|
952
|
+
truncateToWidth(this.theme.fg("dim", `${border} — preview —`), width, "", false),
|
|
953
|
+
);
|
|
954
|
+
for (const line of previewLines) {
|
|
955
|
+
for (const w of this.wrapPreviewLine(line, width)) {
|
|
956
|
+
result.push(truncateToWidth(this.theme.fg("text", w), width, "", false));
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return result;
|
|
680
960
|
}
|
|
681
961
|
}
|
|
682
962
|
|