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