@orkestrel/scaffold 0.0.67 → 0.0.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +4 -4
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1509 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +311 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +437 -6
- package/dist/src/core/index.cjs +38 -16
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +37 -17
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
# Terminal
|
|
2
|
+
|
|
3
|
+
> The terminal side of a form: a key decoder, a presentation theme, the pure per-field reducers,
|
|
4
|
+
> the headless broker that parks a live form until somebody elsewhere answers it, the SSE bridge
|
|
5
|
+
> that carries a parked form to a machine with a keyboard, and the manager that routes parked
|
|
6
|
+
> forms between named endpoints.
|
|
7
|
+
|
|
8
|
+
`@orkestrel/form` owns the document — the schema, the controls, the rules, the values, and the
|
|
9
|
+
settle-once `answer` promise — and this package declares none of it a second time. One contract
|
|
10
|
+
carries the rest: [`src/core`](../src/core) declares `TerminalInterface`, whose `ask(form)` returns
|
|
11
|
+
the settled `FormValues`, and the local TTY, the headless broker, and the SSE bridge each reach a
|
|
12
|
+
person over it. The server `Terminal` ([`src/server`](../src/server)) implements that contract
|
|
13
|
+
against a real TTY — raw-mode stdin, live in-place re-render, a `node:readline` fallback when
|
|
14
|
+
piped — and is the only impure part of the stack. `PromptFormInterface` and its per-control prompt
|
|
15
|
+
methods are gone: a form is one question however many fields it holds, so the contract needs `ask`
|
|
16
|
+
alone and this package holds no second form vocabulary.
|
|
17
|
+
|
|
18
|
+
## The blank line binds as absence
|
|
19
|
+
|
|
20
|
+
A bare return no longer answers `''`. It binds `undefined`.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { createForm } from '@orkestrel/form'
|
|
24
|
+
import { createTerminal } from '@orkestrel/terminal/server'
|
|
25
|
+
|
|
26
|
+
const form = createForm({
|
|
27
|
+
fields: [{ control: 'text', name: 'name', label: 'Name', rule: { required: true } }],
|
|
28
|
+
})
|
|
29
|
+
const terminal = createTerminal()
|
|
30
|
+
const values = await terminal.ask(form)
|
|
31
|
+
// Bare return at `name`: the field binds as absence, `required` refuses it, the failure prints,
|
|
32
|
+
// and the walk asks again. It never resolves `{ name: '' }`.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The driver fills every answer as `fill(name, matchesAnswer(value) ? value : undefined)` — form's own
|
|
36
|
+
projection. The consequences a caller sees directly:
|
|
37
|
+
|
|
38
|
+
- A bare return on a field with **no default** leaves that key out of the resolved values entirely.
|
|
39
|
+
The key is absent, rather than present holding an empty string.
|
|
40
|
+
- A bare return on a field **with a default** binds the declared default, never the value a previous
|
|
41
|
+
pass held, so a rejected answer is never re-offered as the default.
|
|
42
|
+
- `required` therefore refuses a blank line. A field with no `required` rule accepts an empty
|
|
43
|
+
answer.
|
|
44
|
+
|
|
45
|
+
The rest of the vocabulary moved the same way: `numeric` is gone (a numeric-looking string is `text`
|
|
46
|
+
plus a `pattern` or `custom` rule; a real number is the `number` control), per-key validator
|
|
47
|
+
overrides are gone, a choice's `name` / `description` are now `value` / `label` / `help`, and a
|
|
48
|
+
per-choice `checked` is now the checkbox field's `default` list. Rule message copy belongs to form,
|
|
49
|
+
reachable through its `FormOptions.messages`.
|
|
50
|
+
|
|
51
|
+
## Surface
|
|
52
|
+
|
|
53
|
+
Ask one form over one contract — at this keyboard, parked for somebody else, or carried to a
|
|
54
|
+
keyboard elsewhere:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { createForm } from '@orkestrel/form'
|
|
58
|
+
import { createPrompt, createPromptClient } from '@orkestrel/terminal'
|
|
59
|
+
import { createTerminal } from '@orkestrel/terminal/server'
|
|
60
|
+
|
|
61
|
+
const schema = {
|
|
62
|
+
label: 'Deploy',
|
|
63
|
+
fields: [
|
|
64
|
+
{ control: 'text', name: 'name', label: 'Your name', rule: { required: true } },
|
|
65
|
+
{
|
|
66
|
+
control: 'select',
|
|
67
|
+
name: 'role',
|
|
68
|
+
label: 'Role',
|
|
69
|
+
default: 'admin',
|
|
70
|
+
choices: [
|
|
71
|
+
{ value: 'admin', label: 'Admin' },
|
|
72
|
+
{ value: 'viewer', label: 'Viewer', help: 'read-only' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 1. The local TTY — answer at this keyboard.
|
|
79
|
+
const terminal = createTerminal()
|
|
80
|
+
const answers = await terminal.ask(createForm(schema))
|
|
81
|
+
|
|
82
|
+
// 2. The headless broker — park a live form, answer it from a transport.
|
|
83
|
+
const prompt = createPrompt()
|
|
84
|
+
const parked = createForm(schema)
|
|
85
|
+
const id = prompt.park(parked) // emits `pending`; the caller awaits `parked.answer`
|
|
86
|
+
prompt.answer(id, { name: 'Ada', role: 'admin' }) // fills and submits the authoritative form
|
|
87
|
+
|
|
88
|
+
// 3. The SSE bridge — receive a form parked elsewhere, drive it through a local terminal.
|
|
89
|
+
const client = createPromptClient({ url: 'http://host/forms', terminal })
|
|
90
|
+
await client.connect()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Everything that follows is exported. The core module is `@orkestrel/terminal`; the driver is
|
|
94
|
+
`@orkestrel/terminal/server`. No `@orkestrel/form` symbol is re-exported here — import a form
|
|
95
|
+
symbol from form.
|
|
96
|
+
|
|
97
|
+
### The driving contract
|
|
98
|
+
|
|
99
|
+
What a driver is, and what one step of a field reducer produces ([`src/core`](../src/core)).
|
|
100
|
+
|
|
101
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`.
|
|
102
|
+
|
|
103
|
+
| API | Kind | Shape | Summary |
|
|
104
|
+
| ------------------- | --------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
105
|
+
| `TerminalInterface` | interface | `{} plus ask` | Declares the contract for asking a form of a human at a keyboard — `ask` and nothing beside it, because a form is one question however many fields it holds. The server `Terminal` implements it against a real TTY; a `PromptClientInterface` holds one to answer forms parked elsewhere. |
|
|
106
|
+
| `PromptStatus` | type | `'active' \| 'submit' \| 'cancel'` | Names where one field's reducer stands after a key. `active`: keep asking, because the key was consumed or the answer was refused. `submit`: the field resolved with its `value`. `cancel`: the user aborted with ctrl-c. Names its axis, never `kind`. |
|
|
107
|
+
| `PromptStep` | interface | `{ state, view, status, value? }` | Represents the result of one reducer step — the next `state`, the rendered `view`, the `status`, and, on `submit` alone, the candidate `value`. The whole contract between a pure reducer and the impure driver: the driver applies the next `state`, writes the `view`, and reads `value` on `submit`. |
|
|
108
|
+
|
|
109
|
+
### Key decoding
|
|
110
|
+
|
|
111
|
+
The TTY-agnostic decoder every driver reads keystrokes through ([`src/core`](../src/core)). Pure and
|
|
112
|
+
total: no `node:*`, no I/O, and no input throws.
|
|
113
|
+
|
|
114
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to.
|
|
115
|
+
|
|
116
|
+
| API | Kind | Shape | Summary |
|
|
117
|
+
| ------------- | --------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
118
|
+
| `KeyEvent` | interface | `{ name?, sequence, ctrl, meta, shift }` | Represents one decoded keypress — the TTY-agnostic representation of a single key, the output of `parseKey`. A driver reads `name` and the modifier flags to decide its transition; `sequence` is preserved so a printable character round-trips and an unknown escape is never lost. |
|
|
119
|
+
| `parseKey` | function | `(input: string \| Uint8Array) => KeyEvent` | Decodes one keypress's bytes into a `KeyEvent` — total, never throws. A `Uint8Array` is read as UTF-8; the resulting string is matched against the known control bytes and the CRLF pair (`CONTROL_NAMES`) and escape sequences (`SEQUENCE_NAMES`), falling back to a single printable character. An unrecognized sequence carries no `name`, with the raw `sequence` preserved. |
|
|
120
|
+
| `isPrintable` | function | `(character: string) => boolean` | Checks whether a single character is printable — the fallback test `parseKey` applies after the control bytes and the escape sequences, so the C0 controls and DEL are excluded. |
|
|
121
|
+
| `editLine` | function | `(value: string, key: KeyEvent) => string \| undefined` | Applies a single line-editing `KeyEvent` to a text buffer — the editing shared by input, password, and editor. A printable key appends its character; `backspace` drops the last character; `space` appends a space; ctrl-u clears the line; a key that edits nothing returns `undefined`. |
|
|
122
|
+
|
|
123
|
+
### Presentation
|
|
124
|
+
|
|
125
|
+
A theme is data — a glyph per icon slot and a console `Style` per semantic role — plus the shared
|
|
126
|
+
line shapes every view is assembled from ([`src/core`](../src/core)).
|
|
127
|
+
|
|
128
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to.
|
|
129
|
+
|
|
130
|
+
| API | Kind | Shape | Summary |
|
|
131
|
+
| -------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
132
|
+
| `PromptIcon` | type | `'question' \| 'pointer' \| 'dot' \| 'selected' \| 'checked' \| 'unchecked' \| 'success' \| 'error'` | Names one glyph slot a rendered field draws — the icon axis of a `PromptTheme`. A named value set, not a toggle, so it stays a union. |
|
|
133
|
+
| `PromptRole` | type | `'question' \| 'pointer' \| 'message' \| 'content' \| 'success' \| 'error' \| 'selected' \| 'focus' \| 'hint' \| 'muted' \| 'description'` | Names one styling slot a rendered field paints through — the semantic axis of a `PromptTheme`. A role says what a fragment means; the theme decides what that meaning looks like, so a consumer re-maps styled output by naming roles rather than reimplementing a renderer. |
|
|
134
|
+
| `PromptTheme` | interface | `{ icons, roles }` | Represents a resolved presentation — the glyph for every `PromptIcon` and the console `Style` for every `PromptRole`. Plain JSON data with no functions, so it crosses the wire with the form it decorates. Built by `createPromptTheme`. |
|
|
135
|
+
| `PromptThemeOptions` | interface | `{ icons?, roles? }` | Represents the partial `PromptTheme` an option bag carries — every icon and every role is optional, and `createPromptTheme` merges what is supplied over `DEFAULT_PROMPT_THEME` leaf by leaf. Supplying one icon or one role leaves every other slot at its default. |
|
|
136
|
+
| `createPromptTheme` | function | `(options?: PromptThemeOptions) => PromptTheme` | Builds a complete `PromptTheme` by merging a partial one over `DEFAULT_PROMPT_THEME`, leaf by leaf — each supplied icon replaces that glyph, each supplied role replaces that `Style`, and everything else keeps its default. Each supplied style is snapshotted through the console module's own `freezeStyle`, so the result is deeply frozen and a caller mutating its own attribute list afterwards cannot reach into a built theme. |
|
|
137
|
+
| `renderPromptHeader` | function | `(styler: StylerInterface, theme: PromptTheme, message: string) => string` | Renders the styled question header (`? message`) — the leading line every active prompt view shares, themed by the `question` + `message` roles. |
|
|
138
|
+
| `renderHintedHeader` | function | `(styler: StylerInterface, theme: PromptTheme, message: string, hint?: string) => string` | Renders a question header followed by a key hint painted with the `hint` role, or the header alone when no hint is supplied. |
|
|
139
|
+
| `renderSubmitHeader` | function | `(styler: StylerInterface, theme: PromptTheme, message: string) => string` | Renders the styled submit line (`✔ message`) — the committed header an interactive prompt shows after it resolves, themed by the `success` + `message` roles. |
|
|
140
|
+
| `renderErrorLine` | function | `(styler: StylerInterface, theme: PromptTheme, message: string) => string` | Renders the styled failure line (`✖ message`) a form driver writes for each refused field before it asks that field again. |
|
|
141
|
+
|
|
142
|
+
### The field reducers
|
|
143
|
+
|
|
144
|
+
The pure `(state, key) → PromptStep` machines the driver feeds decoded keys into
|
|
145
|
+
([`src/core`](../src/core)). Each is total and copy-on-write, and each produces a candidate value
|
|
146
|
+
only: the form validates, the form settles, and none of this code does either.
|
|
147
|
+
|
|
148
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to.
|
|
149
|
+
|
|
150
|
+
| API | Kind | Shape | Summary |
|
|
151
|
+
| --------------------- | --------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
152
|
+
| `InputState` | interface | `{ message, default, styler, theme, value }` | Represents the immutable state a text field's reducer carries — built by `createInputState`, rendered by `renderInputView`, and advanced by `reduceInput`. |
|
|
153
|
+
| `createInputState` | function | `(field: TextField, styler?: StylerInterface, theme?: PromptThemeOptions) => InputState` | Builds the initial text-field reducer state — the sanitized label, the declared default, the styler, and the resolved theme. |
|
|
154
|
+
| `renderInputView` | function | `(state: InputState) => string` | Renders a text-field reducer state as a styled view — the header, the pointer, and the typed value, or the default shown as a hint while nothing is typed. |
|
|
155
|
+
| `reduceInput` | function | `(state: InputState, key: KeyEvent) => PromptStep<string, InputState>` | Advances an input prompt by one `KeyEvent` — the pure `(state, key) → PromptStep<string>` reducer. Printable characters extend the value; backspace shrinks it; ctrl-u clears it; ctrl-c cancels; return produces the candidate value, with an empty line falling back to the default. |
|
|
156
|
+
| `PasswordState` | interface | `{ message, mask, styler, theme, value }` | Represents the immutable state a password field's reducer carries — the text state with the mask glyph in place of a default, because a secret is never seeded from the schema. |
|
|
157
|
+
| `createPasswordState` | function | `(field: PasswordField, styler?: StylerInterface, theme?: PromptThemeOptions) => PasswordState` | Builds the initial password-field reducer state — the text-field state, plus the mask glyph each typed character renders as. |
|
|
158
|
+
| `renderPasswordView` | function | `(state: PasswordState) => string` | Renders a password-field reducer state as a styled view, with the value replaced by the mask repeated so the secret is never echoed. |
|
|
159
|
+
| `reducePassword` | function | `(state: PasswordState, key: KeyEvent) => PromptStep<string, PasswordState>` | Advances a password prompt by one `KeyEvent` — the pure `(state, key) → PromptStep<string>` reducer. Identical line-editing to `reduceInput` (printable extends, backspace shrinks, ctrl-u clears, ctrl-c cancels) but the view masks the value. Return produces the candidate value. |
|
|
160
|
+
| `ConfirmState` | interface | `{ message, default, styler, theme }` | Represents the immutable state a confirm field's reducer carries. It holds no typed value, because the answer is the key itself. |
|
|
161
|
+
| `createConfirmState` | function | `(field: ConfirmField, styler?: StylerInterface, theme?: PromptThemeOptions) => ConfirmState` | Builds the initial confirm-field reducer state — the sanitized label and the declared default answer. |
|
|
162
|
+
| `renderConfirmView` | function | `(state: ConfirmState) => string` | Renders a confirm-field reducer state as a styled view — the header and the yes/no group, with the default letter capitalized and painted by the `selected` role. |
|
|
163
|
+
| `reduceConfirm` | function | `(state: ConfirmState, key: KeyEvent) => PromptStep<boolean, ConfirmState>` | Advances a confirm prompt by one `KeyEvent` — the pure `(state, key) → PromptStep<boolean>` reducer. `y` / `Y` submits `true`, `n` / `N` submits `false`, return on an empty line submits the `default`, ctrl-c cancels; any other key is ignored (stays active). |
|
|
164
|
+
| `SelectState` | interface | `{ message, choices, styler, theme, focused }` | Represents the immutable state a select field's reducer carries — the choices the list offers and the index the cursor sits on. |
|
|
165
|
+
| `createSelectState` | function | `(field: SelectField, styler?: StylerInterface, theme?: PromptThemeOptions) => SelectState` | Builds the initial select-field reducer state — the offered choices, with the focus pre-placed on the declared default. |
|
|
166
|
+
| `renderSelectView` | function | `(state: SelectState) => string` | Renders a select-field reducer state as a multi-line styled view — one row per choice, with the focused row marked and its help shown. |
|
|
167
|
+
| `reduceSelect` | function | `(state: SelectState, key: KeyEvent) => PromptStep<string, SelectState>` | Advances a select prompt by one `KeyEvent` — the pure `(state, key) → PromptStep<string>` reducer. `up` / `down` (and `k` / `j`) move the focus, wrapping at the ends; return submits the focused choice's `value`; ctrl-c cancels. An empty choice list can never submit (a higher layer guards against it); any other key is ignored. |
|
|
168
|
+
| `CheckboxState` | interface | `{ message, choices, styler, theme, focused, checked }` | Represents the immutable state a checkbox field's reducer carries — the select state plus the ticked set. |
|
|
169
|
+
| `createCheckboxState` | function | `(field: CheckboxField, styler?: StylerInterface, theme?: PromptThemeOptions) => CheckboxState` | Builds the initial checkbox-field reducer state — the offered choices, with every value in the field's `default` list pre-checked. |
|
|
170
|
+
| `renderCheckboxView` | function | `(state: CheckboxState) => string` | Renders a checkbox-field reducer state as a multi-line styled view — one box per choice, and the selected count beneath them. |
|
|
171
|
+
| `reduceCheckbox` | function | `(state: CheckboxState, key: KeyEvent) => PromptStep<readonly string[], CheckboxState>` | Advances a checkbox prompt by one `KeyEvent` — the pure `(state, key) → PromptStep<readonly string[]>` reducer. `up` / `down` (and `k` / `j`) move the focus (wrapping); `space` toggles the focused index in the checked set; return submits the checked values in choice order; ctrl-c cancels. The form applies selection-count rules. |
|
|
172
|
+
| `toggleIndex` | function | `(indices: readonly number[], index: number) => readonly number[]` | Toggles `index` in a readonly index list — copy-on-write, returning the new sorted-by-insertion list; the primitive `reduceCheckbox` calls. |
|
|
173
|
+
| `EditorState` | interface | `{ message, default, styler, theme, lines, current }` | Represents the immutable state an editor field's reducer carries — the committed lines and the line still being typed, kept apart so a return commits one without ending the field. |
|
|
174
|
+
| `createEditorState` | function | `(field: EditorField, styler?: StylerInterface, theme?: PromptThemeOptions) => EditorState` | Builds the initial editor-field reducer state — the committed lines empty, and the declared default held for a finish with nothing typed. |
|
|
175
|
+
| `renderEditorView` | function | `(state: EditorState) => string` | Renders an editor-field reducer state as a multi-line styled view — the finish hint, the committed lines, and the line in progress. |
|
|
176
|
+
| `reduceEditor` | function | `(state: EditorState, key: KeyEvent) => PromptStep<string, EditorState>` | Advances an editor prompt by one `KeyEvent` — the pure `(state, key) → PromptStep<string>` reducer. Printable characters extend the current line; backspace shrinks it; return commits the current line and starts a fresh one; ctrl-d finishes, joining every line and falling back to the default when empty; ctrl-c cancels. The form validates the candidate after the driver fills it. |
|
|
177
|
+
|
|
178
|
+
### Untrusted display
|
|
179
|
+
|
|
180
|
+
A schema that arrived over a wire is data from somebody else. These are the projection that makes it
|
|
181
|
+
safe to print ([`src/core`](../src/core)).
|
|
182
|
+
|
|
183
|
+
| API | Kind | Summary |
|
|
184
|
+
| --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
185
|
+
| `sanitizeDisplayText` | function | Sanitizes text for one single-line display slot. Composes console's ANSI `strip` and C0 `stripControls` passes with removal of tab, line feed, and carriage return. |
|
|
186
|
+
| `sanitizeSchema` | function | Sanitizes every terminal-readable string in a parsed form schema, keeping every identity and answer string verbatim and dropping field metadata. |
|
|
187
|
+
| `sanitizeThemeIcons` | function | Sanitizes every glyph a wire-supplied `PromptThemeOptions` carries for a single-line display slot. Only the icons need it: a role is guard-narrowed to a console `Style`, whose colors and attributes are fixed name sets, so no role can carry a byte a terminal would act on. |
|
|
188
|
+
|
|
189
|
+
### The headless broker
|
|
190
|
+
|
|
191
|
+
The park-as-promise arm — no terminal here, so a transport forwards each `pending` record to whoever
|
|
192
|
+
can answer, and `answer` drives the parked form to settlement ([`src/core`](../src/core)).
|
|
193
|
+
|
|
194
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it implements, or its constructor signature where it implements none.
|
|
195
|
+
|
|
196
|
+
| API | Kind | Shape | Summary |
|
|
197
|
+
| --------------------- | --------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
198
|
+
| `PromptInterface` | interface | `{ emitter, count } plus park / pending / answer / stop / destroy` | Declares the headless form broker — parks a live form until somebody elsewhere answers it. The headless arm of the local-TTY, headless, and remote trio: there is no terminal here, so a transport forwards each `pending` record to whoever can answer, and `answer` drives the parked form to settlement. |
|
|
199
|
+
| `Prompt` | class | `PromptInterface` | Implements the headless form broker. It parks live forms, exposes their serialized schemas, applies remote answers to the authoritative form, and abandons a parked form on timeout, release, or teardown. |
|
|
200
|
+
| `createPrompt` | function | `(options?: PromptOptions) => PromptInterface` | Creates the headless `PromptInterface` broker. It parks live forms and applies remote answers to the authoritative instances. |
|
|
201
|
+
| `PromptOptions` | interface | `{ on?, error?, timeout?, timer?, cap? }` | Configures `createPrompt` and every `PromptInterface` broker, including one a `TerminalManagerInterface` mounts per endpoint. |
|
|
202
|
+
| `ParkRequest` | interface | `{ from?, to? }` | Represents the parking envelope — everything the broker needs about a park that the form itself does not say. |
|
|
203
|
+
| `PendingForm` | interface | `{ id, schema, status, time, from?, to? }` | Represents one form parked by the broker — an id-keyed, wire-safe record of a live form awaiting a remote answer. The value a `pending` listener receives and the broker serializes over SSE to a `PromptClientInterface`. |
|
|
204
|
+
| `PendingFormStatus` | type | `'pending' \| 'answered' \| 'expired'` | Names the lifecycle status of a parked `PendingForm` — where the ticket stands, which is not where the form stands. A ticket is `pending` until somebody answers it; the form it carries has its own status, and each is a separate fact about a separate entity. |
|
|
205
|
+
| `ParkedForm` | interface | `{ form, pending, cancel }` | Represents one parked form's runtime state inside the broker — the live form, the wire-safe record the broker exposes, and the cancel for its expiry timer. |
|
|
206
|
+
| `AnswerError` | type | `{ reason: 'unknown' } \| { reason: 'rejected', errors }` | Explains why `PromptInterface.answer` refused — `unknown` for an id no form is parked under, `rejected` for values the authoritative form itself refused, carrying the `FieldError` list it reported. Names its axis with `reason`. |
|
|
207
|
+
| `PromptEventMap` | type | `{ pending, answer, expire }` | Declares the broker's event map — lean, errors `unknown`, no listener-error event. |
|
|
208
|
+
| `isPendingForm` | function | `PendingForm` | Narrows an unknown wire value to a `PendingForm` envelope — the envelope alone, because the form package's `parseForm` owns the schema payload. |
|
|
209
|
+
| `isPendingFormStatus` | const | `PendingFormStatus` | Narrows an unknown value to a `PendingFormStatus`. |
|
|
210
|
+
| `TimerHandler` | type | `(callback: () => void, ms: number) => TimerCancelFunction` | Represents one injected timer — arms a deadline `callback` to fire after `ms`, returning a `TimerCancelFunction` that cancels it. The broker's timeout seam: the default wraps the host `setTimeout` and `clearTimeout`; a test injects a deterministic timer that captures the callback and fires it on demand, with no real time and no global patching. |
|
|
211
|
+
| `TimerCancelFunction` | type | `() => void` | Cancels a pending `TimerHandler` deadline — idempotent, safe to call after the timer fired. |
|
|
212
|
+
| `defaultTimer` | function | `(callback: () => void, ms: number) => TimerCancelFunction` | Implements the default `TimerHandler` — a thin host `setTimeout` / `clearTimeout` wrapper that arms `callback` after `ms` and returns a `TimerCancelFunction`. The deadline seam behind both the `Prompt` broker (its expiry) and the `PromptClient` (its reconnect backoff); a test injects a deterministic timer instead, so neither entity touches real time. |
|
|
213
|
+
|
|
214
|
+
### The wire seam
|
|
215
|
+
|
|
216
|
+
The `http`-free frame shape a consumer's own HTTP spine mounts the broker over
|
|
217
|
+
([`src/core`](../src/core)).
|
|
218
|
+
|
|
219
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to.
|
|
220
|
+
|
|
221
|
+
| API | Kind | Shape | Summary |
|
|
222
|
+
| ------------------ | --------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
223
|
+
| `WireEvent` | interface | `{ event, data, id? }` | Represents one SSE-shaped wire frame — the `event` name, its already-stringified `data` payload, and an optional `id`. The transport-neutral shape `serializePending`, `serializeExpire`, and `serializeDestroy` build, with no `http` dependency. |
|
|
224
|
+
| `isWireEvent` | const | `WireEvent` | Narrows an unknown value to a transport-neutral `WireEvent` — the guard a consumer's own transport applies to an inbound frame. |
|
|
225
|
+
| `serializePending` | function | `(form: PendingForm) => WireEvent` | Serializes a parked `PendingForm` into a `pending` `WireEvent`, whose frame `id` is the form's own id. |
|
|
226
|
+
| `serializeExpire` | function | `(id: string) => WireEvent` | Serializes a parked form's expiry or release into an `expire` `WireEvent`, whose `data` is the JSON `{ id }` payload. |
|
|
227
|
+
| `serializeDestroy` | function | `() => WireEvent` | Serializes the `destroy` `WireEvent` a broker or manager sends when it is going away, which carries no payload. |
|
|
228
|
+
|
|
229
|
+
### The SSE bridge
|
|
230
|
+
|
|
231
|
+
The client-side counterpart to the broker: receive a form parked elsewhere, rebuild it here, drive it
|
|
232
|
+
through a local terminal, and POST the answers back ([`src/core`](../src/core)). Universal — `fetch`
|
|
233
|
+
and SSE are web standards.
|
|
234
|
+
|
|
235
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it implements, or its constructor signature where it implements none.
|
|
236
|
+
|
|
237
|
+
| API | Kind | Shape | Summary |
|
|
238
|
+
| ----------------------- | --------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
239
|
+
| `PromptClientInterface` | interface | `{ emitter, url, connected } plus connect / disconnect / destroy` | Declares the SSE form bridge — the client-side counterpart to `PromptInterface`. It receives serialized `PendingForm` records from a remote broker, rebuilds each schema locally, drives it through a `TerminalInterface`, and POSTs the answer back, so a human at this machine answers forms a broker parked elsewhere. |
|
|
240
|
+
| `PromptClient` | class | `PromptClientInterface` | Implements the SSE form bridge. It ingests serialized forms from a remote broker without waiting on a render, drives one form at a time through a local terminal, posts each answer back, and asks again when the authoritative form refuses one. |
|
|
241
|
+
| `createPromptClient` | function | `(options: PromptClientOptions) => PromptClientInterface` | Creates the SSE prompt `PromptClientInterface` bridge — it connects to a remote broker's SSE endpoint, dispatches each received form to a local `TerminalInterface`, and POSTs the answer back. Universal — `fetch` and SSE are web standards. |
|
|
242
|
+
| `PromptClientOptions` | interface | `{ url, terminal, token?, reconnect?, delay?, on?, error?, fetch?, timer? }` | Configures `createPromptClient` and the `PromptClientInterface`. |
|
|
243
|
+
| `PromptClientEventMap` | type | `{ connect, disconnect, expire, error }` | Declares the client's event map — lean, errors `unknown`, no listener-error event. |
|
|
244
|
+
| `FetchHandler` | type | `(input: string, init?: FetchInit) => Promise<Response>` | Represents a minimal `fetch` — the subset of the global `fetch` a `PromptClientInterface` uses: open the SSE stream, POST an answer. Injected so a test drives the client with a scripted `Response` instead of a real network. |
|
|
245
|
+
| `FetchInit` | interface | `{ method?, headers?, body?, signal? }` | Represents the request init a `PromptClientInterface` passes to its `FetchHandler` — the `RequestInit` fields it actually sets. |
|
|
246
|
+
| `globalFetch` | function | `(input: string, init?: FetchInit) => Promise<Response>` | Implements the default `FetchHandler` — the global `fetch`, adapted to the minimal injected shape the `PromptClient` uses. |
|
|
247
|
+
| `isAbortError` | function | `(error: unknown) => boolean` | Checks whether a caught value is an `AbortError` — the `PromptClient` distinguishes a deliberate `disconnect` / teardown (an aborted `fetch`) from a real fault, so it exits its connect loop quietly instead of emitting `error` / reconnecting. |
|
|
248
|
+
| `isInsecureRemote` | function | `(url: string) => boolean` | Checks whether `url` is an insecure remote endpoint — a plain `http://` URL whose host is not a loopback address. Pure string parsing (no `URL` global), so it stays total on malformed input; the `PromptClient` warns once when a `token` would cross such an endpoint in cleartext. |
|
|
249
|
+
|
|
250
|
+
### The terminal manager
|
|
251
|
+
|
|
252
|
+
A named registry of brokers, so several parties can ask forms of each other by name with a `from` →
|
|
253
|
+
`to` edge on every parked record ([`src/core`](../src/core)).
|
|
254
|
+
|
|
255
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it implements, or its constructor signature where it implements none.
|
|
256
|
+
|
|
257
|
+
| API | Kind | Shape | Summary |
|
|
258
|
+
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
259
|
+
| `TerminalManagerInterface` | interface | `{ emitter, count } plus terminal / terminals / add / ask / pending / answer / open / save / remove / destroy` | Declares a registry of named `PromptInterface` brokers, one per endpoint, so several parties (agents, tools, humans) can ask forms of each other by name, attributed with a `from` → `to` edge on every parked record. |
|
|
260
|
+
| `TerminalManager` | class | `TerminalManagerInterface` | Registers named `PromptInterface` brokers, one per endpoint, so several parties can `ask` forms of each other by name with a `from` → `to` attribution edge on every parked form, and refuses `DEADLOCK` on a transitive cycle across every in-flight ask. |
|
|
261
|
+
| `createTerminalManager` | function | `(options?: TerminalManagerOptions) => TerminalManagerInterface` | Creates the multi-endpoint `TerminalManager` — a named registry of `PromptInterface` brokers so several parties can `ask` forms of each other by name, with a transitive cycle check that refuses `DEADLOCK` across every in-flight ask. |
|
|
262
|
+
| `TerminalManagerOptions` | interface | `{ store?, timeout?, timer?, cap?, on?, error? }` | Configures `createTerminalManager` and the `TerminalManagerInterface`. |
|
|
263
|
+
| `TerminalManagerEventMap` | type | `{ pending, answer, expire }` | Declares the manager's event map — the name-attributed re-emission of every mounted broker's events, so a caller subscribes once for every endpoint instead of once per broker. |
|
|
264
|
+
| `TerminalAnswerError` | type | `AnswerError \| { reason: 'target' }` | Explains why a `TerminalManagerInterface.answer` call refused — an `AnswerError` from the endpoint's own broker, or `target` when no endpoint is mounted under that name. That is the same condition `TerminalErrorCode`'s `TARGET` names for `TerminalManagerInterface.ask`, so one word carries it on both doors. One discriminant, `reason`, across every member. |
|
|
265
|
+
|
|
266
|
+
### The terminal store
|
|
267
|
+
|
|
268
|
+
The point-access persistence seam for a manager's endpoint config — config only, because a parked
|
|
269
|
+
form is process-bound and is never resurrected ([`src/core`](../src/core)).
|
|
270
|
+
|
|
271
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it implements, or its constructor signature where it implements none.
|
|
272
|
+
|
|
273
|
+
| API | Kind | Shape | Summary |
|
|
274
|
+
| ----------------------------- | --------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
275
|
+
| `TerminalStoreInterface` | interface | `{} plus get / set / delete` | Declares the point-access persistence seam for a `TerminalManagerInterface`'s endpoint configs. Every primitive is async; deleting an absent id is a no-op. |
|
|
276
|
+
| `TerminalSnapshot` | interface | `{ id, timeout? }` | Represents one endpoint's persisted config snapshot — `id` is the endpoint name and `timeout` its configured default. Parked forms are process-bound and are never resurrected, so `open` always restores an empty broker. |
|
|
277
|
+
| `TerminalSnapshotRow` | interface | `{ id, snapshot }` | Represents one opaque persisted row — the shape a table-backed store reads and writes. The store is a `TableInterface<TerminalSnapshotRow>`, and `snapshot` is narrowed with `isTerminalSnapshot` on read. |
|
|
278
|
+
| `isTerminalSnapshot` | const | `TerminalSnapshot` | Narrows an unknown value to a `TerminalSnapshot` — a non-empty `id` and an optional numeric `timeout`, the read boundary a store applies to an untrusted persisted row. |
|
|
279
|
+
| `MemoryTerminalStore` | class | `TerminalStoreInterface` | Implements the in-memory `TerminalStoreInterface` — a process-lifetime `Map` of `TerminalSnapshot` records keyed by endpoint id, the default store `createMemoryTerminalStore` builds and the exact twin of `DatabaseTerminalStore`. It carries no idle expiry and no eviction. |
|
|
280
|
+
| `DatabaseTerminalStore` | class | `TerminalStoreInterface` | Implements a `TerminalStoreInterface` backed by one table of the `databases` layer — an endpoint's durable config state is a row, so persistence reduces to keyed point-access (`get` / `set` / `delete`) over a `TableInterface`, the driver-pluggable twin of the plain-`Map` `MemoryTerminalStore`. A stored `snapshot` is narrowed with `isTerminalSnapshot` on read. |
|
|
281
|
+
| `createMemoryTerminalStore` | function | `() => TerminalStoreInterface` | Creates the in-memory `TerminalStoreInterface` — a process-lifetime `Map` of endpoint config snapshots, the default store backing a `TerminalManagerInterface`'s `open` / `save`. |
|
|
282
|
+
| `createDatabaseTerminalStore` | function | `(driver?: DriverInterface) => TerminalStoreInterface` | Creates a `TerminalStoreInterface` backed by one table of the `databases` layer — the driver-pluggable twin of `createMemoryTerminalStore`, storing each endpoint's config snapshot as one opaque JSON column. The default driver is an in-memory `@orkestrel/database` driver. |
|
|
283
|
+
|
|
284
|
+
### The terminal error
|
|
285
|
+
|
|
286
|
+
Terminal's own failure type. A refusal that belongs to the form — a malformed schema, a value a
|
|
287
|
+
control cannot hold, a write to a settled form — arrives as form's own `FormError` and is never
|
|
288
|
+
re-coded ([`src/core`](../src/core)).
|
|
289
|
+
|
|
290
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it implements, or its constructor signature where it implements none.
|
|
291
|
+
|
|
292
|
+
| API | Kind | Shape | Summary |
|
|
293
|
+
| ------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
294
|
+
| `TerminalErrorCode` | type | `'EXPIRE' \| 'CANCEL' \| 'DRIVER' \| 'DEADLOCK' \| 'TARGET' \| 'LIMIT' \| 'DESTROYED'` | Names the machine-readable condition carried by a `TerminalError` — the axis a `catch` branches on. Names its axis (the failure condition), never `kind`. |
|
|
295
|
+
| `TerminalError` | class | `new (code: TerminalErrorCode, message: string, context?: Readonly<Record<string, unknown>>) => TerminalError` | Represents the error the terminal surfaces for its own refusals: parking on a destroyed or full broker, an unusable driver stream, a manager routing fault, or a ctrl-c cancellation. A parked form's own lifecycle failures reject through the form's `answer` with the form package's error, never with this one. |
|
|
296
|
+
| `isTerminalError` | function | `TerminalError` | Narrows an unknown caught value to a `TerminalError`, so a caller can branch on its `code`. |
|
|
297
|
+
|
|
298
|
+
### The core constants
|
|
299
|
+
|
|
300
|
+
The decode tables, the default mask, the theme defaults, and the broker and SSE defaults
|
|
301
|
+
([`src/core`](../src/core)). UPPER_SNAKE, `Object.freeze`d data; every control byte is built through
|
|
302
|
+
`String.fromCharCode` or read from console's own `ESC` and `CSI`, so no raw control character
|
|
303
|
+
appears in source.
|
|
304
|
+
|
|
305
|
+
A `Shape` cell holds the constant's declared type.
|
|
306
|
+
|
|
307
|
+
| API | Kind | Shape | Summary |
|
|
308
|
+
| ---------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
309
|
+
| `RETURN` | const | `string` | Names the carriage return byte (`\r`, U+000D) — Enter on most terminals. |
|
|
310
|
+
| `NEWLINE` | const | `string` | Names the line feed byte (`\n`, U+000A) — Enter on some terminals / pasted input. |
|
|
311
|
+
| `TAB` | const | `string` | Names the tab byte (`\t`, U+0009). |
|
|
312
|
+
| `BACKSPACE` | const | `string` | Names the backspace byte (BS, U+0008) — Ctrl+H / some terminals' Backspace. |
|
|
313
|
+
| `DELETE` | const | `string` | Names the delete byte (DEL, U+007F) — the usual Backspace byte on a Unix TTY. |
|
|
314
|
+
| `SPACE` | const | `string` | Names the space byte (U+0020). |
|
|
315
|
+
| `CTRL_C` | const | `string` | Names the Ctrl+C byte (ETX, U+0003) — interrupt / cancel. |
|
|
316
|
+
| `CTRL_D` | const | `string` | Names the Ctrl+D byte (EOT, U+0004) — end-of-transmission / finish (the editor's commit key). |
|
|
317
|
+
| `CTRL_U` | const | `string` | Names the Ctrl+U byte (NAK, U+0015) — clear the current line. |
|
|
318
|
+
| `CTRL_A` | const | `string` | Names the Ctrl+A byte (SOH, U+0001) — move to start of line. |
|
|
319
|
+
| `CTRL_E` | const | `string` | Names the Ctrl+E byte (ENQ, U+0005) — move to end of line. |
|
|
320
|
+
| `KEY_SS3` | const | `string` | Names the Single Shift Three lead (`ESCO`) — the alternate arrow-key prefix some terminals emit (`ESC O A`). Built from the console module's own `ESC`; the navigation keys' CSI lead is that module's `CSI`, which this package reuses rather than redeclaring. |
|
|
321
|
+
| `SEQUENCE_NAMES` | const | `Readonly<Record<string, string>>` | Holds the exact escape sequence to canonical key name table `parseKey` consults for the navigation and editing keys. Covers the CSI form (`ESC[A`…) and the SS3 form (`ESCOA`…) of the arrows, plus the `home` / `end` / `delete` CSI sequences with their numeric-tilde variants. The source of truth for the multi-byte key decode; frozen. |
|
|
322
|
+
| `CONTROL_NAMES` | const | `Readonly<Record<string, { readonly name: string; readonly ctrl: boolean }>>` | Holds the control byte (or CRLF pair) to key descriptor table `parseKey` consults for the one-byte keys and the two-byte CRLF Enter chunk. Each entry carries the canonical `name` and whether it is a `ctrl` combination. The source of truth for that decode; frozen. |
|
|
323
|
+
| `DEFAULT_MASK` | const | `string` | Names the default mask glyph `createPasswordState` uses — `*`. |
|
|
324
|
+
| `PROMPT_ICONS` | const | `Readonly<{ readonly question: string; readonly pointer: string; readonly dot: string; readonly selected: string; readonly checked: string; readonly unchecked: string }>` | Holds the terminal-owned glyphs `DEFAULT_PROMPT_THEME` assembles its `icons` from, beside the console module's own success and error marks. Read only when the default theme is assembled; a view reads its resolved theme and never this constant. Frozen. |
|
|
325
|
+
| `PROMPT_ROLES` | const | `readonly PromptRole[]` | Holds every `PromptRole`, in one frozen list — the role axis's source of truth. `createPromptTheme` walks it to merge a partial theme, and a consumer building a complete role map reads it rather than retyping every name. |
|
|
326
|
+
| `DEFAULT_PROMPT_THEME` | const | `PromptTheme` | Holds the `PromptTheme` every prompt renders with unless its options supply another — the glyph set assembled from `PROMPT_ICONS` plus the console `STATUS_ICONS` `success` / `error` marks, and the console `Style` each role is painted with. Deeply frozen through the console module's own `freezeStyle`; the baseline `createPromptTheme` merges a partial theme over. |
|
|
327
|
+
| `DEFAULT_PROMPT_TIMEOUT_MS` | const | `number` | Holds how long (ms) the `PromptInterface` broker parks an unanswered form before it expires — 5 minutes. |
|
|
328
|
+
| `DEFAULT_RECONNECT_DELAY_MS` | const | `number` | Holds how long (ms) the `PromptClientInterface` waits before each reconnect attempt — 2 seconds. |
|
|
329
|
+
| `SSE_EVENTS` | const | `Readonly<{ readonly pending: string; readonly expire: string; readonly destroy: string }>` | Holds the SSE `event:` names the broker emits and the `PromptClientInterface` dispatches on — `pending`, `expire`, and `destroy`. Frozen; the source of truth for the wire event vocabulary. |
|
|
330
|
+
| `HEADER_TOKEN` | const | `string` | Names the auth-token request header the `PromptClientInterface` sends when a `token` is configured — `x-orkestrel-token`. |
|
|
331
|
+
| `ACCEPT_EVENT_STREAM` | const | `string` | Names the `Accept` header value that opens the broker's SSE stream — `text/event-stream`. |
|
|
332
|
+
| `SSE_BUFFER_LIMIT` | const | `number` | Sets the maximum number of characters the `PromptClientInterface` lets its SSE parser buffer before treating the stream as hostile — 1 MiB, comfortably above any legitimate prompt payload. Passed as the `limit` to `createSSEParser` so an unterminated or oversized `data:` field cannot grow the buffer without bound (a memory-exhaustion guard). |
|
|
333
|
+
|
|
334
|
+
### The server Terminal
|
|
335
|
+
|
|
336
|
+
The local-TTY arm and the only impure part of the stack ([`src/server`](../src/server)). It reads
|
|
337
|
+
raw-mode stdin, drives the core reducers, renders each view in place, and falls back to
|
|
338
|
+
`node:readline` when piped. Every form contract is imported from core and none is redeclared here.
|
|
339
|
+
|
|
340
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it implements, or its constructor signature where it implements none.
|
|
341
|
+
|
|
342
|
+
| API | Kind | Shape | Summary |
|
|
343
|
+
| ---------------------- | --------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
344
|
+
| `Terminal` | class | `TerminalInterface` | Implements `TerminalInterface` for a human at this machine's keyboard — the interactive form driver, and the only impure part of the terminal stack. `ask` walks one form's fields in schema order, feeds raw-mode stdin bytes through `parseKey` into the matching pure reducer, renders each returned view in place, binds every answer through the form's own `fill`, and re-asks what the form refused. It owns no form logic: the schema, the rules, the values, and the settlement all belong to the form it is given, and this class owns only raw mode, the cursor, and the re-render. |
|
|
345
|
+
| `createTerminal` | function | `(options?: TerminalOptions) => TerminalInterface` | Creates the interactive terminal form driver — the local-keyboard arm of the terminal trio, beside the core headless `createPrompt` broker and the SSE `createPromptClient` bridge. Where the broker parks a live form until somebody elsewhere answers it, a `Terminal` answers one here: it walks the form's fields in schema order, drives each control's pure reducer over raw-mode stdin, binds every answer through the form's own `fill`, and submits. It is the only impure part of the terminal stack. |
|
|
346
|
+
| `TerminalOptions` | interface | `{ input?, output?, theme? }` | Configures `createTerminal` — every member optional, so a bare `createTerminal()` walks a form over the real `process.stdin` / `process.stdout` with the default theme. |
|
|
347
|
+
| `InputStreamInterface` | interface | `{ isTTY? } plus on / off / setRawMode? / resume? / pause?` | Represents the minimal input-stream shape the driver reads — exactly the slice of a Node `tty.ReadStream` / `process.stdin` it touches, and no more. A `TerminalOptions` `input` is narrowed to this through `isInputStream`, never an assertion, so a test drives a whole form with a hand-built fake stream that emits scripted key chunks, never touches the real `process.stdin`, and asserts that raw mode is entered once and always cleaned up. |
|
|
348
|
+
|
|
349
|
+
### The server helpers
|
|
350
|
+
|
|
351
|
+
The stream guards, the cursor math behind the in-place re-render, and the per-field line projections
|
|
352
|
+
the walk renders with ([`src/server`](../src/server)). All pure, all exported, all unit-tested.
|
|
353
|
+
|
|
354
|
+
| API | Kind | Summary |
|
|
355
|
+
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
356
|
+
| `isInputStream` | function | Checks whether `value` is a usable `InputStreamInterface` — a record with callable `on` / `off` `'data'` subscription methods. A total type guard: it never throws and returns `false` for anything off-shape, so it narrows the one unavoidable input boundary (the real `process.stdin`, or a fake TTY a test injects) to the exact slice the driver reads, never an assertion. |
|
|
357
|
+
| `isReadable` | function | Checks whether `value` is a Node `NodeJS.ReadableStream` — a total structural guard checking for the callable `read` / `pipe` / `on` that `node:readline`'s `createInterface` requires as its `input`. The non-TTY fallback narrows the resolved input stream through this before handing it to readline, never through an assertion, so a real piped `process.stdin` (or a `PassThrough` a test injects) crosses into the readline boundary honestly. Never throws; returns `false` for a minimal fake that isn't a full readable. |
|
|
358
|
+
| `supportsRawMode` | function | Checks whether an input stream can be driven in raw mode — it reports `isTTY === true` and exposes a callable `setRawMode`. The `Terminal` probes this to choose its path: true selects the interactive raw-mode fields, with arrow-key navigation and a live re-render; false selects the `node:readline` line-input fallback, because a piped or non-terminal stream cannot enter raw mode. Total — never throws. |
|
|
359
|
+
| `lineCount` | function | Counts the terminal lines a rendered prompt `view` occupies — one more than its newline count, so a view with no newline is a single line and a view with N newlines spans N+1 lines. The basis of the in-place re-render: the driver records the line count of the view it wrote so the next redraw knows how far up to move the cursor before overwriting. Total; an empty string is one empty line. |
|
|
360
|
+
| `renderCursorUp` | function | Returns the cursor-up control sequence that moves the cursor up `count` lines (`ESC[{count}A`), or the empty string when `count` is zero or negative, because no movement is needed and `ESC[0A` is a wasted write. The pure step the in-place re-render uses to climb back over the previous view before clearing it. Total. |
|
|
361
|
+
| `redrawPrefix` | function | Returns the full reposition-and-clear prefix to write before re-rendering a prompt view in place — given the line count of the previous view, it moves the cursor up over those lines, returns it to column 0, and erases everything from there to the end of the screen, so the next view is drawn on a clean region and a taller previous view leaves no orphaned rows. Pure; the driver writes this immediately followed by the new view. |
|
|
362
|
+
| `fieldToText` | function | Projects any field the walk reads as a line of text into the `TextField` the text reducer takes — `text` itself, and the controls a terminal has no widget for: `number`, `date`, `time`, `datetime`, `color`, and one `file` entry. The label carries that control's format cue from `CONTROL_HINTS`, and a declared `default` becomes the line a bare return submits. The projection carries no rule, because the authoritative form still evaluates the answer this line binds; it exists only so one reducer covers every one of them. |
|
|
363
|
+
| `valueToText` | function | Projects one held answer into the text a read-only line shows — a scalar as itself, a boolean as `yes` / `no` (the word the confirm reducer commits), and a list joined by commas. Absence renders as nothing, because a locked field nobody has answered has nothing to show. |
|
|
364
|
+
| `filterEnabled` | function | Returns the choices a `select` or `checkbox` field actually offers — the form refuses a disabled choice's value at every door, including a fill, so the walk never puts one in front of the cursor. Pair with `filterDisabled` to tell the reader what was withheld. |
|
|
365
|
+
| `filterDisabled` | function | Returns the choices a `select` or `checkbox` field shows but refuses — the complement of `filterEnabled`, rendered by `renderUnavailableLine` above the list so a reader sees why a declared choice is missing from it. |
|
|
366
|
+
| `renderGroupHeader` | function | Renders the section header the walk writes when it enters a new field group, painted by the `message` role. |
|
|
367
|
+
| `renderLockedLine` | function | Renders the read-only line a locked field shows — its label, the `LOCKED_MARK`, and the answer the form already holds. The walk writes this instead of a prompt, because the field is still validated and still submitted but must not be edited here. |
|
|
368
|
+
| `renderSuggestionLine` | function | Renders the line listing an open select's offered values above its text prompt — a suggestion list, because an open select admits an answer the list does not offer. |
|
|
369
|
+
| `renderUnavailableLine` | function | Renders the line naming the choices a field shows but refuses, written above the list the walk drives. |
|
|
370
|
+
| `renderNumberedList` | function | Renders the numbered choice list the non-TTY fallback prints — a piped stream cannot navigate with arrow keys, so each offered choice is printed with the number the reader types back. One line per choice, with no trailing newline. |
|
|
371
|
+
|
|
372
|
+
### The server constants
|
|
373
|
+
|
|
374
|
+
The cursor and clear sequences the driver writes, and the fixed copy the walk renders
|
|
375
|
+
([`src/server`](../src/server)). Sequences are built from console's own `CSI`, so no raw control
|
|
376
|
+
character appears in source.
|
|
377
|
+
|
|
378
|
+
A `Shape` cell holds the constant's declared type.
|
|
379
|
+
|
|
380
|
+
| API | Kind | Shape | Summary |
|
|
381
|
+
| ------------------------ | ----- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
382
|
+
| `CSI_UP` | const | `string` | Holds the cursor-up sequence template (`ESC[{count}A`) — `renderCursorUp` interpolates the `{count}` placeholder with the number of lines to climb. Kept as a template so the count stays out of the constant. |
|
|
383
|
+
| `CURSOR_HIDE` | const | `string` | Hides the cursor (`ESC[?25l`) — written before the driver starts redrawing a prompt so the cursor does not flicker across the view during an in-place re-render; paired with `CURSOR_SHOW`. |
|
|
384
|
+
| `CURSOR_SHOW` | const | `string` | Shows the cursor (`ESC[?25h`) — restores the cursor after a prompt resolves / cancels (the `CURSOR_HIDE` pair). |
|
|
385
|
+
| `CLEAR_DOWN` | const | `string` | Erases from the cursor down to the end of the screen (`ESC[J`) — wipes the whole previous view, which a `select` or `checkbox` can spread over several lines, in one write before the new view is rendered, so a redraw never leaves orphaned rows behind. |
|
|
386
|
+
| `CONTROL_HINTS` | const | `Readonly<Partial<Record<FieldControl, string>>>` | Holds the format cue appended to a field's label for each control the walk reads as a line of text — the terminal has no date picker, no color well, and no file chooser, so the accepted shape is stated instead. A control with no entry needs none: `text` and `editor` accept any line, `password` masks one, and `confirm`, `select`, and `checkbox` are answered by key rather than by format. The form's own rules still decide whether the typed value is acceptable. |
|
|
387
|
+
| `FILE_HINT` | const | `string` | Holds the instruction a `file` field with `multiple` shows before its entries — one path per line, and a blank line ends the list. |
|
|
388
|
+
| `SUGGESTION_LEAD` | const | `string` | Holds the lead on the line listing an open `select`'s offered values, which a typed answer can ignore. |
|
|
389
|
+
| `UNAVAILABLE_LEAD` | const | `string` | Holds the lead on the line listing the choices a `select` or `checkbox` shows but refuses, so a reader sees why one is missing from the list it heads. |
|
|
390
|
+
| `LOCKED_MARK` | const | `string` | Holds the mark on a locked field's line — the walk renders its value and moves on, because the form refuses an edit there. |
|
|
391
|
+
| `REFUSAL_MESSAGE` | const | `string` | States what a field is told when the walk read an answer the control cannot hold — a word typed into a `number`, an off-list value typed into an open `select` whose choice is refused. The value binds as absence and this message is invalidated onto the field, so the walk re-asks it with the reason on screen. |
|
|
392
|
+
| `FALLBACK_SELECT_HINT` | const | `string` | Holds the numbered-list prompt the non-TTY `Terminal` `select` fallback appends — a piped (non-terminal) stream cannot navigate with arrow keys, so the choices are printed numbered and the user types one number on a single readline line. |
|
|
393
|
+
| `FALLBACK_CHECKBOX_HINT` | const | `string` | Holds the comma-separated multi-select hint the non-TTY `checkbox` fallback shows (the user types one or more numbers). |
|
|
394
|
+
| `FALLBACK_EDITOR_HINT` | const | `string` | Holds the hint the non-TTY `editor` fallback shows — a piped stream has no ctrl-d, so end of input finishes the block. |
|
|
395
|
+
| `FALLBACK_CONFIRM_HINT` | const | `string` | Holds the hint the non-TTY `confirm` fallback shows — a piped stream sends a whole line, so the answer is typed rather than pressed. |
|
|
396
|
+
|
|
397
|
+
## Methods
|
|
398
|
+
|
|
399
|
+
One table per behavioral interface, keyed by its backticked name, listing exactly its
|
|
400
|
+
call-signature members. Each interface's readonly data members stay in its Surface row and are not
|
|
401
|
+
repeated here. Each implementing class implements its interface exactly, so each table is also
|
|
402
|
+
the instance method surface of the class that implements it.
|
|
403
|
+
|
|
404
|
+
A `*Options` / `*EventMap` / `*State` / `PendingForm` / `ParkedForm` / `KeyEvent` / `PromptStep` /
|
|
405
|
+
`WireEvent` / `FetchInit` / `TerminalSnapshot` / `TerminalSnapshotRow` row is data with no behavior,
|
|
406
|
+
and `PromptStatus` / `PendingFormStatus` / `TerminalErrorCode` / `AnswerError` /
|
|
407
|
+
`TerminalAnswerError` / `TimerHandler` / `TimerCancelFunction` / `FetchHandler` are unions or callable
|
|
408
|
+
function types. None carries a method table.
|
|
409
|
+
|
|
410
|
+
#### `TerminalInterface`
|
|
411
|
+
|
|
412
|
+
The one driving contract. The server `Terminal` implements it.
|
|
413
|
+
|
|
414
|
+
| Method | Returns | Summary |
|
|
415
|
+
| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
416
|
+
| `ask` | `Promise<FormValues>` | Walks the given form to settlement and resolves its values. The Contract section names the ctrl-c exception. |
|
|
417
|
+
|
|
418
|
+
#### `PromptInterface`
|
|
419
|
+
|
|
420
|
+
The headless broker.
|
|
421
|
+
|
|
422
|
+
| Method | Returns | Summary |
|
|
423
|
+
| --------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
424
|
+
| `park` | `string` | Parks a live form, mints its id, emits `pending`, and arms the expiry deadline. Returns the id; the caller already holds the promise. |
|
|
425
|
+
| `pending` | `readonly PendingForm[]` / `PendingForm \| undefined` | Lists every parked record (`pending()`), or looks one up by id (`pending(id)`). |
|
|
426
|
+
| `answer` | `Result<FormValues, AnswerError>` | Fills and submits the authoritative parked form. Accepted, it settles and the record is dropped; refused, the form stays parked. |
|
|
427
|
+
| `stop` | `boolean` / `void` | Releases a batch (`stop(ids)`, the array overload declared first), one id, or every parked form. The broker stays usable. |
|
|
428
|
+
| `destroy` | `void` | Tears the broker down — abandons every parked form, cancels every deadline, then destroys the emitter. Idempotent. |
|
|
429
|
+
|
|
430
|
+
#### `PromptClientInterface`
|
|
431
|
+
|
|
432
|
+
The SSE bridge.
|
|
433
|
+
|
|
434
|
+
| Method | Returns | Summary |
|
|
435
|
+
| ------------ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
436
|
+
| `connect` | `Promise<void>` | Opens the stream and pumps it, queueing each received form for the local terminal; reconnects on the `delay` backoff. |
|
|
437
|
+
| `disconnect` | `void` | Stops the current connection and the reconnect loop. An active local render continues, and a later `connect()` can restart the stream. |
|
|
438
|
+
| `destroy` | `void` | Tears the client down permanently — disconnects, drops the queue, abandons the active local form, and destroys the emitter. |
|
|
439
|
+
|
|
440
|
+
#### `TerminalManagerInterface`
|
|
441
|
+
|
|
442
|
+
The multi-endpoint registry.
|
|
443
|
+
|
|
444
|
+
| Method | Returns | Summary |
|
|
445
|
+
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
446
|
+
| `terminal` | `PromptInterface \| undefined` | Looks up one endpoint's broker by name. |
|
|
447
|
+
| `terminals` | `readonly PromptInterface[]` | Lists every mounted broker, in insertion order. |
|
|
448
|
+
| `add` | `PromptInterface` | Mints, or returns unchanged, the broker for `name`. Idempotent; it never clobbers a live endpoint. |
|
|
449
|
+
| `ask` | `Promise<FormValues>` | Parks `form` from `from` to `to` and resolves with the settled values. Rejects `TARGET` or `DEADLOCK`. |
|
|
450
|
+
| `pending` | `readonly PendingForm[]` | Lists every endpoint's parked records (`pending()`), or scopes to one endpoint (`pending(to)`). |
|
|
451
|
+
| `answer` | `Result<FormValues, TerminalAnswerError>` | Routes an answer to the named endpoint's broker; `{ reason: 'target' }` when no endpoint carries that name. |
|
|
452
|
+
| `open` | `Promise<PromptInterface \| undefined>` | Returns the live broker for `name`, or restores an empty one from the `store`. Parked forms are never resurrected. |
|
|
453
|
+
| `save` | `Promise<boolean>` | Persists an endpoint's config snapshot; false with no store, or an unknown name. |
|
|
454
|
+
| `remove` | `boolean` / `void` | Removes a batch (`remove(names)`, the array overload declared first, true only when every name was mounted), one endpoint, or every endpoint. |
|
|
455
|
+
| `destroy` | `void` | Tears down every broker, then the manager's own emitter. |
|
|
456
|
+
|
|
457
|
+
#### `TerminalStoreInterface`
|
|
458
|
+
|
|
459
|
+
The persistence seam `MemoryTerminalStore` and `DatabaseTerminalStore` each implement exactly.
|
|
460
|
+
|
|
461
|
+
| Method | Returns | Summary |
|
|
462
|
+
| -------- | ---------------------------------------- | --------------------------------------------------------------------------- |
|
|
463
|
+
| `get` | `Promise<TerminalSnapshot \| undefined>` | Resolves the snapshot stored for `id`, or `undefined` when none is. |
|
|
464
|
+
| `set` | `Promise<void>` | Inserts or replaces under the snapshot's own `id`; there is no id argument. |
|
|
465
|
+
| `delete` | `Promise<void>` | Drops a snapshot by id. An absent id is a no-op, never a throw. |
|
|
466
|
+
|
|
467
|
+
#### `InputStreamInterface`
|
|
468
|
+
|
|
469
|
+
The stream shape the driver reads. Only `on` and `off` are required; a stream missing `setRawMode`
|
|
470
|
+
takes the `node:readline` fallback.
|
|
471
|
+
|
|
472
|
+
| Method | Returns | Summary |
|
|
473
|
+
| ------------ | ------- | ----------------------------------------------------------------------------- |
|
|
474
|
+
| `on` | `void` | Subscribes a `'data'` chunk listener — the irreducible event seam. |
|
|
475
|
+
| `off` | `void` | Unsubscribes that listener. The driver always pairs it, so no listener leaks. |
|
|
476
|
+
| `setRawMode` | `void` | Switches the TTY in and out of raw mode. Absent on a piped stream. |
|
|
477
|
+
| `resume` | `void` | Starts the flow of `'data'` events. |
|
|
478
|
+
| `pause` | `void` | Stops it again on cleanup. |
|
|
479
|
+
|
|
480
|
+
## Contract
|
|
481
|
+
|
|
482
|
+
These invariants hold across `src/core`, `src/server`, and this guide.
|
|
483
|
+
|
|
484
|
+
1. **DOC ↔ SOURCE bijection.** Every row in the `## Surface` tables is a real export of the
|
|
485
|
+
`src/core` and `src/server` trees, and every export appears as a row — exhaustive, both
|
|
486
|
+
directions. Every `## Methods`
|
|
487
|
+
table lists exactly its interface's call-signature members, and each implementing class implements
|
|
488
|
+
every one of them and adds none beyond.
|
|
489
|
+
2. **The form is the unit.** `park(form)` takes a live form and returns its id. It wraps no promise,
|
|
490
|
+
because the caller already holds one: the form's own `answer`. The parked form is authoritative —
|
|
491
|
+
`answer(id, values)` fills and submits that instance, so every rule it carries decides, including
|
|
492
|
+
a `custom` validator the wire could not carry. The wire record is `PendingForm`
|
|
493
|
+
`{ id, schema, status, time, from?, to? }`; `form`, `message`, and `options` are gone, and
|
|
494
|
+
`schema` is form's own `serializeForm` projection, which drops every `custom` validator on the way
|
|
495
|
+
out.
|
|
496
|
+
3. **Absence is `undefined`.** The driver binds every answer as
|
|
497
|
+
`fill(name, matchesAnswer(value) ? value : undefined)`, so a blank line is absence and `required`
|
|
498
|
+
refuses it. A field with no default and a bare return leaves its key out of the resolved values
|
|
499
|
+
entirely. A field with a default binds the declared default, never a value a previous pass held.
|
|
500
|
+
The `''` sentinel is gone. The blank-line-binds-absence rule is per-control: an empty checkbox
|
|
501
|
+
binds `[]`, which `matchesAnswer` counts as an answer, so `required` cannot refuse an unchecked
|
|
502
|
+
checkbox the way it refuses a blank text line.
|
|
503
|
+
4. **A refusal is structured, and the client retries it.** `answer` returns
|
|
504
|
+
`Result<FormValues, AnswerError>`. `{ reason: 'unknown' }` means no form is parked under that id,
|
|
505
|
+
or the one that was has already settled. `{ reason: 'rejected', errors }` carries the
|
|
506
|
+
authoritative form's own `FieldError` list, and the parked form stays parked. The client seeds a
|
|
507
|
+
fresh local form with the values it sent, applies each failure through `invalidate`, and asks
|
|
508
|
+
again — until the answer is accepted, the id comes back `unknown`, the form expires, or the client
|
|
509
|
+
is destroyed. That loop is what makes a server-side `custom` rule enforceable, because the rule
|
|
510
|
+
never crossed the wire and the client could not have checked it. There is no retry counter: the
|
|
511
|
+
lifecycle bounds the loop. A retry cannot withdraw an earlier answer: the parked form is
|
|
512
|
+
authoritative and retains every prior fill, so a corrected retry can only add or replace the
|
|
513
|
+
fields the last refusal named. A parked form settled out of band — its form destroyed or
|
|
514
|
+
submitted through some path other than this `answer` call — leaves the broker's own record
|
|
515
|
+
`pending`: the record is marked `answered` or `expired` only inside `#answer` and `#expire`, so a
|
|
516
|
+
later `answer` for that id does not come back `unknown`. It is submitted against the now-settled
|
|
517
|
+
form and comes back `{ reason: 'rejected', errors }` instead.
|
|
518
|
+
5. **Ctrl-c is the one exception to "the promise is the form's answer".** `ask` normally resolves or
|
|
519
|
+
rejects with the form's own `answer`, so a caller holding the form can await either. Ctrl-c at the
|
|
520
|
+
driver rejects `ask` with a `TerminalError` coded `CANCEL` and leaves the form `editing`, with its
|
|
521
|
+
own `answer` still pending for whoever owns it. A driver never owns a form's lifetime: to
|
|
522
|
+
interrupt the form, destroy it, and the walk stops on its abandon.
|
|
523
|
+
6. **Expiry and release abandon the form.** An unanswered form is destroyed after `timeout` ms
|
|
524
|
+
through the injected timer; `expire` fires and the caller's promise rejects with form's own
|
|
525
|
+
`ABANDONED` error, not a `TerminalError`. `stop(id)`, `stop(ids)`, and `stop()` use that same
|
|
526
|
+
`expired` status and `expire` event while leaving the broker usable. `destroy()` releases every
|
|
527
|
+
still-parked form the same way, then destroys the broker. `park` itself throws `TerminalError` —
|
|
528
|
+
`EXPIRE` when the broker is already destroyed, `LIMIT` when `cap` was already reached — and in
|
|
529
|
+
both cases destroys the form it refused, without minting an id, emitting `pending`, or arming a
|
|
530
|
+
timer.
|
|
531
|
+
7. **Display is sanitized; identity and answers are not.** Every string a wire schema renders passes
|
|
532
|
+
through `sanitizeDisplayText` — labels, help, placeholders, masks, choice labels and help, file
|
|
533
|
+
accept entries, and pattern source text — which strips ANSI sequences, every C0 control, DEL, tab,
|
|
534
|
+
line feed, and carriage return. Schema, group, and field names, group references, choice values,
|
|
535
|
+
and every default stays verbatim, because rewriting them would sever the local rendering copy from
|
|
536
|
+
the authoritative form: the client would answer under keys the parked form does not have, and
|
|
537
|
+
every retry would produce the same rejection forever. Field metadata is dropped, since terminal
|
|
538
|
+
neither renders nor interprets it. A preserved identity or answer string that reaches the screen —
|
|
539
|
+
a prefilled default, a locked held value, an open select's suggested values, a group or label
|
|
540
|
+
fallback, an authoritative rejection message — is sanitized at that output boundary only, and the
|
|
541
|
+
submitted value stays byte-for-byte what arrived. The server driver's `#report` sanitizes both
|
|
542
|
+
operands it writes — the field's label (falling back to its raw name) and the failure message —
|
|
543
|
+
so a hostile field name never reaches the screen through a refusal line. A form carrying refusals
|
|
544
|
+
when `ask` is called renders them at walk entry, before any field is filled, so a caller who
|
|
545
|
+
re-asks an already-invalid form sees why before typing anything.
|
|
546
|
+
8. **A wire `pattern` never executes locally, and its cost is bounded in length only.** Form
|
|
547
|
+
evaluates a `pattern` rule with a real `RegExp`, at construction and on every fill, so the client
|
|
548
|
+
strips `rule.pattern` from each local rendering form before building it. Every other rule stays.
|
|
549
|
+
The authoritative parked form still holds and runs the original pattern, so a pattern refusal
|
|
550
|
+
comes back as a `FieldError`, is applied through `invalidate`, and re-renders — the rule is
|
|
551
|
+
enforced exactly once, at the broker. The residual is honest: form's `PATTERN_LIMIT` bounds a
|
|
552
|
+
pattern source's length, never its matching time, so a catastrophically backtracking pattern short
|
|
553
|
+
enough to pass that limit costs the machine that runs it. That machine is the broker, which owns
|
|
554
|
+
the schema it parked. A broker that parks a schema it did not author owns that decision.
|
|
555
|
+
9. **The reducers are pure, total, and copy-on-write.** Each `reduce*` is a total
|
|
556
|
+
`(state, key) → PromptStep` — it never throws, never mutates the state it is given, and always
|
|
557
|
+
returns a rendered `view` and a `status`. A key it does not consume returns the same state with
|
|
558
|
+
`status: 'active'`. `value` is present only on a `submit` step, and it is a candidate: the form
|
|
559
|
+
validates it after the driver fills it. `parseKey` is equally total — a known control byte or
|
|
560
|
+
escape sequence maps to its canonical name, a printable character names itself, and anything else
|
|
561
|
+
carries no `name` with the raw sequence preserved, so the driver cannot crash on a stray byte.
|
|
562
|
+
`KeyEvent.name` is therefore optional and absence is `undefined`: an undecoded key has no name
|
|
563
|
+
rather than an empty one, and every reducer reads it as a key it does not consume.
|
|
564
|
+
10. **Every control reaches a reducer.** `text`, `number`, `date`, `time`, `datetime`, `color`, and
|
|
565
|
+
each `file` entry are read as one line of text through `fieldToText`, which appends that
|
|
566
|
+
control's format cue to the label; `password`, `confirm`, `editor`, `select`, and `checkbox`
|
|
567
|
+
each drive their own reducer. An open select is a suggestion list plus a typed line, because
|
|
568
|
+
`open` means the answer need not come from the list. A disabled choice is named on an
|
|
569
|
+
unavailable line and never offered, since the form refuses its value at every door. A `hidden`
|
|
570
|
+
field and a field in `form.disabled` are skipped; a `locked` field renders read-only
|
|
571
|
+
and is still submitted; entering a group writes its label as a section header. Coercion is
|
|
572
|
+
form's own `parseValue`, and an answer the control cannot hold binds as absence and invalidates
|
|
573
|
+
the field, so it comes back with the reason on screen rather than vanishing.
|
|
574
|
+
11. **An unanswerable form is abandoned, not looped.** After the walk the form is submitted. A
|
|
575
|
+
refusal prints every failure, then re-walks only the erroring fields the walk can edit. When
|
|
576
|
+
that set is empty — every failure sits on a hidden, locked, or runtime-disabled field — or the
|
|
577
|
+
input stream has already ended, the form is destroyed and `ask` rejects on its `answer`. Asking
|
|
578
|
+
again could not change the answer, so it does not ask again.
|
|
579
|
+
12. **Ingestion never waits on a render.** The client's SSE reader is synchronous: each decoded
|
|
580
|
+
record is narrowed by `isPendingForm`, parsed by form's `parseForm`, sanitized, and queued. One
|
|
581
|
+
form is driven at a time while the stream keeps reading, so an unanswered form never starves the
|
|
582
|
+
connection. An `expire` destroys the active local form or drops the queued entry; a `destroy`
|
|
583
|
+
frame disconnects the client, clears the queue, and interrupts the active render while leaving
|
|
584
|
+
the client reusable; the client's own `destroy()` does the same permanently. An id already in
|
|
585
|
+
flight is ignored, so a reconnect that replays buffered events cannot double-answer.
|
|
586
|
+
13. **The manager attributes every ask and refuses a cycle.** `add(name, options?)` mints or reuses
|
|
587
|
+
one broker per endpoint and re-emits its events attributed by name. `ask(from, to, form)`
|
|
588
|
+
requires `to` to be mounted, records the `from` → `to` edge keyed by the parked form's id, and
|
|
589
|
+
parks through `to`'s broker: an unknown `to` rejects `TARGET`, and an edge that would close a
|
|
590
|
+
transitive cycle over the current in-flight edges rejects `DEADLOCK` without parking. The edge
|
|
591
|
+
clears on acceptance, expiry, removal, and teardown — but not on a rejection, because the ask is
|
|
592
|
+
still live. `open` restores an empty broker from the store; `save` persists the endpoint's
|
|
593
|
+
configured timeout.
|
|
594
|
+
14. **The wire seam carries no HTTP.** `serializePending`, `serializeExpire`, and
|
|
595
|
+
`serializeDestroy` build a `WireEvent`, so a consumer mounts the broker on their own HTTP spine
|
|
596
|
+
without this package importing `node:http`, and `isWireEvent` narrows an inbound frame. The
|
|
597
|
+
answer POST body is exactly `{ id, values }`.
|
|
598
|
+
15. **The core / server split.** Core owns everything universal — the decoder, the reducers and their
|
|
599
|
+
views, the theme, the sanitizer, the broker, the bridge, the manager, and the store — with no
|
|
600
|
+
`node:*`, no TTY, and no I/O. The server module owns only raw mode, the cursor, the re-render,
|
|
601
|
+
and the readline fallback, and imports every contract from core. Every view is painted through
|
|
602
|
+
console's `StylerInterface`, so this package holds no second style vocabulary.
|
|
603
|
+
|
|
604
|
+
**A view line wider than the terminal leaves residue.** The in-place re-render climbs
|
|
605
|
+
`lineCount(view)`, the view's NEWLINE count, while a line the terminal wraps occupies more physical
|
|
606
|
+
rows than that. `redrawPrefix` therefore returns to the start of the wrap's last row and erases from
|
|
607
|
+
there down, leaving the earlier rows of the previous view on screen above the new one. Keep every
|
|
608
|
+
label, choice, help string, and hint inside the narrowest terminal you support, or drive the
|
|
609
|
+
non-TTY fallback, which writes each line fresh and never re-renders in place. A resize mid-walk is
|
|
610
|
+
the same limit from the other side. Closing it needs the columns fact console's
|
|
611
|
+
`StreamTargetInterface` already carries, read from the resolved output stream, and cursor-column
|
|
612
|
+
tracking in the redraw, which tracks lines only.
|
|
613
|
+
|
|
614
|
+
**Fixed, not seams.** A theme moves glyphs and styled fragments. The rest of a view is fixed by
|
|
615
|
+
design: the layout (the single spaces between header, pointer, and value; the two-space gap before a
|
|
616
|
+
choice's help; the parentheses around the confirm group; the `N selected` summary), the cursor and
|
|
617
|
+
clear mechanics, and the fallback's numbered-list format. Build a bespoke view from the exported
|
|
618
|
+
reducers and view helpers rather than reading these as extension points.
|
|
619
|
+
|
|
620
|
+
**Deliberately not here.** The SSE-server end of the bridge: the broker emits `pending` on its
|
|
621
|
+
emitter and a consumer mounts it on their own HTTP spine with an answer POST route, and this package
|
|
622
|
+
ships the bridge rather than that spine. Cursor movement within a line: the reducers edit at the end
|
|
623
|
+
of the buffer, and `ctrl-a` / `ctrl-e` decode but no left / right insertion is modelled.
|
|
624
|
+
|
|
625
|
+
## Patterns
|
|
626
|
+
|
|
627
|
+
### Ask one form at this keyboard
|
|
628
|
+
|
|
629
|
+
This example asks a form through a local terminal.
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
import { createForm } from '@orkestrel/form'
|
|
633
|
+
import { isTerminalError } from '@orkestrel/terminal'
|
|
634
|
+
import { createTerminal } from '@orkestrel/terminal/server'
|
|
635
|
+
|
|
636
|
+
const terminal = createTerminal() // process.stdin / process.stdout by default
|
|
637
|
+
const form = createForm({
|
|
638
|
+
label: 'Sign up',
|
|
639
|
+
fields: [
|
|
640
|
+
{ control: 'text', name: 'name', label: 'Your name', rule: { required: true, minimum: 2 } },
|
|
641
|
+
{ control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } },
|
|
642
|
+
{ control: 'password', name: 'token', label: 'Token' },
|
|
643
|
+
{ control: 'confirm', name: 'terms', label: 'Accept the terms', rule: { required: true } },
|
|
644
|
+
{
|
|
645
|
+
control: 'select',
|
|
646
|
+
name: 'role',
|
|
647
|
+
label: 'Role',
|
|
648
|
+
choices: [
|
|
649
|
+
{ value: 'admin', label: 'Admin' },
|
|
650
|
+
{ value: 'viewer', label: 'Viewer', help: 'read-only' },
|
|
651
|
+
],
|
|
652
|
+
},
|
|
653
|
+
],
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
const values = await terminal.ask(form)
|
|
658
|
+
deploy(values)
|
|
659
|
+
} catch (error) {
|
|
660
|
+
// Ctrl-c: the walk ended, and the form is still `editing` for whoever owns it.
|
|
661
|
+
if (isTerminalError(error) && error.code === 'CANCEL') form.destroy()
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
### Park a form, answer it from elsewhere
|
|
666
|
+
|
|
667
|
+
This example parks a form and accepts an answer after a refusal.
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
import { createForm } from '@orkestrel/form'
|
|
671
|
+
import { createPrompt } from '@orkestrel/terminal'
|
|
672
|
+
|
|
673
|
+
const prompt = createPrompt({ timeout: 60_000 })
|
|
674
|
+
prompt.emitter.on('pending', (parked) => send(parked)) // forward the wire record to who can answer
|
|
675
|
+
prompt.emitter.on('expire', (id) => log(`form ${id} was abandoned`))
|
|
676
|
+
|
|
677
|
+
const form = createForm({
|
|
678
|
+
fields: [
|
|
679
|
+
// A `custom` rule never crosses the wire, so only the parked form can enforce it.
|
|
680
|
+
{
|
|
681
|
+
control: 'text',
|
|
682
|
+
name: 'name',
|
|
683
|
+
rule: { required: true, custom: (value) => value !== 'root' || 'root is reserved' },
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
})
|
|
687
|
+
const id = prompt.park(form) // the id; the promise you await is `form.answer`
|
|
688
|
+
prompt.pending() // every parked record
|
|
689
|
+
prompt.pending(id) // this one, or undefined once it settles
|
|
690
|
+
|
|
691
|
+
// ...elsewhere, an answer arrives over the transport:
|
|
692
|
+
const result = prompt.answer(id, { name: 'root' })
|
|
693
|
+
if (!result.success && result.error.reason === 'rejected') {
|
|
694
|
+
result.error.errors // the authoritative form's own FieldError list; the form stays parked
|
|
695
|
+
}
|
|
696
|
+
prompt.answer(id, { name: 'Ada' }) // { success: true, value: { name: 'Ada' } }
|
|
697
|
+
const values = await form.answer // { name: 'Ada' }
|
|
698
|
+
|
|
699
|
+
prompt.destroy() // abandon every still-parked form, then destroy the emitter
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
### Bridge a parked form to a keyboard elsewhere
|
|
703
|
+
|
|
704
|
+
This example bridges a parked form to another terminal.
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
import {
|
|
708
|
+
createPromptClient,
|
|
709
|
+
defaultTimer,
|
|
710
|
+
globalFetch,
|
|
711
|
+
isAbortError,
|
|
712
|
+
isInsecureRemote,
|
|
713
|
+
} from '@orkestrel/terminal'
|
|
714
|
+
import { createTerminal } from '@orkestrel/terminal/server'
|
|
715
|
+
|
|
716
|
+
const client = createPromptClient({
|
|
717
|
+
url: 'http://host/forms',
|
|
718
|
+
terminal: createTerminal(), // the local TerminalInterface each remote form is driven through
|
|
719
|
+
token: process.env.TOKEN,
|
|
720
|
+
on: { connect: () => log('connected'), error: (error) => log(error) },
|
|
721
|
+
fetch: globalFetch, // the default; inject a scripted fetch to drive this with no network
|
|
722
|
+
timer: defaultTimer, // the default; inject a manual timer to drive the reconnect backoff
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
isInsecureRemote('http://host/forms') // true — a non-loopback http endpoint; the client warns once
|
|
726
|
+
isInsecureRemote('http://localhost:3000/forms') // false — loopback needs no warning
|
|
727
|
+
await client.connect() // streams parked forms in, POSTs { id, values } back, retries a refusal
|
|
728
|
+
client.disconnect() // stop streaming and stop reconnecting; a later connect() restarts it
|
|
729
|
+
client.destroy() // permanent: drop the queue, abandon the active local form, destroy the emitter
|
|
730
|
+
|
|
731
|
+
isAbortError(new DOMException('aborted', 'AbortError')) // true — a deliberate disconnect, not a fault
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
### Mount the broker on your own HTTP spine
|
|
735
|
+
|
|
736
|
+
This example projects broker events onto an application-owned HTTP transport.
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
import {
|
|
740
|
+
createPrompt,
|
|
741
|
+
serializeDestroy,
|
|
742
|
+
serializeExpire,
|
|
743
|
+
serializePending,
|
|
744
|
+
} from '@orkestrel/terminal'
|
|
745
|
+
|
|
746
|
+
const prompt = createPrompt()
|
|
747
|
+
prompt.emitter.on('pending', (form) => {
|
|
748
|
+
writeSSE(serializePending(form)) // { event: 'pending', data: '{...}', id: form.id }
|
|
749
|
+
})
|
|
750
|
+
prompt.emitter.on('expire', (id) => writeSSE(serializeExpire(id))) // { event: 'expire', data: '{"id":"..."}' }
|
|
751
|
+
onTeardown(() => writeSSE(serializeDestroy())) // { event: 'destroy', data: '' }
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
### Narrow what arrives from the wire
|
|
755
|
+
|
|
756
|
+
This example narrows a wire payload before using it.
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
import {
|
|
760
|
+
isPendingForm,
|
|
761
|
+
isPendingFormStatus,
|
|
762
|
+
isTerminalSnapshot,
|
|
763
|
+
isWireEvent,
|
|
764
|
+
} from '@orkestrel/terminal'
|
|
765
|
+
import { parseForm } from '@orkestrel/form'
|
|
766
|
+
|
|
767
|
+
// A relay receives an opaque frame: narrow the envelope, then the payload, then the schema.
|
|
768
|
+
const frame: unknown = JSON.parse(received)
|
|
769
|
+
if (isWireEvent(frame) && frame.event === 'pending') {
|
|
770
|
+
const payload: unknown = JSON.parse(frame.data)
|
|
771
|
+
if (isPendingForm(payload)) {
|
|
772
|
+
isPendingFormStatus(payload.status) // true — the ticket's own status
|
|
773
|
+
const schema = parseForm(payload.schema) // form owns the payload; the guard owns the envelope
|
|
774
|
+
if (schema !== undefined) render(schema)
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
isPendingForm({ id: '7', schema: 'nope', status: 'pending', time: 0 }) // false — schema must be a record
|
|
778
|
+
isTerminalSnapshot({ id: 'agent', timeout: 30_000 }) // true — the store's read boundary
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
### Sanitize a schema you did not author
|
|
782
|
+
|
|
783
|
+
This example sanitizes an untrusted schema before rendering it.
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
import { sanitizeDisplayText, sanitizeSchema, sanitizeThemeIcons } from '@orkestrel/terminal'
|
|
787
|
+
|
|
788
|
+
sanitizeDisplayText('Q\rOVERWRITE\nNEXT\tX') // 'QOVERWRITENEXTX'
|
|
789
|
+
|
|
790
|
+
const clean = sanitizeSchema({
|
|
791
|
+
fields: [
|
|
792
|
+
{
|
|
793
|
+
control: 'select',
|
|
794
|
+
name: 'ro\u0000le', // an identity: preserved byte for byte
|
|
795
|
+
label: '\u001b[31mRole', // display: the ANSI run is stripped
|
|
796
|
+
default: 'ad\u0000min', // an answer: preserved byte for byte
|
|
797
|
+
choices: [{ value: 'ad\u0000min', label: 'Ad\u0000min' }], // value preserved, label cleaned
|
|
798
|
+
meta: { anything: true }, // dropped: terminal neither renders nor interprets it
|
|
799
|
+
},
|
|
800
|
+
],
|
|
801
|
+
})
|
|
802
|
+
clean.fields[0] // name and default unchanged; label 'Role'; choice label 'Admin'; no meta
|
|
803
|
+
|
|
804
|
+
sanitizeThemeIcons({ icons: { pointer: '=>\u0007' } }) // every supplied glyph loses its control bytes
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
### Drive the field reducers directly
|
|
808
|
+
|
|
809
|
+
This example drives each field reducer without a terminal.
|
|
810
|
+
|
|
811
|
+
```ts
|
|
812
|
+
import {
|
|
813
|
+
createCheckboxState,
|
|
814
|
+
createConfirmState,
|
|
815
|
+
createEditorState,
|
|
816
|
+
createInputState,
|
|
817
|
+
createPasswordState,
|
|
818
|
+
createSelectState,
|
|
819
|
+
editLine,
|
|
820
|
+
isPrintable,
|
|
821
|
+
parseKey,
|
|
822
|
+
reduceCheckbox,
|
|
823
|
+
reduceConfirm,
|
|
824
|
+
reduceEditor,
|
|
825
|
+
reduceInput,
|
|
826
|
+
reducePassword,
|
|
827
|
+
reduceSelect,
|
|
828
|
+
renderCheckboxView,
|
|
829
|
+
renderConfirmView,
|
|
830
|
+
renderEditorView,
|
|
831
|
+
renderInputView,
|
|
832
|
+
renderPasswordView,
|
|
833
|
+
renderSelectView,
|
|
834
|
+
toggleIndex,
|
|
835
|
+
} from '@orkestrel/terminal'
|
|
836
|
+
|
|
837
|
+
// No TTY and no broker: this is what the driver does with each field, one key at a time.
|
|
838
|
+
let text = createInputState({ control: 'text', name: 'name', label: 'Name' })
|
|
839
|
+
renderInputView(text) // '? Name › ' — the header, the pointer, and the value so far
|
|
840
|
+
text = reduceInput(text, parseKey('A')).state
|
|
841
|
+
reduceInput(text, parseKey('\r')) // { status: 'submit', value: 'A', ... }
|
|
842
|
+
|
|
843
|
+
let password = createPasswordState({ control: 'password', name: 'token', label: 'Token' })
|
|
844
|
+
password = reducePassword(password, parseKey('s')).state
|
|
845
|
+
renderPasswordView(password) // the header and one mask glyph; the real value is never echoed
|
|
846
|
+
|
|
847
|
+
const confirm = createConfirmState({ control: 'confirm', name: 'ok', label: 'Continue?' })
|
|
848
|
+
renderConfirmView(confirm) // '? Continue? (y/N)'
|
|
849
|
+
reduceConfirm(confirm, parseKey('y')) // { status: 'submit', value: true, ... }
|
|
850
|
+
|
|
851
|
+
let select = createSelectState({
|
|
852
|
+
control: 'select',
|
|
853
|
+
name: 'role',
|
|
854
|
+
label: 'Role',
|
|
855
|
+
default: 'admin',
|
|
856
|
+
choices: [
|
|
857
|
+
{ value: 'admin', label: 'Admin' },
|
|
858
|
+
{ value: 'viewer', label: 'Viewer' },
|
|
859
|
+
],
|
|
860
|
+
})
|
|
861
|
+
select = reduceSelect(select, parseKey('\u001b[B')).state // down, wrapping at the ends
|
|
862
|
+
renderSelectView(select) // a multi-line view with the focused row marked
|
|
863
|
+
|
|
864
|
+
let checkbox = createCheckboxState({
|
|
865
|
+
control: 'checkbox',
|
|
866
|
+
name: 'scopes',
|
|
867
|
+
label: 'Scopes',
|
|
868
|
+
default: ['read'],
|
|
869
|
+
choices: [
|
|
870
|
+
{ value: 'read', label: 'Read' },
|
|
871
|
+
{ value: 'write', label: 'Write' },
|
|
872
|
+
],
|
|
873
|
+
})
|
|
874
|
+
checkbox = reduceCheckbox(checkbox, parseKey(' ')).state // space toggles the focused box
|
|
875
|
+
renderCheckboxView(checkbox) // one box per choice, then the selected count
|
|
876
|
+
toggleIndex(checkbox.checked, 1) // the copy-on-write primitive the reducer calls
|
|
877
|
+
|
|
878
|
+
let editor = createEditorState({ control: 'editor', name: 'notes', label: 'Notes' })
|
|
879
|
+
editor = reduceEditor(editor, parseKey('h')).state
|
|
880
|
+
renderEditorView(editor) // the finish hint, the committed lines, and the line in progress
|
|
881
|
+
|
|
882
|
+
// The shared line editing, and the printable test behind it.
|
|
883
|
+
editLine('hi', parseKey('!')) // 'hi!'
|
|
884
|
+
editLine('hi', parseKey('\u001b[A')) // undefined — a navigation key does not edit the line
|
|
885
|
+
isPrintable('a') // true
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
### Re-theme what a walk draws
|
|
889
|
+
|
|
890
|
+
This example supplies custom presentation roles and icons.
|
|
891
|
+
|
|
892
|
+
```ts
|
|
893
|
+
import {
|
|
894
|
+
createPromptTheme,
|
|
895
|
+
createSelectState,
|
|
896
|
+
DEFAULT_PROMPT_THEME,
|
|
897
|
+
renderErrorLine,
|
|
898
|
+
renderHintedHeader,
|
|
899
|
+
renderPromptHeader,
|
|
900
|
+
renderSelectView,
|
|
901
|
+
renderSubmitHeader,
|
|
902
|
+
} from '@orkestrel/terminal'
|
|
903
|
+
import { createStyler } from '@orkestrel/console'
|
|
904
|
+
import { createTerminal } from '@orkestrel/terminal/server'
|
|
905
|
+
|
|
906
|
+
// A theme is data: a glyph per icon slot, a console `Style` per semantic role. Every slot you do
|
|
907
|
+
// not name keeps its default, and each supplied style is frozen through console's own freezeStyle.
|
|
908
|
+
const theme = createPromptTheme({
|
|
909
|
+
icons: { pointer: '=>', selected: '*' },
|
|
910
|
+
roles: {
|
|
911
|
+
message: { foreground: 'magenta', attributes: ['bold'] },
|
|
912
|
+
hint: { attributes: ['italic'] },
|
|
913
|
+
},
|
|
914
|
+
})
|
|
915
|
+
theme.icons.question // '?' — untouched
|
|
916
|
+
DEFAULT_PROMPT_THEME.roles.content // the empty style: unthemed content renders as bare text
|
|
917
|
+
|
|
918
|
+
// Pass the partial bag to the driver; every view it renders is painted through it.
|
|
919
|
+
const terminal = createTerminal({ theme: { icons: { pointer: '=>' } } })
|
|
920
|
+
|
|
921
|
+
// Or render the shared line shapes yourself. Each state factory takes the styler and the partial
|
|
922
|
+
// theme after the field, so a view is themed by what built its state.
|
|
923
|
+
const styler = createStyler()
|
|
924
|
+
renderPromptHeader(styler, theme, 'Role') // '? Role'
|
|
925
|
+
renderHintedHeader(styler, theme, 'Role', 'arrows move') // '? Role arrows move'
|
|
926
|
+
renderSubmitHeader(styler, theme, 'Role') // '✔ Role'
|
|
927
|
+
renderErrorLine(styler, theme, 'Role: This field is required') // '✖ Role: This field is required'
|
|
928
|
+
renderSelectView(
|
|
929
|
+
createSelectState(
|
|
930
|
+
{ control: 'select', name: 'role', choices: [{ value: 'admin', label: 'Admin' }] },
|
|
931
|
+
styler,
|
|
932
|
+
{ icons: { pointer: '=>' } },
|
|
933
|
+
),
|
|
934
|
+
) // the '=>' cursor, every other slot at its default
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
### Route forms between named endpoints
|
|
938
|
+
|
|
939
|
+
This example routes forms through named manager endpoints.
|
|
940
|
+
|
|
941
|
+
```ts
|
|
942
|
+
import { createTerminalManager, isTerminalError } from '@orkestrel/terminal'
|
|
943
|
+
import { createForm } from '@orkestrel/form'
|
|
944
|
+
|
|
945
|
+
const manager = createTerminalManager()
|
|
946
|
+
manager.add('agent') // mint, or return unchanged, the 'agent' endpoint's broker
|
|
947
|
+
manager.add('user')
|
|
948
|
+
manager.terminals() // the 'agent' and 'user' brokers, in insertion order
|
|
949
|
+
manager.terminal('agent') // that endpoint's PromptInterface, or undefined
|
|
950
|
+
|
|
951
|
+
const form = createForm({ fields: [{ control: 'text', name: 'name' }] })
|
|
952
|
+
const answers = manager.ask('user', 'agent', form) // parks from 'user' to 'agent'
|
|
953
|
+
|
|
954
|
+
// While that edge is live, the reverse ask would close a cycle, so it refuses without parking.
|
|
955
|
+
try {
|
|
956
|
+
await manager.ask('agent', 'user', createForm({ fields: [{ control: 'text', name: 'x' }] }))
|
|
957
|
+
} catch (error) {
|
|
958
|
+
if (isTerminalError(error) && error.code === 'DEADLOCK') log('would deadlock')
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
await manager.ask('user', 'nobody', createForm({ fields: [{ control: 'text', name: 'x' }] }))
|
|
962
|
+
} catch (error) {
|
|
963
|
+
if (isTerminalError(error) && error.code === 'TARGET') log('no endpoint by that name')
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const [parked] = manager.pending('agent')
|
|
967
|
+
manager.answer('agent', parked.id, { name: 'Ada' }) // { success: true, value: { name: 'Ada' } }
|
|
968
|
+
await answers // { name: 'Ada' }
|
|
969
|
+
|
|
970
|
+
await manager.save('agent') // persist the endpoint's configured timeout (needs a store)
|
|
971
|
+
await manager.open('agent') // the live broker, or an empty one restored from the store
|
|
972
|
+
|
|
973
|
+
manager.add('bounded', { cap: 100 }) // refuse a 101st park with LIMIT instead of growing memory
|
|
974
|
+
manager.remove(['agent']) // the array overload is declared first; it is true only when every name was mounted
|
|
975
|
+
manager.remove() // remove every endpoint; the manager stays usable
|
|
976
|
+
manager.destroy() // destroy every broker, then the manager's own emitter
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
**Operations notes.**
|
|
980
|
+
|
|
981
|
+
- **Never answer or ask synchronously from inside a `pending` listener.** The deadlock guard records
|
|
982
|
+
an ask edge only after the parking call returns, so a synchronous call back into the manager runs
|
|
983
|
+
ahead of that bookkeeping. Hop a microtask or a transport round trip first, which is exactly what a
|
|
984
|
+
real remote answer does.
|
|
985
|
+
- **Remove ephemeral endpoints.** The registry never evicts an idle endpoint. An embedder minting a
|
|
986
|
+
broker per short-lived session must `remove(name)` it when the session ends.
|
|
987
|
+
- **`TimerHandler` is the scaling lever.** The default arms one host timer per parked form. At high
|
|
988
|
+
volume, inject a handler backed by one shared deadline wheel.
|
|
989
|
+
- **Set `cap` where the ask rate is unbounded.** With no cap, the worst-case parked count is bounded
|
|
990
|
+
only by the park rate times the timeout.
|
|
991
|
+
|
|
992
|
+
### Persist endpoint config
|
|
993
|
+
|
|
994
|
+
This example persists and restores endpoint configuration.
|
|
995
|
+
|
|
996
|
+
```ts
|
|
997
|
+
import {
|
|
998
|
+
createDatabaseTerminalStore,
|
|
999
|
+
createMemoryTerminalStore,
|
|
1000
|
+
createTerminalManager,
|
|
1001
|
+
} from '@orkestrel/terminal'
|
|
1002
|
+
|
|
1003
|
+
const memory = createMemoryTerminalStore()
|
|
1004
|
+
await memory.set({ id: 'agent', timeout: 30_000 }) // keyed by the snapshot's own id
|
|
1005
|
+
await memory.get('agent') // { id: 'agent', timeout: 30_000 }
|
|
1006
|
+
await memory.delete('agent') // an absent id is a no-op
|
|
1007
|
+
|
|
1008
|
+
const database = createDatabaseTerminalStore() // an in-memory @orkestrel/database driver by default
|
|
1009
|
+
await database.set({ id: 'agent', timeout: 30_000 })
|
|
1010
|
+
await database.get('agent') // narrowed back from the opaque JSON column on read
|
|
1011
|
+
|
|
1012
|
+
createTerminalManager({ store: database })
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
### Drive the walk over injected streams
|
|
1016
|
+
|
|
1017
|
+
This example drives the terminal through injected streams.
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
import {
|
|
1021
|
+
createTerminal,
|
|
1022
|
+
fieldToText,
|
|
1023
|
+
filterDisabled,
|
|
1024
|
+
filterEnabled,
|
|
1025
|
+
isInputStream,
|
|
1026
|
+
isReadable,
|
|
1027
|
+
lineCount,
|
|
1028
|
+
redrawPrefix,
|
|
1029
|
+
renderCursorUp,
|
|
1030
|
+
renderGroupHeader,
|
|
1031
|
+
renderLockedLine,
|
|
1032
|
+
renderNumberedList,
|
|
1033
|
+
renderSuggestionLine,
|
|
1034
|
+
renderUnavailableLine,
|
|
1035
|
+
supportsRawMode,
|
|
1036
|
+
valueToText,
|
|
1037
|
+
} from '@orkestrel/terminal/server'
|
|
1038
|
+
import { createPromptTheme } from '@orkestrel/terminal'
|
|
1039
|
+
import { createStyler } from '@orkestrel/console'
|
|
1040
|
+
|
|
1041
|
+
// The stream shapes are minimal on purpose, so a test drives a whole walk with no real TTY.
|
|
1042
|
+
// `listeners` is a real emitter in a real test; the walk subscribes on entry and always pairs the
|
|
1043
|
+
// `off`, so nothing leaks whichever way a field ends.
|
|
1044
|
+
const input = {
|
|
1045
|
+
on: (event: 'data', listener: (chunk: string | Uint8Array) => void) => listeners.add(listener),
|
|
1046
|
+
off: (event: 'data', listener: (chunk: string | Uint8Array) => void) =>
|
|
1047
|
+
listeners.delete(listener),
|
|
1048
|
+
setRawMode: (mode: boolean) => raw.record(mode),
|
|
1049
|
+
resume: () => undefined,
|
|
1050
|
+
pause: () => undefined,
|
|
1051
|
+
isTTY: true,
|
|
1052
|
+
}
|
|
1053
|
+
const output = { write: (text: string) => written.push(text), isTTY: true }
|
|
1054
|
+
const terminal = createTerminal({ input, output })
|
|
1055
|
+
|
|
1056
|
+
isInputStream(input) // true — callable on/off
|
|
1057
|
+
supportsRawMode(input) // true: a TTY with setRawMode, so the walk runs interactively
|
|
1058
|
+
isReadable(process.stdin) // true — the node:readline boundary the fallback narrows to
|
|
1059
|
+
|
|
1060
|
+
// The cursor math behind the in-place re-render.
|
|
1061
|
+
lineCount('one\ntwo\nthree') // 3
|
|
1062
|
+
renderCursorUp(2) // the ESC[2A cursor-up sequence; '' when the count is not positive
|
|
1063
|
+
redrawPrefix(3) // climb 2 lines, return to column 0, erase to end of screen
|
|
1064
|
+
|
|
1065
|
+
// The per-field projections the walk renders with.
|
|
1066
|
+
fieldToText({ control: 'date', name: 'born', label: 'Birthday' })
|
|
1067
|
+
// { control: 'text', name: 'born', label: 'Birthday (YYYY-MM-DD)' }
|
|
1068
|
+
valueToText(true) // 'yes' — the word the confirm reducer commits
|
|
1069
|
+
valueToText(['read', 'write']) // 'read, write'
|
|
1070
|
+
|
|
1071
|
+
// Each line that follows comes back already painted through the theme. The comments show it with the
|
|
1072
|
+
// styling stripped.
|
|
1073
|
+
const styler = createStyler()
|
|
1074
|
+
const theme = createPromptTheme()
|
|
1075
|
+
const choices = [
|
|
1076
|
+
{ value: 'admin', label: 'Admin' },
|
|
1077
|
+
{ value: 'root', label: 'Root', disabled: true },
|
|
1078
|
+
]
|
|
1079
|
+
filterEnabled(choices) // the offered choices — the form refuses a disabled value at every door
|
|
1080
|
+
filterDisabled(choices) // the withheld ones, named rather than silently missing
|
|
1081
|
+
renderGroupHeader(styler, theme, 'Account') // the section header a new group writes
|
|
1082
|
+
renderLockedLine(styler, theme, 'Code', valueToText('fixed')) // '○ Code (locked) fixed'
|
|
1083
|
+
renderSuggestionLine(styler, theme, choices) // 'Suggestions: admin, root' — an open select's offered values
|
|
1084
|
+
renderUnavailableLine(styler, theme, filterDisabled(choices)) // 'Unavailable: Root'
|
|
1085
|
+
renderNumberedList(styler, theme, filterEnabled(choices)) // ' 1) Admin' — the non-TTY fallback's list
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
## Tests
|
|
1089
|
+
|
|
1090
|
+
- [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ source bijection across
|
|
1091
|
+
`src/core` and `src/server`, the interface ↔ implementing-class method bijection, that every
|
|
1092
|
+
documented name resolves to a real export, and the equality gate: every `Summary` cell against
|
|
1093
|
+
its declaration's description paragraph, the titled `Ask one form at this keyboard` fence against
|
|
1094
|
+
the `@example` block of that title (pinned so the titled pair cannot be retired silently), and
|
|
1095
|
+
the README pitch against this guide's tagline. It also runs the flagship fences and asserts the
|
|
1096
|
+
values their comments claim.
|
|
1097
|
+
- [`tests/integration.test.ts`](../tests/integration.test.ts) — the whole round trip over a real
|
|
1098
|
+
loopback socket: a parked form, a real HTTP/SSE fixture forwarding the broker's own wire frames, a
|
|
1099
|
+
real client, and a real TTY walk that settles the authoritative form; plus a hostile schema driven
|
|
1100
|
+
end to end with no control byte in the rendered output, proven against a failing control.
|
|
1101
|
+
- [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — `parseKey` totality, the
|
|
1102
|
+
reducers over every key path, `editLine`, the theme merge and glyph sanitization, schema
|
|
1103
|
+
sanitization with its hostile negative control, the wire serializers, and the host seams.
|
|
1104
|
+
- [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) — the wire guards:
|
|
1105
|
+
the ticket status, the pending-form envelope, the wire frame, and the store's read boundary.
|
|
1106
|
+
- [`tests/src/core/Prompt.test.ts`](../tests/src/core/Prompt.test.ts) — the broker: parking a live
|
|
1107
|
+
form with its serialized schema, exact authoritative `FieldError`s on refusal, acceptance settling
|
|
1108
|
+
the form, `unknown` for an absent or settled id, expiry and teardown abandoning through the
|
|
1109
|
+
injected timer, and the `cap` refusal.
|
|
1110
|
+
- [`tests/src/core/PromptClient.test.ts`](../tests/src/core/PromptClient.test.ts) — the bridge over a
|
|
1111
|
+
scripted `fetch`: parse, sanitize, render, POST `{ id, values }`, the retry with seeded values and
|
|
1112
|
+
exact invalidations, expiry and the `destroy` frame interrupting an active render, the in-flight
|
|
1113
|
+
dedupe, the token header, and permanent `destroy`.
|
|
1114
|
+
- [`tests/src/core/TerminalManager.test.ts`](../tests/src/core/TerminalManager.test.ts) — idempotent
|
|
1115
|
+
`add`, the attributed ask, `TARGET` and transitive `DEADLOCK`, edge lifetime across rejection,
|
|
1116
|
+
acceptance, expiry and removal, durable `open` / `save`, every `remove` scope, and `destroy`.
|
|
1117
|
+
- [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — each core factory
|
|
1118
|
+
returns a working instance of its interface with its seams forwarded.
|
|
1119
|
+
- [`tests/src/core/stores/MemoryTerminalStore.test.ts`](../tests/src/core/stores/MemoryTerminalStore.test.ts)
|
|
1120
|
+
— the shared store case matrix against the memory twin.
|
|
1121
|
+
- [`tests/src/core/stores/DatabaseTerminalStore.test.ts`](../tests/src/core/stores/DatabaseTerminalStore.test.ts)
|
|
1122
|
+
— the same matrix against the one-table twin, plus the read-boundary guard on an off-shape row.
|
|
1123
|
+
- [`tests/src/server/Terminal.test.ts`](../tests/src/server/Terminal.test.ts) — the walk over a
|
|
1124
|
+
scripted TTY: every control settling one form, the blank line binding as absence, a refused
|
|
1125
|
+
value re-asked, an open select accepting a value outside its list, hidden / disabled / locked /
|
|
1126
|
+
group handling, the unanswerable form abandoned, ctrl-c leaving the form editing, and the shared
|
|
1127
|
+
readline fallback; plus the `#report` output-boundary regression — a hostile field name carrying
|
|
1128
|
+
NUL/DEL bytes, proven sanitized in the rendered failure line against a raw-write negative control
|
|
1129
|
+
that does contain those bytes.
|
|
1130
|
+
- [`tests/src/server/helpers.test.ts`](../tests/src/server/helpers.test.ts) — the stream guards, the
|
|
1131
|
+
cursor math, the field projections, and the whole-form line shapes.
|
|
1132
|
+
- [`tests/src/server/factories.test.ts`](../tests/src/server/factories.test.ts) — `createTerminal`
|
|
1133
|
+
returns the one-method whole-form interface over the resolved or injected streams.
|
|
1134
|
+
|
|
1135
|
+
## See also
|
|
1136
|
+
|
|
1137
|
+
- [`AGENTS.md`](../AGENTS.md) — the rules this package is written to.
|
|
1138
|
+
- [`console.md`](console.md) — the `StylerInterface` every view is painted through, and the `strip` /
|
|
1139
|
+
`stripControls` the sanitizer composes.
|
|
1140
|
+
- [`contract.md`](contract.md) — the `Result` and `Guard` vocabulary the broker's outcome and the
|
|
1141
|
+
wire guards are built from.
|
|
1142
|
+
- [`emitter.md`](emitter.md) — the typed emitter the broker, the client, and the manager each expose.
|
|
1143
|
+
- [`sse.md`](sse.md) — the parser the client decodes the broker's event stream with.
|
|
1144
|
+
- [`database.md`](database.md) — the table the database store twin persists a snapshot through.
|
|
1145
|
+
- [`README.md`](README.md) — the guides index, and where `@orkestrel/form` fits.
|