Build your own client
Docs/Extend/Build your own client

Build your own client

Edit on GitHub
docs-site/extend/your-own-client.md

An extension never draws: it composes catalog components. When the catalog lacks something (a Gantt chart, a domain view), you build your own client: one of Orrery's terminal clients as a library, plus your components, built into one program. Two libraries: the ratatui client in Rust (--from ratatui), and the Ink client in TypeScript and React (--from ink). It is installed like any client and draws everything the built-in TUI draws.

Scaffold one

orrery client new --from ratatui acme-client
cd acme-client
cargo build --release
cp target/release/acme-client .      # acme-client.exe on Windows
orrery install .
orrery --ui acme-client

orrery client new writes four files into ./acme-client and fails, naming another name to use, if the folder exists:

FileWhat
Cargo.tomlA project on orrery-client-ratatui, at the version this orrery ships.
src/main.rsClient::builder() with one example component, acme-client.card.
orrery.toml[[provides.clients]]: the executable, and draws listing the library's elements plus your kind.
README.mdHow to build, install and add a component.

For Ink, see Ink: scaffold one.

Add a component

A component draws one element id from that element's props, as ratatui lines:

use orrery_client_ratatui::{Client, Component};
use ratatui::text::Line;
use serde_json::Value;

struct Gantt;

impl Component for Gantt {
    fn lines(&self, props: &Value, width: u16) -> Vec<Line<'static>> {
        // one line per task, at most `width` columns
        vec![Line::from(format!("{} tasks", props["tasks"].as_array().map_or(0, Vec::len)))]
    }
}

fn main() -> std::process::ExitCode {
    Client::builder().component("acme.gantt", Gantt).run()
}

A closure |props: &Value, width: u16| -> Vec<Line<'static>> works too. Add the id to draws in orrery.toml.

CallDoes
Client::builder()The whole client, with no components of yours: what orrery-ratatui is.
.component(id, c)Draw custom nodes and surfaces of kind id with c. id is <ext>.<name>.
.build()Register the components. Fails with the fix on an id that is not <ext>.<name> or one given twice.
.run()Build, then run as the host's client process (and answer the install-time fixture probe). Exits 2 on a build error.
components::draw(id, props, width)What your component draws, for a test.

Ink: scaffold one

orrery client new --from ink acme-ink
cd acme-ink
pnpm add link:<orrery-checkout>/harness/clients/ink   # until @getorrery/client-ink is on npm
pnpm install
pnpm run build
orrery install .
orrery --ui acme-ink

It writes five files into ./acme-ink, with the same refusals:

FileWhat
package.jsonA pnpm project on @getorrery/client-ink at the version this orrery ships, ink and react at the versions it uses, and a build script bundling src/main.tsx into dist/main.js.
tsconfig.jsonTypeScript with React's JSX.
src/main.tsxcreateClient() with one example component, acme-ink.card.
orrery.tomlruntime = "node", [[provides.clients]] with entry = "dist/main.js", and draws listing the Ink library's elements plus your kind.
README.mdHow to build, install and add a component.

Ink: add a component

A component is an Ink/React component handed { props, width, kind }:

import { createClient, type ComponentProps } from "@getorrery/client-ink";
import { Text } from "ink";

function Gantt({ props, width }: ComponentProps) {
  const tasks = Array.isArray(props["tasks"]) ? props["tasks"] : [];
  return <Text>{tasks.length} tasks in {width} columns</Text>;
}

createClient({ components: { "acme.gantt": Gantt } }).run();
CallDoes
createClient()The whole Ink client, with no components of yours: what orrery --ui ink runs.
createClient({ components })Draw custom nodes and surfaces of each key's kind with its component. A key is <ext>.<name>. Throws Misconfigured with the fix on a key that is not, or one already registered to another component.
.run()Run as the host's client process (or against --endpoint), and answer the install-time fixture probe under ORRERY_CONFORMANCE=1. Exits 2 when misconfigured.
componentLines(id, props, width)What your component draws in the full screen, as rows, for a test.

The element is declared as below, exactly as for ratatui. The worked example is harness/extensions/examples/acme-client-ink: the same Gantt chart, schema, fallback and fixture, in Ink, declared as acme-ink.gantt so it installs beside acme-client.

Declare the element

The extension that sends the element declares it, so every other client still draws something:

[[provides.elements]]
id       = "acme.gantt"
props    = "gantt.schema.json"     # a JSON schema for its props
fallback = "table"                 # a catalog element every client draws
fixture  = "fixtures/acme.gantt.json"

It sends a custom node with the fallback beside the props:

{ "type": "custom", "kind": "acme.gantt",
  "props": { "tasks": [{ "name": "build", "start": 2, "end": 9 }] },
  "fallback": { "type": "table", "columns": ["task", "days"], "rows": [["build", "2-9"]] } }

Your client draws the Gantt chart. Every other client draws the table; one without the catalog draws the props as text.

With fixtures = "fixtures" in your client's entry, orrery install hands it fixtures/acme.gantt.json in probe mode and your component answers it. A client missing a required element is refused, as any client is; the library draws all of them, so yours passes from the start.

The worked example is harness/extensions/examples/acme-client: a Gantt chart, its schema, its fallback and its fixture.

Typing in a component

While a placed component has the keys (F6) and is typing into a textarea, a form field or a free question: characters, Backspace and Esc are its own, Enter sends (the prompt's submit chord), and Shift+Enter or Alt+Enter is a newline. The first Esc leaves typing; the next one dismisses the surface.

Limits

  • A rebuild per upgrade. A Rust client needs a Rust toolchain, an Ink one Node 20.19 and pnpm, and either a rebuild on each Orrery upgrade you take: the library is compiled (or bundled) in.
  • Your component draws only in your client. A Gantt chart in ratatui does not draw in the ADE, Cloud web or Ink, nor one in Ink in ratatui; they draw the fallback.
  • Composed when built, never loaded. No component is loaded at runtime; adding one means rebuilding.
  • Full screen maps styles. In the full screen a named colour is drawn as the theme's (red, green, yellow, blue, magenta, cyan, gray), an RGB colour as given, with bold, dim, italic and underline (Ink adds strikethrough and inverse and keeps 256 colours as RGB); mono drops the colours. An Ink component is rendered there once per kind, width and props by a throwaway Ink instance, and is on the first frame; a width change (a resize, a pane opening) draws the fallback for one frame, then the component.
  • Ink redraws on props. An Ink component reading anything but its props and width (a clock, a file) is not redrawn in the full screen until its props change.
  • Nested width. In the full screen a component inside a placed structure is drawn at its pane's width (or the thread's); inside a box or a row it is narrower than that, so its lines wrap. Draw it at the top of the structure, or keep lines short.
  • One set per process. Components are registered once, for the client's life.
  • Library version. orrery-client-ratatui follows semver from 0.1.0. Client, Builder, Component and components::draw are its stable API; the rest of the crate is public for orrery itself and may change.
  • Ink library version. @getorrery/client-ink follows semver from 0.1.0. createClient, ComponentProps, Client and ClientOptions are its stable API; the rest of the package may change.
  • Not on crates.io or npm yet. The ratatui scaffold takes the library from the Orrery repository by version; point it at a checkout with path = ... to build offline. The Ink scaffold asks for @getorrery/client-ink by version, which pnpm install cannot find until it is published: link a checkout (pnpm add link:<checkout>/harness/clients/ink), built once with pnpm install at its root.
  • Ink's bundle has no picture addon. The scaffold's build leaves @resvg/resvg-js out: the mark draws as pixels, not a picture.
  • One id, one declaration. Two installed packages cannot declare the same element id: acme-client declares acme.gantt, acme-client-ink acme-ink.gantt, so both install. A sender picks the client by the id it sends; the other draws the fallback.