# 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.