components

The live values a line shows. A component is a name, what changes it, and what it says.

interface ComponentsApi {
    // Register a component. Throws if the name is taken.
    create(name: string, component: LineComponent): Disposer

    // Every registered name, built-ins included.
    list(): readonly string[]
}

type LineComponent = {
    on?: readonly TuiHookName[]   // hook events that change this value
    every?: number                // or a tick, in ms — for values time changes
    icon?: IconName | (string & {})
    click?: () => void | Promise<void>
    render: () => string | number | null | undefined
}

Declaring when it changes

components.create("me:sent", {
    on: ["message:sent"],
    render: () => `${sent} sent`,
})

on takes the same names tui.hook does — the terminal already emits a precise event for everything it does, so you pick from a list you have learned rather than inventing a subscription.

The trigger is declared, and that is the whole design. A bare function leaves the terminal with no idea when to call it: re-running every component on every frame lets one slow value stall the paint, and asking users to wire their own reactivity is the raw-power design this avoids. So a component says what wakes it, and the renderer evaluates it then and never otherwise.

A component with no trigger renders once and stays — the right shape for something static, and free.

Values that change with time

components.create("me:uptime", {
    every: 1000,
    render: () => `up ${Math.floor(process.uptime())}s`,
})

every is for values nothing in the terminal marks the passing of. Taken as written, with no floor — painting a string is cheap, and a component doing heavy work on a fast interval is your call.

render is synchronous

A frame cannot await. A component that needs real work does it in its trigger and returns the cached value:

let branch = ""

components.create("me:branch", {
    every: 5000,
    render: () => {
        void refresh()   // fills `branch`, does not block
        return branch
    },
})

Returns a string, a number, or null/undefined for "nothing right now" — which renders as no columns rather than the text "null". Primitives only: an object would render as [object Object], and a stray async as [object Promise], silently.

A render that throws costs that component and nothing else. It renders empty, the fault is reported, and the line around it is unaffected.

Icons

components.create("me:branch", {
    icon: "branch",
    render: () => branch,
})

An icon is a name from the catalogue, resolved through the active set — so one setting swaps every icon at once, which is what lets a shared component work on a machine without a patched font.

Set it in profile.config.ts:

settings: {
    icons: "auto",   // "auto" | "nerd" | "unicode" | "none"
}

auto guesses from the environment and errs toward unicode, because the two failure modes are not symmetrical: guessing low costs you a plainer bar, which one setting fixes. Guessing high fills the row with boxes — and tofu does not look like a font problem, it looks like the tool is broken.

A literal glyph (icon: "▸") passes through untouched and ignores the setting.

Names: agent model modules session tokens messages branchchanges ahead behind folder clock user warning error

Clickable

components.create("nav:zeno", {
    render: () => "zeno",
    click: () => tui.nav("zeno"),
})

What turns a status line into a control surface — a row of agent names becomes a nav bar. Unawaited, so a handler that takes a second does not hold the frame, and a failure is reported rather than surfacing as a click that silently did nothing.

What ships

GroupComponents
the focused agentaxon:agent/name axon:agent/model axon:agent/modules axon:agent/status axon:agent/error
every agentaxon:agents/active axon:agents/total
the conversationaxon:session/name axon:session/id axon:session/tokens axon:session/messages
gitaxon:git/branch axon:git/dirty axon:git/ahead axon:git/behind
the machineaxon:process/cwd axon:process/pid axon:profile/email
the terminalaxon:mode axon:keys axon:clock
your configaxon:ext/loaded axon:ext/degraded

agent/ is the focused one, agents/ is all of them, session/ is the conversation — genuinely different nouns, which is why an agent's name and a session's name are two components rather than one.

The git ones read a local cache filled by git plumbing commands. No auth, no network. Outside a repository they render empty rather than complaining.

Names

provider:name, not a package specifier. A package ships many things; a component comes from one provider, and naming it after a package would make every component read like an import path. Prefix yours with something of your own.

Every registered name is generated into your types, so lines.create completes them and a typo is a compile error rather than a fault you find at runtime.