@black942026/pi-ask-user 0.1.0 → 0.1.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/README.md +41 -17
- package/package.json +2 -2
- package/src/adapters/pi.ts +1 -0
- package/src/config.ts +3 -1
- package/src/core.ts +26 -6
- package/src/deadline.ts +22 -6
- package/src/types.ts +4 -2
- package/src/ui/custom.ts +30 -9
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# @black942026/pi-ask-user
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Pi extension for asking structured questions through interactive terminal
|
|
4
|
+
or native input dialogs. Supports single-choice, multiple-choice, and free-text
|
|
5
|
+
answers, with configurable timeouts and cancellation.
|
|
6
|
+
|
|
7
|
+
Install the package to add the **`ask_user`** tool to Pi. Other extensions can
|
|
8
|
+
reuse the same interaction through the TypeScript API.
|
|
4
9
|
|
|
5
10
|
Two entry points share one strict questionnaire contract and one route resolution:
|
|
6
11
|
|
|
@@ -22,16 +27,14 @@ or probed when the host is created, never per request.
|
|
|
22
27
|
|
|
23
28
|
```bash
|
|
24
29
|
pi install npm:@black942026/pi-ask-user
|
|
25
|
-
# or, from a local checkout
|
|
26
|
-
pi install /absolute/path/to/pi-ask-user
|
|
27
|
-
# or, during development
|
|
28
|
-
pi --extension ./index.ts
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
Start a new Pi session, or run `/reload` in an existing session, to load the
|
|
33
|
+
extension. The `ask_user` tool is registered automatically.
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
`typebox
|
|
35
|
+
Requires Node.js ≥ 22.19.0 and Pi ≥ 0.87.0. Pi supplies the host dependencies
|
|
36
|
+
`@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, and `typebox`;
|
|
37
|
+
no separate dependency installation is needed when using `pi install`.
|
|
35
38
|
|
|
36
39
|
## Tool parameters
|
|
37
40
|
|
|
@@ -111,8 +114,8 @@ against the questionnaire's own controls. A malformed spec (`alt+banana`), a
|
|
|
111
114
|
reserved key (`escape`/`esc` — which must keep cancelling — plus
|
|
112
115
|
`enter`/`tab`/arrows/`space`/`pageUp`/…), a bare printable key that would swallow
|
|
113
116
|
typing (`o`), and conflicting chords (`ctrl+c`, `ctrl+d`, `ctrl+u`, `ctrl+p`,
|
|
114
|
-
`ctrl+n`, `ctrl+enter`) are each rejected with a specific reason. A key
|
|
115
|
-
silently substituted.
|
|
117
|
+
`ctrl+n`, `ctrl+enter`, `alt+q`) are each rejected with a specific reason. A key
|
|
118
|
+
is never silently substituted.
|
|
116
119
|
|
|
117
120
|
### Effective precedence and read timing
|
|
118
121
|
|
|
@@ -275,6 +278,19 @@ an optional sink — `createAskUser(ctx, { events: { waitStarted, waitEnded } })
|
|
|
275
278
|
or `createAskUser(host, { events })` — or accept that no runtime events are
|
|
276
279
|
emitted. `AskUserEventSink` is exported for this.
|
|
277
280
|
|
|
281
|
+
## Cancel countdown shortcut
|
|
282
|
+
|
|
283
|
+
In the **custom terminal UI** (overlay or inline), press **`Alt+Q`** to cancel
|
|
284
|
+
this questionnaire's countdown without dismissing it or changing your answers.
|
|
285
|
+
The header shows **`No timeout`**; you can keep answering, submit normally, or
|
|
286
|
+
press `Esc` to dismiss. Caller aborts still work. This is a one-way change for
|
|
287
|
+
the current questionnaire only; the next call starts with its configured timeout.
|
|
288
|
+
An already-expired deadline cannot be cancelled.
|
|
289
|
+
|
|
290
|
+
The key is shown in the bottom hints and reserved against `overlayToggleKey`
|
|
291
|
+
conflicts. Native input dialogs do not support this shortcut. If an overlay is
|
|
292
|
+
hidden, restore it first with its show/hide key before pressing `Alt+Q`.
|
|
293
|
+
|
|
278
294
|
## Overlay show/hide key
|
|
279
295
|
|
|
280
296
|
On the **custom overlay route only** (never inline, never native), the overlay
|
|
@@ -325,12 +341,14 @@ for a refused explicit config, names the requested route.
|
|
|
325
341
|
## Key constraints
|
|
326
342
|
|
|
327
343
|
- **The deadline is shared and owned by the core.** Per questionnaire it is
|
|
328
|
-
|
|
344
|
+
initially `timeoutPerQuestionMs × questionCount` (default 60 s each), clamped to
|
|
329
345
|
`MAX_SAFE_TIMEOUT_MS` (2³¹−1); there is no absolute override. The deadline and
|
|
330
346
|
the caller's cancellation are raced independently of the renderer, so a renderer
|
|
331
347
|
that never resolves or ignores its `AbortSignal` still yields
|
|
332
|
-
`timeout`/`aborted
|
|
333
|
-
|
|
348
|
+
`timeout`/`aborted`, unless the user explicitly cancels the timeout with `Alt+Q`
|
|
349
|
+
in the custom UI (caller aborts remain effective); late results are discarded.
|
|
350
|
+
Cancellation is checked before any route branch, so an already-aborted call
|
|
351
|
+
never renders a UI.
|
|
334
352
|
- **A missing route is an error, not a downgrade.** JSON/print, or an explicit
|
|
335
353
|
`mode` that cannot run, is refused with `unsupported_mode` and never replaced by
|
|
336
354
|
another route.
|
|
@@ -357,10 +375,16 @@ for a refused explicit config, names the requested route.
|
|
|
357
375
|
```bash
|
|
358
376
|
npm install
|
|
359
377
|
npm run typecheck # tsc --noEmit
|
|
360
|
-
npm test # node --test test/*.test.ts
|
|
378
|
+
npm test # node --test test/*.test.ts
|
|
361
379
|
npm run check # typecheck + tests
|
|
380
|
+
|
|
381
|
+
# Load a local checkout for development
|
|
382
|
+
pi --extension ./index.ts
|
|
383
|
+
# Or install it as a local package
|
|
384
|
+
pi install /absolute/path/to/pi-ask-user
|
|
362
385
|
```
|
|
363
386
|
|
|
364
|
-
`@earendil-works/pi-tui`
|
|
365
|
-
|
|
366
|
-
|
|
387
|
+
`@earendil-works/pi-tui` and `typebox` are host-provided peer dependencies used
|
|
388
|
+
by the custom UI and parameter schema (`src/schema.ts`). They are also declared
|
|
389
|
+
as development dependencies for local checks. The routing, parsing, and deadline
|
|
390
|
+
layers are pure, non-TUI, and free of both.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@black942026/pi-ask-user",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Structured user questions for Pi, with interactive terminal dialogs, native input dialogs, and a reusable TypeScript API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
package/src/adapters/pi.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -32,6 +32,8 @@ export const USER_CONFIG_PATH = join(USER_CONFIG_DIR, "config.json");
|
|
|
32
32
|
|
|
33
33
|
/** Built-in default key that toggles the overlay between hidden and visible. */
|
|
34
34
|
export const DEFAULT_OVERLAY_TOGGLE_KEY = "alt+o";
|
|
35
|
+
/** Cancel the current questionnaire's countdown without dismissing the UI. */
|
|
36
|
+
export const CANCEL_TIMEOUT_KEY = "alt+q";
|
|
35
37
|
/** Built-in default custom-UI presentation. */
|
|
36
38
|
export const DEFAULT_DISPLAY_MODE: AskUserDisplayMode = "overlay";
|
|
37
39
|
|
|
@@ -129,7 +131,7 @@ const RESERVED_BASES = new Set([
|
|
|
129
131
|
]);
|
|
130
132
|
/** Modifier chords that conflict with a questionnaire control. */
|
|
131
133
|
const RESERVED_SIGNATURES = new Set(
|
|
132
|
-
["ctrl+c", "ctrl+d", "ctrl+u", "ctrl+p", "ctrl+n", "ctrl+enter"].map((id) => {
|
|
134
|
+
["ctrl+c", "ctrl+d", "ctrl+u", "ctrl+p", "ctrl+n", "ctrl+enter", CANCEL_TIMEOUT_KEY].map((id) => {
|
|
133
135
|
const parts = id.split("+");
|
|
134
136
|
const base = parts.pop()!;
|
|
135
137
|
return [...parts.sort(), base].join("+");
|
package/src/core.ts
CHANGED
|
@@ -92,9 +92,17 @@ export async function askUserNormalized(
|
|
|
92
92
|
const effectiveRequest = applyPreferences(request, host.preferences);
|
|
93
93
|
const deadline = createDeadline(effectiveRequest.totalTimeoutMs, now);
|
|
94
94
|
const deadlineController = new AbortController();
|
|
95
|
+
const timeoutCancelled = new AbortController();
|
|
95
96
|
const linked = combineSignals(signal, deadlineController.signal);
|
|
96
97
|
|
|
97
98
|
const input: AskUIInput = { request: effectiveRequest, deadline, signal: linked.signal };
|
|
99
|
+
if (route === "custom") {
|
|
100
|
+
input.cancelTimeout = () => {
|
|
101
|
+
if (!deadline.cancel()) return false;
|
|
102
|
+
timeoutCancelled.abort();
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
98
106
|
if (onUpdate) input.onUpdate = onUpdate;
|
|
99
107
|
|
|
100
108
|
// Wait events are emitted exactly around a real UI attempt: `waitStarted`
|
|
@@ -138,12 +146,14 @@ export async function askUserNormalized(
|
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
// The core owns the guarantee: even a renderer that ignores its AbortSignal
|
|
141
|
-
// cannot hang the call
|
|
142
|
-
//
|
|
149
|
+
// cannot hang the call unless the user explicitly disables the timeout.
|
|
150
|
+
// The deadline and caller's cancellation are raced independently against
|
|
151
|
+
// the renderer; a late renderer result is ignored.
|
|
143
152
|
let race: DeadlineRace;
|
|
144
153
|
try {
|
|
145
|
-
race = await raceWithDeadline(
|
|
146
|
-
|
|
154
|
+
race = await raceWithDeadline(
|
|
155
|
+
renderPromise, deadline.remainingMs(), signal,
|
|
156
|
+
() => deadlineController.abort(), timeoutCancelled.signal,
|
|
147
157
|
);
|
|
148
158
|
} finally {
|
|
149
159
|
// Always release the listeners installed on the caller's signal.
|
|
@@ -178,7 +188,8 @@ type DeadlineRace =
|
|
|
178
188
|
*
|
|
179
189
|
* This is what makes the timeout guarantee independent of renderer behaviour: a
|
|
180
190
|
* renderer that never resolves (or that ignores its signal) still yields
|
|
181
|
-
* `timeout
|
|
191
|
+
* `timeout`, unless the user explicitly cancels the timeout. Late results are
|
|
192
|
+
* discarded, and the rejection path is always
|
|
182
193
|
* handled so a late failure cannot become an unhandled rejection.
|
|
183
194
|
*/
|
|
184
195
|
function raceWithDeadline(
|
|
@@ -186,15 +197,23 @@ function raceWithDeadline(
|
|
|
186
197
|
remainingMs: number,
|
|
187
198
|
callerSignal: AbortSignal | undefined,
|
|
188
199
|
onDeadline: () => void,
|
|
200
|
+
timeoutCancelled: AbortSignal,
|
|
189
201
|
): Promise<DeadlineRace> {
|
|
190
202
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
191
203
|
let abortHandler: (() => void) | undefined;
|
|
204
|
+
const clearDeadlineTimer = (): void => {
|
|
205
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
206
|
+
timer = undefined;
|
|
207
|
+
};
|
|
192
208
|
const racers: Array<Promise<DeadlineRace>> = [
|
|
193
209
|
render.then(
|
|
194
210
|
(outcome): DeadlineRace => ({ kind: "outcome", outcome }),
|
|
195
211
|
(error): DeadlineRace => ({ kind: "outcome", outcome: { kind: "error", message: messageOf(error) } }),
|
|
196
212
|
),
|
|
197
213
|
new Promise<DeadlineRace>((resolve) => {
|
|
214
|
+
// Cancellation may happen synchronously during renderer construction.
|
|
215
|
+
if (timeoutCancelled.aborted) return;
|
|
216
|
+
timeoutCancelled.addEventListener("abort", clearDeadlineTimer, { once: true });
|
|
198
217
|
// `safeTimeoutMs` prevents a >32-bit delay from firing immediately.
|
|
199
218
|
timer = setTimeout(() => {
|
|
200
219
|
onDeadline();
|
|
@@ -215,7 +234,8 @@ function raceWithDeadline(
|
|
|
215
234
|
);
|
|
216
235
|
}
|
|
217
236
|
return Promise.race(racers).finally(() => {
|
|
218
|
-
|
|
237
|
+
clearDeadlineTimer();
|
|
238
|
+
timeoutCancelled.removeEventListener("abort", clearDeadlineTimer);
|
|
219
239
|
// Remove the listener when the render settled first, so repeated calls do
|
|
220
240
|
// not accumulate listeners on a long-lived caller signal.
|
|
221
241
|
if (abortHandler && callerSignal) callerSignal.removeEventListener("abort", abortHandler);
|
package/src/deadline.ts
CHANGED
|
@@ -13,27 +13,43 @@ export function safeTimeoutMs(ms: number): number {
|
|
|
13
13
|
return Math.min(Math.floor(ms), MAX_SAFE_TIMEOUT_MS);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** A shared deadline that the user can disable for the current questionnaire. */
|
|
17
|
+
export interface CancellableDeadline extends Deadline {
|
|
18
|
+
/** Disable expiry. Returns false if the deadline has already expired. */
|
|
19
|
+
cancel(): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
/**
|
|
17
23
|
* Create a single overall deadline for a questionnaire.
|
|
18
24
|
*
|
|
19
|
-
* The deadline is
|
|
25
|
+
* The initial deadline is `totalMs` (which callers compute as
|
|
20
26
|
* `timeoutPerQuestionMs × questionCount`). It is clamped to
|
|
21
27
|
* {@link MAX_SAFE_TIMEOUT_MS} so `setTimeout` never overflows. Every interaction
|
|
22
28
|
* for the questionnaire shares this one deadline; native dialogs receive only
|
|
23
|
-
* the remaining time.
|
|
29
|
+
* the remaining time. `cancel()` disables expiry but does not clear any timers;
|
|
30
|
+
* the core and custom UI clear their timers separately. A deadline never
|
|
31
|
+
* degrades to another route.
|
|
24
32
|
*/
|
|
25
|
-
export function createDeadline(totalMs: number, now: () => number = Date.now):
|
|
33
|
+
export function createDeadline(totalMs: number, now: () => number = Date.now): CancellableDeadline {
|
|
26
34
|
const startedAt = now();
|
|
27
35
|
const safeTotal = safeTimeoutMs(totalMs);
|
|
28
36
|
const expiresAt = safeTotal === 0 ? startedAt : startedAt + safeTotal;
|
|
37
|
+
let cancelled = false;
|
|
29
38
|
return {
|
|
30
39
|
startedAt,
|
|
31
|
-
expiresAt
|
|
40
|
+
get expiresAt(): number {
|
|
41
|
+
return cancelled ? Infinity : expiresAt;
|
|
42
|
+
},
|
|
32
43
|
remainingMs(): number {
|
|
33
|
-
return Math.max(0, expiresAt - now());
|
|
44
|
+
return cancelled ? Infinity : Math.max(0, expiresAt - now());
|
|
34
45
|
},
|
|
35
46
|
expired(): boolean {
|
|
36
|
-
return now() >= expiresAt;
|
|
47
|
+
return !cancelled && now() >= expiresAt;
|
|
48
|
+
},
|
|
49
|
+
cancel(): boolean {
|
|
50
|
+
if (!cancelled && now() >= expiresAt) return false;
|
|
51
|
+
cancelled = true;
|
|
52
|
+
return true;
|
|
37
53
|
},
|
|
38
54
|
};
|
|
39
55
|
}
|
package/src/types.ts
CHANGED
|
@@ -70,8 +70,8 @@ export interface AskUserRequest {
|
|
|
70
70
|
/**
|
|
71
71
|
* Base timeout per question in milliseconds. Defaults to 60000.
|
|
72
72
|
*
|
|
73
|
-
* The questionnaire's
|
|
74
|
-
* `timeoutPerQuestionMs × questionCount`;
|
|
73
|
+
* The questionnaire's initial shared deadline is
|
|
74
|
+
* `timeoutPerQuestionMs × questionCount`; the user can cancel it in the custom UI.
|
|
75
75
|
*/
|
|
76
76
|
timeoutPerQuestionMs?: number;
|
|
77
77
|
}
|
|
@@ -220,6 +220,8 @@ export type AskUserSupport =
|
|
|
220
220
|
export interface AskUIInput {
|
|
221
221
|
request: NormalizedRequest;
|
|
222
222
|
deadline: Deadline;
|
|
223
|
+
/** Custom UI only: disable this questionnaire's timeout; false if already expired. */
|
|
224
|
+
cancelTimeout?: () => boolean;
|
|
223
225
|
signal?: AbortSignal;
|
|
224
226
|
/** Report progress such as "Waiting for user input...". */
|
|
225
227
|
onUpdate?: (text: string) => void;
|
package/src/ui/custom.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
Input,
|
|
3
3
|
Key,
|
|
4
4
|
Markdown,
|
|
5
|
+
isKeyRelease,
|
|
5
6
|
matchesKey,
|
|
6
7
|
truncateToWidth,
|
|
7
8
|
visibleWidth,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
import type { Component, Focusable, MarkdownTheme } from "@earendil-works/pi-tui";
|
|
11
12
|
import { draftFromDefault, draftIsEmpty, emptyDraft, finalizeAnswers, type DraftAnswer } from "../answers.ts";
|
|
12
13
|
import type { AskUserAnswer, Deadline, NormalizedQuestion, NormalizedRequest } from "../types.ts";
|
|
14
|
+
import { CANCEL_TIMEOUT_KEY } from "../config.ts";
|
|
13
15
|
|
|
14
16
|
/** Structural theme surface the component needs (a real Pi Theme satisfies it). */
|
|
15
17
|
export interface AskUserTheme {
|
|
@@ -35,6 +37,8 @@ export interface CustomUIResult {
|
|
|
35
37
|
export interface AskUserComponentOptions {
|
|
36
38
|
request: NormalizedRequest;
|
|
37
39
|
deadline: Deadline;
|
|
40
|
+
/** Disable the core deadline as well as this component's local timer. */
|
|
41
|
+
cancelTimeout?: () => boolean;
|
|
38
42
|
theme: AskUserTheme;
|
|
39
43
|
tui: AskUserTUI;
|
|
40
44
|
done: (result: CustomUIResult) => void;
|
|
@@ -132,6 +136,8 @@ function printableText(data: string): string | undefined {
|
|
|
132
136
|
export class AskUserComponent implements Component, Focusable {
|
|
133
137
|
private readonly request: NormalizedRequest;
|
|
134
138
|
private readonly deadline: Deadline;
|
|
139
|
+
private readonly cancelTimeout: (() => boolean) | undefined;
|
|
140
|
+
private timeoutCancelled = false;
|
|
135
141
|
private readonly theme: AskUserTheme;
|
|
136
142
|
private readonly tui: AskUserTUI;
|
|
137
143
|
private readonly done: (result: CustomUIResult) => void;
|
|
@@ -164,6 +170,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
164
170
|
constructor(options: AskUserComponentOptions) {
|
|
165
171
|
this.request = options.request;
|
|
166
172
|
this.deadline = options.deadline;
|
|
173
|
+
this.cancelTimeout = options.cancelTimeout;
|
|
167
174
|
this.theme = options.theme;
|
|
168
175
|
this.tui = options.tui;
|
|
169
176
|
this.done = options.done;
|
|
@@ -369,6 +376,14 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
369
376
|
handleInput(data: string): void {
|
|
370
377
|
if (this.finished) return;
|
|
371
378
|
|
|
379
|
+
if (matchesKey(data, CANCEL_TIMEOUT_KEY)) {
|
|
380
|
+
if (isKeyRelease(data) || this.timeoutCancelled || this.deadline.expired()) return;
|
|
381
|
+
if (this.cancelTimeout && !this.cancelTimeout()) return;
|
|
382
|
+
this.timeoutCancelled = true;
|
|
383
|
+
this.dispose();
|
|
384
|
+
this.refresh();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
372
387
|
if (matchesKey(data, Key.escape)) {
|
|
373
388
|
this.finish({ kind: "cancelled" });
|
|
374
389
|
return;
|
|
@@ -574,6 +589,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
574
589
|
}
|
|
575
590
|
|
|
576
591
|
private countdownLabel(): string {
|
|
592
|
+
if (this.timeoutCancelled) return "No timeout";
|
|
577
593
|
const hours = Math.floor(this.remainingSeconds / 3600);
|
|
578
594
|
const minutes = Math.floor((this.remainingSeconds % 3600) / 60);
|
|
579
595
|
const seconds = String(this.remainingSeconds % 60).padStart(2, "0");
|
|
@@ -789,13 +805,20 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
789
805
|
return parts.join(" | ") + suffix;
|
|
790
806
|
}
|
|
791
807
|
|
|
792
|
-
private hintLine(): string {
|
|
793
|
-
|
|
808
|
+
private hintLine(width: number): string {
|
|
809
|
+
// Keep cancellation controls discoverable even when contextual hints grow.
|
|
810
|
+
const controls = ["Esc cancel"];
|
|
811
|
+
if (!this.timeoutCancelled) controls.push(`${CANCEL_TIMEOUT_KEY} stop timer`);
|
|
812
|
+
if (this.overlayToggleKey) controls.push(`${this.overlayToggleKey} hide`);
|
|
813
|
+
const controlText = controls.join(" · ");
|
|
814
|
+
const withControls = (hints: string[]): string => {
|
|
815
|
+
const contextWidth = width - visibleWidth(controlText) - 3;
|
|
816
|
+
const context = contextWidth > 0 ? truncateToWidth(hints.join(" · "), contextWidth) : "";
|
|
817
|
+
return this.theme.fg("dim", context ? `${context} · ${controlText}` : controlText);
|
|
818
|
+
};
|
|
794
819
|
if (this.isReview) {
|
|
795
820
|
const reviewHints = ["↓ focus submit", "Enter submit", "Tab switch question"];
|
|
796
|
-
|
|
797
|
-
reviewHints.push("Esc cancel");
|
|
798
|
-
return this.theme.fg("dim", reviewHints.join(" · "));
|
|
821
|
+
return withControls(reviewHints);
|
|
799
822
|
}
|
|
800
823
|
const question = this.currentQuestion();
|
|
801
824
|
const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() ? "confirm" : "select"}`];
|
|
@@ -803,9 +826,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
803
826
|
if (question.hasDefault) hints.push("s use default");
|
|
804
827
|
if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn preview");
|
|
805
828
|
if (this.isInputRow() && question.options.length > 0) hints.push("↑ back to options");
|
|
806
|
-
|
|
807
|
-
hints.push("Esc cancel");
|
|
808
|
-
return this.theme.fg("dim", hints.join(" · "));
|
|
829
|
+
return withControls(hints);
|
|
809
830
|
}
|
|
810
831
|
|
|
811
832
|
render(width: number): string[] {
|
|
@@ -853,7 +874,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
853
874
|
? this.wrap(this.theme.fg("warning", `⚠ ${this.notice}`), contentWidth).slice(0, MAX_NOTICE_LINES)
|
|
854
875
|
: [];
|
|
855
876
|
const inputBlock = this.isReview ? this.renderSubmitBar(contentWidth) : this.renderInputBar(contentWidth);
|
|
856
|
-
const hintBlock = [this.hintLine()];
|
|
877
|
+
const hintBlock = [this.hintLine(contentWidth)];
|
|
857
878
|
|
|
858
879
|
let remaining = contentRows;
|
|
859
880
|
const take = (block: string[]): string[] => {
|