@black942026/pi-ask-user 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
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, free-text,
4
+ or native dialogs. Supports single-choice, multiple-choice, free-text,
5
5
  boolean, and flat typed form answers, with configurable timeouts and cancellation.
6
6
 
7
7
  Install the package to add the **`ask_user`** tool to Pi. Other extensions can
@@ -18,7 +18,7 @@ is created:
18
18
  | Route | Runs when | The user sees |
19
19
  | --- | --- | --- |
20
20
  | `custom` | the host is a real Pi TUI with a bound renderer | a tabbed terminal dialog: options, boolean controls, forms, text input, and Markdown previews |
21
- | `native` | `ctx.hasUI` with a callable `ctx.ui.input` (TUI, RPC/ACP) | native dialogs, one logical question at a time; form fields are sequential dialogs |
21
+ | `native` | `ctx.hasUI` with a callable `ctx.ui.input` (TUI, RPC/ACP) | native dialogs, one logical question at a time; with `allowFreeText: false`, single/bool use `ctx.ui.select`; multi/text keep input boxes; form fields are sequential dialogs |
22
22
 
23
23
  Only these two routes exist, and the route belongs to the host: it is configured
24
24
  or probed when the host is created, never per request.
@@ -57,6 +57,7 @@ The registered tool name is exactly **`ask_user`**.
57
57
  | `fields[].default` | `(boolean \| string \| string[])?` | boolean, selected label, selected-label array, or text according to field kind |
58
58
  | `questions[].id` | `string?` | non-empty; generated as `q1`, `q2`, … when omitted |
59
59
  | `header` | `string?` | heading above the questionnaire |
60
+ | `allowFreeText` | `boolean?` | default `true`; `false` removes supplemental/free-text answers on single/multi questions; native single/bool (including form fields) use selectors while multi/text keep input boxes |
60
61
  | `displayMode` | `"overlay" \| "inline"?` | custom route only; default `overlay`; a user-configured preference overrides it |
61
62
  | `timeoutPerQuestionMs` | `number?` | base timeout per question; default `60000`; a user-configured preference overrides it |
62
63
 
@@ -67,6 +68,37 @@ error. An explicit legacy `kind: "input"` keeps its existing behavior: supplied
67
68
  options are validated, then dropped. Duplicate option labels are de-duplicated
68
69
  and reported in `warnings`.
69
70
 
71
+ ### Disable supplemental text
72
+
73
+ Set top-level `allowFreeText: false` to hide the custom UI's bottom extra input
74
+ on single/multi questions and require option selections. Regular `input`
75
+ questions and form text fields remain editable. Choice defaults must match an
76
+ option label when supplemental text is disabled.
77
+
78
+ ```json
79
+ {
80
+ "allowFreeText": false,
81
+ "questions": [
82
+ { "title": "Environment", "kind": "single", "options": [{ "label": "dev" }, { "label": "prod" }] }
83
+ ]
84
+ }
85
+ ```
86
+
87
+ On the native route with this flag:
88
+
89
+ - Single-choice questions/fields use `ctx.ui.select`, returning option labels.
90
+ - Boolean questions/fields use a True/False selector. We deliberately avoid
91
+ `ctx.ui.confirm`: Pi returns `false` both for a negative answer and dismissal,
92
+ so a selector is necessary to preserve cancellation semantics.
93
+ - Multi-choice questions/fields **still use a text box**, accepting numbers such
94
+ as `1,3`; standalone multi questions reject prose and `| note`.
95
+ - Text questions/fields keep their text boxes.
96
+ - Selectors provide explicit default/skip actions where applicable. A host
97
+ without `ui.select` refuses requests that need it rather than falling back
98
+ to text input. The shared deadline and abort signal apply to every dialog.
99
+
100
+ Omitting the flag (or setting `true`) preserves the native input-box behavior.
101
+
70
102
  Forms are flat. Field names cannot contain dots, brackets, spaces, or path-like
71
103
  syntax, and `__proto__`, `constructor`, and `prototype` are rejected. Names must
72
104
  be unique. Defaults are checked against the field kind and, for choice fields,
@@ -338,16 +370,20 @@ hidden, restore it first with its show/hide key before pressing `Alt+Q`.
338
370
 
339
371
  Legacy `single`/`multi`/`input` questions retain their controls: `Tab` and
340
372
  `Shift+Tab` switch question/review tabs, arrows move within choices, `Space`
341
- selects, and `Enter` confirms. A standalone `bool` shows explicit `✓ True` and
342
- `✗ False` controls; arrows choose, `Space` sets the value, and `Enter` sets and
343
- continues. A required boolean without a default begins unset, so the user must
344
- choose; `false` is an answered value.
373
+ selects, and `Enter` confirms. With `allowFreeText: false`, multi-choice uses `Space` to
374
+ toggle selections and `Enter` to continue (there is no extra text row).
375
+ A standalone `bool` shows **one** control: `[✓] True` or `[✗] False`.
376
+ `Space` toggles, up/left sets true, down/right sets false, and `Enter` confirms
377
+ and continues. An unset boolean shows `[ ] True (unset)`; Enter explicitly
378
+ accepts true. Defaults are displayed immediately, and confirming a false
379
+ default keeps it false. `false` is an answered value, never an empty answer.
345
380
 
346
381
  A `simple-form` is one question tab with all fields in one scrollable view:
347
382
 
348
383
  - `Tab` / `Shift+Tab` cycles fields and the Continue/Submit button.
349
384
  - `Ctrl+Left` / `Ctrl+Right` switches questionnaire tabs from any question.
350
- - Boolean fields use arrows plus `Space`/`Enter`; single-choice fields use
385
+ - Boolean fields use the same single toggle control (`Space` toggles, arrows set,
386
+ `Enter` confirms and moves on); single-choice fields use
351
387
  arrows plus `Space`/`Enter`; multi-choice fields use `Space` to toggle and
352
388
  `Enter` to move on; text fields use `Enter` to move on.
353
389
  - `Ctrl+Enter` validates and submits the questionnaire. Required missing/empty
@@ -448,7 +484,7 @@ for a refused explicit config, names the requested route.
448
484
  correlation id, route, and status.
449
485
  - **The overlay toggle is overlay-only.** Inline and native register no raw
450
486
  input listener; hiding never pauses the deadline.
451
- - **The native route uses single-line `input`**, one logical question at a time (not
487
+ - **The native route defaults to single-line `input`**, one logical question at a time (not
452
488
  `editor`, which accepts no timeout/signal). The number and an optional note
453
489
  share one box: `2`, `2 | note`, or free text; a number list may be
454
490
  comma/space/`,`/`、` separated (`1,3`). Out-of-range or malformed numbers
@@ -456,11 +492,14 @@ for a refused explicit config, names the requested route.
456
492
  A form opens sequential per-field dialogs under the same questionnaire
457
493
  deadline: boolean fields accept `true`/`false`, form choices accept only
458
494
  option numbers and return labels, and optional empty fields are omitted (or
459
- use their field default). Cancel/abort/timeout at any field terminates the form.
495
+ use their field default). With `allowFreeText: false`, single/bool use native
496
+ selectors (including form fields); multi stays a numbers-only text box, and
497
+ text fields stay editable. Cancel/abort/timeout at any field terminates the form.
460
498
  - **Tool calls run sequentially** (`executionMode: "sequential"`).
461
- - **Short terminals degrade in a defined order:** the pinned legacy free-input row and
499
+ - **Short terminals degrade in a defined order:** when enabled, the pinned legacy free-input row and
462
500
  the key hints survive first, down to `MIN_USABLE_ROWS` (3); 0 rows renders
463
- nothing. Forms reserve a visible row for the focused field and auto-scroll it.
501
+ nothing. Bool, forms, and choices without supplemental text reserve a visible
502
+ row for the focused control and auto-scroll it.
464
503
  `render(width)` never exceeds the requested width or `terminal.rows`.
465
504
 
466
505
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@black942026/pi-ask-user",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
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",
@@ -59,7 +59,8 @@ export interface PiHostOptions {
59
59
  *
60
60
  * - `customUI` — only in `tui` mode with a callable `ctx.ui.custom()`.
61
61
  * - `nativeDialogs` — only when `ctx.hasUI` and `ctx.ui.input` is callable,
62
- * i.e. native input dialogs shown one question at a time. RPC reports
62
+ * i.e. native dialogs shown one question at a time. Requests disabling extra
63
+ * text additionally require `ui.select` for single/bool controls. RPC reports
63
64
  * `hasUI: true`, so a nonresponsive RPC client is *not* pre-judged: the
64
65
  * attempt is made and the shared deadline turns silence into an actionable
65
66
  * `timeout`.
package/src/answers.ts CHANGED
@@ -162,6 +162,7 @@ export function validateAnswer(question: NormalizedQuestion, candidate: unknown)
162
162
  if (answer.selections.length > 0) return "input answers cannot contain selections";
163
163
  return (answer.freeText ?? "").trim() === "" ? "input answer is empty" : undefined;
164
164
  }
165
+ if (question.allowFreeText === false && answer.freeText !== undefined) return "freeText is disabled for this question";
165
166
  if (new Set(answer.selections).size !== answer.selections.length) return "selections contain duplicate labels";
166
167
  if (!answer.selections.every((label) => question.options.some((option) => option.label === label))) return "selection is not an option label";
167
168
  if (question.kind === "single" && answer.selections.length > 1) return "single answer contains multiple selections";
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ export * from "./types.ts";
23
23
  export { askUser, askUserNormalized } from "./core.ts";
24
24
  export { createPiHost, createPiCustomRenderer, type PiHostOptions } from "./adapters/pi.ts";
25
25
  export { createAskUser, type AskUser, type CreateAskUserOptions } from "./ask.ts";
26
- export { createNativeRunner, type NativeDialogUI } from "./ui/native.ts";
26
+ export { createNativeRunner, type NativeDialogUI, type NativeDialogOptions } from "./ui/native.ts";
27
27
  export {
28
28
  AskUserComponent,
29
29
  MIN_USABLE_ROWS,
@@ -109,6 +109,7 @@ const PROMPT_GUIDELINES = [
109
109
  "If the tool reports the UI could not run, ask the user the question yourself in ordinary text instead.",
110
110
  "Never invent an answer; on aborted or timeout, report it plainly and do not re-ask.",
111
111
  "Use kind=bool for a real boolean. Use kind=simple-form with flat uniquely named fields when several typed values belong in one logical question; forms cannot nest.",
112
+ "Set allowFreeText=false to require option selections without supplemental text. Native single/bool then use selectors; native multi still uses a numbers-only text box.",
112
113
  ];
113
114
 
114
115
  /**
package/src/parse.ts CHANGED
@@ -66,12 +66,14 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
66
66
 
67
67
  // No number in the box: the (optional) right side is the free input.
68
68
  if (leftTrim === "") {
69
+ if (question.allowFreeText === false) return { ok: false, error: "Free text is disabled; enter option numbers only." };
69
70
  return { ok: true, value: { selections: [], freeText: comment, empty: comment === "" } };
70
71
  }
71
72
 
72
73
  const isStrictList = DIGIT_SEPARATOR_ONLY.test(leftTrim);
73
74
  const isListAttempt = isStrictList || NUMBER_LIST_ATTEMPT.test(leftTrim);
74
75
  if (!isListAttempt) {
76
+ if (question.allowFreeText === false) return { ok: false, error: "Free text is disabled; enter option numbers only." };
75
77
  // Free text, taken verbatim including any `|`.
76
78
  return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
77
79
  }
@@ -96,6 +98,9 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
96
98
  return { ok: false, error: "This is a single-select question; enter only one number." };
97
99
  }
98
100
 
101
+ if (question.allowFreeText === false && pipeIndex >= 0) {
102
+ return { ok: false, error: "Notes are disabled; enter option numbers only." };
103
+ }
99
104
  const ordered = question.kind === "single" ? unique : [...unique].sort((a, b) => a - b);
100
105
  const value: ParsedAnswer = {
101
106
  selections: question.kind === "single" ? [ordered[0]!] : ordered,
@@ -107,6 +112,11 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
107
112
 
108
113
  /** Render the per-question input instruction used by the native route. */
109
114
  export function nativeInputHint(question: NormalizedQuestion): string {
115
+ if (question.allowFreeText === false && (question.kind === "single" || question.kind === "multi")) {
116
+ return question.kind === "single"
117
+ ? "Single select. Reply with one option number only (e.g. 2)."
118
+ : "Multi select. Reply with option numbers only, separated by commas (e.g. 1,3).";
119
+ }
110
120
  switch (question.kind) {
111
121
  case "single":
112
122
  return question.options.length > 0
package/src/schema.ts CHANGED
@@ -30,7 +30,7 @@ const FIELD_KINDS = ["single", "multi", "input", "bool"] as const;
30
30
  const QUESTION_FIELDS = ["id", "title", "prompt", "kind", "options", "default", "fields"] as const;
31
31
  const FORM_FIELD_FIELDS = ["name", "label", "kind", "required", "options", "default"] as const;
32
32
  const OPTION_FIELDS = ["label", "description", "preview"] as const;
33
- const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs"] as const;
33
+ const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs", "allowFreeText"] as const;
34
34
  const SAFE_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
35
35
  const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
36
36
 
@@ -77,6 +77,7 @@ const QuestionSchema = Type.Object({
77
77
  export const AskUserParams = Type.Object({
78
78
  questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: MAX_QUESTIONS }),
79
79
  header: Type.Optional(Type.String({ maxLength: LIMITS.header })),
80
+ allowFreeText: Type.Optional(Type.Boolean({ description: "Allow supplemental/free-text answers on single/multi questions (default true). False hides the extra text input and requires option selections; native single/bool use selectors (including form fields), while native multi/text keep input boxes." })),
80
81
  displayMode: Type.Optional(StringEnum(["overlay", "inline"] as const)),
81
82
  timeoutPerQuestionMs: Type.Optional(Type.Number({ minimum: 0 })),
82
83
  }, { additionalProperties: false });
@@ -312,6 +313,10 @@ export function normalizeAskUserRequest(raw: unknown): { request: NormalizedRequ
312
313
  if (rawQuestions.length === 0) throw new AskUserValidationError("At least 1 question is required.");
313
314
  if (rawQuestions.length > MAX_QUESTIONS) throw new AskUserValidationError(`At most ${MAX_QUESTIONS} questions; received ${rawQuestions.length}.`);
314
315
 
316
+ if (raw.allowFreeText !== undefined && typeof raw.allowFreeText !== "boolean") {
317
+ throw new AskUserValidationError("allowFreeText must be a boolean.");
318
+ }
319
+ const allowFreeText = raw.allowFreeText !== false;
315
320
  const warnings: string[] = [];
316
321
  const usedIds = new Set<string>();
317
322
  const questions: NormalizedQuestion[] = [];
@@ -338,12 +343,19 @@ export function normalizeAskUserRequest(raw: unknown): { request: NormalizedRequ
338
343
  };
339
344
  if (question.prompt !== undefined) normalized.prompt = question.prompt;
340
345
  if (question.default !== undefined) normalized.default = question.default;
346
+ if (question.kind === "single" || question.kind === "multi") {
347
+ normalized.allowFreeText = allowFreeText;
348
+ if (!allowFreeText && question.hasDefault && !question.options.some((option) => option.label === String(question.default).trim())) {
349
+ throw new AskUserValidationError(`Question ${index + 1} default must match an option label when allowFreeText is false.`);
350
+ }
351
+ }
341
352
  questions.push(normalized);
342
353
  });
343
354
 
344
355
  const timeoutPerQuestionMs = readTimeoutPerQuestionMs(raw.timeoutPerQuestionMs);
345
356
  const request: NormalizedRequest = {
346
357
  questions,
358
+ allowFreeText,
347
359
  displayMode: readDisplayMode(raw.displayMode),
348
360
  timeoutPerQuestionMs,
349
361
  totalTimeoutMs: timeoutPerQuestionMs * questions.length,
package/src/types.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * answered through one of two routes:
6
6
  *
7
7
  * - `custom` — a custom terminal UI (only a real Pi TUI)
8
- * - `native` — native input dialogs (`ctx.hasUI` + callable input), one logical
8
+ * - `native` — native dialogs (`ctx.hasUI` + callable input), one logical
9
9
  * question at a time (a form uses sequential per-field dialogs)
10
10
  *
11
11
  * The route is resolved **once, when the host adapter is created**, from
@@ -141,6 +141,12 @@ export interface AskUserRequest {
141
141
  questions: AskUserQuestion[];
142
142
  /** Optional heading shown above the questionnaire. */
143
143
  header?: string;
144
+ /**
145
+ * Allow supplemental/free-text answers on single/multi questions. Defaults to true.
146
+ * False also uses native selectors for single/bool (including form fields);
147
+ * native multi/text continue using input boxes.
148
+ */
149
+ allowFreeText?: boolean;
144
150
  /** Custom-UI presentation. Defaults to `overlay`. */
145
151
  displayMode?: AskUserDisplayMode;
146
152
  /**
@@ -403,6 +409,8 @@ export interface NormalizedQuestion {
403
409
  prompt?: string;
404
410
  kind: QuestionKind;
405
411
  options: AskUserOption[];
412
+ /** False disables free text for single/multi; absent preserves legacy behavior. */
413
+ allowFreeText?: boolean;
406
414
  default?: string | boolean;
407
415
  hasDefault: boolean;
408
416
  /** Present for `simple-form`; omitted by older already-normalized callers. */
@@ -421,6 +429,8 @@ export interface NormalizedFormField {
421
429
 
422
430
  export interface NormalizedRequest {
423
431
  header?: string;
432
+ /** Absent in older normalized callers means true. */
433
+ allowFreeText?: boolean;
424
434
  questions: NormalizedQuestion[];
425
435
  displayMode: AskUserDisplayMode;
426
436
  timeoutPerQuestionMs: number;
package/src/ui/custom.ts CHANGED
@@ -190,7 +190,9 @@ export class AskUserComponent implements Component, Focusable {
190
190
  this.mdTheme = buildMarkdownTheme(options.theme);
191
191
  this.overlayToggleKey =
192
192
  options.request.displayMode === "overlay" ? options.overlayToggleKey : undefined;
193
- this.drafts = options.request.questions.map(() => emptyDraft());
193
+ this.drafts = options.request.questions.map((question) =>
194
+ question.kind === "bool" ? draftFromDefault(question) ?? emptyDraft() : emptyDraft(),
195
+ );
194
196
  this.cursors = options.request.questions.map(() => 0);
195
197
  this.inputs = options.request.questions.map(
196
198
  (question) => new Input({
@@ -226,7 +228,6 @@ export class AskUserComponent implements Component, Focusable {
226
228
  selections: [], freeText: "", value: { ...values }, valueSet: true, usedDefault: usedDefaults.size > 0,
227
229
  };
228
230
  const optionCursors = fields.map((field) => {
229
- if (field.kind === "bool" && field.default === false) return 1;
230
231
  if (field.kind === "single" && typeof field.default === "string") {
231
232
  return Math.max(0, field.options.findIndex((option) => option.label === field.default));
232
233
  }
@@ -322,10 +323,15 @@ export class AskUserComponent implements Component, Focusable {
322
323
  return this.request.questions[this.tab]!;
323
324
  }
324
325
 
326
+ private hasInputBar(question: NormalizedQuestion): boolean {
327
+ return question.kind === "input" ||
328
+ ((question.kind === "single" || question.kind === "multi") && question.allowFreeText !== false && this.request.allowFreeText !== false);
329
+ }
330
+
325
331
  private rowCount(question: NormalizedQuestion): number {
326
- if (question.kind === "bool") return 2;
332
+ if (question.kind === "bool") return 1;
327
333
  if (question.kind === "simple-form") return (question.fields?.length ?? 0) + 1;
328
- return question.options.length + 1;
334
+ return question.options.length + (this.hasInputBar(question) ? 1 : 0);
329
335
  }
330
336
 
331
337
  private rowIndex(): number {
@@ -336,7 +342,7 @@ export class AskUserComponent implements Component, Focusable {
336
342
 
337
343
  private isInputRow(): boolean {
338
344
  if (this.isReview) return false;
339
- if (this.currentQuestion().kind === "bool" || this.currentQuestion().kind === "simple-form") return false;
345
+ if (!this.hasInputBar(this.currentQuestion())) return false;
340
346
  return this.rowIndex() >= this.currentQuestion().options.length;
341
347
  }
342
348
 
@@ -374,7 +380,7 @@ export class AskUserComponent implements Component, Focusable {
374
380
  private syncDraftFromInput(index: number): void {
375
381
  const draft = this.drafts[index];
376
382
  const input = this.inputs[index];
377
- if (!draft || !input) return;
383
+ if (!draft || !input || !this.hasInputBar(this.request.questions[index]!)) return;
378
384
  draft.freeText = input.getValue();
379
385
  }
380
386
 
@@ -540,18 +546,14 @@ export class AskUserComponent implements Component, Focusable {
540
546
  return;
541
547
  }
542
548
  if (field.kind === "bool") {
543
- if (matchesKey(data, Key.left) || matchesKey(data, Key.up)) cursor = 0;
544
- else if (matchesKey(data, Key.right) || matchesKey(data, Key.down)) cursor = 1;
545
- else if (matchesKey(data, Key.space)) {
546
- this.setFormValue(field, cursor === 0, fieldIndex);
547
- } else if (matchesKey(data, Key.enter)) {
548
- this.setFormValue(field, cursor === 0, fieldIndex);
549
- this.advanceFormField();
550
- return;
551
- } else return;
552
- form.optionCursors[fieldIndex] = cursor;
549
+ const current = form.present.has(field.name) ? form.values[field.name] : undefined;
550
+ const next = this.boolValueForKey(data, typeof current === "boolean" ? current : undefined);
551
+ if (next === undefined) return;
552
+ // Confirming an existing default preserves its provenance.
553
+ if (!matchesKey(data, Key.enter) || current === undefined) this.setFormValue(field, next, fieldIndex);
553
554
  this.syncFormDraft(this.tab);
554
- this.refresh();
555
+ if (matchesKey(data, Key.enter)) this.advanceFormField();
556
+ else this.refresh();
555
557
  return;
556
558
  }
557
559
  if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) cursor = clamp(cursor - 1, 0, field.options.length - 1);
@@ -575,6 +577,15 @@ export class AskUserComponent implements Component, Focusable {
575
577
  this.refresh();
576
578
  }
577
579
 
580
+ /** One boolean control: arrows set, Space toggles, Enter confirms (unset -> true). */
581
+ private boolValueForKey(data: string, current: boolean | undefined): boolean | undefined {
582
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.left) || matchesKey(data, "ctrl+p")) return true;
583
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.right) || matchesKey(data, "ctrl+n")) return false;
584
+ if (matchesKey(data, Key.space)) return current === undefined ? true : !current;
585
+ if (matchesKey(data, Key.enter)) return current ?? true;
586
+ return undefined;
587
+ }
588
+
578
589
  // -- key handling --------------------------------------------------------
579
590
 
580
591
  handleInput(data: string): void {
@@ -642,28 +653,19 @@ export class AskUserComponent implements Component, Focusable {
642
653
 
643
654
  const question = this.currentQuestion();
644
655
  if (question.kind === "bool") {
645
- if (matchesKey(data, Key.up) || matchesKey(data, Key.left) || matchesKey(data, "ctrl+p")) {
646
- this.cursors[this.tab] = 0;
647
- this.refresh();
648
- return;
649
- }
650
- if (matchesKey(data, Key.down) || matchesKey(data, Key.right) || matchesKey(data, "ctrl+n")) {
651
- this.cursors[this.tab] = 1;
652
- this.refresh();
653
- return;
654
- }
655
656
  if (matchesKey(data, "s")) {
656
657
  this.skipWithDefault();
657
658
  return;
658
659
  }
659
- if (matchesKey(data, Key.space) || matchesKey(data, Key.enter)) {
660
- this.drafts[this.tab] = {
661
- selections: [], freeText: "", usedDefault: false, value: this.rowIndex() === 0, valueSet: true,
662
- };
663
- this.notice = undefined;
664
- if (matchesKey(data, Key.enter)) this.advance();
665
- else this.refresh();
660
+ const draft = this.drafts[this.tab]!;
661
+ const next = this.boolValueForKey(data, draft.valueSet && typeof draft.value === "boolean" ? draft.value : undefined);
662
+ if (next === undefined) return;
663
+ if (!matchesKey(data, Key.enter) || !draft.valueSet) {
664
+ this.drafts[this.tab] = { selections: [], freeText: "", usedDefault: false, value: next, valueSet: true };
666
665
  }
666
+ this.notice = undefined;
667
+ if (matchesKey(data, Key.enter)) this.advance();
668
+ else this.refresh();
667
669
  return;
668
670
  }
669
671
  if (question.kind === "input") {
@@ -721,7 +723,7 @@ export class AskUserComponent implements Component, Focusable {
721
723
  }
722
724
 
723
725
  const printable = printableText(data);
724
- if (printable !== undefined) {
726
+ if (printable !== undefined && this.hasInputBar(question)) {
725
727
  this.moveRowToInput();
726
728
  this.forwardToInput(data);
727
729
  this.refresh();
@@ -731,7 +733,8 @@ export class AskUserComponent implements Component, Focusable {
731
733
  private onOptionEnter(): void {
732
734
  const question = this.currentQuestion();
733
735
  if (question.kind === "multi") {
734
- this.toggleRow();
736
+ if (this.hasInputBar(question)) this.toggleRow();
737
+ else this.commitAndAdvance();
735
738
  return;
736
739
  }
737
740
  // Sync any typed note before advancing, so selecting an option never drops
@@ -782,7 +785,9 @@ export class AskUserComponent implements Component, Focusable {
782
785
  this.syncDraftFromInput(this.tab);
783
786
  const draft = this.drafts[this.tab]!;
784
787
  if (draftIsEmpty(draft) && !question.hasDefault) {
785
- this.notice = "This question is required: select an option or type text.";
788
+ this.notice = this.hasInputBar(question)
789
+ ? "This question is required: select an option or type text."
790
+ : "This question is required: select an option.";
786
791
  this.refresh();
787
792
  return;
788
793
  }
@@ -944,22 +949,19 @@ export class AskUserComponent implements Component, Focusable {
944
949
  return lines;
945
950
  }
946
951
 
952
+ private renderBoolControl(value: boolean | undefined): string {
953
+ if (value === undefined) return this.theme.fg("muted", "[ ] True (unset)");
954
+ return this.theme.fg(value ? "success" : "error", value ? "[✓] True" : "[✗] False");
955
+ }
956
+
947
957
  private renderBoolOptions(width: number): { lines: string[]; focusLine: number } {
948
958
  const draft = this.drafts[this.tab]!;
949
- const row = this.rowIndex();
950
- const selected = draft.valueSet ? draft.value : undefined;
951
- const choices: Array<{ value: boolean; mark: string; label: string }> = [
952
- { value: true, mark: "✓", label: "True" },
953
- { value: false, mark: "✗", label: "False" },
954
- ];
955
- const lines = choices.map((choice, index) => {
956
- const cursor = index === row ? this.theme.fg("accent", "▸") : " ";
957
- const chosen = selected === choice.value;
958
- const control = chosen ? this.theme.fg(choice.value ? "success" : "error", `[${choice.mark}]`) : this.theme.fg("muted", `[ ] ${choice.mark}`);
959
- const label = index === row ? this.theme.bold(this.theme.fg("accent", choice.label)) : choice.label;
960
- return truncateToWidth(` ${cursor} ${control} ${label}`, width);
961
- });
962
- return { lines, focusLine: row };
959
+ const value = draft.valueSet && typeof draft.value === "boolean" ? draft.value : undefined;
960
+ const defaultMark = draft.usedDefault ? this.theme.fg("muted", " (default)") : "";
961
+ return {
962
+ lines: [truncateToWidth(` ${this.theme.fg("accent", "▸")} ${this.renderBoolControl(value)}${defaultMark}`, width)],
963
+ focusLine: 0,
964
+ };
963
965
  }
964
966
 
965
967
  /** Options only (no input row): the input stays pinned outside the body. */
@@ -1011,15 +1013,8 @@ export class AskUserComponent implements Component, Focusable {
1011
1013
  }
1012
1014
  if (field.kind === "bool") {
1013
1015
  const value = form.present.has(field.name) ? form.values[field.name] : undefined;
1014
- const cursor = form.optionCursors[fieldIndex] ?? 0;
1015
- for (const [choiceIndex, choice] of [true, false].entries()) {
1016
- if (cursor === choiceIndex) focusLine = lines.length;
1017
- const at = cursor === choiceIndex ? this.theme.fg("accent", "▸") : " ";
1018
- const selected = value === choice;
1019
- const mark = choice ? "✓" : "✗";
1020
- const box = selected ? this.theme.fg(choice ? "success" : "error", `[${mark}]`) : this.theme.fg("muted", `[ ] ${mark}`);
1021
- lines.push(truncateToWidth(` ${at} ${box} ${choice ? "True" : "False"}`, width));
1022
- }
1016
+ focusLine = lines.length;
1017
+ lines.push(truncateToWidth(` ${this.renderBoolControl(typeof value === "boolean" ? value : undefined)}`, width));
1023
1018
  return;
1024
1019
  }
1025
1020
 
@@ -1116,7 +1111,7 @@ export class AskUserComponent implements Component, Focusable {
1116
1111
 
1117
1112
  /** The free-input row, rendered as a fixed line that never scrolls away. */
1118
1113
  private renderInputBar(width: number): string[] {
1119
- if (this.currentQuestion().kind === "bool" || this.currentQuestion().kind === "simple-form") return [];
1114
+ if (!this.hasInputBar(this.currentQuestion())) return [];
1120
1115
  const input = this.inputs[this.tab]!;
1121
1116
  input.focused = this.focused && this.isInputRow();
1122
1117
  return input
@@ -1192,18 +1187,18 @@ export class AskUserComponent implements Component, Focusable {
1192
1187
  const form = this.forms[this.tab]!;
1193
1188
  const field = question.fields?.[form.active];
1194
1189
  if (field?.kind === "multi") hints.push("Space toggle", "Enter next");
1195
- else if (field?.kind === "bool") hints.push("←/→ choose", "Space set", "Enter next");
1190
+ else if (field?.kind === "bool") hints.push("Space toggle", "←/→ set", "Enter next");
1196
1191
  else if (field) hints.push("Enter next");
1197
1192
  else hints.push("Enter continue");
1198
1193
  if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn scroll");
1199
1194
  return withControls(hints);
1200
1195
  }
1201
1196
  if (question.kind === "bool") {
1202
- const hints = ["↑/↓ choose", "Space set", "Enter confirm", "Tab switch"];
1197
+ const hints = ["Space toggle", "↑/↓ set", "Enter confirm", "Tab switch"];
1203
1198
  if (question.hasDefault) hints.push("s use default");
1204
1199
  return withControls(hints);
1205
1200
  }
1206
- const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() ? "confirm" : "select"}`];
1201
+ const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() || (question.kind === "multi" && !this.hasInputBar(question)) ? "confirm" : "select"}`];
1207
1202
  if (question.kind === "multi") hints.push("Space multi-select");
1208
1203
  if (question.hasDefault) hints.push("s use default");
1209
1204
  if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn preview");
@@ -1259,7 +1254,7 @@ export class AskUserComponent implements Component, Focusable {
1259
1254
  const hintBlock = [this.hintLine(contentWidth)];
1260
1255
 
1261
1256
  let remaining = contentRows;
1262
- const typedQuestion = !this.isReview && (this.currentQuestion().kind === "simple-form" || this.currentQuestion().kind === "bool");
1257
+ const typedQuestion = !this.isReview && !this.hasInputBar(this.currentQuestion());
1263
1258
  const reserveBody = typedQuestion && contentRows > 0 ? 1 : 0;
1264
1259
  const take = (block: string[]): string[] => {
1265
1260
  if (remaining <= reserveBody || block.length === 0) return [];
package/src/ui/native.ts CHANGED
@@ -11,12 +11,21 @@ import type {
11
11
  NormalizedRequest,
12
12
  } from "../types.ts";
13
13
 
14
- /** Minimal shape of the Pi UI context needed by the native route. */
14
+ export interface NativeDialogOptions {
15
+ signal?: AbortSignal;
16
+ timeout?: number;
17
+ }
18
+
19
+ /** Native select is required for typed controls when allowFreeText is false. */
15
20
  export interface NativeDialogUI {
16
- input(title: string, placeholder?: string, opts?: { signal?: AbortSignal; timeout?: number }): Promise<string | undefined>;
21
+ input(title: string, placeholder?: string, opts?: NativeDialogOptions): Promise<string | undefined>;
22
+ select?(title: string, options: string[], opts?: NativeDialogOptions): Promise<string | undefined>;
17
23
  }
18
24
 
19
25
  function placeholderFor(question: NormalizedQuestion): string {
26
+ if (question.allowFreeText === false && (question.kind === "single" || question.kind === "multi")) {
27
+ return question.kind === "single" ? "one option number, e.g. 2" : "option numbers, e.g. 1,3";
28
+ }
20
29
  switch (question.kind) {
21
30
  case "single": return 'e.g. 2 or "2 | note" or type free text';
22
31
  case "multi": return 'e.g. 1,3 or "1,3 | note" or type free text';
@@ -26,7 +35,7 @@ function placeholderFor(question: NormalizedQuestion): string {
26
35
  }
27
36
  }
28
37
 
29
- function buildPrompt(request: NormalizedRequest, question: NormalizedQuestion, invalid: string | undefined): string {
38
+ function buildPrompt(request: NormalizedRequest, question: NormalizedQuestion, invalid: string | undefined, typed = false): string {
30
39
  const lines: string[] = [];
31
40
  if (request.header?.trim()) lines.push(request.header.trim());
32
41
  lines.push(`[Question ${question.index + 1}/${request.questions.length}] ${question.title}`);
@@ -35,8 +44,8 @@ function buildPrompt(request: NormalizedRequest, question: NormalizedQuestion, i
35
44
  const description = option.description?.trim();
36
45
  lines.push(` ${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`);
37
46
  });
38
- if (question.hasDefault && question.default !== undefined) lines.push(`Default: ${String(question.default)} (empty box uses it)`);
39
- lines.push(nativeInputHint(question));
47
+ if (question.hasDefault && question.default !== undefined) lines.push(`Default: ${String(question.default)}${typed ? "" : " (empty box uses it)"}`);
48
+ if (!typed) lines.push(nativeInputHint(question));
40
49
  const body = lines.join("\n");
41
50
  return invalid ? `⚠ Invalid input: ${invalid}\n\n${body}` : body;
42
51
  }
@@ -56,6 +65,7 @@ function buildFieldPrompt(
56
65
  field: NormalizedFormField,
57
66
  fieldIndex: number,
58
67
  invalid: string | undefined,
68
+ typed = false,
59
69
  ): string {
60
70
  const lines: string[] = [];
61
71
  if (request.header?.trim()) lines.push(request.header.trim());
@@ -66,7 +76,8 @@ function buildFieldPrompt(
66
76
  const description = option.description?.trim();
67
77
  lines.push(` ${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`);
68
78
  });
69
- if (field.hasDefault) lines.push(`Default: ${Array.isArray(field.default) ? field.default.join(", ") : String(field.default)} (empty box uses it)`);
79
+ if (field.hasDefault) lines.push(`Default: ${Array.isArray(field.default) ? field.default.join(", ") : String(field.default)}${typed ? "" : " (empty box uses it)"}`);
80
+ if (typed) return lines.join("\n");
70
81
  if (field.kind === "bool") lines.push("Enter exactly true or false (case-insensitive).");
71
82
  else if (field.kind === "single") lines.push("Enter exactly one option number; arbitrary text is not accepted in a form.");
72
83
  else if (field.kind === "multi") lines.push("Enter option numbers separated by commas/spaces; arbitrary text is not accepted in a form.");
@@ -108,13 +119,21 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
108
119
  return {
109
120
  async run({ request, deadline, signal, onUpdate }: AskUIInput): Promise<AskUIOutcome> {
110
121
  const answers: AskUserAnswer[] = [];
111
- const ask = async (title: string, placeholder: string): Promise<{ raw?: string; outcome?: AskUIOutcome }> => {
122
+ const usesTypedControl = (question: NormalizedQuestion): boolean =>
123
+ question.kind !== "input" && question.kind !== "multi" && (request.allowFreeText === false || question.allowFreeText === false);
124
+ // Refuse before showing any dialogs; never substitute text input for a missing selector.
125
+ const needsSelect = request.questions.some((question) => usesTypedControl(question) &&
126
+ (question.kind !== "simple-form" || question.fields?.some((field) => field.kind === "bool" || field.kind === "single")));
127
+ if (needsSelect && typeof ui.select !== "function") {
128
+ return { kind: "error", message: "allowFreeText=false requires native select dialogs, but this host does not provide ui.select. No text-input fallback was used." };
129
+ }
130
+ const dialog = async (invoke: (opts: NativeDialogOptions) => Promise<string | undefined>): Promise<{ raw?: string; outcome?: AskUIOutcome }> => {
112
131
  if (signal?.aborted) return { outcome: { kind: "abort" } };
113
132
  if (deadline.expired()) return { outcome: { kind: "timeout" } };
114
133
  const opts: { signal?: AbortSignal; timeout?: number } = { timeout: deadline.remainingMs() };
115
134
  if (signal) opts.signal = signal;
116
135
  try {
117
- const raw = await ui.input(title, placeholder, opts);
136
+ const raw = await invoke(opts);
118
137
  if (signal?.aborted) return { outcome: { kind: "abort" } };
119
138
  if (deadline.expired()) return { outcome: { kind: "timeout" } };
120
139
  if (raw !== undefined) return { raw };
@@ -126,6 +145,26 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
126
145
  }
127
146
  };
128
147
 
148
+ const ask = (title: string, placeholder: string) => dialog((opts) => ui.input(title, placeholder, opts));
149
+ const select = (title: string, options: string[]) => dialog((opts) => ui.select!(title, options, opts));
150
+ const typedField = async (title: string, field: NormalizedFormField): Promise<{
151
+ value?: boolean | string | string[]; usedDefault?: boolean; outcome?: AskUIOutcome;
152
+ }> => {
153
+ const options = field.kind === "bool" ? ["✓ True", "✗ False"] : field.options.map((option, index) =>
154
+ `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`);
155
+ const defaultOption = `Use default: ${String(field.default)}`;
156
+ const skip = "Skip optional field";
157
+ const response = await select(title, [
158
+ ...options, ...(field.hasDefault ? [defaultOption] : []), ...(!field.required ? [skip] : []),
159
+ ]);
160
+ if (response.outcome) return { outcome: response.outcome };
161
+ if (field.hasDefault && response.raw === defaultOption) return { value: cloneDefault(field.default!), usedDefault: true };
162
+ if (!field.required && response.raw === skip) return {};
163
+ const index = options.indexOf(response.raw!);
164
+ if (index < 0) return { outcome: { kind: "error", message: "Native select returned an unknown option." } };
165
+ return { value: field.kind === "bool" ? index === 0 : field.options[index]!.label, usedDefault: false };
166
+ };
167
+
129
168
  for (const question of request.questions) {
130
169
  if (question.kind === "simple-form") {
131
170
  const value: AskUserFormValue = {};
@@ -133,6 +172,14 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
133
172
  const fields = question.fields ?? [];
134
173
  for (let fieldIndex = 0; fieldIndex < fields.length; fieldIndex += 1) {
135
174
  const field = fields[fieldIndex]!;
175
+ if (usesTypedControl(question) && (field.kind === "bool" || field.kind === "single")) {
176
+ onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}, field ${fieldIndex + 1}/${fields.length}…`);
177
+ const response = await typedField(buildFieldPrompt(request, question, field, fieldIndex, undefined, true), field);
178
+ if (response.outcome) return response.outcome;
179
+ if (response.value !== undefined) value[field.name] = response.value;
180
+ usedDefault ||= response.usedDefault === true;
181
+ continue;
182
+ }
136
183
  let invalid: string | undefined;
137
184
  for (;;) {
138
185
  onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}, field ${fieldIndex + 1}/${fields.length}…`);
@@ -166,6 +213,26 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
166
213
  continue;
167
214
  }
168
215
 
216
+ if (usesTypedControl(question)) {
217
+ onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}…`);
218
+ const field: NormalizedFormField = {
219
+ name: question.id, label: question.title, kind: question.kind as "bool" | "single",
220
+ required: true, options: question.options, hasDefault: question.hasDefault,
221
+ default: typeof question.default === "string" ? question.default.trim() : question.default,
222
+ };
223
+ const response = await typedField(buildPrompt(request, question, undefined, true), field);
224
+ if (response.outcome) return response.outcome;
225
+ const selections = question.kind === "bool" ? [] : question.options
226
+ .map((option, index) => ({ option, index }))
227
+ .filter(({ option }) => response.value === option.label)
228
+ .map(({ index }) => index);
229
+ answers.push(toAnswer(question, {
230
+ selections, freeText: "", usedDefault: response.usedDefault === true,
231
+ ...(question.kind === "bool" ? { value: response.value as boolean, valueSet: true } : {}),
232
+ }));
233
+ continue;
234
+ }
235
+
169
236
  let invalid: string | undefined;
170
237
  for (;;) {
171
238
  onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}…`);
@@ -202,7 +269,9 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
202
269
  answers.push(toAnswer(question, fallback));
203
270
  break;
204
271
  }
205
- invalid = question.kind === "input" ? "This question is required and cannot be empty." : "This question is required; enter an option number or free text.";
272
+ invalid = question.kind === "input" ? "This question is required and cannot be empty."
273
+ : question.allowFreeText === false ? "This question is required; enter option numbers only."
274
+ : "This question is required; enter an option number or free text.";
206
275
  continue;
207
276
  }
208
277
  answers.push(toAnswer(question, { selections: parsed.value.selections, freeText: parsed.value.freeText ?? "", usedDefault: false, valueSet: false }));