Show things: components
Docs/Extend/Show things: components

Show things: components

Edit on GitHub
docs-site/extend/components.md

A 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:

ModeYou writeData comes fromActions go to
DeclaredOne orrery.toml, runtime = "none", no processA workspace file, or a value the host holds[provides.actions.<name>]: a message to the agent, or a slash command
CodeAn extension in any languageui.show then ui.data(id).set / .patchYour 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.

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] read must 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 by ext:<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.dataBytes is 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.

ValueShapeFields
$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>]:

KeyWhat happensNeeds
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: in send or command it is replaced by the action's value. A string goes in as is (a row's key, 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 command line 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.
  • command reaches 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.send grant, no tool answers the command, the policy denied it) is a notice labelled with the extension, naming the fix.
  • open on a link is 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 ~.

FormMeaning
{"$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 conditionWith 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.

formatInputGives
durationmilliseconds450ms, 12s, 1m 05s, 2h 03m
bytesbytes512 B, 1.2 KB, 3.4 MB (1024-based)
numbera number1,234,567.5
percenta fraction0.42 → 42%
relativeunix millisecondsjust now, 45s ago, 3m ago, in 1m
dateunix milliseconds2026-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: [] } });
BuilderGives
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 each over 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 with json!. Typed structs from orrery_proto::catalog cannot 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-check gets 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/ext ships 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 of harness/clients/conformance/catalog-*.jsonl): the component in the thread with its data, and the store state it must give.
  • --now <ms> fixes "now" for relative and date, 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.toml for 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_COLOR or --mono prints plain text; --mono also 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 each template 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/conformance and SCENARIO_COUNT is bumped in both clients.
  • --width is 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 file is 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. $git runs git status after 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.elapsed does not tick: bind startedMs with format: "relative" for a moving clock.
  • $cost and $session.turns count 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 inline structure.
  • A structure file that 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 each expands, 64 levels deep, a structure under limits.componentBytes and data under limits.dataBytes (Config keys). A bigger file is refused before it is read.
  • A number bound into a text prop (stat.value, kv values, 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.dataUpdatesPerSec are coalesced.
  • No colour, 40 columns, --json: the component's text form is what orrery run -p --json, session/read and a client that does not bind show. A narrow screen reflows rows with wrap and drops low-priority table columns.
  • A source other than a file or a host value (a command, a URL) is not available in declared mode.