@orkestrel/terminal 0.0.8 → 0.0.9

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.
@@ -1,6 +1,7 @@
1
- import { ATTRIBUTES, COLORS, STATUS_ICONS, createStyler, freezeStyle, stripControls } from "@orkestrel/console";
2
- import { FORMAT_PATTERNS, arrayOf, isArray, isBoolean, isNonEmptyString, isNumber, isRecord, isString, literalOf, parseJSON, rawShape, recordOf, stringShape } from "@orkestrel/contract";
1
+ import { STATUS_ICONS, createStyler, freezeStyle, strip, stripControls } from "@orkestrel/console";
2
+ import { arrayOf, attempt, isArray, isBoolean, isNonEmptyString, isNumber, isRecord, isString, literalOf, parseJSON, rawShape, recordOf, stringShape } from "@orkestrel/contract";
3
3
  import { Emitter } from "@orkestrel/emitter";
4
+ import { createForm, isFieldError, isFormError, parseForm, serializeForm } from "@orkestrel/form";
4
5
  import { createSSEParser } from "@orkestrel/sse";
5
6
  import { createDatabase, createMemoryDriver } from "@orkestrel/database";
6
7
  //#region src/core/constants.ts
@@ -127,38 +128,9 @@ var CONTROL_NAMES = Object.freeze({
127
128
  ctrl: true
128
129
  })
129
130
  });
130
- /** The default mask glyph a {@link import('./types.js').PasswordState} renders each input character as — `*`. */
131
+ /** The default mask glyph {@link import('./helpers.js').createPasswordState} uses — `*`. */
131
132
  var DEFAULT_MASK = "*";
132
133
  /**
133
- * Matches an HTTP(S) URL. The `url` rule tests against this. Kept locally because it is
134
- * narrower than `FORMAT_PATTERNS.uri`, which accepts other URI schemes.
135
- */
136
- var URL_PATTERN = /^https?:\/\/.+/;
137
- /** Matches a numeric value (integer or decimal, optional sign). The `numeric` rule tests against this. */
138
- var NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/;
139
- /** Matches an integer (optional sign). The `integer` rule tests against this. */
140
- var INTEGER_PATTERN = /^-?\d+$/;
141
- /** Matches an alphanumeric string (letters and digits only). The `alphanumeric` rule tests against this. */
142
- var ALPHANUMERIC_PATTERN = /^[a-zA-Z0-9]+$/;
143
- /**
144
- * Each built-in validation rule's default error message — what the composed {@link
145
- * import('./types.js').Validator} returns when that rule fails (the `minimum` / `maximum`
146
- * messages are interpolated with the configured length at build time). Frozen; the source of
147
- * truth for the rule-failure copy.
148
- */
149
- var RULE_MESSAGES = Object.freeze({
150
- required: "This field is required",
151
- minimum: "Must be at least {count} characters",
152
- maximum: "Must be at most {count} characters",
153
- pattern: "Must match pattern: {pattern}",
154
- email: "Must be a valid email address",
155
- url: "Must be a valid URL",
156
- numeric: "Must be a numeric value",
157
- integer: "Must be an integer",
158
- alphanumeric: "Must contain only letters and digits",
159
- invalid: "Invalid input"
160
- });
161
- /**
162
134
  * The six default glyphs {@link DEFAULT_PROMPT_THEME} assembles its `icons` from. Read only when
163
135
  * the default theme is assembled; a view reads its resolved theme and never this constant. Frozen.
164
136
  *
@@ -254,11 +226,11 @@ var DEFAULT_PROMPT_TIMEOUT_MS = 3e5;
254
226
  /** How long (ms) the {@link import('./types.js').PromptClientInterface} waits before each reconnect attempt — 2 seconds. */
255
227
  var DEFAULT_RECONNECT_DELAY_MS = 2e3;
256
228
  /**
257
- * The SSE `event:` names the broker emits and the {@link import('./types.js').PromptClient}
229
+ * The SSE `event:` names the broker emits and the {@link import('./types.js').PromptClientInterface}
258
230
  * dispatches on. Frozen; the source of truth for the wire event vocabulary.
259
231
  *
260
232
  * @remarks
261
- * - `pending` — a serialized {@link import('./types.js').PendingPrompt} to dispatch + answer.
233
+ * - `pending` — a serialized {@link import('./types.js').PendingForm} to dispatch and answer.
262
234
  * - `expire` — an `{ id }` payload: the broker expired a parked prompt (the client drops it).
263
235
  * - `shutdown` — the broker is going away; the client disconnects (no auto-reconnect) but stays reusable.
264
236
  */
@@ -267,12 +239,12 @@ var SSE_EVENTS = Object.freeze({
267
239
  expire: "expire",
268
240
  shutdown: "shutdown"
269
241
  });
270
- /** The auth-token request header the {@link import('./types.js').PromptClient} sends when a `token` is configured. */
242
+ /** The auth-token request header the {@link import('./types.js').PromptClientInterface} sends. */
271
243
  var HEADER_TOKEN = "x-orkestrel-token";
272
244
  /** The `Accept` header value that opens the broker's SSE stream. */
273
245
  var ACCEPT_EVENT_STREAM = "text/event-stream";
274
246
  /**
275
- * The maximum number of characters the {@link import('./types.js').PromptClient} lets its
247
+ * The maximum number of characters the {@link import('./types.js').PromptClientInterface} lets its
276
248
  * SSE parser buffer before treating the stream as hostile — 1 MiB, comfortably above any
277
249
  * legitimate prompt payload. Passed as the `limit` to `createSSEParser` so an unterminated
278
250
  * or oversized `data:` field cannot grow the buffer without bound (a memory-exhaustion guard).
@@ -281,19 +253,20 @@ var SSE_BUFFER_LIMIT = 1048576;
281
253
  //#endregion
282
254
  //#region src/core/errors.ts
283
255
  /**
284
- * An error a {@link import('./Prompt.js').Prompt} broker rejects a parked prompt's Promise with.
256
+ * The error the terminal surfaces for its own refusals: parking on a destroyed or full broker, an
257
+ * unusable driver stream, a manager routing fault, or a ctrl-c cancellation. A parked form's own
258
+ * lifecycle failures reject through the form's `answer` with the form package's error, never with
259
+ * this one.
285
260
  *
286
261
  * @remarks
287
- * Carries a {@link TerminalErrorCode} and an optional `context` bag (the prompt `id`). Thrown —
288
- * as a Promise rejection on the awaited prompt call — when a parked prompt is not answered before
289
- * its `timeout`, or when the broker is `destroy`ed while the prompt is still `pending` (both
290
- * `EXPIRE`); or when the user aborts an interactive server-`Terminal` prompt with ctrl-c
291
- * (`CANCEL`). Narrow a caught value with {@link isTerminalError} and branch on `error.code`.
262
+ * Carries a {@link TerminalErrorCode} and an optional `context` bag naming the offending values:
263
+ * `{ cap }` on `LIMIT`, `{ to, known }` on `TARGET`, and `{ from, to, path }` on `DEADLOCK`. Narrow
264
+ * a caught value with {@link isTerminalError} and branch on `error.code`.
292
265
  */
293
266
  var TerminalError = class extends Error {
294
267
  /** The machine-readable condition — see {@link TerminalErrorCode}. */
295
268
  code;
296
- /** An optional context bag naming the offending prompt id. */
269
+ /** An optional context bag naming the offending values — see the class {@link TerminalError remarks}. */
297
270
  context;
298
271
  constructor(code, message, context) {
299
272
  super(message);
@@ -311,9 +284,11 @@ var TerminalError = class extends Error {
311
284
  * @example
312
285
  * ```ts
313
286
  * try {
314
- * const name = await prompt.input({ message: 'Your name' })
287
+ * await terminal.ask(form)
315
288
  * } catch (error) {
316
- * if (isTerminalError(error) && error.code === 'EXPIRE') retryLater()
289
+ * if (isTerminalError(error) && error.code === 'CANCEL') {
290
+ * // the person aborted
291
+ * }
317
292
  * }
318
293
  * ```
319
294
  */
@@ -322,83 +297,39 @@ function isTerminalError(value) {
322
297
  }
323
298
  //#endregion
324
299
  //#region src/core/validators.ts
325
- /** Narrow an unknown value to a {@link PromptType} — one of the six prompt forms. */
326
- var isPromptType = literalOf("input", "password", "confirm", "select", "checkbox", "editor");
327
- /** Narrow an unknown value to a {@link PendingPromptStatus}. */
328
- var isPendingPromptStatus = literalOf("pending", "answered", "expired");
329
- /**
330
- * Narrow an unknown wire value to a {@link PendingPrompt} before dispatching it.
331
- *
332
- * @param value - The decoded wire value to inspect
333
- * @returns Whether `value` is a complete pending-prompt record
334
- */
335
- var isPendingPrompt = recordOf({
336
- id: isNonEmptyString,
337
- form: isPromptType,
338
- message: isString,
339
- options: isRecord,
340
- status: isPendingPromptStatus,
341
- time: isNumber,
342
- from: isString,
343
- to: isString
344
- }, ["from", "to"]);
300
+ /** Narrow an unknown value to a {@link PendingFormStatus}. */
301
+ var isPendingFormStatus = literalOf("pending", "answered", "expired");
345
302
  /**
346
- * Narrow an unknown value to a console {@link Style} — the value every
347
- * {@link import('./types.js').PromptRole} carries, built from the console module's own
348
- * {@link import('@orkestrel/console').COLORS} + {@link import('@orkestrel/console').ATTRIBUTES} so
349
- * the style vocabulary has ONE source.
303
+ * Narrow an unknown wire value to a {@link PendingForm} envelope.
350
304
  *
351
305
  * @remarks
352
- * The record is CLOSED and matches the `Style` contract exactly: `foreground` and `background` are
353
- * optional and each must name a color (`default` included, as the console `Color` union has it),
354
- * `attributes` is REQUIRED and must be an array over the attribute set, and any other key rejects
355
- * the whole value.
306
+ * This guard checks the transport record and proves only that `schema` is a record. The Form
307
+ * package's `parseForm` owns the schema payload and its semantic audit.
308
+ *
309
+ * @param value - The decoded wire value to inspect
310
+ * @returns Whether the value is a complete pending-form envelope
356
311
  */
357
- var isStyle = recordOf({
358
- foreground: literalOf(...COLORS, "default"),
359
- background: literalOf(...COLORS, "default"),
360
- attributes: arrayOf(literalOf(...ATTRIBUTES))
361
- }, ["foreground", "background"]);
312
+ function isPendingForm(value) {
313
+ return recordOf({
314
+ id: isNonEmptyString,
315
+ schema: isRecord,
316
+ status: isPendingFormStatus,
317
+ time: isNumber,
318
+ from: isString,
319
+ to: isString
320
+ }, ["from", "to"])(value);
321
+ }
362
322
  /**
363
- * Narrow an unknown wire value to a {@link PromptThemeOptions} — the §14 guard a remote prompt's
364
- * `theme` option passes before it reaches a local prompt.
323
+ * Narrow an unknown value to a transport-neutral {@link WireEvent}.
365
324
  *
366
- * @remarks
367
- * The shape is CLOSED at every level: an unknown icon slot, an unknown role, a non-string glyph, or
368
- * a role whose value is not a {@link isStyle}-shaped console style rejects the whole theme, so a
369
- * hostile payload degrades to the default theme rather than partially applying. Glyphs still carry
370
- * arbitrary text at this point — {@link import('./helpers.js').sanitizeThemeIcons} strips their
371
- * control bytes on dispatch.
372
- *
373
- * The icons and roles shapes are checked against their complete union-keyed guard records. A slot
374
- * added to either union without an entry here is a compile error rather than a wire theme that is
375
- * silently refused.
325
+ * @param value - The candidate wire event
326
+ * @returns Whether the value carries an event name, serialized data, and an optional id
376
327
  */
377
- var isPromptThemeOptions = recordOf({
378
- icons: recordOf({
379
- question: isString,
380
- pointer: isString,
381
- dot: isString,
382
- selected: isString,
383
- checked: isString,
384
- unchecked: isString,
385
- success: isString,
386
- error: isString
387
- }, true),
388
- roles: recordOf({
389
- question: isStyle,
390
- pointer: isStyle,
391
- message: isStyle,
392
- content: isStyle,
393
- success: isStyle,
394
- error: isStyle,
395
- selected: isStyle,
396
- focus: isStyle,
397
- hint: isStyle,
398
- description: isStyle,
399
- muted: isStyle
400
- }, true)
401
- }, true);
328
+ var isWireEvent = recordOf({
329
+ event: isString,
330
+ data: isString,
331
+ id: isString
332
+ }, ["id"]);
402
333
  /** Narrow an unknown value to a {@link TerminalSnapshot}. */
403
334
  var isTerminalSnapshot = recordOf({
404
335
  id: isNonEmptyString,
@@ -479,156 +410,6 @@ function isPrintable(character) {
479
410
  return code >= 32 && code !== 127;
480
411
  }
481
412
  /**
482
- * Evaluate ONE built-in validation rule against `input`, returning its error message when the
483
- * rule fails or `undefined` when it passes. The atomic check {@link buildRuleValidator} wraps
484
- * into a {@link Validator}. Pure.
485
- *
486
- * @remarks
487
- * A function `check` is the custom-override path: it is called and its `true` ⇒ pass, a string
488
- * ⇒ that message, anything else ⇒ the generic {@link RULE_MESSAGES.invalid}. A primitive `check`
489
- * runs the named built-in: `required` (non-empty trimmed), `minimum` / `maximum` (length
490
- * bounds, the message interpolated with the count), `pattern` (a regex source), and the
491
- * `email` / `url` / `numeric` / `integer` / `alphanumeric` pattern tests.
492
- *
493
- * @param rule - The rule name (`'required'`, `'minimum'`, …)
494
- * @param check - The configured value (a primitive toggle / bound / pattern, or a custom {@link Validator})
495
- * @param input - The input string to test
496
- * @returns The error message when the rule fails, else `undefined`
497
- */
498
- function evaluateRule(rule, check, input) {
499
- if (typeof check === "function") {
500
- const result = check(input);
501
- if (result === true) return void 0;
502
- return isString(result) ? result : RULE_MESSAGES.invalid;
503
- }
504
- switch (rule) {
505
- case "required":
506
- if (check === true && input.trim().length === 0) return RULE_MESSAGES.required;
507
- break;
508
- case "minimum":
509
- if (isNumber(check) && [...input].length < check) return RULE_MESSAGES.minimum.replace("{count}", String(check));
510
- break;
511
- case "maximum":
512
- if (isNumber(check) && [...input].length > check) return RULE_MESSAGES.maximum.replace("{count}", String(check));
513
- break;
514
- case "pattern":
515
- if (isString(check)) {
516
- let compiled;
517
- try {
518
- compiled = new RegExp(check);
519
- } catch {
520
- return RULE_MESSAGES.pattern.replace("{pattern}", check);
521
- }
522
- if (!compiled.test(input)) return RULE_MESSAGES.pattern.replace("{pattern}", check);
523
- }
524
- break;
525
- case "email":
526
- if (check === true && !FORMAT_PATTERNS.email.test(input)) return RULE_MESSAGES.email;
527
- break;
528
- case "url":
529
- if (check === true && !URL_PATTERN.test(input)) return RULE_MESSAGES.url;
530
- break;
531
- case "numeric":
532
- if (check === true && !NUMERIC_PATTERN.test(input)) return RULE_MESSAGES.numeric;
533
- break;
534
- case "integer":
535
- if (check === true && !INTEGER_PATTERN.test(input)) return RULE_MESSAGES.integer;
536
- break;
537
- case "alphanumeric":
538
- if (check === true && !ALPHANUMERIC_PATTERN.test(input)) return RULE_MESSAGES.alphanumeric;
539
- break;
540
- }
541
- }
542
- /** Wrap a named rule + its primitive check into a {@link Validator} (returns `true` or the rule's message). */
543
- function buildRuleValidator(rule, check) {
544
- return (input) => evaluateRule(rule, check, input) ?? true;
545
- }
546
- /**
547
- * Append a rule-backed {@link Validator} to `validators` when the rule is enabled — a `false` /
548
- * `undefined` `check` is skipped, a function `check` is added verbatim (the custom override), and
549
- * a primitive `check` is wrapped via {@link buildRuleValidator}. Mutates `validators` in place.
550
- */
551
- function appendRule(validators, rule, check) {
552
- if (check === void 0 || check === false) return;
553
- if (typeof check === "function") {
554
- validators.push(check);
555
- return;
556
- }
557
- validators.push(buildRuleValidator(rule, check));
558
- }
559
- /**
560
- * Compose several {@link Validator}s into ONE short-circuiting validator — it runs them in order
561
- * and returns the FIRST error message, or `true` when all pass. The empty composition always
562
- * passes.
563
- */
564
- function composeValidators(...validators) {
565
- return (input) => {
566
- for (const validator of validators) {
567
- const result = validator(input);
568
- if (result !== true) return result;
569
- }
570
- return true;
571
- };
572
- }
573
- /**
574
- * Compile a {@link Validator} or declarative {@link ValidationRules} (or nothing) into ONE
575
- * composed {@link Validator}. A bare validator passes through; rules are appended in the fixed
576
- * order (required → minimum → maximum → pattern → email → url → numeric → integer → alphanumeric
577
- * → custom) and composed; absent / empty input yields an always-passing validator. Pure.
578
- *
579
- * @remarks
580
- * Unlike a prior variant (which returned `Validator | undefined`), this ALWAYS returns a
581
- * `Validator` — an absent or empty rule set yields a validator that returns `true` for every
582
- * input. That keeps a prompt's state unconditional (it always holds a real validator to apply on
583
- * submit), with no `undefined` branch at the call site.
584
- *
585
- * @param validate - A custom {@link Validator}, a {@link ValidationRules} bag, or `undefined`
586
- * @returns The composed {@link Validator} (always-passing when nothing was supplied)
587
- *
588
- * @example
589
- * ```ts
590
- * const v = resolveValidation({ required: true, minimum: 3 })
591
- * v('') // 'This field is required'
592
- * v('ab') // 'Must be at least 3 characters'
593
- * v('abc') // true
594
- * ```
595
- */
596
- function resolveValidation(validate) {
597
- if (validate === void 0) return passing;
598
- if (typeof validate === "function") return validate;
599
- const validators = [];
600
- appendRule(validators, "required", validate.required);
601
- appendRule(validators, "minimum", validate.minimum);
602
- appendRule(validators, "maximum", validate.maximum);
603
- appendRule(validators, "pattern", validate.pattern);
604
- appendRule(validators, "email", validate.email);
605
- appendRule(validators, "url", validate.url);
606
- appendRule(validators, "numeric", validate.numeric);
607
- appendRule(validators, "integer", validate.integer);
608
- appendRule(validators, "alphanumeric", validate.alphanumeric);
609
- if (typeof validate.custom === "function") validators.push(validate.custom);
610
- if (validators.length === 0) return passing;
611
- return composeValidators(...validators);
612
- }
613
- /** The always-passing {@link Validator} — the resolved validator when no rules were supplied. */
614
- function passing(_input) {
615
- return true;
616
- }
617
- /** Normalize a select choice input into a full {@link PromptChoice} (a bare string becomes both name and value). */
618
- function normalizeChoice(choice) {
619
- return isString(choice) ? {
620
- name: choice,
621
- value: choice
622
- } : choice;
623
- }
624
- /** Normalize a checkbox choice input into a full {@link CheckboxChoice} (a bare string becomes both name and value). */
625
- function normalizeCheckboxChoice(choice) {
626
- return isString(choice) ? {
627
- name: choice,
628
- value: choice
629
- } : choice;
630
- }
631
- /**
632
413
  * Build a complete {@link PromptTheme} by merging a partial one over
633
414
  * {@link DEFAULT_PROMPT_THEME}, leaf by leaf — each supplied icon replaces that glyph, each
634
415
  * supplied role replaces that {@link Style}, and everything else keeps its default. Each supplied
@@ -664,11 +445,11 @@ function createPromptTheme(options) {
664
445
  });
665
446
  }
666
447
  /**
667
- * Sanitize text for one single-line display slot. Composes console's {@link stripControls} pass
668
- * with removal of tab, line feed, and carriage return, so no C0 control or DEL survives.
448
+ * Sanitize text for one single-line display slot. Composes console's ANSI {@link strip} and C0
449
+ * {@link stripControls} passes with removal of tab, line feed, and carriage return.
669
450
  *
670
451
  * @param text - The text to sanitize for a glyph or hint slot
671
- * @returns The text with every C0 control character and DEL removed
452
+ * @returns The text with ANSI sequences, every C0 control character, and DEL removed
672
453
  *
673
454
  * @example
674
455
  * ```ts
@@ -676,7 +457,126 @@ function createPromptTheme(options) {
676
457
  * ```
677
458
  */
678
459
  function sanitizeDisplayText(text) {
679
- return stripControls(text).replaceAll(" ", "").replaceAll("\n", "").replaceAll("\r", "");
460
+ return stripControls(strip(text)).replaceAll(" ", "").replaceAll("\n", "").replaceAll("\r", "");
461
+ }
462
+ /**
463
+ * Sanitize every terminal-readable string in a parsed form schema.
464
+ *
465
+ * @remarks
466
+ * Display strings pass through {@link sanitizeDisplayText}: labels, help, placeholders, masks,
467
+ * choice labels and help, file accept entries, and pattern sources. Identity and answer strings
468
+ * stay verbatim: schema, group, and field names, group references, choice values, and defaults.
469
+ * Rewriting those would sever the rendering copy from the authoritative form. Field metadata is
470
+ * removed because terminal neither renders nor interprets it. Pattern sources are sanitized as
471
+ * text only and are never compiled or executed here.
472
+ *
473
+ * @param schema - A schema already accepted by the Form package's `parseForm`
474
+ * @returns A new schema with terminal-readable strings sanitized and field metadata omitted
475
+ *
476
+ * @example
477
+ * ```ts
478
+ * sanitizeSchema({ fields: [{ control: 'text', name: 'na\u001bme', label: 'N\u0000ame' }] })
479
+ * // { fields: [{ control: 'text', name: 'na\u001bme', label: 'Name' }] }
480
+ * ```
481
+ */
482
+ function sanitizeSchema(schema) {
483
+ const groups = schema.groups?.map((group) => ({
484
+ name: group.name,
485
+ label: sanitizeDisplayText(group.label),
486
+ ...group.help !== void 0 ? { help: sanitizeDisplayText(group.help) } : {}
487
+ }));
488
+ const fields = [];
489
+ for (const source of schema.fields) {
490
+ const { meta: _meta, ...field } = source;
491
+ const rule = field.rule === void 0 ? void 0 : {
492
+ ...field.rule,
493
+ ...field.rule.pattern !== void 0 ? { pattern: sanitizeDisplayText(field.rule.pattern) } : {}
494
+ };
495
+ const shared = {
496
+ name: field.name,
497
+ ...field.label !== void 0 ? { label: sanitizeDisplayText(field.label) } : {},
498
+ ...field.help !== void 0 ? { help: sanitizeDisplayText(field.help) } : {},
499
+ ...field.group !== void 0 ? { group: field.group } : {},
500
+ ...rule !== void 0 ? { rule } : {}
501
+ };
502
+ switch (field.control) {
503
+ case "text":
504
+ case "editor":
505
+ fields.push({
506
+ ...field,
507
+ ...shared,
508
+ ...field.placeholder !== void 0 ? { placeholder: sanitizeDisplayText(field.placeholder) } : {}
509
+ });
510
+ break;
511
+ case "password":
512
+ fields.push({
513
+ ...field,
514
+ ...shared,
515
+ ...field.mask !== void 0 ? { mask: sanitizeDisplayText(field.mask) } : {}
516
+ });
517
+ break;
518
+ case "number":
519
+ fields.push({
520
+ ...field,
521
+ ...shared,
522
+ ...field.placeholder !== void 0 ? { placeholder: sanitizeDisplayText(field.placeholder) } : {}
523
+ });
524
+ break;
525
+ case "date":
526
+ case "time":
527
+ case "datetime":
528
+ case "color":
529
+ fields.push({
530
+ ...field,
531
+ ...shared
532
+ });
533
+ break;
534
+ case "confirm":
535
+ fields.push({
536
+ ...field,
537
+ ...shared
538
+ });
539
+ break;
540
+ case "select":
541
+ fields.push({
542
+ ...field,
543
+ ...shared,
544
+ choices: field.choices.map((choice) => ({
545
+ ...choice,
546
+ value: choice.value,
547
+ label: sanitizeDisplayText(choice.label),
548
+ ...choice.help !== void 0 ? { help: sanitizeDisplayText(choice.help) } : {}
549
+ }))
550
+ });
551
+ break;
552
+ case "checkbox":
553
+ fields.push({
554
+ ...field,
555
+ ...shared,
556
+ choices: field.choices.map((choice) => ({
557
+ ...choice,
558
+ value: choice.value,
559
+ label: sanitizeDisplayText(choice.label),
560
+ ...choice.help !== void 0 ? { help: sanitizeDisplayText(choice.help) } : {}
561
+ }))
562
+ });
563
+ break;
564
+ case "file":
565
+ fields.push({
566
+ ...field,
567
+ ...shared,
568
+ ...field.accept !== void 0 ? { accept: field.accept.map(sanitizeDisplayText) } : {}
569
+ });
570
+ break;
571
+ }
572
+ }
573
+ return {
574
+ ...schema.name !== void 0 ? { name: schema.name } : {},
575
+ ...schema.label !== void 0 ? { label: sanitizeDisplayText(schema.label) } : {},
576
+ ...schema.help !== void 0 ? { help: sanitizeDisplayText(schema.help) } : {},
577
+ ...groups !== void 0 ? { groups } : {},
578
+ fields
579
+ };
680
580
  }
681
581
  /**
682
582
  * Sanitize every glyph a wire-supplied {@link PromptThemeOptions} carries for a single-line display
@@ -718,32 +618,38 @@ function hintedHeader(styler, theme, message, hint) {
718
618
  function submitHeader(styler, theme, message) {
719
619
  return `${styler.render(theme.roles.success, theme.icons.success)} ${styler.render(theme.roles.message, message)}`;
720
620
  }
721
- /** The styled error line (`✖ message`) — appended beneath a prompt view when the last submit failed validation, themed by the `error` role. */
621
+ /** The styled error line (`✖ message`) a form driver appends for a field failure. */
722
622
  function errorLine(styler, theme, message) {
723
623
  return `${styler.render(theme.roles.error, theme.icons.error)} ${styler.render(theme.roles.error, message)}`;
724
624
  }
725
- /** Build the initial {@link InputState} from {@link InputOptions} — resolving the validator + styler + theme, seeding an empty value. */
726
- function createInputState(options) {
625
+ /**
626
+ * Build the initial text-field key state.
627
+ *
628
+ * @param field - The text field to render
629
+ * @param styler - The styler used to render the view
630
+ * @param theme - The optional terminal theme
631
+ * @returns The initial immutable key state
632
+ */
633
+ function createInputState(field, styler = createStyler(), theme) {
727
634
  return {
728
- message: options.message,
729
- default: options.default ?? "",
730
- validator: resolveValidation(options.validate),
731
- styler: options.styler ?? createStyler(),
732
- theme: createPromptTheme(options.theme),
635
+ message: sanitizeDisplayText(field.label ?? field.name),
636
+ default: field.default ?? "",
637
+ styler,
638
+ theme: createPromptTheme(theme),
733
639
  value: ""
734
640
  };
735
641
  }
736
- /** Render an {@link InputState} as a styled view — the header, the typed value painted by the `content` role (or the default painted as a hint), and any error. */
642
+ /** Render a text-field key state as a styled view. */
737
643
  function inputView(state) {
738
- const shown = state.value.length > 0 ? state.styler.render(state.theme.roles.content, state.value) : state.styler.render(state.theme.roles.hint, state.default);
739
- const head = `${promptHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer)} ${shown}`;
740
- return state.error === void 0 ? head : `${head}\n${errorLine(state.styler, state.theme, state.error)}`;
644
+ const content = state.value.length > 0 ? state.value : state.default;
645
+ const role = state.value.length > 0 ? state.theme.roles.content : state.theme.roles.hint;
646
+ const shown = state.styler.render(role, sanitizeDisplayText(content));
647
+ return `${promptHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer)} ${shown}`;
741
648
  }
742
649
  /**
743
650
  * Advance an input prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
744
651
  * reducer. Printable characters extend the value; backspace shrinks it; ctrl-u clears it; ctrl-c
745
- * cancels; return submits (the empty line falls back to the default) through the validator — an
746
- * invalid submit stays active with the error in the view.
652
+ * cancels; return produces the candidate value, with an empty line falling back to the default.
747
653
  */
748
654
  function inputReduce(state, key) {
749
655
  if (key.ctrl && key.name === "c") return {
@@ -751,27 +657,14 @@ function inputReduce(state, key) {
751
657
  view: inputView(state),
752
658
  status: "cancel"
753
659
  };
754
- const { error: _error, ...clear } = state;
755
660
  if (key.name === "return") {
756
661
  const answer = state.value.length > 0 ? state.value : state.default;
757
- const result = state.validator(answer);
758
- if (result !== true) {
759
- const next = {
760
- ...state,
761
- error: result
762
- };
763
- return {
764
- state: next,
765
- view: inputView(next),
766
- status: "active"
767
- };
768
- }
769
662
  return {
770
663
  state: {
771
- ...clear,
664
+ ...state,
772
665
  value: answer
773
666
  },
774
- view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, answer)}`,
667
+ view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, sanitizeDisplayText(answer))}`,
775
668
  status: "submit",
776
669
  value: answer
777
670
  };
@@ -783,7 +676,7 @@ function inputReduce(state, key) {
783
676
  status: "active"
784
677
  };
785
678
  const next = {
786
- ...clear,
679
+ ...state,
787
680
  value
788
681
  };
789
682
  return {
@@ -792,28 +685,32 @@ function inputReduce(state, key) {
792
685
  status: "active"
793
686
  };
794
687
  }
795
- /** Build the initial {@link PasswordState} from {@link PasswordOptions} — resolving the validator + styler + theme + mask. */
796
- function createPasswordState(options) {
688
+ /**
689
+ * Build the initial password-field key state.
690
+ *
691
+ * @param field - The password field to render
692
+ * @param styler - The styler used to render the view
693
+ * @param theme - The optional terminal theme
694
+ * @returns The initial immutable key state
695
+ */
696
+ function createPasswordState(field, styler = createStyler(), theme) {
797
697
  return {
798
- message: options.message,
799
- mask: options.mask ?? "*",
800
- validator: resolveValidation(options.validate),
801
- styler: options.styler ?? createStyler(),
802
- theme: createPromptTheme(options.theme),
698
+ message: sanitizeDisplayText(field.label ?? field.name),
699
+ mask: sanitizeDisplayText(field.mask ?? "*"),
700
+ styler,
701
+ theme: createPromptTheme(theme),
803
702
  value: ""
804
703
  };
805
704
  }
806
- /** Render a {@link PasswordState} as a styled view — the header, the value masked to `mask` repeated and painted by the `content` role, and any error. */
705
+ /** Render a password-field key state as a styled view. */
807
706
  function passwordView(state) {
808
707
  const masked = state.styler.render(state.theme.roles.content, state.mask.repeat(state.value.length));
809
- const head = `${promptHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer)} ${masked}`;
810
- return state.error === void 0 ? head : `${head}\n${errorLine(state.styler, state.theme, state.error)}`;
708
+ return `${promptHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer)} ${masked}`;
811
709
  }
812
710
  /**
813
711
  * Advance a password prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
814
712
  * reducer. Identical line-editing to {@link inputReduce} (printable extends, backspace shrinks,
815
- * ctrl-u clears, ctrl-c cancels) but the view masks the value; return submits through the
816
- * validator (no default fallback — a password has no echoed default).
713
+ * ctrl-u clears, ctrl-c cancels) but the view masks the value. Return produces the candidate value.
817
714
  */
818
715
  function passwordReduce(state, key) {
819
716
  if (key.ctrl && key.name === "c") return {
@@ -821,27 +718,12 @@ function passwordReduce(state, key) {
821
718
  view: passwordView(state),
822
719
  status: "cancel"
823
720
  };
824
- const { error: _error, ...clear } = state;
825
- if (key.name === "return") {
826
- const result = state.validator(state.value);
827
- if (result !== true) {
828
- const next = {
829
- ...state,
830
- error: result
831
- };
832
- return {
833
- state: next,
834
- view: passwordView(next),
835
- status: "active"
836
- };
837
- }
838
- return {
839
- state: clear,
840
- view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, state.mask.repeat(state.value.length))}`,
841
- status: "submit",
842
- value: state.value
843
- };
844
- }
721
+ if (key.name === "return") return {
722
+ state,
723
+ view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, state.mask.repeat(state.value.length))}`,
724
+ status: "submit",
725
+ value: state.value
726
+ };
845
727
  const value = editLine(state.value, key);
846
728
  if (value === void 0) return {
847
729
  state,
@@ -849,7 +731,7 @@ function passwordReduce(state, key) {
849
731
  status: "active"
850
732
  };
851
733
  const next = {
852
- ...clear,
734
+ ...state,
853
735
  value
854
736
  };
855
737
  return {
@@ -858,23 +740,26 @@ function passwordReduce(state, key) {
858
740
  status: "active"
859
741
  };
860
742
  }
861
- /** Build the initial {@link ConfirmState} from {@link ConfirmOptions} — defaulting the answer to `false`, resolving the styler + theme, carrying any hint override. */
862
- function createConfirmState(options) {
743
+ /**
744
+ * Build the initial confirm-field key state.
745
+ *
746
+ * @param field - The confirm field to render
747
+ * @param styler - The styler used to render the view
748
+ * @param theme - The optional terminal theme
749
+ * @returns The initial immutable key state
750
+ */
751
+ function createConfirmState(field, styler = createStyler(), theme) {
863
752
  return {
864
- message: options.message,
865
- default: options.default ?? false,
866
- ...options.hint !== void 0 ? { hint: options.hint } : {},
867
- styler: options.styler ?? createStyler(),
868
- theme: createPromptTheme(options.theme)
753
+ message: sanitizeDisplayText(field.label ?? field.name),
754
+ default: field.default ?? false,
755
+ styler,
756
+ theme: createPromptTheme(theme)
869
757
  };
870
758
  }
871
759
  /**
872
- * Render a {@link ConfirmState} as a styled view — the header plus the `(Y/n)` group with the
873
- * default letter painted by the `selected` role. A supplied `hint` replaces that whole group,
874
- * parentheses included, painted by the `hint` role.
760
+ * Render a confirm-field key state as a styled view. The selected role paints the default letter.
875
761
  */
876
762
  function confirmView(state) {
877
- if (state.hint !== void 0) return hintedHeader(state.styler, state.theme, state.message, state.hint);
878
763
  const head = promptHeader(state.styler, state.theme, state.message);
879
764
  const answer = state.default ? `${state.styler.render(state.theme.roles.selected, "Y")}${state.styler.render(state.theme.roles.hint, "/n")}` : `${state.styler.render(state.theme.roles.hint, "y/")}${state.styler.render(state.theme.roles.selected, "N")}`;
880
765
  return `${head} ${state.styler.render(state.theme.roles.hint, "(")}${answer}${state.styler.render(state.theme.roles.hint, ")")}`;
@@ -907,26 +792,32 @@ function confirmReduce(state, key) {
907
792
  value: answer
908
793
  };
909
794
  }
910
- /** Build the initial {@link SelectState} from {@link SelectOptions} — normalizing choices, resolving the styler + theme, carrying any hint, and pre-focusing the default. */
911
- function createSelectState(options) {
912
- const choices = options.choices.map(normalizeChoice);
913
- const index = choices.findIndex((choice) => choice.value === options.default);
795
+ /**
796
+ * Build the initial select-field key state.
797
+ *
798
+ * @param field - The select field to render
799
+ * @param styler - The styler used to render the view
800
+ * @param theme - The optional terminal theme
801
+ * @returns The initial immutable key state
802
+ */
803
+ function createSelectState(field, styler = createStyler(), theme) {
804
+ const choices = [...field.choices];
805
+ const index = choices.findIndex((choice) => choice.value === field.default);
914
806
  return {
915
- message: options.message,
807
+ message: sanitizeDisplayText(field.label ?? field.name),
916
808
  choices,
917
- ...options.hint !== void 0 ? { hint: options.hint } : {},
918
- styler: options.styler ?? createStyler(),
919
- theme: createPromptTheme(options.theme),
809
+ styler,
810
+ theme: createPromptTheme(theme),
920
811
  focused: index >= 0 ? index : 0
921
812
  };
922
813
  }
923
- /** Render a {@link SelectState} as a MULTI-LINE styled view — the header (plus any hint) followed by one row per choice, the focused row marked. */
814
+ /** Render a select-field key state as a multi-line styled view. */
924
815
  function selectView(state) {
925
816
  const lines = state.choices.map((choice, index) => {
926
817
  const active = index === state.focused;
927
- return `${active ? state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer) : " "} ${active ? state.styler.render(state.theme.roles.selected, state.theme.icons.selected) : state.styler.render(state.theme.roles.muted, state.theme.icons.dot)} ${active ? state.styler.render(state.theme.roles.focus, choice.name) : state.styler.render(state.theme.roles.content, choice.name)}${choice.description === void 0 ? "" : ` ${state.styler.render(state.theme.roles.description, choice.description)}`}`;
818
+ return `${active ? state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer) : " "} ${active ? state.styler.render(state.theme.roles.selected, state.theme.icons.selected) : state.styler.render(state.theme.roles.muted, state.theme.icons.dot)} ${active ? state.styler.render(state.theme.roles.focus, choice.label) : state.styler.render(state.theme.roles.content, choice.label)}${choice.help === void 0 ? "" : ` ${state.styler.render(state.theme.roles.description, choice.help)}`}`;
928
819
  });
929
- return [hintedHeader(state.styler, state.theme, state.message, state.hint), ...lines].join("\n");
820
+ return [promptHeader(state.styler, state.theme, state.message), ...lines].join("\n");
930
821
  }
931
822
  /**
932
823
  * Advance a select prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
@@ -973,7 +864,7 @@ function selectReduce(state, key) {
973
864
  const value = choice?.value ?? "";
974
865
  return {
975
866
  state,
976
- view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, choice?.name ?? "")}`,
867
+ view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, choice?.label ?? "")}`,
977
868
  status: "submit",
978
869
  value
979
870
  };
@@ -984,46 +875,48 @@ function selectReduce(state, key) {
984
875
  status: "active"
985
876
  };
986
877
  }
987
- /** Build the initial {@link CheckboxState} from {@link CheckboxOptions} — normalizing choices, resolving the styler + theme, carrying any hint, seeding the checked set, carrying min/max. */
988
- function createCheckboxState(options) {
989
- const choices = options.choices.map(normalizeCheckboxChoice);
878
+ /**
879
+ * Build the initial checkbox-field key state.
880
+ *
881
+ * @param field - The checkbox field to render
882
+ * @param styler - The styler used to render the view
883
+ * @param theme - The optional terminal theme
884
+ * @returns The initial immutable key state
885
+ */
886
+ function createCheckboxState(field, styler = createStyler(), theme) {
887
+ const choices = [...field.choices];
990
888
  const checked = choices.reduce((indices, choice, index) => {
991
- if (choice.checked === true) indices.push(index);
889
+ if (field.default?.includes(choice.value) === true) indices.push(index);
992
890
  return indices;
993
891
  }, []);
994
892
  return {
995
- message: options.message,
893
+ message: sanitizeDisplayText(field.label ?? field.name),
996
894
  choices,
997
- ...options.hint !== void 0 ? { hint: options.hint } : {},
998
- styler: options.styler ?? createStyler(),
999
- theme: createPromptTheme(options.theme),
895
+ styler,
896
+ theme: createPromptTheme(theme),
1000
897
  focused: 0,
1001
- checked,
1002
- ...options.min !== void 0 ? { min: options.min } : {},
1003
- ...options.max !== void 0 ? { max: options.max } : {}
898
+ checked
1004
899
  };
1005
900
  }
1006
- /** Render a {@link CheckboxState} as a MULTI-LINE styled view — the header (plus any hint), one box per choice (focused + checked marked), a count, and any error. */
901
+ /** Render a checkbox-field key state as a multi-line styled view. */
1007
902
  function checkboxView(state) {
1008
903
  const lines = state.choices.map((choice, index) => {
1009
904
  const active = index === state.focused;
1010
905
  const ticked = state.checked.includes(index);
1011
- return `${active ? state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer) : " "} ${ticked ? state.styler.render(state.theme.roles.selected, state.theme.icons.checked) : state.styler.render(state.theme.roles.muted, state.theme.icons.unchecked)} ${active ? state.styler.render(state.theme.roles.focus, choice.name) : state.styler.render(state.theme.roles.content, choice.name)}${choice.description === void 0 ? "" : ` ${state.styler.render(state.theme.roles.description, choice.description)}`}`;
906
+ return `${active ? state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer) : " "} ${ticked ? state.styler.render(state.theme.roles.selected, state.theme.icons.checked) : state.styler.render(state.theme.roles.muted, state.theme.icons.unchecked)} ${active ? state.styler.render(state.theme.roles.focus, choice.label) : state.styler.render(state.theme.roles.content, choice.label)}${choice.help === void 0 ? "" : ` ${state.styler.render(state.theme.roles.description, choice.help)}`}`;
1012
907
  });
1013
908
  const summary = state.styler.render(state.theme.roles.hint, `${state.checked.length} selected`);
1014
- const body = [
1015
- hintedHeader(state.styler, state.theme, state.message, state.hint),
909
+ return [
910
+ promptHeader(state.styler, state.theme, state.message),
1016
911
  ...lines,
1017
912
  summary
1018
913
  ].join("\n");
1019
- return state.error === void 0 ? body : `${body}\n${errorLine(state.styler, state.theme, state.error)}`;
1020
914
  }
1021
915
  /**
1022
916
  * Advance a checkbox prompt by one {@link KeyEvent} — the pure
1023
917
  * `(state, key) → PromptStep<readonly string[]>` reducer. `up` / `down` (and `k` / `j`) move the
1024
918
  * focus (wrapping); `space` toggles the focused index in the checked set; return submits the
1025
- * checked values in CHOICE order — gated by `min` / `max` (an out-of-range count stays active with
1026
- * the reason in the view); ctrl-c cancels.
919
+ * checked values in choice order; ctrl-c cancels. The form applies selection-count rules.
1027
920
  */
1028
921
  function checkboxReduce(state, key) {
1029
922
  if (key.ctrl && key.name === "c") return {
@@ -1032,10 +925,9 @@ function checkboxReduce(state, key) {
1032
925
  status: "cancel"
1033
926
  };
1034
927
  const count = state.choices.length;
1035
- const { error: _error, ...clear } = state;
1036
928
  if ((key.name === "up" || key.name === "k") && count > 0) {
1037
929
  const next = {
1038
- ...clear,
930
+ ...state,
1039
931
  focused: (state.focused - 1 + count) % count
1040
932
  };
1041
933
  return {
@@ -1046,7 +938,7 @@ function checkboxReduce(state, key) {
1046
938
  }
1047
939
  if ((key.name === "down" || key.name === "j") && count > 0) {
1048
940
  const next = {
1049
- ...clear,
941
+ ...state,
1050
942
  focused: (state.focused + 1) % count
1051
943
  };
1052
944
  return {
@@ -1058,7 +950,7 @@ function checkboxReduce(state, key) {
1058
950
  if (key.name === "space" && count > 0) {
1059
951
  const checked = toggleIndex(state.checked, state.focused);
1060
952
  const next = {
1061
- ...clear,
953
+ ...state,
1062
954
  checked
1063
955
  };
1064
956
  return {
@@ -1068,23 +960,11 @@ function checkboxReduce(state, key) {
1068
960
  };
1069
961
  }
1070
962
  if (key.name === "return") {
1071
- const error = gateSelection(state.checked.length, state.min, state.max);
1072
- if (error !== void 0) {
1073
- const next = {
1074
- ...state,
1075
- error
1076
- };
1077
- return {
1078
- state: next,
1079
- view: checkboxView(next),
1080
- status: "active"
1081
- };
1082
- }
1083
963
  const ordered = [...state.checked].sort((a, b) => a - b);
1084
964
  const values = ordered.map((index) => state.choices[index]?.value).filter((value) => value !== void 0);
1085
- const summary = ordered.map((index) => state.choices[index]?.name).filter((name) => name !== void 0).join(", ");
965
+ const summary = ordered.map((index) => state.choices[index]?.label).filter((name) => name !== void 0).join(", ");
1086
966
  return {
1087
- state: clear,
967
+ state,
1088
968
  view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, summary)}`,
1089
969
  status: "submit",
1090
970
  value: values
@@ -1100,41 +980,37 @@ function checkboxReduce(state, key) {
1100
980
  function toggleIndex(indices, index) {
1101
981
  return indices.includes(index) ? indices.filter((i) => i !== index) : [...indices, index];
1102
982
  }
1103
- /** The min/max gate for a checkbox submit — the rejection message when `count` is out of range, else `undefined`. */
1104
- function gateSelection(count, min, max) {
1105
- if (min !== void 0 && count < min) return `Select at least ${String(min)} option${min === 1 ? "" : "s"}`;
1106
- if (max !== void 0 && count > max) return `Select no more than ${String(max)} option${max === 1 ? "" : "s"}`;
1107
- }
1108
- /** Build the initial {@link EditorState} from {@link EditorOptions} — resolving the validator + styler + theme, carrying any hint override, seeding empty lines. */
1109
- function createEditorState(options) {
983
+ /**
984
+ * Build the initial editor-field key state.
985
+ *
986
+ * @param field - The editor field to render
987
+ * @param styler - The styler used to render the view
988
+ * @param theme - The optional terminal theme
989
+ * @returns The initial immutable key state
990
+ */
991
+ function createEditorState(field, styler = createStyler(), theme) {
1110
992
  return {
1111
- message: options.message,
1112
- default: options.default ?? "",
1113
- ...options.hint !== void 0 ? { hint: options.hint } : {},
1114
- validator: resolveValidation(options.validate),
1115
- styler: options.styler ?? createStyler(),
1116
- theme: createPromptTheme(options.theme),
993
+ message: sanitizeDisplayText(field.label ?? field.name),
994
+ default: field.default ?? "",
995
+ styler,
996
+ theme: createPromptTheme(theme),
1117
997
  lines: [],
1118
998
  current: ""
1119
999
  };
1120
1000
  }
1121
1001
  /**
1122
- * Render an {@link EditorState} as a MULTI-LINE styled view — the header with its finish hint, the
1123
- * committed and in-progress lines painted by the `content` role, and any error. A supplied `hint`
1124
- * replaces `(Ctrl+D to finish)` in full; the finish key stays ctrl-d.
1002
+ * Render an editor-field key state as a multi-line styled view with its finish hint.
1125
1003
  */
1126
1004
  function editorView(state) {
1127
- const head = hintedHeader(state.styler, state.theme, state.message, state.hint ?? "(Ctrl+D to finish)");
1005
+ const head = hintedHeader(state.styler, state.theme, state.message, "(Ctrl+D to finish)");
1128
1006
  const pointer = state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer);
1129
- const view = [head, ...[...state.lines.map((line) => state.styler.render(state.theme.roles.content, line)), `${pointer} ${state.styler.render(state.theme.roles.content, state.current)}`]].join("\n");
1130
- return state.error === void 0 ? view : `${view}\n${errorLine(state.styler, state.theme, state.error)}`;
1007
+ return [head, ...[...state.lines.map((line) => state.styler.render(state.theme.roles.content, line)), `${pointer} ${state.styler.render(state.theme.roles.content, state.current)}`]].join("\n");
1131
1008
  }
1132
1009
  /**
1133
1010
  * Advance an editor prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
1134
1011
  * reducer. Printable characters extend the current line; backspace shrinks it; return commits the
1135
1012
  * current line and starts a fresh one; ctrl-d FINISHES (joining all lines, falling back to the
1136
- * default when empty) through the validator; ctrl-c cancels. An invalid finish stays active with
1137
- * the error.
1013
+ * default when empty); ctrl-c cancels. The form validates the candidate after the driver fills it.
1138
1014
  */
1139
1015
  function editorReduce(state, key) {
1140
1016
  if (key.ctrl && key.name === "c") return {
@@ -1142,25 +1018,12 @@ function editorReduce(state, key) {
1142
1018
  view: editorView(state),
1143
1019
  status: "cancel"
1144
1020
  };
1145
- const { error: _error, ...clear } = state;
1146
1021
  if (key.ctrl && key.name === "d") {
1147
1022
  const lines = state.current.length > 0 ? [...state.lines, state.current] : state.lines;
1148
1023
  const joined = lines.join("\n");
1149
1024
  const answer = joined.length > 0 ? joined : state.default;
1150
- const result = state.validator(answer);
1151
- if (result !== true) {
1152
- const next = {
1153
- ...state,
1154
- error: result
1155
- };
1156
- return {
1157
- state: next,
1158
- view: editorView(next),
1159
- status: "active"
1160
- };
1161
- }
1162
1025
  return {
1163
- state: clear,
1026
+ state,
1164
1027
  view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, `${String(lines.length)} line${lines.length === 1 ? "" : "s"}`)}`,
1165
1028
  status: "submit",
1166
1029
  value: answer
@@ -1168,7 +1031,7 @@ function editorReduce(state, key) {
1168
1031
  }
1169
1032
  if (key.name === "return") {
1170
1033
  const next = {
1171
- ...clear,
1034
+ ...state,
1172
1035
  lines: [...state.lines, state.current],
1173
1036
  current: ""
1174
1037
  };
@@ -1185,7 +1048,7 @@ function editorReduce(state, key) {
1185
1048
  status: "active"
1186
1049
  };
1187
1050
  const next = {
1188
- ...clear,
1051
+ ...state,
1189
1052
  current
1190
1053
  };
1191
1054
  return {
@@ -1207,227 +1070,6 @@ function editLine(value, key) {
1207
1070
  if (!key.ctrl && !key.meta && [...key.name].length === 1 && isPrintable(key.name)) return `${value}${key.sequence}`;
1208
1071
  }
1209
1072
  /**
1210
- * Produce the WIRE-SAFE form of a prompt's options — the {@link PendingPrompt.options} a broker
1211
- * serializes over SSE. Drops everything that cannot cross the wire (a `styler`, and any
1212
- * function-valued `validate` rule), while KEEPING the declarative data a remote client needs to
1213
- * reconstruct the prompt: the {@link ValidationRules} as data, plus `message` / `choices` /
1214
- * `default` / `mask` / `min` / `max` / `hint` / `theme`. This is WHY T-a's validation is
1215
- * rules-as-data, and why a theme is data too: a `Validator`
1216
- * FUNCTION can't be serialized, but its declarative rules can — the client rebuilds the validator
1217
- * from them via {@link resolveValidation}.
1218
- *
1219
- * @remarks
1220
- * - **Functions are dropped.** A top-level function value (e.g. a bare-`Validator` `validate`) is
1221
- * omitted entirely; the `styler` (a fluent function-bearing object) is dropped by key.
1222
- * - **`validate` rules are flattened.** A {@link ValidationRules} bag is copied with each
1223
- * function rule replaced by `true` (the rule's INTENT survives as the built-in check; its
1224
- * custom function does not). A bare-function `validate` is dropped (no declarative form to keep).
1225
- * - **`choices` are function-stripped.** Each choice keeps its plain fields (`name` / `value` /
1226
- * `description` / `checked`); a bare string passes through.
1227
- *
1228
- * @param options - The raw prompt options bag (may hold functions / a styler)
1229
- * @returns A JSON-safe options record — only serializable, declarative data
1230
- */
1231
- function serializePromptOptions(options) {
1232
- const result = {};
1233
- for (const [key, value] of Object.entries(options)) {
1234
- if (key === "styler" || typeof value === "function") continue;
1235
- if (key === "validate") {
1236
- const rules = serializeValidationRules(value);
1237
- if (rules !== void 0) result[key] = rules;
1238
- continue;
1239
- }
1240
- if (key === "choices") {
1241
- result[key] = serializeChoices(value);
1242
- continue;
1243
- }
1244
- result[key] = value;
1245
- }
1246
- return result;
1247
- }
1248
- /**
1249
- * Flatten a `validate` option to its wire-safe {@link ValidationRules} DATA — a function rule
1250
- * becomes `true` (its built-in check survives; its body cannot cross the wire). A bare-function
1251
- * `validate` (no rules object) has no declarative form, so it yields `undefined` (dropped).
1252
- */
1253
- function serializeValidationRules(validate) {
1254
- if (!isRecord(validate)) return void 0;
1255
- const rules = {};
1256
- for (const [rule, value] of Object.entries(validate)) rules[rule] = typeof value === "function" ? true : value;
1257
- return rules;
1258
- }
1259
- /** Strip functions from a `choices` option — each choice keeps its plain fields; a bare string passes through. */
1260
- function serializeChoices(choices) {
1261
- if (!Array.isArray(choices)) return [];
1262
- return choices.map((choice) => {
1263
- if (isString(choice)) return choice;
1264
- if (!isRecord(choice)) return choice;
1265
- const normalized = {};
1266
- for (const [key, value] of Object.entries(choice)) if (typeof value !== "function") normalized[key] = value;
1267
- return normalized;
1268
- });
1269
- }
1270
- /**
1271
- * Rebuild a wire-decoded `validate` payload into a {@link ValidationRules} bag — the inverse of
1272
- * {@link serializeValidationRules}. Keeps only the primitive rule values (`boolean` / `number` /
1273
- * `string`) a serialized prompt could carry; an empty / non-record / all-dropped payload yields
1274
- * `undefined` (no rules to apply). The client feeds the result back through {@link resolveValidation}.
1275
- */
1276
- function reconstructValidationRules(value) {
1277
- if (!isRecord(value)) return void 0;
1278
- const rules = {};
1279
- let count = 0;
1280
- for (const [rule, item] of Object.entries(value)) {
1281
- if (rule === "pattern") continue;
1282
- if (isBoolean(item) || isNumber(item) || isString(item)) {
1283
- rules[rule] = item;
1284
- count += 1;
1285
- }
1286
- }
1287
- if (count === 0) return void 0;
1288
- return rules;
1289
- }
1290
- /** Read one option by key, narrowed by `guard` — `undefined` when absent or off-shape (§14, never an `as`). */
1291
- function resolveOption(options, key, guard) {
1292
- const value = options[key];
1293
- return guard(value) ? value : void 0;
1294
- }
1295
- /** Narrow an unknown value to a {@link PromptChoice} — the `recordOf` shape inlined so no non-exported member lingers (§5). */
1296
- function isPromptChoice(value) {
1297
- return recordOf({
1298
- name: isString,
1299
- value: isString,
1300
- description: isString
1301
- }, ["description"])(value);
1302
- }
1303
- /** Narrow an unknown value to a {@link CheckboxChoice} — the `recordOf` shape inlined so no non-exported member lingers (§5). */
1304
- function isCheckboxChoice(value) {
1305
- return recordOf({
1306
- name: isString,
1307
- value: isString,
1308
- description: isString,
1309
- checked: isBoolean
1310
- }, ["description", "checked"])(value);
1311
- }
1312
- /** Read a `choices` option as a list of bare strings / full choices — each element narrowed by `guard`, off-shape elements stringified. */
1313
- function resolveChoices(options, guard) {
1314
- const choices = options.choices;
1315
- if (!Array.isArray(choices)) return [];
1316
- return choices.map((choice) => {
1317
- if (isString(choice)) return choice;
1318
- if (guard(choice)) return choice;
1319
- return String(choice);
1320
- });
1321
- }
1322
- /**
1323
- * Sanitize a list of resolved choices' human-readable labels (`name` + `description`) with
1324
- * {@link stripControls} — shared by the `select` and `checkbox` branches of
1325
- * {@link dispatchPendingPrompt} so a remote-supplied choice can never inject raw control bytes
1326
- * into the local terminal's rendered view.
1327
- *
1328
- * @param choices - The resolved choices (bare strings or full {@link PromptChoice} /
1329
- * {@link CheckboxChoice} objects) as returned by {@link resolveChoices}
1330
- * @returns The same choices with every `name` / `description` control-stripped
1331
- */
1332
- function sanitizeChoiceLabels(choices) {
1333
- return choices.map((choice) => {
1334
- if (isString(choice)) return stripControls(choice);
1335
- return {
1336
- ...choice,
1337
- name: stripControls(choice.name),
1338
- ...choice.description !== void 0 ? { description: stripControls(choice.description) } : {}
1339
- };
1340
- });
1341
- }
1342
- /**
1343
- * Dispatch a {@link PendingPrompt} to the matching {@link PromptFormInterface} method — the bridge
1344
- * step a {@link PromptClient} runs to drive a LOCAL terminal with a prompt issued elsewhere.
1345
- * Reconstructs typed options from the wire-safe {@link PendingPrompt.options} (every field
1346
- * §14-narrowed, never an `as`; the validator rebuilt from rules via {@link reconstructValidationRules}),
1347
- * then calls the matching prompt form and returns its resolved value.
1348
- *
1349
- * @remarks
1350
- * `theme` and `hint` are plain data, so they cross the wire with the rest of the options bag and
1351
- * are reconstructed here for every form that accepts them: the theme is narrowed by
1352
- * {@link import('./validators.js').isPromptThemeOptions} (an off-shape theme is dropped, leaving
1353
- * the defaults), then every glyph and hint is passed through {@link sanitizeDisplayText}. Choice
1354
- * labels use the stream-safe {@link sanitizeChoiceLabels} treatment instead.
1355
- *
1356
- * @param terminal - The local {@link PromptFormInterface} to drive
1357
- * @param pending - The decoded pending prompt to dispatch
1358
- * @returns The prompt's resolved value (a `string` / `boolean` / `readonly string[]` per form)
1359
- */
1360
- function dispatchPendingPrompt(terminal, pending) {
1361
- const options = pending.options;
1362
- const validate = reconstructValidationRules(options.validate);
1363
- const message = stripControls(pending.message);
1364
- const supplied = resolveOption(options, "theme", isPromptThemeOptions);
1365
- const theme = supplied === void 0 ? void 0 : sanitizeThemeIcons(supplied);
1366
- const raw = resolveOption(options, "hint", isString);
1367
- const hint = raw === void 0 ? void 0 : sanitizeDisplayText(raw);
1368
- switch (pending.form) {
1369
- case "input": {
1370
- const value = resolveOption(options, "default", isString);
1371
- return terminal.input({
1372
- message,
1373
- ...value !== void 0 ? { default: stripControls(value) } : {},
1374
- ...validate !== void 0 ? { validate } : {},
1375
- ...theme !== void 0 ? { theme } : {}
1376
- });
1377
- }
1378
- case "password": {
1379
- const value = resolveOption(options, "mask", isString);
1380
- return terminal.password({
1381
- message,
1382
- ...value !== void 0 ? { mask: stripControls(value) } : {},
1383
- ...validate !== void 0 ? { validate } : {},
1384
- ...theme !== void 0 ? { theme } : {}
1385
- });
1386
- }
1387
- case "confirm": {
1388
- const value = resolveOption(options, "default", isBoolean);
1389
- return terminal.confirm({
1390
- message,
1391
- ...value !== void 0 ? { default: value } : {},
1392
- ...hint !== void 0 ? { hint } : {},
1393
- ...theme !== void 0 ? { theme } : {}
1394
- });
1395
- }
1396
- case "select": {
1397
- const value = resolveOption(options, "default", isString);
1398
- return terminal.select({
1399
- message,
1400
- choices: sanitizeChoiceLabels(resolveChoices(options, isPromptChoice)),
1401
- ...value !== void 0 ? { default: stripControls(value) } : {},
1402
- ...hint !== void 0 ? { hint } : {},
1403
- ...theme !== void 0 ? { theme } : {}
1404
- });
1405
- }
1406
- case "checkbox": {
1407
- const min = resolveOption(options, "min", isNumber);
1408
- const max = resolveOption(options, "max", isNumber);
1409
- return terminal.checkbox({
1410
- message,
1411
- choices: sanitizeChoiceLabels(resolveChoices(options, isCheckboxChoice)),
1412
- ...min !== void 0 ? { min } : {},
1413
- ...max !== void 0 ? { max } : {},
1414
- ...hint !== void 0 ? { hint } : {},
1415
- ...theme !== void 0 ? { theme } : {}
1416
- });
1417
- }
1418
- case "editor": {
1419
- const value = resolveOption(options, "default", isString);
1420
- return terminal.editor({
1421
- message,
1422
- ...value !== void 0 ? { default: stripControls(value) } : {},
1423
- ...validate !== void 0 ? { validate } : {},
1424
- ...hint !== void 0 ? { hint } : {},
1425
- ...theme !== void 0 ? { theme } : {}
1426
- });
1427
- }
1428
- }
1429
- }
1430
- /**
1431
1073
  * The default {@link import('./types.js').TimerHandler} — a thin host `setTimeout` / `clearTimeout`
1432
1074
  * wrapper that arms `callback` after `ms` and returns a {@link TimerCancel}. The deadline seam
1433
1075
  * behind both the {@link import('./Prompt.js').Prompt} broker (its expiry) and the
@@ -1478,12 +1120,12 @@ function isInsecureRemote(url) {
1478
1120
  const hostname = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0] ?? "";
1479
1121
  return hostname !== "localhost" && hostname !== "127.0.0.1" && hostname !== "[::1]";
1480
1122
  }
1481
- /** Serialize a parked {@link PendingPrompt} into a {@link WireEvent} — event `'pending'`, `data` the JSON-stringified prompt, `id` the prompt's own id (an SSE bridge sets its frame `id:` from this). */
1482
- function serializePending(prompt) {
1123
+ /** Serialize a parked {@link PendingForm} into a {@link WireEvent}. */
1124
+ function serializePending(form) {
1483
1125
  return {
1484
1126
  event: "pending",
1485
- data: JSON.stringify(prompt),
1486
- id: prompt.id
1127
+ data: JSON.stringify(form),
1128
+ id: form.id
1487
1129
  };
1488
1130
  }
1489
1131
  /** Serialize a parked prompt's expiry into a {@link WireEvent} — event `'expire'`, `data` the JSON-stringified `{ id }` payload. */
@@ -1500,44 +1142,24 @@ function serializeShutdown() {
1500
1142
  data: ""
1501
1143
  };
1502
1144
  }
1503
- /** Narrow an unknown wire payload to an answer POST body — a non-empty `id` string plus a `value` key (of any shape) present. */
1504
- function isAnswerPayload(value) {
1505
- return isRecord(value) && isNonEmptyString(value.id) && "value" in value;
1506
- }
1507
1145
  //#endregion
1508
1146
  //#region src/core/Prompt.ts
1509
1147
  /**
1510
- * The headless prompt BROKER (observable §13) — parks each {@link PromptInterface} call as a
1511
- * pending prompt and returns a Promise that resolves when the prompt is {@link answer}ed (or
1512
- * rejects on timeout / teardown). The tri-surface's headless arm: there is no terminal here, so a
1513
- * transport forwards each `pending` event to whoever can answer, and {@link answer} resolves the
1514
- * parked Promise — for environments where direct user access is unavailable (a headless server on
1515
- * stdio, a browser with no TTY, a prompt issued on one machine answered on another).
1148
+ * The headless form broker. It parks live forms, exposes their serialized schemas, and applies
1149
+ * remote answers to the authoritative form.
1516
1150
  *
1517
1151
  * @remarks
1518
- * - **Park-as-Promise.** Each `input` / `password` / `confirm` / `select` / `checkbox` / `editor`
1519
- * mints an id (`crypto.randomUUID()`), parks a wire-safe {@link PendingPrompt}, emits `pending`,
1520
- * and returns an unresolved Promise. The prompt's options are serialized
1521
- * ({@link serializePromptOptions}) so a transport can forward them as-is.
1522
- * - **Answer validates + type-checks.** {@link answer} runs the prompt's per-form gate: it
1523
- * type-checks `value` to the form (string / boolean / string[]) AND, for the text forms, runs
1524
- * the validator resolved from the original `validate` rules. A rejected answer returns a failure
1525
- * `Result` with error `'rejected'` and the prompt stays `pending`; an accepted answer resolves the
1526
- * Promise, emits `answer`, and removes the prompt.
1527
- * - **Timeout → expire → reject.** An unanswered prompt expires after `timeout` ms (via the
1528
- * INJECTED timer): `expire` fires and the parked Promise rejects with a {@link TerminalError}
1529
- * (`code: 'EXPIRE'`). {@link destroy} expires every still-pending prompt the same way.
1530
- * - **Deterministic.** The timer is injectable ({@link import('./types.js').TimerHandler}); a test
1531
- * supplies one that fires on demand, so expiry is driven without real time.
1152
+ * A parked record carries one call to `serializeForm`. A failed fill or submit leaves the record
1153
+ * parked for another answer. A successful submit settles the form once, emits `answer`, and removes
1154
+ * the record. Timeout and teardown abandon every unsettled form through its own `destroy` method.
1532
1155
  *
1533
1156
  * @example
1534
1157
  * ```ts
1535
- * const prompt = createPrompt({ timeout: 60_000 })
1536
- * prompt.emitter.on('pending', (pending) => broadcast(pending)) // forward to a client
1537
- *
1538
- * const name = await prompt.input({ message: 'Your name' }) // parks; resolves on answer()
1539
- * // ...elsewhere, a client POSTs the answer back:
1540
- * prompt.answer(id, 'Ada') // resolves the awaited input() above
1158
+ * const form = createForm({ fields: [{ control: 'text', name: 'name' }] })
1159
+ * const prompt = createPrompt()
1160
+ * const id = prompt.park(form)
1161
+ * prompt.answer(id, { name: 'Ada' })
1162
+ * await form.answer // { name: 'Ada' }
1541
1163
  * ```
1542
1164
  */
1543
1165
  var Prompt = class {
@@ -1563,211 +1185,164 @@ var Prompt = class {
1563
1185
  return this.#parked.size;
1564
1186
  }
1565
1187
  pending(id) {
1566
- if (id !== void 0) return this.#parked.get(id)?.prompt;
1567
- const result = [];
1568
- for (const parked of this.#parked.values()) result.push(parked.prompt);
1569
- return result;
1570
- }
1571
- answer(id, value) {
1572
- const parked = this.#parked.get(id);
1573
- if (parked === void 0) return {
1574
- success: false,
1575
- error: "unknown"
1576
- };
1577
- const accepted = parked.respond(value);
1578
- if (accepted === void 0) return {
1579
- success: false,
1580
- error: "rejected"
1188
+ if (id !== void 0) return this.#parked.get(id)?.pending;
1189
+ const forms = [];
1190
+ for (const parked of this.#parked.values()) forms.push(parked.pending);
1191
+ return forms;
1192
+ }
1193
+ park(form, request) {
1194
+ if (this.#destroyed) {
1195
+ form.destroy();
1196
+ throw new TerminalError("EXPIRE", "The broker has been destroyed");
1197
+ }
1198
+ if (this.#cap !== void 0 && this.#parked.size >= this.#cap) {
1199
+ form.destroy();
1200
+ throw new TerminalError("LIMIT", `The parked-form cap (${String(this.#cap)}) was reached`, { cap: this.#cap });
1201
+ }
1202
+ const id = crypto.randomUUID();
1203
+ const pending = {
1204
+ id,
1205
+ schema: serializeForm(form.schema),
1206
+ status: "pending",
1207
+ time: Date.now(),
1208
+ ...request?.from !== void 0 ? { from: request.from } : {},
1209
+ ...request?.to !== void 0 ? { to: request.to } : {}
1581
1210
  };
1582
- parked.cancel();
1583
- this.#emitter.emit("answer", id, accepted);
1584
- this.#parked.delete(id);
1211
+ const cancel = this.#timer(() => this.#expire(id), this.#timeout);
1212
+ this.#parked.set(id, {
1213
+ form,
1214
+ pending,
1215
+ cancel
1216
+ });
1217
+ this.#emitter.emit("pending", pending);
1218
+ return id;
1219
+ }
1220
+ answer(id, values) {
1221
+ const outcome = attempt(() => this.#answer(id, values));
1222
+ if (outcome.success) return outcome.value;
1585
1223
  return {
1586
- success: true,
1587
- value: accepted
1224
+ success: false,
1225
+ error: {
1226
+ reason: "rejected",
1227
+ errors: [{
1228
+ field: "form",
1229
+ message: "The form rejected the answer"
1230
+ }]
1231
+ }
1588
1232
  };
1589
1233
  }
1590
- park(request) {
1591
- const gate = this.#gate(request.form, request.options);
1592
- return this.#park(request.form, request.options.message, request.options, gate, request.from, request.to);
1593
- }
1594
- input(options) {
1595
- return this.#park("input", options.message, options, this.#gate("input", options)).value;
1596
- }
1597
- password(options) {
1598
- return this.#park("password", options.message, options, this.#gate("password", options)).value;
1599
- }
1600
- confirm(options) {
1601
- return this.#park("confirm", options.message, options, this.#gate("confirm", options)).value;
1602
- }
1603
- select(options) {
1604
- return this.#park("select", options.message, options, this.#gate("select", options)).value;
1605
- }
1606
- checkbox(options) {
1607
- return this.#park("checkbox", options.message, options, this.#gate("checkbox", options)).value;
1608
- }
1609
- editor(options) {
1610
- return this.#park("editor", options.message, options, this.#gate("editor", options)).value;
1611
- }
1612
1234
  destroy() {
1613
1235
  if (this.#destroyed) return;
1614
1236
  this.#destroyed = true;
1615
1237
  for (const id of [...this.#parked.keys()]) this.#expire(id);
1616
- this.#parked.clear();
1617
1238
  this.#emitter.destroy();
1618
1239
  }
1619
- #park(form, message, options, gate, from, to) {
1620
- if (this.#destroyed) return {
1621
- id: crypto.randomUUID(),
1622
- value: Promise.reject(new TerminalError("EXPIRE", "broker destroyed"))
1623
- };
1624
- if (this.#cap !== void 0 && this.#parked.size >= this.#cap) return {
1625
- id: crypto.randomUUID(),
1626
- value: Promise.reject(new TerminalError("LIMIT", `parked-prompt cap (${this.#cap}) reached`, { cap: this.#cap }))
1627
- };
1628
- const id = crypto.randomUUID();
1629
- const prompt = {
1630
- id,
1631
- form,
1632
- message,
1633
- options: serializePromptOptions(options),
1634
- status: "pending",
1635
- time: Date.now(),
1636
- ...from !== void 0 ? { from } : {},
1637
- ...to !== void 0 ? { to } : {}
1638
- };
1639
- return {
1640
- id,
1641
- value: new Promise((resolve, reject) => {
1642
- const cancel = this.#timer(() => this.#expire(id), this.#timeout);
1643
- const respond = this.#createRespond(id, gate, resolve);
1644
- const expire = this.#createExpire(id, reject);
1645
- this.#parked.set(id, {
1646
- prompt,
1647
- respond,
1648
- expire,
1649
- cancel
1650
- });
1651
- this.#emitter.emit("pending", prompt);
1652
- })
1653
- };
1654
- }
1655
- #createRespond(id, gate, resolve) {
1656
- return (answer) => {
1657
- const accepted = gate(answer);
1658
- if (accepted === void 0) return void 0;
1659
- const current = this.#parked.get(id);
1660
- if (current !== void 0) this.#parked.set(id, {
1661
- ...current,
1662
- prompt: {
1663
- ...current.prompt,
1664
- status: "answered"
1665
- }
1666
- });
1667
- resolve(accepted);
1668
- return accepted;
1240
+ #answer(id, values) {
1241
+ const parked = this.#parked.get(id);
1242
+ if (parked === void 0 || parked.pending.status !== "pending") return {
1243
+ success: false,
1244
+ error: { reason: "unknown" }
1669
1245
  };
1670
- }
1671
- #createExpire(id, reject) {
1672
- return () => reject(new TerminalError("EXPIRE", `prompt ${id} expired`, { id }));
1673
- }
1674
- #gate(form, options) {
1675
- switch (form) {
1676
- case "input":
1677
- case "password":
1678
- case "editor": {
1679
- if (!this.#isTextOptions(form, options)) return () => void 0;
1680
- const validator = resolveValidation(options.validate);
1681
- return (value) => isString(value) && validator(value) === true ? value : void 0;
1246
+ const outcome = attempt(() => this.#submit(parked.form, values));
1247
+ if (!outcome.success) return {
1248
+ success: false,
1249
+ error: {
1250
+ reason: "rejected",
1251
+ errors: this.#errors(outcome.error, parked.form)
1682
1252
  }
1683
- case "confirm":
1684
- if (!this.#isConfirmOptions(form, options)) return () => void 0;
1685
- return (value) => isBoolean(value) ? value : void 0;
1686
- case "select": {
1687
- if (!this.#isSelectOptions(form, options)) return () => void 0;
1688
- const values = new Set(options.choices.map(normalizeChoice).map((choice) => choice.value));
1689
- return (value) => isString(value) && values.has(value) ? value : void 0;
1253
+ };
1254
+ if (!outcome.value.success) return {
1255
+ success: false,
1256
+ error: {
1257
+ reason: "rejected",
1258
+ errors: outcome.value.error
1690
1259
  }
1691
- case "checkbox": {
1692
- if (!this.#isCheckboxOptions(form, options)) return () => void 0;
1693
- const values = new Set(options.choices.map(normalizeCheckboxChoice).map((choice) => choice.value));
1694
- const { min, max } = options;
1695
- return (value) => {
1696
- if (!isArray(value) || !value.every(isString)) return void 0;
1697
- if (!value.every((item) => values.has(item))) return void 0;
1698
- if (min !== void 0 && value.length < min) return void 0;
1699
- if (max !== void 0 && value.length > max) return void 0;
1700
- return value;
1701
- };
1260
+ };
1261
+ parked.cancel();
1262
+ const answered = {
1263
+ ...parked,
1264
+ pending: {
1265
+ ...parked.pending,
1266
+ status: "answered"
1702
1267
  }
1268
+ };
1269
+ this.#parked.set(id, answered);
1270
+ this.#emitter.emit("answer", id, outcome.value.value);
1271
+ this.#parked.delete(id);
1272
+ return outcome.value;
1273
+ }
1274
+ #submit(form, values) {
1275
+ for (const [field, value] of Object.entries(values)) {
1276
+ const outcome = attempt(() => form.fill(field, value));
1277
+ if (!outcome.success) return {
1278
+ success: false,
1279
+ error: this.#errors(outcome.error, form, field)
1280
+ };
1703
1281
  }
1282
+ return form.submit();
1283
+ }
1284
+ #errors(error, form, field) {
1285
+ if (isFormError(error)) {
1286
+ if (isFieldError(error.context)) return [error.context];
1287
+ const named = error.context?.field;
1288
+ return [{
1289
+ field: typeof named === "string" ? named : this.#field(form, field),
1290
+ message: error.message
1291
+ }];
1292
+ }
1293
+ return [{
1294
+ field: this.#field(form, field),
1295
+ message: "The form rejected the answer"
1296
+ }];
1704
1297
  }
1705
- #isTextOptions(form, _options) {
1706
- return form === "input" || form === "password" || form === "editor";
1707
- }
1708
- #isConfirmOptions(form, _options) {
1709
- return form === "confirm";
1710
- }
1711
- #isSelectOptions(form, _options) {
1712
- return form === "select";
1713
- }
1714
- #isCheckboxOptions(form, _options) {
1715
- return form === "checkbox";
1298
+ #field(form, field) {
1299
+ return field ?? form.schema.fields[0]?.name ?? form.schema.name ?? "form";
1716
1300
  }
1717
1301
  #expire(id) {
1718
1302
  const parked = this.#parked.get(id);
1719
- if (parked === void 0) return;
1303
+ if (parked === void 0 || parked.pending.status !== "pending") return;
1720
1304
  parked.cancel();
1721
- this.#parked.set(id, {
1305
+ const expired = {
1722
1306
  ...parked,
1723
- prompt: {
1724
- ...parked.prompt,
1307
+ pending: {
1308
+ ...parked.pending,
1725
1309
  status: "expired"
1726
1310
  }
1727
- });
1311
+ };
1312
+ this.#parked.set(id, expired);
1313
+ parked.form.destroy();
1728
1314
  this.#emitter.emit("expire", id);
1729
1315
  this.#parked.delete(id);
1730
- parked.expire();
1731
1316
  }
1732
1317
  };
1733
1318
  //#endregion
1734
1319
  //#region src/core/PromptClient.ts
1735
1320
  /**
1736
- * The SSE prompt BRIDGE (observable §13) — the client-side counterpart to
1737
- * {@link import('./Prompt.js').Prompt}. Connects to a remote broker's SSE endpoint, receives
1738
- * serialized pending prompts, dispatches EACH to a LOCAL {@link PromptFormInterface} terminal (so
1739
- * a human at THIS machine answers a prompt parked elsewhere), and POSTs the answer back.
1740
- * Universal — `fetch` + SSE are web-standard, so it runs in a browser or on a server; the
1741
- * injected `fetch` / timer make it fully deterministic in tests.
1321
+ * The SSE form bridge. It ingests serialized forms from a remote broker, renders them through a
1322
+ * local terminal, and posts answers back without blocking the event stream.
1742
1323
  *
1743
1324
  * @remarks
1744
- * - **Connect + reconnect.** {@link connect} opens the SSE stream (via the injected `fetch` + the
1745
- * core `SSEParser`) and loops, reconnecting after the stream drops with the `delay` backoff
1746
- * (driven by the injected timer) — unless `reconnect` is `false`, the client was
1747
- * {@link destroy}ed, or the drop was a deliberate {@link disconnect} (an abort).
1748
- * - **Dispatch + answer.** Each decoded `pending` event is narrowed to a `PendingPrompt` (§14 —
1749
- * never an `as`), dispatched to `terminal`, and its resolved value POSTed back to `url`.
1750
- * Dispatch is strictly SERIAL: the read loop drives the terminal for ONE prompt at a time and
1751
- * only reads/dispatches the next event after the current prompt fully settles (its answer
1752
- * POSTed). A prompt id redelivered (e.g. after a reconnect) WHILE its prior dispatch is still
1753
- * in flight is skipped (a `#seen` set tracks in-flight ids); once that dispatch settles (or the
1754
- * broker sends `expire` for the id) the id is dropped from `#seen`, so a later redelivery of the
1755
- * SAME id after completion is dispatched again — the client does not dedupe across completion.
1756
- * - **Server signals.** An `expire` event (the broker dropped a parked prompt) emits `expire`; a
1757
- * `shutdown` event calls {@link disconnect} (not {@link destroy}) — the client stops streaming
1758
- * without auto-reconnect but stays reusable; a later {@link connect} recovers it.
1759
- * - **Lean events (§13).** `connect` / `disconnect` / `expire` / `error` — errors are `unknown`.
1760
- * `disconnect` fires exactly once per connected-to-disconnected transition, whether triggered by
1761
- * the server ending the stream cleanly or by a deliberate {@link disconnect} / {@link destroy}.
1325
+ * - **Connect + reconnect.** {@link connect} opens the SSE stream and reconnects after a transport
1326
+ * drop with the injected backoff unless reconnect is disabled, the client was destroyed, or
1327
+ * {@link disconnect} deliberately stopped it.
1328
+ * - **Ingest + render.** Each `pending` envelope passes through `isPendingForm`, Form's `parseForm`,
1329
+ * and terminal's `sanitizeSchema`, then enters a serial render queue. The SSE reader never awaits
1330
+ * that queue, so `expire` and `shutdown` remain live while a person is answering.
1331
+ * - **Safe local form.** The rendering copy omits every wire `pattern`, because Form compiles a
1332
+ * pattern during local evaluation. The broker's parked form retains it and remains authoritative.
1333
+ * - **Refusal retry.** A structured `rejected` response seeds a new rendering form with the values
1334
+ * just submitted, applies every {@link FieldError} through `invalidate`, and asks again. No retry
1335
+ * counter truncates the loop; acceptance, expiry, and shutdown are its bounds.
1336
+ * - **Replay safety.** A replayed id is skipped while it is queued, rendering, or posting. Once an
1337
+ * attempt ends, a later delivery of that id may be rendered again.
1762
1338
  *
1763
1339
  * @example
1764
1340
  * ```ts
1765
1341
  * const client = createPromptClient({
1766
1342
  * url: 'http://localhost:3001/prompts',
1767
- * terminal: createTerminal(), // a local PromptFormInterface (T-c)
1768
- * on: { connect: () => log('connected') },
1343
+ * terminal: createTerminal(),
1769
1344
  * })
1770
- * await client.connect() // streams remote prompts to the local terminal, POSTs answers back
1345
+ * await client.connect()
1771
1346
  * ```
1772
1347
  */
1773
1348
  var PromptClient = class {
@@ -1785,8 +1360,11 @@ var PromptClient = class {
1785
1360
  #connecting = false;
1786
1361
  #connected = false;
1787
1362
  #destroyed = false;
1363
+ #draining = false;
1788
1364
  #warnedInsecureToken = false;
1789
1365
  #seen = /* @__PURE__ */ new Set();
1366
+ #queue = /* @__PURE__ */ new Map();
1367
+ #active;
1790
1368
  constructor(options) {
1791
1369
  this.url = options.url;
1792
1370
  this.#terminal = options.terminal;
@@ -1807,8 +1385,7 @@ var PromptClient = class {
1807
1385
  return this.#connected;
1808
1386
  }
1809
1387
  async connect() {
1810
- if (this.#destroyed) return;
1811
- if (this.#connecting) return;
1388
+ if (this.#destroyed || this.#connecting) return;
1812
1389
  this.#connecting = true;
1813
1390
  while (this.#connecting && !this.#destroyed) {
1814
1391
  try {
@@ -1837,7 +1414,7 @@ var PromptClient = class {
1837
1414
  if (this.#destroyed) return;
1838
1415
  this.#destroyed = true;
1839
1416
  this.disconnect();
1840
- this.#seen.clear();
1417
+ this.#interrupt();
1841
1418
  this.#emitter.destroy();
1842
1419
  }
1843
1420
  async #stream() {
@@ -1863,49 +1440,158 @@ var PromptClient = class {
1863
1440
  for (;;) {
1864
1441
  const { done, value } = await reader.read();
1865
1442
  if (done) break;
1866
- for (const event of parser.parse(decoder.decode(value, { stream: true }))) await this.#handle(event);
1443
+ for (const event of parser.parse(decoder.decode(value, { stream: true }))) {
1444
+ this.#handle(event);
1445
+ if (!this.#connecting) break;
1446
+ }
1447
+ if (!this.#connecting) break;
1867
1448
  }
1868
1449
  } finally {
1869
1450
  reader.releaseLock();
1870
1451
  }
1871
1452
  this.#markDisconnected();
1872
1453
  }
1873
- async #handle(event) {
1454
+ #handle(event) {
1874
1455
  if (event.event === SSE_EVENTS.pending) {
1875
1456
  const parsed = parseJSON(event.data);
1876
- if (isPendingPrompt(parsed) && !this.#seen.has(parsed.id)) await this.#dispatch(parsed.id, parsed);
1457
+ if (!isPendingForm(parsed) || this.#seen.has(parsed.id)) return;
1458
+ const schema = parseForm(parsed.schema);
1459
+ if (schema === void 0) {
1460
+ this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker sent an invalid form schema for ${parsed.id}`));
1461
+ return;
1462
+ }
1463
+ this.#seen.add(parsed.id);
1464
+ this.#queue.set(parsed.id, sanitizeSchema(schema));
1465
+ this.#drain();
1877
1466
  return;
1878
1467
  }
1879
1468
  if (event.event === SSE_EVENTS.expire) {
1880
1469
  const parsed = parseJSON(event.data);
1881
- if (isRecord(parsed) && isString(parsed.id)) {
1882
- this.#seen.delete(parsed.id);
1883
- this.#emitter.emit("expire", parsed.id);
1884
- }
1470
+ if (isRecord(parsed) && isString(parsed.id)) this.#expire(parsed.id);
1885
1471
  return;
1886
1472
  }
1887
- if (event.event === SSE_EVENTS.shutdown) this.disconnect();
1473
+ if (event.event === SSE_EVENTS.shutdown) {
1474
+ this.disconnect();
1475
+ this.#interrupt();
1476
+ }
1888
1477
  }
1889
- async #dispatch(id, pending) {
1890
- this.#seen.add(id);
1478
+ async #drain() {
1479
+ if (this.#draining) return;
1480
+ this.#draining = true;
1891
1481
  try {
1892
- const value = await dispatchPendingPrompt(this.#terminal, pending);
1893
- await this.#post(id, value);
1894
- } catch (error) {
1895
- this.#emitter.emit("error", error);
1482
+ while (!this.#destroyed) {
1483
+ let queued;
1484
+ for (const entry of this.#queue) {
1485
+ queued = entry;
1486
+ break;
1487
+ }
1488
+ if (queued === void 0) return;
1489
+ const [id, schema] = queued;
1490
+ this.#queue.delete(id);
1491
+ try {
1492
+ await this.#render(id, schema);
1493
+ } catch (error) {
1494
+ const active = this.#active;
1495
+ if (active?.id !== id || !active.stopped) this.#emitter.emit("error", error);
1496
+ } finally {
1497
+ const active = this.#active;
1498
+ if (active?.id === id) {
1499
+ active.form.destroy();
1500
+ this.#active = void 0;
1501
+ }
1502
+ if (!this.#queue.has(id)) this.#seen.delete(id);
1503
+ }
1504
+ }
1896
1505
  } finally {
1897
- this.#seen.delete(id);
1506
+ this.#draining = false;
1507
+ }
1508
+ }
1509
+ async #render(id, schema) {
1510
+ let values;
1511
+ let errors = [];
1512
+ while (this.#seen.has(id) && !this.#destroyed) {
1513
+ const form = this.#createRenderingForm(schema, values);
1514
+ this.#active = {
1515
+ id,
1516
+ form,
1517
+ stopped: false
1518
+ };
1519
+ for (const error of errors) form.invalidate(error.field, sanitizeDisplayText(error.message));
1520
+ const submitted = await this.#terminal.ask(form);
1521
+ const active = this.#active;
1522
+ if (active?.id !== id || active.stopped) return;
1523
+ const rejected = await this.#post(id, submitted);
1524
+ const posted = this.#active;
1525
+ if (posted?.id !== id || posted.stopped || rejected === void 0) return;
1526
+ values = submitted;
1527
+ errors = rejected;
1528
+ form.destroy();
1529
+ this.#active = void 0;
1898
1530
  }
1899
1531
  }
1900
- async #post(id, value) {
1901
- if (!(await this.#fetch(this.url, {
1532
+ #createRenderingForm(schema, values) {
1533
+ const fields = schema.fields.map((field) => {
1534
+ if (field.rule?.pattern === void 0) return field;
1535
+ const { pattern: _pattern, ...rule } = field.rule;
1536
+ return {
1537
+ ...field,
1538
+ rule
1539
+ };
1540
+ });
1541
+ const form = createForm({
1542
+ ...schema,
1543
+ fields
1544
+ }, values === void 0 ? void 0 : { values });
1545
+ form.answer.catch(() => void 0);
1546
+ return form;
1547
+ }
1548
+ async #post(id, values) {
1549
+ const response = await this.#fetch(this.url, {
1902
1550
  method: "POST",
1903
1551
  headers: this.#headers({ "Content-Type": "application/json" }),
1904
1552
  body: JSON.stringify({
1905
1553
  id,
1906
- value
1554
+ values
1907
1555
  })
1908
- })).ok) this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker rejected answer ${id}`));
1556
+ });
1557
+ if (!response.ok) this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker rejected answer ${id}`));
1558
+ const parsed = parseJSON(await response.text());
1559
+ if (!isRecord(parsed) || !isBoolean(parsed.success)) {
1560
+ this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker returned an invalid answer result for ${id}`));
1561
+ return;
1562
+ }
1563
+ if (parsed.success) return void 0;
1564
+ const error = parsed.error;
1565
+ if (!isRecord(error) || !isString(error.reason)) {
1566
+ this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker returned an invalid answer error for ${id}`));
1567
+ return;
1568
+ }
1569
+ if (error.reason === "unknown") return void 0;
1570
+ if (error.reason === "rejected" && arrayOf(isFieldError)(error.errors)) return error.errors;
1571
+ this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker returned an invalid answer refusal for ${id}`));
1572
+ }
1573
+ #expire(id) {
1574
+ const active = this.#active;
1575
+ if (active?.id === id) {
1576
+ this.#active = {
1577
+ ...active,
1578
+ stopped: true
1579
+ };
1580
+ this.#seen.delete(id);
1581
+ active.form.destroy();
1582
+ } else if (this.#queue.delete(id)) this.#seen.delete(id);
1583
+ this.#emitter.emit("expire", id);
1584
+ }
1585
+ #interrupt() {
1586
+ this.#queue.clear();
1587
+ this.#seen.clear();
1588
+ const active = this.#active;
1589
+ if (active === void 0) return;
1590
+ this.#active = {
1591
+ ...active,
1592
+ stopped: true
1593
+ };
1594
+ active.form.destroy();
1909
1595
  }
1910
1596
  #markDisconnected() {
1911
1597
  if (!this.#connected) return;
@@ -1938,8 +1624,8 @@ var PromptClient = class {
1938
1624
  //#region src/core/TerminalManager.ts
1939
1625
  /**
1940
1626
  * The multi-endpoint terminal MANAGER — a registry of named {@link PromptInterface} brokers (one
1941
- * per endpoint), so several parties can `ask` prompts of each other by NAME with a `from` → `to`
1942
- * attribution edge on every parked prompt, and a transitive DEADLOCK check across all in-flight
1627
+ * per endpoint), so several parties can `ask` forms of each other by NAME with a `from` → `to`
1628
+ * attribution edge on every parked form, and a transitive DEADLOCK check across all in-flight
1943
1629
  * asks.
1944
1630
  *
1945
1631
  * @remarks
@@ -1950,22 +1636,23 @@ var PromptClient = class {
1950
1636
  * - **`ask`.** The target must already be mounted via {@link add} — `ask` never auto-adds it;
1951
1637
  * rejects `TARGET` for an unknown `to` (listing the known names). Rejects `DEADLOCK` when parking
1952
1638
  * `from → to` would close a cycle over the CURRENT in-flight edge set (walked transitively);
1953
- * otherwise parks through the target's broker and resolves with the ORIGINAL ticket Promise (edge
1954
- * cleanup is attached via `.then`, never altering the value/rejection the caller observes).
1639
+ * otherwise parks the caller's live form through the target's broker and returns that form's own
1640
+ * `answer` promise. Edge cleanup never alters the value or rejection the caller observes.
1955
1641
  * - **Durable open / save.** `open(name)` restores an EMPTY broker from the `store` (parked
1956
1642
  * Promises are process-bound and never resurrected); `save(name)` persists the endpoint's
1957
1643
  * configured `timeout`.
1958
1644
  * - **Removal.** `remove` drops one endpoint, a batch (§9.2, array overload FIRST), or every
1959
- * endpoint when called without an argument. It destroys each broker, which expires every prompt
1645
+ * endpoint when called without an argument. It destroys each broker, which expires every form
1960
1646
  * still parked on it. `destroy` is idempotent.
1961
1647
  *
1962
1648
  * @example
1963
1649
  * ```ts
1650
+ * const form = createForm({ fields: [{ control: 'text', name: 'name' }] })
1964
1651
  * const manager = new TerminalManager()
1965
1652
  * manager.add('agent')
1966
- * const name = manager.ask('user', 'agent', 'input', { message: 'Your name' })
1967
- * manager.answer('agent', manager.pending('agent')[0].id, 'Ada')
1968
- * await name // 'Ada'
1653
+ * const answer = manager.ask('user', 'agent', form)
1654
+ * manager.answer('agent', manager.pending('agent')[0].id, { name: 'Ada' })
1655
+ * await answer // { name: 'Ada' }
1969
1656
  * ```
1970
1657
  */
1971
1658
  var TerminalManager = class {
@@ -2009,6 +1696,8 @@ var TerminalManager = class {
2009
1696
  const timer = options?.timer ?? this.#timer;
2010
1697
  const cap = options?.cap ?? this.#cap;
2011
1698
  const broker = createPrompt({
1699
+ ...options?.on !== void 0 ? { on: options.on } : {},
1700
+ ...options?.error !== void 0 ? { error: options.error } : {},
2012
1701
  ...timeout !== void 0 ? { timeout } : {},
2013
1702
  ...timer !== void 0 ? { timer } : {},
2014
1703
  ...cap !== void 0 ? { cap } : {}
@@ -2022,11 +1711,11 @@ var TerminalManager = class {
2022
1711
  broker.emitter.on("answer", listeners.answer);
2023
1712
  broker.emitter.on("expire", listeners.expire);
2024
1713
  this.#terminals.set(name, broker);
2025
- this.#config.set(name, options ?? {});
1714
+ this.#config.set(name, { ...options });
2026
1715
  this.#listeners.set(name, listeners);
2027
1716
  return broker;
2028
1717
  }
2029
- ask(from, to, form, options) {
1718
+ ask(from, to, form) {
2030
1719
  const broker = this.#terminals.get(to);
2031
1720
  if (broker === void 0) {
2032
1721
  const known = this.terminals();
@@ -2041,21 +1730,18 @@ var TerminalManager = class {
2041
1730
  to,
2042
1731
  path: cycle
2043
1732
  }));
2044
- const ticket = broker.park({
2045
- form,
2046
- options,
1733
+ const id = broker.park(form, {
2047
1734
  from,
2048
1735
  to
2049
1736
  });
2050
- if (broker.pending(ticket.id) !== void 0) {
2051
- this.#edges.set(ticket.id, {
1737
+ if (broker.pending(id) !== void 0) {
1738
+ this.#edges.set(id, {
2052
1739
  from,
2053
1740
  to
2054
1741
  });
2055
- const clear = this.#createEdgeClear(ticket.id);
2056
- ticket.value.then(clear, clear);
1742
+ form.answer.then(this.#createEdgeClear(id), this.#createEdgeClear(id));
2057
1743
  }
2058
- return ticket.value;
1744
+ return form.answer;
2059
1745
  }
2060
1746
  pending(to) {
2061
1747
  if (to !== void 0) {
@@ -2066,13 +1752,15 @@ var TerminalManager = class {
2066
1752
  for (const broker of this.#terminals.values()) result.push(...broker.pending());
2067
1753
  return result;
2068
1754
  }
2069
- answer(to, id, value) {
1755
+ answer(to, id, values) {
2070
1756
  const broker = this.#terminals.get(to);
2071
1757
  if (broker === void 0) return {
2072
1758
  success: false,
2073
- error: "terminal"
1759
+ error: { reason: "terminal" }
2074
1760
  };
2075
- return broker.answer(id, value);
1761
+ const result = broker.answer(id, values);
1762
+ if (result.success) this.#edges.delete(id);
1763
+ return result;
2076
1764
  }
2077
1765
  async open(name) {
2078
1766
  if (this.#destroyed) throw new TerminalError("DESTROYED", "manager destroyed");
@@ -2111,16 +1799,23 @@ var TerminalManager = class {
2111
1799
  if (this.#destroyed) return;
2112
1800
  this.#destroyed = true;
2113
1801
  this.remove();
1802
+ this.#edges.clear();
2114
1803
  this.#emitter.destroy();
2115
1804
  }
2116
1805
  #createPendingListener() {
2117
- return (prompt) => this.#emitter.emit("pending", prompt);
1806
+ return (form) => this.#emitter.emit("pending", form);
2118
1807
  }
2119
1808
  #createAnswerListener(name) {
2120
- return (id, value) => this.#emitter.emit("answer", name, id, value);
1809
+ return (id, values) => {
1810
+ this.#edges.delete(id);
1811
+ this.#emitter.emit("answer", name, id, values);
1812
+ };
2121
1813
  }
2122
1814
  #createExpireListener(name) {
2123
- return (id) => this.#emitter.emit("expire", name, id);
1815
+ return (id) => {
1816
+ this.#edges.delete(id);
1817
+ this.#emitter.emit("expire", name, id);
1818
+ };
2124
1819
  }
2125
1820
  #createEdgeClear(id) {
2126
1821
  return () => {
@@ -2140,8 +1835,12 @@ var TerminalManager = class {
2140
1835
  this.#terminals.delete(name);
2141
1836
  this.#config.delete(name);
2142
1837
  this.#listeners.delete(name);
1838
+ this.#clearEdges(name);
2143
1839
  return true;
2144
1840
  }
1841
+ #clearEdges(to) {
1842
+ for (const [id, edge] of this.#edges) if (edge.to === to) this.#edges.delete(id);
1843
+ }
2145
1844
  #findCycle(from, to) {
2146
1845
  if (from === to) return [from, to];
2147
1846
  const visited = /* @__PURE__ */ new Set([to]);
@@ -2289,30 +1988,26 @@ var DatabaseTerminalStore = class {
2289
1988
  //#endregion
2290
1989
  //#region src/core/factories.ts
2291
1990
  /**
2292
- * Create the headless prompt {@link PromptInterface} BROKER — it parks each prompt call as a
2293
- * pending prompt and resolves it when {@link PromptInterface.answer} arrives (or rejects on
2294
- * timeout). The tri-surface's headless arm: subscribe `emitter.on('pending', …)` to forward each
2295
- * prompt to whoever can answer (an SSE transport, a remote terminal), then route the answer back
2296
- * through `answer(id, value)`.
1991
+ * Create the headless {@link PromptInterface} broker. It parks live forms and applies remote
1992
+ * answers to the authoritative instances.
2297
1993
  *
2298
1994
  * @param options - See {@link PromptOptions}
2299
1995
  * @returns A {@link PromptInterface}
2300
1996
  *
2301
1997
  * @remarks
2302
- * - **Park-as-Promise.** `await prompt.input({ message })` blocks until `answer(id, value)` accepts
2303
- * a matching value; the value is validated (text forms) and type-checked to the form first.
2304
- * - **Timeout → expire → reject (deterministic).** An unanswered prompt expires after
2305
- * `options.timeout` (default {@link import('./constants.js').DEFAULT_PROMPT_TIMEOUT_MS}) and its
2306
- * Promise rejects with a {@link import('./errors.js').TerminalError}; inject `options.timer` to
2307
- * drive expiry without real time.
1998
+ * The caller awaits the parked form's own `answer`. Timeout or teardown destroys the form, so that
1999
+ * promise rejects with the Form package's `ABANDONED` error. Inject `options.timer` to drive
2000
+ * expiry without real time.
2308
2001
  *
2309
2002
  * @example
2310
2003
  * ```ts
2311
2004
  * import { createPrompt } from '@src/core'
2005
+ * import { createForm } from '@orkestrel/form'
2312
2006
  *
2313
2007
  * const prompt = createPrompt()
2314
- * prompt.emitter.on('pending', (pending) => send(pending)) // forward to a remote client
2315
- * const name = await prompt.input({ message: 'Your name', validate: { required: true } })
2008
+ * const form = createForm({ fields: [{ control: 'text', name: 'name' }] })
2009
+ * const id = prompt.park(form)
2010
+ * prompt.answer(id, { name: 'Ada' })
2316
2011
  * ```
2317
2012
  */
2318
2013
  function createPrompt(options) {
@@ -2320,9 +2015,8 @@ function createPrompt(options) {
2320
2015
  }
2321
2016
  /**
2322
2017
  * Create the SSE prompt {@link PromptClientInterface} BRIDGE — it connects to a remote broker's SSE
2323
- * endpoint, dispatches each received prompt to a LOCAL {@link import('./types.js').PromptFormInterface}
2324
- * terminal (so a human at this machine answers a prompt parked elsewhere), and POSTs the answer
2325
- * back. Universal — `fetch` / SSE are web-standard.
2018
+ * endpoint, dispatches each received form to a local {@link import('./types.js').TerminalInterface},
2019
+ * and POSTs the answer back. Universal — `fetch` / SSE are web-standard.
2326
2020
  *
2327
2021
  * @param options - See {@link PromptClientOptions} (`url` + `terminal` required)
2328
2022
  * @returns A {@link PromptClientInterface}
@@ -2346,12 +2040,12 @@ function createPromptClient(options) {
2346
2040
  return new PromptClient(options);
2347
2041
  }
2348
2042
  /**
2349
- * Create the multi-endpoint {@link TerminalManagerInterface} — a named registry of
2043
+ * Create the multi-endpoint {@link TerminalManager} — a named registry of
2350
2044
  * {@link PromptInterface} brokers so several parties can `ask` prompts of each other by name,
2351
2045
  * with a transitive DEADLOCK check across every in-flight ask.
2352
2046
  *
2353
2047
  * @param options - See {@link TerminalManagerOptions}
2354
- * @returns A {@link TerminalManagerInterface}
2048
+ * @returns A {@link TerminalManager}
2355
2049
  *
2356
2050
  * @example
2357
2051
  * ```ts
@@ -2405,6 +2099,6 @@ function createDatabaseTerminalStore(driver = createMemoryDriver()) {
2405
2099
  }).table("terminals"));
2406
2100
  }
2407
2101
  //#endregion
2408
- export { ACCEPT_EVENT_STREAM, ALPHANUMERIC_PATTERN, BACKSPACE, CONTROL_NAMES, CTRL_A, CTRL_C, CTRL_D, CTRL_E, CTRL_U, DEFAULT_MASK, DEFAULT_PROMPT_THEME, DEFAULT_PROMPT_TIMEOUT_MS, DEFAULT_RECONNECT_DELAY_MS, DELETE, DatabaseTerminalStore, ESCAPE, HEADER_TOKEN, INTEGER_PATTERN, KEY_CSI, KEY_SS3, MemoryTerminalStore, NEWLINE, NUMERIC_PATTERN, PROMPT_ICONS, PROMPT_ROLES, Prompt, PromptClient, RETURN, RULE_MESSAGES, SEQUENCE_NAMES, SPACE, SSE_BUFFER_LIMIT, SSE_EVENTS, TAB, TerminalError, TerminalManager, URL_PATTERN, appendRule, buildRuleValidator, checkboxReduce, checkboxView, composeValidators, confirmReduce, confirmView, createCheckboxState, createConfirmState, createDatabaseTerminalStore, createEditorState, createInputState, createMemoryTerminalStore, createPasswordState, createPrompt, createPromptClient, createPromptTheme, createSelectState, createTerminalManager, defaultTimer, dispatchPendingPrompt, editLine, editorReduce, editorView, errorLine, evaluateRule, gateSelection, globalFetch, hintedHeader, inputReduce, inputView, isAbortError, isAnswerPayload, isCheckboxChoice, isInsecureRemote, isPendingPrompt, isPendingPromptStatus, isPrintable, isPromptChoice, isPromptThemeOptions, isPromptType, isStyle, isTerminalError, isTerminalSnapshot, normalizeCheckboxChoice, normalizeChoice, parseKey, passing, passwordReduce, passwordView, promptHeader, reconstructValidationRules, resolveChoices, resolveOption, resolveValidation, sanitizeChoiceLabels, sanitizeDisplayText, sanitizeThemeIcons, selectReduce, selectView, serializeChoices, serializeExpire, serializePending, serializePromptOptions, serializeShutdown, serializeValidationRules, submitHeader, toggleIndex };
2102
+ export { ACCEPT_EVENT_STREAM, BACKSPACE, CONTROL_NAMES, CTRL_A, CTRL_C, CTRL_D, CTRL_E, CTRL_U, DEFAULT_MASK, DEFAULT_PROMPT_THEME, DEFAULT_PROMPT_TIMEOUT_MS, DEFAULT_RECONNECT_DELAY_MS, DELETE, DatabaseTerminalStore, ESCAPE, HEADER_TOKEN, KEY_CSI, KEY_SS3, MemoryTerminalStore, NEWLINE, PROMPT_ICONS, PROMPT_ROLES, Prompt, PromptClient, RETURN, SEQUENCE_NAMES, SPACE, SSE_BUFFER_LIMIT, SSE_EVENTS, TAB, TerminalError, TerminalManager, checkboxReduce, checkboxView, confirmReduce, confirmView, createCheckboxState, createConfirmState, createDatabaseTerminalStore, createEditorState, createInputState, createMemoryTerminalStore, createPasswordState, createPrompt, createPromptClient, createPromptTheme, createSelectState, createTerminalManager, defaultTimer, editLine, editorReduce, editorView, errorLine, globalFetch, hintedHeader, inputReduce, inputView, isAbortError, isInsecureRemote, isPendingForm, isPendingFormStatus, isPrintable, isTerminalError, isTerminalSnapshot, isWireEvent, parseKey, passwordReduce, passwordView, promptHeader, sanitizeDisplayText, sanitizeSchema, sanitizeThemeIcons, selectReduce, selectView, serializeExpire, serializePending, serializeShutdown, submitHeader, toggleIndex };
2409
2103
 
2410
2104
  //# sourceMappingURL=index.js.map