@black942026/pi-ask-user 0.0.0-stage → 0.1.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.
@@ -0,0 +1,238 @@
1
+ import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { isKeyRelease, isKeyRepeat, matchesKey, type KeyId, type OverlayHandle } from "@earendil-works/pi-tui";
3
+ import {
4
+ DEFAULT_OVERLAY_TOGGLE_KEY,
5
+ isAllowedOverlayToggleSpec,
6
+ resolveAskUserConfig,
7
+ toUiPreferences,
8
+ type AskUserConfigOverrides,
9
+ } from "../config.ts";
10
+ import { createAskUserHost, type CreateAskUserHostOptions } from "../route.ts";
11
+ import type {
12
+ AskUIInput,
13
+ AskUIOutcome,
14
+ AskUserDisplayMode,
15
+ AskUserHost,
16
+ AskUserMode,
17
+ CustomUIRenderer,
18
+ } from "../types.ts";
19
+ import { AskUserComponent, type AskUserTheme, type CustomUIResult } from "../ui/custom.ts";
20
+ import { createNativeRunner } from "../ui/native.ts";
21
+
22
+ export interface PiHostOptions {
23
+ /**
24
+ * Explicit UI route: `custom` | `native`. The single highest precedence. When
25
+ * omitted, the route is probed from the implementations this context really
26
+ * supports, or read from the user config file. An invalid value is reported as
27
+ * `invalid_config`.
28
+ */
29
+ mode?: AskUserMode;
30
+ /** Explicit display mode; overrides the config file and the request. */
31
+ displayMode?: AskUserDisplayMode;
32
+ /** Explicit overlay toggle key; `null` disables it. Overrides the config file. */
33
+ overlayToggleKey?: string | null;
34
+ /** Explicit timeout (ms) per question; overrides the config file and the request. */
35
+ timeoutPerQuestionMs?: number;
36
+ /**
37
+ * Path to the user config file, or `false` to skip reading it. Defaults to
38
+ * `~/.pi/ask-user/config.json`. There are no environment variables.
39
+ */
40
+ configFile?: string | false;
41
+ }
42
+
43
+ /**
44
+ * Build the default host adapter for a running Pi extension, from an available
45
+ * `ExtensionContext`.
46
+ *
47
+ * The Pi lifecycle only exposes the context during a tool call / command
48
+ * handler, so the capability probe happens here, at host creation — not at
49
+ * extension registration, which can capture only the explicit options.
50
+ *
51
+ * The user config file is read here too, once per host, and resolved as
52
+ * `explicit option > file > built-in`. An invalid file does not throw: it makes
53
+ * the host unusable (`invalid_config`), so every call is refused actionably and
54
+ * nothing falls back to another route.
55
+ *
56
+ * Both implementations are bound wherever they can really run:
57
+ *
58
+ * - `customUI` — only in `tui` mode with a callable `ctx.ui.custom()`.
59
+ * - `nativeDialogs` — only when `ctx.hasUI` and `ctx.ui.input` is callable,
60
+ * i.e. native input dialogs shown one question at a time. RPC reports
61
+ * `hasUI: true`, so a nonresponsive RPC client is *not* pre-judged: the
62
+ * attempt is made and the shared deadline turns silence into an actionable
63
+ * `timeout`.
64
+ *
65
+ * The route is then resolved once (explicit `mode`, else the probe with `custom`
66
+ * preferred, else unsupported) and stored on the host. JSON/print have no
67
+ * dialog-capable UI, so with no explicit mode the result is `no_available_ui`,
68
+ * and an explicit `custom`/`native` there is `configured_unavailable` — neither
69
+ * silently becomes a different route. The model can never influence any of this:
70
+ * it comes from this adapter, not from tool parameters.
71
+ */
72
+ export function createPiHost(ctx: ExtensionContext, options: PiHostOptions = {}): AskUserHost {
73
+ const programmatic: AskUserConfigOverrides = {};
74
+ if (options.mode !== undefined) programmatic.mode = options.mode;
75
+ if (options.displayMode !== undefined) programmatic.displayMode = options.displayMode;
76
+ if ("overlayToggleKey" in options) programmatic.overlayToggleKey = options.overlayToggleKey;
77
+ if (options.timeoutPerQuestionMs !== undefined) programmatic.timeoutPerQuestionMs = options.timeoutPerQuestionMs;
78
+
79
+ const resolved = resolveAskUserConfig(programmatic, { configFile: options.configFile });
80
+ const config = resolved.config;
81
+
82
+ const hostOptions: CreateAskUserHostOptions = { name: "pi" };
83
+ if (config.mode !== undefined) hostOptions.mode = config.mode;
84
+ hostOptions.preferences = toUiPreferences(config);
85
+ if (resolved.error !== undefined) hostOptions.configError = resolved.error.message;
86
+
87
+ const overlayToggleKey =
88
+ config.overlayToggleKey === undefined ? DEFAULT_OVERLAY_TOGGLE_KEY : config.overlayToggleKey;
89
+ if (ctx.mode === "tui" && typeof ctx.ui?.custom === "function") {
90
+ hostOptions.customUI = createPiCustomRenderer(ctx.ui, { overlayToggleKey });
91
+ }
92
+ if (ctx.hasUI && typeof ctx.ui?.input === "function") {
93
+ hostOptions.nativeDialogs = createNativeRunner(ctx.ui);
94
+ }
95
+ return createAskUserHost(hostOptions);
96
+ }
97
+
98
+ export interface PiCustomRendererOptions {
99
+ /**
100
+ * The overlay show/hide key, or `null` to disable it. When omitted, the
101
+ * built-in default (`alt+o`) applies. Ignored on the native route and for
102
+ * inline display, which register no listener.
103
+ */
104
+ overlayToggleKey?: string | null;
105
+ }
106
+
107
+ /**
108
+ * Custom renderer backed by `ctx.ui.custom()`. TUI mode only.
109
+ *
110
+ * For an overlay it also installs the show/hide shortcut: the overlay handle
111
+ * (`onHandle` → `OverlayHandle.setHidden`) plus a raw `ctx.ui.onTerminalInput`
112
+ * listener, so the *same* key restores the overlay even while it is hidden
113
+ * (hidden overlays receive no component input). The listener is removed and the
114
+ * handle dropped in `finally`, on every exit path. Hiding neither resolves the
115
+ * interaction nor pauses its deadline: aborts and timeouts still complete while
116
+ * hidden, and `Esc` still cancels.
117
+ */
118
+ export function createPiCustomRenderer(ui: ExtensionUIContext, options: PiCustomRendererOptions = {}): CustomUIRenderer {
119
+ const rawToggle = options.overlayToggleKey;
120
+ const candidate = rawToggle === null ? undefined : (rawToggle ?? DEFAULT_OVERLAY_TOGGLE_KEY);
121
+ // Defensive guard: the config layer rejects reserved/conflicting keys with an
122
+ // actionable error, so a validated chord always arrives here. A direct caller
123
+ // that bypasses it gets the toggle disabled rather than a listener that could
124
+ // shadow Esc or steal typing — never a silent substitution of another key.
125
+ const toggleSpec = candidate !== undefined && isAllowedOverlayToggleSpec(candidate) ? candidate : undefined;
126
+ return {
127
+ async render(input: AskUIInput): Promise<AskUIOutcome> {
128
+ const { request, deadline, signal } = input;
129
+ const overlay = request.displayMode !== "inline";
130
+ const toggle = overlay ? toggleSpec : undefined;
131
+
132
+ let handle: OverlayHandle | undefined;
133
+ let unsubscribe: (() => void) | undefined;
134
+ let component: AskUserComponent | undefined;
135
+ let abortHandler: (() => void) | undefined;
136
+ let cleanedUp = false;
137
+ let announced = false;
138
+
139
+ // Idempotent: safe to call from the abort path, `finally`, and repeatedly.
140
+ const cleanup = (): void => {
141
+ if (cleanedUp) return;
142
+ cleanedUp = true;
143
+ if (abortHandler && signal) {
144
+ signal.removeEventListener("abort", abortHandler);
145
+ abortHandler = undefined;
146
+ }
147
+ unsubscribe?.();
148
+ unsubscribe = undefined;
149
+ handle = undefined;
150
+ component = undefined;
151
+ };
152
+ // On abort/timeout the core settles independently of `ui.custom`; remove
153
+ // the raw listener and drop the handle immediately so keys can no longer
154
+ // be captured even if the custom UI never resolves.
155
+ const onAbort = (): void => {
156
+ try {
157
+ component?.abort();
158
+ } catch {
159
+ // Settling the component must not mask the abort.
160
+ }
161
+ cleanup();
162
+ };
163
+
164
+ try {
165
+ if (signal?.aborted) {
166
+ // Already cancelled before any UI: install no side effects.
167
+ onAbort();
168
+ return { kind: "abort" };
169
+ }
170
+ if (signal) {
171
+ abortHandler = onAbort;
172
+ signal.addEventListener("abort", onAbort, { once: true });
173
+ }
174
+
175
+ if (toggle !== undefined && typeof ui.onTerminalInput === "function") {
176
+ unsubscribe = ui.onTerminalInput((data) => {
177
+ if (!handle || !matchesKey(data, toggle as KeyId)) return undefined;
178
+ // Kitty's progressive protocol reports press, repeat and release
179
+ // separately. Toggle only on the initial press; consume repeat and
180
+ // release so they cannot reach the component behind the overlay.
181
+ if (isKeyRepeat(data) || isKeyRelease(data)) return { consume: true };
182
+ const nextHidden = !handle.isHidden();
183
+ handle.setHidden(nextHidden);
184
+ if (nextHidden && !announced) {
185
+ announced = true;
186
+ ui.notify?.(`ask_user hidden — press ${toggle} to reopen`, "info");
187
+ }
188
+ return { consume: true };
189
+ });
190
+ }
191
+
192
+ input.onUpdate?.("Waiting for user input…");
193
+ const result = await ui.custom<CustomUIResult>(
194
+ (tui, theme, _keybindings, done) => {
195
+ component = new AskUserComponent({
196
+ request,
197
+ deadline,
198
+ theme: theme as unknown as AskUserTheme,
199
+ tui,
200
+ done,
201
+ overlayToggleKey: toggle,
202
+ });
203
+ if (signal?.aborted) queueMicrotask(() => component?.abort());
204
+ return component;
205
+ },
206
+ {
207
+ overlay,
208
+ overlayOptions: {
209
+ anchor: "center",
210
+ // Keep the dialog readable on very wide terminals; Pi clamps
211
+ // this fixed width to the available columns on smaller ones.
212
+ width: 96,
213
+ minWidth: 64,
214
+ maxHeight: "85%",
215
+ margin: 1,
216
+ },
217
+ // Only an overlay has a handle to hide/show.
218
+ ...(
219
+ overlay
220
+ ? {
221
+ onHandle: (overlayHandle: OverlayHandle) => {
222
+ handle = overlayHandle;
223
+ },
224
+ }
225
+ : {}
226
+ ),
227
+ },
228
+ );
229
+ if (!result) {
230
+ return { kind: "error", message: "custom UI returned no result (the host failed to render the custom component)." };
231
+ }
232
+ return result;
233
+ } finally {
234
+ cleanup();
235
+ }
236
+ },
237
+ };
238
+ }
package/src/answers.ts ADDED
@@ -0,0 +1,84 @@
1
+ import type { AskUserAnswer, NormalizedQuestion } from "./types.ts";
2
+
3
+ /**
4
+ * A draft answer as manipulated by a UI before being finalized. Selections are
5
+ * option indices; free text is kept verbatim while editing.
6
+ */
7
+ export interface DraftAnswer {
8
+ selections: number[];
9
+ freeText: string;
10
+ usedDefault: boolean;
11
+ }
12
+
13
+ export function emptyDraft(): DraftAnswer {
14
+ return { selections: [], freeText: "", usedDefault: false };
15
+ }
16
+
17
+ /** A draft is empty when it carries neither a selection nor any free text. */
18
+ export function draftIsEmpty(draft: DraftAnswer): boolean {
19
+ return draft.selections.length === 0 && draft.freeText.trim() === "";
20
+ }
21
+
22
+ /**
23
+ * Materialize the question default as a draft, or `undefined` when the question
24
+ * has no default. A default that matches an option label selects that option;
25
+ * otherwise it is treated as free text (which is what `input` questions want).
26
+ */
27
+ export function draftFromDefault(question: NormalizedQuestion): DraftAnswer | undefined {
28
+ if (!question.hasDefault || question.default === undefined) return undefined;
29
+ if (question.kind !== "input") {
30
+ const idx = question.options.findIndex((option) => option.label === question.default!.trim());
31
+ if (idx >= 0) {
32
+ return { selections: [idx], freeText: "", usedDefault: true };
33
+ }
34
+ }
35
+ return { selections: [], freeText: question.default, usedDefault: true };
36
+ }
37
+
38
+ /** Finalize a draft into an answer. Assumes the draft is non-empty. */
39
+ export function toAnswer(question: NormalizedQuestion, draft: DraftAnswer): AskUserAnswer {
40
+ const selections = draft.selections
41
+ .filter((idx) => idx >= 0 && idx < question.options.length)
42
+ .map((idx) => question.options[idx]!.label);
43
+ const freeText = draft.freeText.trim();
44
+ const answer: AskUserAnswer = {
45
+ index: question.index,
46
+ id: question.id,
47
+ title: question.title,
48
+ kind: question.kind,
49
+ selections,
50
+ usedDefault: draft.usedDefault,
51
+ };
52
+ if (freeText !== "") answer.freeText = freeText;
53
+ return answer;
54
+ }
55
+
56
+ export interface FinalizeResult {
57
+ answers: AskUserAnswer[];
58
+ /** Indices (0-based question indices) still unanswered and without a default. */
59
+ unanswered: number[];
60
+ }
61
+
62
+ /**
63
+ * Apply defaults to empty drafts and finalize the questionnaire. Questions that
64
+ * are still empty and have no default are reported in `unanswered`; they are
65
+ * omitted from `answers`.
66
+ */
67
+ export function finalizeAnswers(questions: NormalizedQuestion[], drafts: DraftAnswer[]): FinalizeResult {
68
+ const answers: AskUserAnswer[] = [];
69
+ const unanswered: number[] = [];
70
+ questions.forEach((question, index) => {
71
+ const draft = drafts[index] ?? emptyDraft();
72
+ if (draftIsEmpty(draft)) {
73
+ const fallback = draftFromDefault(question);
74
+ if (fallback) {
75
+ answers.push(toAnswer(question, fallback));
76
+ } else {
77
+ unanswered.push(index);
78
+ }
79
+ return;
80
+ }
81
+ answers.push(toAnswer(question, draft));
82
+ });
83
+ return { answers, unanswered };
84
+ }
package/src/ask.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { createPiHost } from "./adapters/pi.ts";
3
+ import { askUser } from "./core.ts";
4
+ import type {
5
+ AskUserDisplayMode,
6
+ AskUserEventSink,
7
+ AskUserHost,
8
+ AskUserMode,
9
+ AskUserResult,
10
+ } from "./types.ts";
11
+
12
+ /** Options for {@link createAskUser}. */
13
+ export interface CreateAskUserOptions {
14
+ /**
15
+ * Explicit UI route for the bound host (`custom` | `native`); highest
16
+ * precedence. A route the environment cannot run is refused, never
17
+ * substituted.
18
+ */
19
+ mode?: AskUserMode;
20
+ /** Explicit display mode; overrides the config file and the request. */
21
+ displayMode?: AskUserDisplayMode;
22
+ /** Explicit overlay toggle key; `null` disables it. Overrides the config file. */
23
+ overlayToggleKey?: string | null;
24
+ /** Explicit timeout (ms) per question; overrides the config file and the request. */
25
+ timeoutPerQuestionMs?: number;
26
+ /** Path to the user config file, or `false` to skip reading it. */
27
+ configFile?: string | false;
28
+ /**
29
+ * Optional observer for the interactive wait. A direct caller has no Pi event
30
+ * bus, so nothing is emitted unless this is supplied — there is no fabricated
31
+ * bus access. Pass a sink to receive `waitStarted`/`waitEnded` around each UI
32
+ * attempt.
33
+ */
34
+ events?: AskUserEventSink;
35
+ }
36
+
37
+ /**
38
+ * Options accepted when reusing an already-built {@link AskUserHost}. The config
39
+ * knobs were resolved into the host at its creation, so only call-scoped
40
+ * observers apply here.
41
+ */
42
+ export interface CreateAskUserFromHostOptions {
43
+ /** Optional observer for the interactive wait (see {@link CreateAskUserOptions.events}). */
44
+ events?: AskUserEventSink;
45
+ }
46
+
47
+ /**
48
+ * A host-bound, directly callable asker for other extensions — not the model.
49
+ *
50
+ * Availability is readable **before** any UI is shown:
51
+ *
52
+ * - `isAvailable` — `true` when the bound host has a ready interactive route;
53
+ * - `notAvailableReason` — why it has none, or `undefined` when it has one.
54
+ *
55
+ * This reports the host's structural/configured capability; it is not a promise
56
+ * that a render will succeed.
57
+ *
58
+ * Calling it resolves to an {@link AskUserResult} for the caller and never
59
+ * forwards anything to the model; there is no plain-text fallback.
60
+ */
61
+ export interface AskUser {
62
+ (request: unknown): Promise<AskUserResult>;
63
+ readonly isAvailable: boolean;
64
+ readonly notAvailableReason: string | undefined;
65
+ /** The host this asker is bound to. */
66
+ readonly host: AskUserHost;
67
+ }
68
+
69
+ function isAskUserHost(value: ExtensionContext | AskUserHost): value is AskUserHost {
70
+ return typeof value === "object" && value !== null && "support" in value;
71
+ }
72
+
73
+ /**
74
+ * Bind a directly callable `ask` to a host.
75
+ *
76
+ * Pass a Pi `ExtensionContext` (optionally with explicit options) to probe the
77
+ * environment and read the user config once, or pass a pre-built
78
+ * {@link AskUserHost} to reuse one. The result exposes `isAvailable` /
79
+ * `notAvailableReason` up front, so callers can decide before showing any UI.
80
+ *
81
+ * A direct call has no Pi event bus: pass `events` to observe the interactive
82
+ * wait, or accept that no runtime events are emitted.
83
+ */
84
+ export function createAskUser(ctx: ExtensionContext, options?: CreateAskUserOptions): AskUser;
85
+ export function createAskUser(host: AskUserHost, options?: CreateAskUserFromHostOptions): AskUser;
86
+ export function createAskUser(
87
+ source: ExtensionContext | AskUserHost,
88
+ options: CreateAskUserOptions | CreateAskUserFromHostOptions = {},
89
+ ): AskUser {
90
+ const host = isAskUserHost(source)
91
+ ? source
92
+ : createPiHost(source, options as CreateAskUserOptions);
93
+ const support = host.support;
94
+ const events = options.events;
95
+ const ask = ((request: unknown) =>
96
+ askUser(request, events ? { host, events } : { host })) as AskUser;
97
+ Object.defineProperties(ask, {
98
+ isAvailable: { value: support.status === "available", enumerable: true },
99
+ notAvailableReason: { value: support.status === "available" ? undefined : support.reason, enumerable: true },
100
+ host: { value: host, enumerable: true },
101
+ });
102
+ return ask;
103
+ }