Show things: components
Edit on GitHubA component is a structure over the catalog orrery/1 plus one JSON object of data. The structure reads the data by JSON Pointer. The client binds the two and draws the result; no code of yours runs on the host or in the client.
There are two ways to feed one, with the same result on screen:
| Mode | You write | Data comes from | Actions go to |
|---|---|---|---|
| Declared | One orrery.toml, runtime = "none", no process | A workspace file, or a value the host holds | [provides.actions.<name>]: a message to the agent, or a slash command |
| Code | An extension in any language | ui.show then ui.data(id).set / .patch | Your process, as ui/event |
This page is declared mode end to end, then code mode with the SDKs' typed builders; the wire calls are in Show things: placements. Both modes use the binding forms below.
A pane from a file: ci-watch
A CI job, a script or a watcher writes .ci/status.json in the workspace. The extension shows it in the right pane, with two buttons.
~/.orrery/extensions/ci-watch-declared/orrery.toml:
[extension]
api = "orrery-ext/1"
name = "ci-watch-declared"
version = "0.1.0"
runtime = "none"
[requires]
read = ["$WORKSPACE/.ci/**"]
"run.send" = true
[[provides.components]]
name = "ci"
summary = "The last CI run: its status, what failed, and buttons to explain or rerun it"
file = "ci.json"
schema = { type = "object", properties = { workflow = { type = "string", default = "CI" }, status = { type = "string", default = "unknown" }, failed = { type = "integer", default = 0 }, total = { type = "integer", default = 0 } } }
[[provides.components]]
name = "session"
summary = "The session's turns"
structure = { type = "text", value = "${/title} · ${/turns} turns" }
schema = { type = "object", properties = { title = { type = "string", default = "" }, turns = { type = "integer", default = 0 } } }
[[provides.placements]]
id = "ci"
placement = "pane.right"
title = "CI"
component = "ci"
data = { file = ".ci/status.json" }
[[provides.placements]]
id = "turns"
placement = "footer"
title = "Session"
component = "session"
data = "$session"
[provides.actions.explain]
send = "Explain why CI failed, from .ci/status.json"
[provides.actions.rerun]
command = "/ci rerun"ci.json, next to it, is the structure:
{
"type": "column",
"children": [
{ "type": "text", "value": "${/workflow} · ${/status}" },
{ "type": "text", "value": "${/failed} failed of ${/total}" },
{
"type": "row",
"children": [
{ "type": "button", "label": "Explain", "action": "explain" },
{ "type": "button", "label": "Rerun", "action": "rerun" }
]
}
]
}.ci/status.json, in the workspace, is the data:
{ "workflow": "build", "status": "failed", "failed": 1, "total": 12 }Start orrery. The pane shows build · failed, 1 failed of 12 and the two buttons. Rewrite the file and the pane follows within about a second. F6 moves focus to the pane, Tab between the buttons, Enter presses one.
The same folder is the test fixture harness/extensions/fixtures/ci-watch-declared. Every manifest key is in the Manifest reference.
The worked examples draw one panel both ways: the workflow, N failed of M, the jobs as a list (choose one to rerun it, its name sent through ${value}) and an Explain button.
harness/extensions/examples/ci-watch-toml: declared, fed from.ci/status.json.harness/extensions/examples/ci-watch: code mode in Python with raw JSON-RPC,ui/show {component},ui/dataandui/event.
Data sources
A file
data = { file = "<path>" }, relative to the workspace.
- The file holds one JSON object. An array or a scalar at the top is refused.
- Polled every second. A change is read 200 ms after it is seen, so a writer's burst is one read.
- Needs a read grant.
[requires] readmust cover the file, or the manifest is refused at load with the line to add. Each read is then checked under the policy as a read byext:<id>, the same rule a process extension's reads meet. - A bad file keeps the last good data drawn. A missing file, bad JSON, a denied read or a file over
limits.dataBytesis shown in the extension's status segment (keyed by the placement id) with its fix. With no good data yet, the placement shows the refusal itself. - The schema's
defaults fill whatever the file leaves out.
A host value
data = "$session", "$cost", "$git" or "$tasks". Built from what the host already holds, and again after each burst of loop events (turns, tools, agents, runs). None carries a secret.
| Value | Shape | Fields |
|---|---|---|
$session | { id, title, turns, elapsed, startedMs } | id: the session id. title: the workspace folder's name (sessions have no title of their own). turns: the main agent's settled turns. elapsed: milliseconds since the session opened, as of the last refresh. startedMs: when it opened, in unix milliseconds. |
$cost | { usd, tokensIn, tokensOut } | Summed over every agent's settled turns. usd is dollars as a number, 0 while no provider reported a price. |
$git | { branch, dirty, ahead, behind } | branch: the checked-out branch, null when detached or outside git. dirty: anything changed, staged or untracked. ahead, behind: commits against the upstream, 0 without one. Outside a repository: { "branch": null, "dirty": false, "ahead": 0, "behind": 0 }. |
$tasks | { items } | The session's tasks, oldest first, each as /tasks shows it: id, kind, label, state, startedMs, and agent, port, log when they apply. |
[[provides.components]]
name = "spend"
summary = "Tokens and dollars this session"
structure = { type = "kv", items = [ { label = "in", value = { "$data" = "/tokensIn", format = "number" } }, { label = "out", value = { "$data" = "/tokensOut", format = "number" } }, { label = "usd", value = "${/usd}" } ] }
[[provides.placements]]
id = "spend"
placement = "band"
title = "Spend"
component = "spend"
data = "$cost"No source
A placement with component and no data is filled from the schema's defaults alone, once.
Actions
A button, a list or tree choice, a table row, a form or a textarea names an action. In a declared component, the host runs the matching [provides.actions.<name>]:
| Key | What happens | Needs |
|---|---|---|
send = "text" | The text goes to the agent as the extension's message (labelled ext:<id>), never as the person's. | "run.send" = true under [requires], granted at install. |
command = "/cmd args" | The slash command runs as ext:<id>, through the tool's policy. | An installed extension whose tool answers that command. |
${value}carries what came with the action: insendorcommandit is replaced by the action's value. A string goes in as is (a row'skey, a chosen path, typed text); an object or a list goes in as compact JSON (a form's values); no value leaves it empty.command = "/ci rerun ${value}"on a table whose rows carry the run id reruns the chosen run.- Never a shell: the filled
commandline goes through the command registry like a typed one, so the value is the command's argument text and nothing in it runs as a program. commandreaches extensions' commands only: those an installed tool registers. The client's built-in commands (/model,/theme,/ui) live in the client and cannot be run this way.- A refusal (no such action, no
run.sendgrant, no tool answers the command, the policy denied it) is a notice labelled with the extension, naming the fix. openon alinkis the host's in every mode: it opens the target the link shows and never reaches the action table (Links).
Binding forms
A value anywhere in a structure may be a literal or one of these. Pointers are JSON Pointers: /runs/0/name; ~1 is /, ~0 is ~.
| Form | Meaning |
|---|---|
{"$data": "/failed"} | The value at that pointer. Missing is null. |
"${/failed} failed" | A string with values put in. null is empty, whole numbers lose .0, objects and arrays print as JSON. $${ is a literal ${. |
{"$data": "/ms", "format": "duration"} | The value, formatted (table below). |
{"$data": "/runs", "each": <template>} | One copy of the template per item of the array. Pointers inside the template are relative to the item. A missing or null array is empty. |
"children": {"$data": "/runs", "each": <node>} | Nodes from data: one node per item. children (and tabs) is a literal array or an each, never a bare $data. |
"visible": {"$data": "/failed", "gt": 0} | The node shows only while the condition holds. |
{"$data": "/failed", "gt": 0, "then": "error", "else": "ok"} | One value or the other. A missing branch is null. |
{"all": [ … ], "then": …, "else": …} · {"any": [ … ]} | Conditions combined. |
{"$data": "/ok"} as a condition | With no op, true when the value is truthy: not false, 0, null, "", [] or {}. |
Ops: eq, ne, gt, ge, lt, le, in (an array of literals), empty (true or false). Several ops on one binding must all hold. Numbers compare by value (1 equals 1.0); gt/lt compare two numbers or two strings, and are false otherwise.
format | Input | Gives |
|---|---|---|
duration | milliseconds | 450ms, 12s, 1m 05s, 2h 03m |
bytes | bytes | 512 B, 1.2 KB, 3.4 MB (1024-based) |
number | a number | 1,234,567.5 |
percent | a fraction | 0.42 → 42% |
relative | unix milliseconds | just now, 45s ago, 3m ago, in 1m |
date | unix milliseconds | 2026-10-31 14:05, UTC |
A list of rows from data:
{ "type": "table", "columns": ["Job", "Took"], "empty": "No runs yet",
"rows": { "$data": "/runs", "each": [ { "$data": "/name" }, { "$data": "/ms", "format": "duration" } ] } }Code mode: build the structure
In code, ui.show takes an inline structure, then ui.data(id).set / .patch changes its data (Components and data). Both SDKs build that structure from typed builders instead of hand-written JSON. The output is the same plain JSON, so everything on this page (bindings, formats, limits) holds unchanged.
Node, @getorrery/ext: catalog has one builder per catalog id, bind the binding forms.
import { bind, catalog as c } from "@getorrery/ext";
const ci = c.column([
c.text(bind.fmt("${/workflow} · ${/status}")),
c.badge("${/failed} failed", { tone: bind.when(bind.gt("/failed", 0), "error", "success") }),
c.table({
columns: ["Job", { title: "Took", align: "right" }],
empty: "No runs yet",
rowAction: "open-run",
rows: bind.each("/runs", (row) => ({ key: row.fmt("${/id}"), cells: [row.data("/name"), row.data("/ms", { format: "duration" })] })),
}),
c.button({ label: "Rerun", action: "rerun", visible: bind.gt("/failed", 0) }),
]);
await ctx.ui.show({ id: "ci", placement: "pane.right", title: "CI", structure: ci, data: { workflow: "build", status: "ok", failed: 0, runs: [] } });| Builder | Gives |
|---|---|
c.<id>(props) | {"type": "<id>", ...props}, for all 32 ids |
c.row(children) · c.box({title}, children) · c.collapsible({title}, children) | row, column, box and collapsible with children: an array or a bind.each |
c.text(value, props?) | Also heading, badge, markdown, divider, code: their main value first |
bind.data("/ptr", {format, ...ops}) | {"$data": "/ptr", ...} |
bind.fmt("${/a} of ${/b}") | The string as it is: ${…} is already bound in any string |
bind.each("/runs", template) | {"$data": "/runs", "each": template}; template may be (b) => …, b being bind |
bind.when(cond, then, else?) | The condition with then / else |
bind.eq · ne · gt · ge · lt · le · in · empty ("/ptr", value) · bind.all(...) · bind.any(...) | Conditions |
With TypeScript, or // @ts-check in a .mjs, a wrong id, a wrong prop, a missing required prop, a heading level 4, a neutral callout, a pointer without its leading / or an unknown format is a type error. Any value may be a binding.
Rust, orrery_ext_sdk::catalog: one function per id, its required props as arguments, the rest as methods. It gives a serde_json::Value for orrery_ext_sdk::structure.
use orrery_ext_sdk::catalog::{self as c, data, each, Format};
use serde_json::{Value, json};
let ci: Value = c::column()
.child(c::text("${/workflow} · ${/status}"))
.child(c::badge("${/failed} failed").tone(data("/failed").gt(0).then("error").otherwise("success")))
.child(
c::table(["Job", "Took"])
.row_action("open-run")
.rows(each("/runs", json!({ "key": "${/id}", "cells": [data("/name"), data("/ms").format(Format::Duration)] })))
.empty("No runs yet"),
)
.child(c::button("Rerun", "rerun").visible(data("/failed").gt(0)))
.into();
ctx.ui_show(orrery_ext_sdk::structure("ci", Placement::PaneRight, ci, json!({ "failed": 0, "runs": [] }))).await?;Methods are snake_case and write the camelCase prop (row_action is rowAction). box is c::box_, else is .otherwise, in is .one_of, a question's default is .default_value. Nodes and bindings serialize, so they go inside json! as they are.
The fixtures harness/clients/conformance/binding/ are rebuilt with both builders, and must give the same JSON: harness/core/crates/orrery-ext-sdk/tests/catalog.rs, harness/extensions/node/ext-sdk/test/catalog.test.mjs.
Limits of the builders
- The builders check names, not data. A pointer that names nothing, a number bound into a text prop, or an
eachover a value that is not an array is found by the host when it binds, and refused naming the JSON path. - Rust checks ids and prop names only. Values are
impl Into<Value>, so a heading level 4 or a neutral callout compiles and is refused by the host. Nested shapes (a form field, a chart series, a keyed row) are written withjson!. Typed structs fromorrery_proto::catalogcannot hold a binding in a value, and a binding-aware copy would repeat all 32 types. - Node checks only with types. Plain JavaScript without
// @ts-checkgets the same JSON and no check: a wrong prop is passed through to the host. - The Node types are written by hand from
harness/protocol/protocol.d.ts, because@getorrery/extships without@getorrery/protocol. A prop added to the catalog is missing from the types until they are updated. ui.show({structure})still accepts any object, so hand-written JSON keeps working; only the builders are checked.- No outer data inside
each, no arithmetic: the builders write the binding language, not a bigger one. Compute into the data. - Mixing nodes and bindings in one Rust array needs
.into()on each ([Value::from(false), all([data("/z")]).into()]).
Preview a component
orrery ui preview draws a component without a session, as the TUI draws it, and prints it. It runs the host's checks in the host's order: the structure against the catalog, the schema's defaults, the schema, the binding, and the actions against [provides.actions].
orrery ui preview ./ci.json --data status.json # a structure file
orrery ui preview ./orrery.toml --name ci --data status.json --width 40
orrery ui preview ci-watch-declared.ci --data status.json --mono
orrery ui preview ./ci.json --data status.json --fixture catalog-ci.jsonl- A mistake exits 2 with the fix: an unknown component, a refused structure or prop (its JSON path), data that misses the schema (its path), an action the manifest does not declare.
- A pointer that resolves to nothing is a warning on stderr naming where it is written and the pointer, and the command exits 0: it draws empty, as in a session.
--fixture <file>also writes a conformance scenario (the shape ofharness/clients/conformance/catalog-*.jsonl): the component in the thread with its data, and the store state it must give.--now <ms>fixes "now" forrelativeanddate, so a preview or a fixture is the same on every run.
Limits of preview
- A bare structure file has no manifest: no schema, no defaults, no action check. Preview it from its
orrery.tomlfor those. - The size limits are not checked (
limits.componentBytes,limits.dataBytes); a session still refuses an oversized component. - Colour is the 16-colour palette, not the TUI's themes. A pipe,
NO_COLORor--monoprints plain text;--monoalso draws each glyph with its word. - Nothing is focused or interactive: no keys, a collapsible as its structure says, the first tab.
- The pointer warning reads an
eachtemplate against each item it has. An empty list checks nothing inside it. - A written fixture is not in the set until it is copied into
harness/clients/conformanceandSCENARIO_COUNTis bumped in both clients. --widthis 20 to 500 columns.
Limits
- Polling, not watching: one look at the file a second per placement. A change shows up to about 1.2 s late.
- The structure
fileis read once, at session start. Edit it, then start a new session. A structure that is not valid JSON or fails the catalog check is drawn as its refusal in its placement. - An update the structure refuses is not retried until the source changes again.
- Host values refresh on loop events only.
$gitrunsgit statusafter each burst of them (at most 5 s, else refused), so a change made outside the session shows at the next turn or tool call.$session.elapseddoes not tick: bindstartedMswithformat: "relative"for a moving clock. $costand$session.turnscount from when this process opened the session. A resumed session starts them at zero, and usage the event bus skipped under load is not counted.- A value over 4 KiB is refused when an action puts it in with
${value}: send a key or a path, not the content. An action without${value}ignores the value. ${value}is text substitution only: no escaping, no paths into the value (${value.name}stays as typed). A value with spaces splits into the command's arguments as a typed line would; a form's JSON stays one piece only if the command takes the rest of the line.- A component is its own extension's.
ui.show {component}from another extension, or from the agent, is refused: declare your own in[[provides.components]], or send an inlinestructure. - A structure
filethat fails to read or parse refuses its component at load, naming the file, the JSON error's line and column, and the fix. - An action the structure names but the manifest lacks is found only when pressed.
- The binding language is deliberately small. No arithmetic, no string functions, no outer data from inside
each, and condition operands are literals. Compute anything harder into the data (in the file's writer, or in code mode). - Size: at most 10 000 nodes after
eachexpands, 64 levels deep, a structure underlimits.componentBytesand data underlimits.dataBytes(Config keys). A bigger file is refused before it is read. - A number bound into a text prop (
stat.value,kvvalues,badge.text) must be formatted or interpolated: a raw number is refused. - Every accepted update is written to the session log, so a file rewritten often grows it. Updates over
limits.dataUpdatesPerSecare coalesced. - No colour, 40 columns,
--json: the component's text form is whatorrery run -p --json,session/readand a client that does not bind show. A narrow screen reflowsrows withwrapand drops low-prioritytable columns. - A source other than a file or a host value (a command, a URL) is not available in declared mode.