- Approval: re-prompt unknown answers (max 3), shell-only affirmative guidance recovery; destructive actions stay strict - GoalVerifier subagent with strict JSON verdicts, bounded follow-up rounds, nonzero exit plus open turn on unverified one-shots - Retrieval eval (Hit@K/MRR/nDCG/Context Precision), FTS cascade with title weighting, query/read event logging, --eval-retrieval CLI - Supervisor caps (8 parallel, 64 records) and scheduler hardening (4 fires/tick, per-schedule lease, stated misfire policy) - Sandbox box resource trio: 2g/2.0/256 at create, best-effort update for existing boxes, rm -f recovery pointer - Research archive: 12 subjects, 667 sources, 27 conclusions; suites 979 + 16 green
170 lines
8.2 KiB
Markdown
170 lines
8.2 KiB
Markdown
# uiflow: interactive UI flows on the user's terminal
|
|
|
|
Specification of the `plexer_show` agent tool and the JSON contract behind it.
|
|
The code is `validate_flow_spec`, `PlexerFlow` and `PlexerController.ask` in
|
|
`tai.py`; `PlexerFlowSpecTests`, `PlexerFlowDriverTests`, `MplexFlowServerTests`,
|
|
`TmuxAskTests`, `NativeAskTests`, `PlexerShowToolTests` and `UiflowDocTests` in
|
|
`test_tai.py` check the contract against that code, so this page cannot
|
|
silently drift.
|
|
|
|
## What it is
|
|
|
|
One tool call renders a whole interactive app (dialogs, forms, branching
|
|
multi-screen flows) on the terminal the user is looking at, and returns the
|
|
outcome as JSON. Three layers, each dumber than the one above:
|
|
|
|
- **Main agent**: makes a single `plexer_show` call with the spec, gets back
|
|
`{status, answers, action}`. Its turn stays lean no matter how complex the
|
|
flow gets: it never sees intermediate screens or keypresses.
|
|
- **Flow driver** (`PlexerFlow`, the separate agent that owns the interaction):
|
|
validates the spec, decomposes screens into one-screen `ask` calls,
|
|
evaluates `show_if` conditions and `next` branches locally, enforces the
|
|
timeout and the visit cap, and reports only the final outcome.
|
|
- **Backends** (`PlexerController.ask`): dumb renderers of one screen at a
|
|
time. Native mplex shows the screen as an overlay or status prompt on the
|
|
attached client (control socket `show`/`result`/`cancel`, see
|
|
[mplex.md](mplex.md)); tmux maps it onto `display-menu` (choice) and
|
|
`display-popup` (text, message). Both answer the same contract: `ok` with a
|
|
value, `cancelled`, or `timeout`.
|
|
|
|
## Spec format (v1)
|
|
|
|
```json
|
|
{
|
|
"v": 1,
|
|
"title": "Deploy",
|
|
"timeout": 300,
|
|
"start": "main",
|
|
"screens": [
|
|
{"id": "main", "title": "Where", "body": "Pick a target.",
|
|
"fields": [
|
|
{"id": "env", "type": "choice", "label": "Environment",
|
|
"options": ["staging", "prod"], "default": 0},
|
|
{"id": "tag", "type": "text", "label": "Tag", "initial": "v1",
|
|
"show_if": {"env": "prod"}},
|
|
{"id": "note", "type": "info", "text": "Prod deploys page the team."}
|
|
],
|
|
"actions": [{"id": "go", "label": "Deploy"}, {"id": "back", "label": "Back"}],
|
|
"next": {"go": "$done", "back": "$cancel"}}
|
|
]
|
|
}
|
|
```
|
|
|
|
Top level: `v` must be 1 (missing means 1, anything else is rejected);
|
|
`title` is the fallback for screens without one; `timeout` is the whole-flow
|
|
budget in seconds (5 to 600, default 300); `start` is the first screen id
|
|
(default: the first screen); `screens` is a non-empty list (at most 32).
|
|
|
|
Screen: `id` (unique, no whitespace); `title`, `body` (plain text); `fields`
|
|
(at most 12); `actions` (at most 12, each with unique `id` and non-empty
|
|
`label`); `next` (a screen id, an `{action: screen}` map, `$done` or `$cancel`,
|
|
default: next screen in order, `$done` after the last); `show_if` (skip the
|
|
screen unless it holds). A screen with no body, fields or actions is rejected.
|
|
|
|
Field types:
|
|
|
|
- `choice`: single select. `options` is 1 to 12 strings or
|
|
`{label, value}` objects; values must be JSON scalars (string, number,
|
|
boolean, null). `default` preselects an option index. The answer recorded is
|
|
the option's `value`.
|
|
- `text`: free input. `initial` is the starting text. The answer is the typed
|
|
string (capped at 2000 chars).
|
|
- `info`: display only, never asked, never recorded. Shown with the screen's
|
|
context (see below).
|
|
|
|
Conditions: `show_if` is `{field id: expected}`, where expected is a scalar or
|
|
a list of accepted scalars. Every clause must hold. A field that has not been
|
|
answered yet never holds. Referring to an unknown field id is rejected, so a
|
|
typo fails loudly instead of silently skipping.
|
|
|
|
Ids (screens, fields, actions) are unique per scope (fields: whole app),
|
|
non-empty, at most 64 chars, no whitespace.
|
|
|
|
## Flow semantics
|
|
|
|
Screens run in order from `start`. A screen whose `show_if` fails is skipped
|
|
(its `next` still applies as if it had no actions). Otherwise each visible
|
|
choice/text field is asked in turn, then the action when the screen has
|
|
actions. A screen with only body/info and no actions is shown once and waits
|
|
for acknowledgement.
|
|
|
|
`next` routing: a string applies to the whole screen; a map routes per taken
|
|
action, and an action missing from the map falls through to the next screen in
|
|
order (`$done` after the last). `$done` ends with status `done`, `$cancel`
|
|
ends with status `cancelled`. Every target must exist (unknown ids are
|
|
rejected upfront); at most 64 screen visits per run, more aborts with status
|
|
`error` (a loop guard, not a feature).
|
|
|
|
Outcomes, always one JSON object:
|
|
|
|
- `{status: done, answers: {field: value}, action}`: the user finished the
|
|
flow. `action` is the last taken action id, or null when no screen had
|
|
actions.
|
|
- `{status: cancelled, at, answers}`: the user pressed Escape (or `q`, `C-c`)
|
|
on a screen, or the flow routed to `$cancel`. `answers` holds what was
|
|
answered so far.
|
|
- `{status: timeout, at, answers}`: the flow budget ran out.
|
|
- `{status: error, error, at, answers}`: the visit cap tripped.
|
|
|
|
Escape anywhere aborts the whole flow; there is no per-screen back button in
|
|
v1 (route an explicit Back action where you need one).
|
|
|
|
## Screen decomposition
|
|
|
|
Backends render one screen at a time, so the driver asks a screen as a short
|
|
sequence: one `ask` per visible choice/text field, then one for the action.
|
|
The first ask carries the context (screen title, body, visible info texts,
|
|
field label); later asks show just their label. Each ask gets the remaining
|
|
flow budget as its timeout. Backend answers are indices and strings; the
|
|
driver maps them back to option values and evaluates the flow.
|
|
|
|
## Backends
|
|
|
|
Native (mplex): choice and message render as a centered overlay dialog
|
|
(`MplexFlowScreen`: arrows/`j`/`k`, Enter, digits, Escape); text renders as a
|
|
status-row prompt with emacs editing. Wire: `show` renders and returns a flow
|
|
id, `result` polls (`{done: false}` until answered), `cancel` drops it. One
|
|
pending dialog per client; a busy client refuses loudly, as does a pane whose
|
|
session has no attached client. Server caps: `MPLEX_FLOW_MAX` pending flows,
|
|
`MPLEX_FLOW_KEEP` seconds of grace for dead drivers. Details:
|
|
[mplex.md](mplex.md) (control socket).
|
|
|
|
tmux: choice renders as `display-menu` (each option writes its index to a
|
|
staged answer file via `run-shell`), text and message as `display-popup`
|
|
running `read` (no file means dismissed, an empty file means an empty answer).
|
|
Staging files live under the system temp dir as `tai-flow-*.prompt/answer`
|
|
and are removed after each ask. Two known differences from native: `default`
|
|
does not preselect (tmux menus always start at the first item) and `initial`
|
|
is ignored (no portable prefill for `read`). After a timeout the driver closes
|
|
popups server-side (`display-popup -C`); a `display-menu` has no server-side
|
|
close and stays until the user presses Escape, which is harmless because the
|
|
flow is already over.
|
|
|
|
## Hard caps
|
|
|
|
| Constant | Value | Limits |
|
|
|---|---|---|
|
|
| `PLEXER_FLOW_VERSION` | 1 | the only accepted spec version |
|
|
| `PLEXER_FLOW_SCREENS_MAX` | 32 | screens per spec |
|
|
| `PLEXER_FLOW_FIELDS_MAX` | 12 | fields per screen |
|
|
| `PLEXER_FLOW_OPTIONS_MAX` | 12 | options per choice, and per ask screen |
|
|
| `PLEXER_FLOW_ACTIONS_MAX` | 12 | actions per screen |
|
|
| `PLEXER_FLOW_VISITS_MAX` | 64 | screen visits per run (loop guard) |
|
|
| `PLEXER_FLOW_ID_MAX` | 64 | chars per screen/field/action id |
|
|
| `PLEXER_FLOW_TITLE_MAX` | 200 | chars per title |
|
|
| `PLEXER_FLOW_TEXT_MAX` | 2000 | chars per body/info/ask text |
|
|
| `PLEXER_FLOW_LABEL_MAX` | 200 | chars per field/action/option label |
|
|
| `PLEXER_FLOW_ANSWER_MAX` | 2000 | chars per text answer and initial |
|
|
| `PLEXER_FLOW_TIMEOUT_MIN` / `PLEXER_FLOW_TIMEOUT_DEFAULT` / `PLEXER_FLOW_TIMEOUT_MAX` | 5 / 300 / 600 | flow budget in seconds |
|
|
| `PLEXER_FLOW_POLL` | 0.25 s | native result poll interval |
|
|
| `MPLEX_FLOW_MAX` | 16 | pending flows per mplex server |
|
|
| `MPLEX_FLOW_KEEP` | 300 s | grace before a dead driver's record is reclaimed |
|
|
|
|
## Gaps
|
|
|
|
Single-screen rich forms (all fields visible at once with focus navigation),
|
|
multi-select choices, progress/live screens, and per-screen back navigation do
|
|
not exist in v1; the driver decomposes every screen into sequential asks,
|
|
which is the documented behavior, not a fallback. The spec carries a version
|
|
precisely so these can extend the contract without breaking v1 specs.
|