Show things: placements
Edit on GitHubA placement is where a surface goes on the screen. The person places one with config or /ui, an extension with ui.show, and the agent with the ui.show tool. The client in use draws it with its own elements. An extension never draws: it says what to show and where, and the client does the rest.
| Placement | Where it shows |
|---|---|
inline | The thread, in order. |
footer | One status segment. Only the latest text shows. |
band | Rows above the input, under the dock. |
pane.left · pane.right | A column beside the thread, from the top of the screen down to the footer. |
hidden | Not drawn. It stays in the session's record. |
A manifest's default placements may name only band, pane.left, pane.right or footer.
Show and hide
In Node, from onLoad (it runs once the extension is up), or from any tool:
import { defineExtension } from "@getorrery/ext";
export default defineExtension({
onLoad: async (ctx) => {
await ctx.ui.show({
id: "build-times",
placement: "pane.right",
title: "Build times",
surface: {
kind: {
t: "chart",
kind: "columns",
unit: "s",
labels: ["mon", "tue", "wed"],
series: [{ label: "build", values: [212, 198, 240] }],
},
},
fallback: { kind: { t: "text", value: "build times: mon 212 s, tue 198 s, wed 240 s" } },
});
},
});await ctx.ui.hide({ id: "build-times" });The same calls in Rust are ui_show and ui_hide on the host handle, with the same fields.
On the wire, a process extension sends the requests itself. Each is a JSON-RPC request, framed with a Content-Length header as the host frames ext/load:
{"jsonrpc":"2.0","id":7,"method":"ui/show","params":{"id":"ci","placement":"pane.right","title":"CI","surface":{"kind":{"t":"text","value":"3 of 5 green"}},"fallback":{"kind":{"t":"text","value":"CI: 3 of 5 green"}}}}The host answers {"jsonrpc":"2.0","id":7,"result":{}}. ui/hide takes {"id":"ci"}.
idnames the placement. The same id replaces what was there, in place. A newplacementmoves it.- An owner changes only what it placed. One extension cannot replace or hide another's surface, and the person changes anything through config or
/ui. surfaceis{"kind": …}, wherekindis one of the surface kinds.titleis the label drawn above it.- Every placed surface is labelled with its owner:
ext:<id>,agentorconfig.
Components and data (code mode)
Instead of a whole surface, an extension can show a component: a structure over the catalog (orrery/1) and one JSON object of data that the structure reads by JSON Pointer. After the first show, it sends only data. The client binds the data into the structure and draws it. No code of the extension runs on the host or in the client.
ui.show takes exactly one of surface, component or structure:
componentnames one the manifest declares in[[provides.components]](Manifest reference).structureis an inline structure. The host checks it against the catalog and registers it as<owner>#<id>.datais the first data of acomponentor astructure: one JSON object. It is refused with asurface.
In Node:
await ctx.ui.show({ id: "ci", placement: "pane.right", title: "CI", component: "runs", data: { failed: 0, runs: [] } });
// The whole object; the host diffs it into the smallest change.
await ctx.ui.data("ci").set({ failed: 2, runs });
// Or RFC 6902 operations, applied in order, all or none.
await ctx.ui.data("ci").patch([{ op: "replace", path: "/failed", value: 3 }]);
// A press, a submit or a choice in a component it showed. `stop()` ends it.
const stop = ctx.ui.onEvent("ci", ({ action, value }) => {
if (action === "rerun") rerun(value);
});ctx.ui.onEvent(handler), with no id, hears every component the extension showed. The calls are on onLoad's host too, and on every hook's.
In Rust, orrery_ext_sdk::component(id, placement, name, data) and orrery_ext_sdk::structure(id, placement, structure, data) build the ui_show params. The data goes through the host handle, and actions reach each on_ui_event handler:
use std::sync::Arc;
use orrery_ext_api::Placement;
use serde_json::json;
ctx.ui_show(orrery_ext_sdk::component("ci", Placement::PaneRight, "runs", json!({ "failed": 0 }))).await?;
let host = orrery_ext_sdk::host().ok_or("not connected")?;
host.ui_data("ci").set(json!({ "failed": 2 })).await?;
orrery_ext_sdk::on_ui_event(Arc::new(|e| eprintln!("{} pressed {} with {}", e.id, e.action, e.value)));On the wire, ui/data takes exactly one of set or patch, and is answered {}:
{"jsonrpc":"2.0","id":8,"method":"ui/data","params":{"id":"ci","patch":[{"op":"replace","path":"/failed","value":3}]}}The host sends each action as a notification: {"jsonrpc":"2.0","method":"ui/event","params":{"id":"ci","action":"rerun","value":7}}. action is the action id the structure names. value is what came with it: a form's values, the chosen item, a row's key.
Only committed actions reach the owner. Scroll, focus, the open tab and text not yet submitted stay in the client.
Links open through the host. open on a link (ui.intent {node, action: "open", value: <href>}) never reaches the owner, whoever showed the component. The host opens it only when the href is one the component's bound tree shows, and only an http:// or https:// URL or a path inside the workspace. It passes the target to the system's opener (explorer on Windows, open on macOS, xdg-open elsewhere) as one argument, never through a shell, and says what it opened in a notice.
Limits of components and data
- A refused update keeps the last good data. An update is checked against the component's JSON Schema (its
schema, after itsdefaults are filled), and the refusal names the JSON path, such asthe data at `/runs/0/name` does not match. Nothing is half-applied, and apatchis all or none. The firstdataof aui.showis checked the same way. - Only a declared component has a schema. The data of an inline
structureis checked only by binding it into a valid catalog tree. - A
schemathat is not valid JSON Schema refuses every show and update of that component, naming the problem: fix it inorrery.toml. - Size: one instance's data is at most
limits.dataBytes(256 KiB by default), and a structure at mostlimits.componentBytes(64 KiB). An update that goes over is refused (Config keys). - Rate: updates over
limits.dataUpdatesPerSec(10 a second by default) are coalesced: the last value wins at the next allowed moment. Nothing is dropped, but a fast value is drawn late. - No code on the host. A binding only reads, compares and formats. Anything harder is computed by the extension into the data.
- Structures are checked against the catalog
orrery/1. A component type outside it is refused, naming the JSON path and the component. datamust be one JSON object, never an array or a scalar. The host refuses anything else, and the Rust SDK'ssetrefuses it before sending.ui.datachanges only an id the extension itself showed with a component or a structure.- Every accepted update is session content and is written to the log. A pane that updates often grows the log.
ui.onEventhandlers run in the extension's process, on the connection. A slow handler delays the next message: start long work without waiting for it.- An action whose owner is gone (unloaded or crashed) is refused in the client, with the fix. Use
resident = truefor a component people act on. - A link is refused with the fix when it names anything else:
file:,javascript:, an absolute path,.., a path that does not exist, or a symlink out of the workspace. The notice says what a link may name. - A link opens only on the host's own screen, the person at bare
orrery.orrery run -pand a client attached toorrery servenever open anything on the serving machine, which may not be the person's: they get a notice naming the target instead, and the link's text form (label (href)) shows it too. - An
openwhosehrefthe component does not show is not a link: it goes to the owner like any other action.
Fallback
A client that cannot draw a surface natively uses, in order:
- the fallback its sender gave, if any;
- otherwise the text form, which is the same in every client.
fallback is optional. When you give one, it is a surface too, and it may use core kinds only (text, markdown, table, tree, diff, progress, stream, task, question, form, stack, chart, raster, image). A fallback that uses another kind is refused, and the refusal names the kind. In a tool, build it with ctx.ui.text, ctx.ui.markdown or ctx.ui.table. In onLoad, which has no such helpers, write the object as above.
The text form is not stored. It is worked out from the surface:
| Kind | Text form |
|---|---|
chart | Its values. |
raster, image | Its alt. |
| Core kinds | The same text orrery run --json gives them. |
| Any other kind | Its props, one key value line each. |
orrery run -p prints nothing for a placement. orrery run -p --json emits CUSTOM orrery.placed {id, placement, owner, surface, text} when one is shown and CUSTOM orrery.unplaced {id, owner} when it goes.
The agent's route
The agent has two core tools, ui.show and ui.hide, with the same fields. Each surface is checked against its element's schema before it is shown, and a surface that fails names the path that failed.
- The tools are searchable, not declared: the model finds them with
tools.searchwhen it needs them, so their schema does not sit in every request (Which tools the model sees).
Components from the agent
The agent's ui.show takes the same three forms as an extension's: a surface, a component with data, or an inline structure with data. It sends data rather than whole trees. What the search answers for ui.show lists the catalog's ids and their props, so the model can write a structure without that list sitting in every prompt.
{"id": "todo", "placement": "pane.right", "title": "Todo",
"structure": {"type": "text", "value": "${/left} left"}, "data": {"left": 3}}{"id": "ci", "placement": "pane.right", "structure": {"type": "text", "value": "${/failed} failed"}, "data": {"failed": 0}}- The agent sends an inline
structure; it is registered asagent#<id>. A component an extension declared belongs to that extension: the agent naming one is refused with the fix. - Every refusal comes back as the tool's error, with the JSON path and the fix: an unknown component, a structure outside the catalog, data that does not match the component's schema, data over
limits.dataBytes.
Limits
- The agent has no
ui.data: it changes a component by callingui.showagain with the same id and the whole new data. Each call is a tool call in its history, so a component the agent updates often costs tokens every time. - An action on the agent's component is a note, not a message. A press, a choice or a submit in one the agent showed is added to the conversation as a host message the model reads on its next turn:
[ext:ui] <id>: <action> <value>, such as[ext:ui] todo: select "row-3". It is never written as the person's typed message, so the model cannot take a press for words the person said; it is hidden from the thread, where the person already saw the press; and it starts no turn: with the agent idle, the note waits for the person's next message. Insession/readit reads as anext.messagefromui. - The search answer lists the catalog. The agent cannot show an extension's declared components: it composes its own structure.
- The catalog list makes
ui.showmatch many searches (list,table,diff), so it can take one of the ten places a broadtools.searchreturns. - The agent's placements stay in its history. Each one costs input tokens on later turns.
- By default the agent may use every placement (
ui(*)).
Config and /ui
A placement can be written in config, so it is there from the next session on:
[ui.placements."build-times"]
placement = "pane.left"orrery config set 'ui.placements."build-times".placement' pane.left writes the same key. In the TUI, /ui lists what is placed now, with its id, placement, owner and state. /ui move <id> <placement>, /ui hide <id> and /ui save <id> write the key for you (CLI reference). Config keys are listed in Config keys.
A config key moves a surface that exists. It does not create one: the owner still provides the content.
Permissions
ui(<placement>) rules decide where an agent or an extension may place a surface:
[permissions."ext:build-times"]
deny = ["ui(pane.right)"]A deny names the rule in its refusal, and the fix is in the same message. With no rule, a placement is allowed. See Permissions and consent.
Default placements and resident
An extension can declare the surfaces it will fill, so they are drawn at once, in their loading state, before the extension is up:
[extension]
api = "orrery-ext/1"
name = "build-times"
version = "0.1.0"
runtime = "node"
resident = true
[[provides.placements]]
id = "build-times"
placement = "pane.right"
title = "Build times"- The extension fills each one after
ext/load, with the sameid. resident = truekeeps its process for the whole session (Manifest reference).
Surface kinds
A surface is {"kind": {"t": "<kind>", …}}. The older core kinds are text, table, tree, diff, progress, stream, task, question, form, stack and markdown; ctx.ui.text, .table and .markdown build three of them. Three kinds were added for placements:
| Kind | Fields |
|---|---|
chart | kind, title?, unit?, series: [{label, values}], labels?. kind is one of the chart kinds below. |
raster | w, h, cells, labels?, legend?, alt. cells holds w × h colours, row by row, each packed 0xRRGGBB. labels is {rows, cols}; legend is [{colour, label}]. |
image | mime, data, alt, title?, w?, h?. data is base64, inline. Never a path. |
Chart kinds: bars, columns, stacked, line, area, scatter, sparkline, donut and gauge.
scatterreadsvaluesas x, y pairs.gaugetakesvalues: [value, max].maxdefaults to 100.stackeddraws absolute values only.
Limits
What is drawn
- One client draws the screen. A placement is drawn by the client in use, with that client's own elements. An extension is never a second client.
- A third-party client that has not added panes and the band shows no panes and no band. Its manifest stays valid, so nothing refuses it, and its author learns it from the conformance fixtures.
- The ADE does not draw placements yet.
- A pane can look like something it is not. The owner label always shows, and
input,consentandtrustcannot be imitated in place. - A raster is capped at 64 × 32 cells, and it is coarse in a terminal.
- An image shows only where the terminal has a graphics protocol: PNG on Kitty, any format on iTerm2. Elsewhere, such as over SSH or in tmux without passthrough, it shows its
alt. An image is at most 5 MB once decoded. - Narrow screens: a pane is 25% of the width. It folds into one band row when that would be under 18 columns, or when the thread would be under 40. The band shows at most 3 rows, then
+N more. - Colour is never the only signal. Bars, glyphs, patterns and labels carry the meaning.
What it costs
- A resident extension keeps one process for the whole session. An idle process costs about 1.4 MB in Rust, 7.4 MB in Python, 11.9 MB in Go and 15.5 MB in Node.
- A non-resident extension parks after a quiet spell, and its placements stop changing without a sign. A crash shows
stopped(dimmed, with the fix the screen names:/ext restart <id>). - A placement the agent makes costs input tokens on every later turn, because it stays in history. Keep surfaces small.
- A placement an extension makes is not in the model's context. The model reads a tool's
value, not a surface.
What it refuses
askrules onui(<placement>)are refused when the call is made: a placement cannot wait for an answer. Useallowordeny.input,consentandtrustare the host's alone. Showing under one of those ids is refused.- A surface that fails its element's schema is refused, naming the path.
- A fallback that uses a non-core kind is refused, naming the kind.
- A manifest may not declare
inlineorhiddenas a default placement. - A placement that a rule denies is refused, naming the rule.
Lifetime
- A runtime placement is session content. It is recorded in the session, drawn again on resume, and goes with the session.
/ui savekeeps where a surface is, not what it shows. - Unloading or uninstalling an extension removes the surfaces it placed.
- A placed surface is not sealed when its turn ends. Its owner may replace it, by id, for as long as the session lives.
- Dismissing a surface (Esc,
xor Delete while it has focus, in the TUI;F6moves focus in) hides it for this client, for this session only. Another attached client still draws it, and the host has no record of the dismissal yet.
Before you rely on it
- Config moves a placement only for an id something has placed. An id nothing has placed does nothing, and says nothing.
- The
rendererskey in a manifest does nothing. An extension never draws: give an element a fallback instead (Manifest reference). ui/*calls made whileext/loadis being answered are held and drawn once the session's screen is up, in order (at most 256 wait). Their sender is answered{}at once, so a refusal found then (a denied rule, a bad fallback) comes back as a warning notice from that extension: a toast on a screen, a line on stderr withorrery run -p. Show fromext/loadfor a first draw, and expect the answer to mean "taken", not "drawn".ui/*calls from one extension take effect in the order sent, even when it sends the next without waiting for the answer:ui.showthenui.dataunder the same id never races. The same holds forstate/set,state/get,tools/hide,tools/show,ext/registerandext/unregister, which share that one queue. Its other calls (model calls,session/append, agents, timers) may run concurrently and finish in any order: await one before a call that depends on it. Calls from two extensions are never ordered against each other.- An extension with
[[provides.placements]]starts with the session, like one that subscribes to events, so its declared surfaces are filled without waiting for a first call. - A crash is noticed at the next heartbeat (up to 15 seconds), so
stoppedcan appear late; a supervisor restart that follows at once may meanstoppedis drawn only for a moment./ext restart <id>starts it again by hand.