@orkestrel/terminal 0.0.8 → 0.0.10

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