@orkestrel/terminal 0.0.7 → 0.0.9

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