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
| Group | Components |
|---|---|
| the focused agent | axon:agent/name axon:agent/model axon:agent/modules axon:agent/status axon:agent/error |
| every agent | axon:agents/active axon:agents/total |
| the conversation | axon:session/name axon:session/id axon:session/tokens axon:session/messages |
| git | axon:git/branch axon:git/dirty axon:git/ahead axon:git/behind |
| the machine | axon:process/cwd axon:process/pid axon:profile/email |
| the terminal | axon:mode axon:keys axon:clock |
| your config | axon: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.