Every pane you see in thurbox is a Lua file in here. There is no built-in interface underneath: the kernel reads this directory, and draws whatever it finds.
You are probably reading this because an agent was pointed at this directory to change or add a pane. Read the rules below before editing — most of them are not guessable from the code, and two of them are the difference between a plugin that works and one that fails at runtime with nothing on screen to say why.
layout.lua the arrangement: which slots exist, and where
lib/ shared helpers — widgets, theme roles, fuzzy match, text input,
focus styling, modal shells, scrolling, and the session-list
and repo-picker models
lib/thurbox.d.lua the API as types: node props, ctx, the event payloads, every
published `thurbox.*` row, every command verb's options, and the
theme roles. Declarations only — nothing loads it at runtime
plugins/ the panes themselves, loaded in filename order
Do this after every edit. It loads the interface exactly as thurbox does and
exits non-zero on failure, so it catches the mistakes that are otherwise
invisible until something is missing from the screen.
thurbox-cli plugin check # load it the way thurbox does
thurbox-cli plugin list # every file, where it came from, whether it is drawing
thurbox-cli plugin dir # which directory is live, and which rule chose itcheck fails on two things, and the second is the one worth knowing about. A file
that will not load is obvious. A pane that loads and draws nothing — because no
arrangement places its slot — is not: it compiles, declares its keys, appears in
plugin list, and is absent from the screen. check reports it with the
layout.lua line to add, and exits non-zero, so it is caught rather than puzzled
over.
In a running thurbox, F10 reloads from disk and Ctrl+, → ] shows the same
inventory plugin list prints.
Two rules pick the directory: THURBOX_UI_DIR if set, otherwise your own copy —
~/.config/thurbox/ui, or ~/.config/thurbox-dev/ui for a dev build, which sits
beside that build's own settings.toml exactly as every other config path does.
plugin dir says which rule won, and thurbox-cli config show prints the resolved
ui_dir beside the rest of the config: worth checking before concluding an edit did
nothing.
Standing in a checkout does not change which interface loads. There used to be a
third rule — a ./ui beside the working directory won automatically — and it made
the interface the one config that ignored the dev/release split: cargo run in the
repository read ~/.config/thurbox-dev for agents, settings and the database, and
the checkout for its panes. Editing a checkout's interface is
THURBOX_UI_DIR=ui now (just tui-ui in the repository), which is the same request
said out loud.
- Four node kinds, forever:
text,box,input,surface. Lists, panels, tables and gauges are not node kinds — they compose from these inlib/widgets.lua. If you find yourself wanting a fifth kind, you want a widget. New appearances arrive as props on these four: atextnode takes astylepainted across its whole rect before the spans go on top, which is what a selection bar or a hover band is — nothing pads a row with a spacer span, and a span that names its own colour keeps it.widgets.listtakesselected_style/hover_styleand puts them there for you. - Layout resolves before render. Size is declared statically in the
plugin's table, never returned from
render. That is what lets the kernel tell yourrenderthe rect it is drawing into. - Snapshot-read, command-write. Reads come from an in-memory snapshot and return instantly. Writes are commands, accepted now and applied later. Lua never blocks, so nothing you write here can stall the render loop on SQLite, git, or an unreachable host.
- Capabilities by absence. An ungranted capability is not in the environment — not a function that returns an error. See below.
- Anything touching the world runs on a worker. You ask; an answer appears on a later frame.
local theme = require("lib.theme")
local ui = require("lib.ui")
return {
name = "example", -- what the focus ring and help call it
slot = "center", -- which slot in layout.lua it occupies
order = 90, -- draw/focus order within that slot
focusable = true,
keys = {
{ key = "f8", action = "example.open", desc = "show the example",
scope = "global" },
},
settings = {
{ id = "loud", desc = "say it twice", default = false },
},
-- Reachable from the Ctrl+P palette without spending a chord.
commands = {
{ action = "example.open", desc = "show the example" },
},
-- Told once per change, off the render path: `thurbox-cli plugin events`.
events = { "session.status" },
on_event = function(name, payload)
if payload.to == "blocked" then
state.needs_you = payload.session
end
end,
render = function(ctx)
-- ctx carries the RESOLVED rect: ctx.width, ctx.height, ctx.focused.
local items = thurbox.sessions
return ui.panel({
title = "Example",
focused = ctx.focused,
body = ui.list({
items = items,
cursor = ui.cursor("example", items),
width = ctx.width - 2,
height = ctx.height - 2,
on_overflow = "rows",
row = function(session)
return ui.row({ width = ctx.width - 2 })
:add(" " .. session.name, { fg = theme.text })
:trailing(session.branch, { fg = theme.muted })
:spans_list()
end,
empty = ui.empty({ title = "Nothing here yet", width = ctx.width - 2 }),
}),
})
end,
on_action = function(action)
if action == "example.open" then
command("focus", { text = "example" })
return true -- handled; returning false lets it fall through
end
return false
end,
}Save it as plugins/90_example.lua and it is a pane. thurbox-cli plugin new <name> writes a starter that already loads.
lib/widgets.lua is the primitive kit — measurement, windowing, a row of text.
lib/ui.lua is the layer above it: the shapes a pane turns out to be, with the
conventions that make six panes look like one program. Reach for it first; drop
to widgets for the piece it does not cover.
| what it is | |
|---|---|
ui.panel{title, focused, body, overlay_left, overlay_right, right_column, border, title_align} |
a framed pane in the one focus convention: with focus a thick border and a ▸ badge on the title, without it a thin border in the theme's unfocused role. Pass focused = ctx.focused and that is all — see Focus below |
ui.list{items, cursor, width, height, row, header, style_of, empty, on_overflow, pad, len, fill} |
a scrolling list. Variable row heights (header glues a group heading to its first row), the selection bar, hover, and the window arithmetic; style_of(item) gives a row a band or tint of its own, under the bar and the hover |
ui.cursor(key, items, opts) |
the selection over a list, with move/select/select_by_id/follow. It remembers the selected item, not its row, so a list reordered between two builds keeps the cursor where it was; the row number answers only once that item has gone. opts.steer is the store key another pane moves this list with; opts.request a one-shot "go to this row" |
ui.row{width, tone} |
a span builder that knows the row's columns: :add, :gap, :button, :match (search hits), :trailing (a note budgeted against what is left), :spans_list |
ui.empty{title, width, hint, hint_action} |
the one empty state — a blank line, the sentence centred, and the chord that fixes it, shown only while something is bound to it |
ui.modal{title, cols, children, crumbs, border} |
a float sized from what is in it |
ui.footer{actions, primary, cancel} |
key hints resolved from the registry, plus the confirm/dismiss pills |
ui.status(name, elapsed, printing) / ui.dots(items, elapsed, status_of, id_of) |
a status glyph, spinner included; and the strip of them a panel puts on its border. working always animates; running animates only while printing says its pane is producing output |
ui.printing(session_id) |
whether that session's pane is producing output, from thurbox.printing |
ui.rule(label, width) |
── label ────, the group heading |
ui.chord(action) / ui.describe(action) / ui.follow(key, id) / ui.reset(key) |
the registry lookups, and a cursor addressed without holding one |
Two things the layer decides so a pane does not: a selected row is a
full-width bar (the row's own style, so a span that names a colour keeps it),
and hints come from the key registry, so a rebind moves the hint and a
removed action takes its hint with it.
Every separately clickable thing has two looks beyond its resting one: hover
while the pointer is over it, and selected or focused once it has been
picked. The second is what carries the feedback on a terminal that reports no
motion, so hover is only ever an extra, and it never overrides it. The kernel
publishes the identity under the pointer (thurbox.hover), resolved through the
same hitboxes a click is routed by, so matching on the node's own id or role
means the lit thing is the thing a press lands on.
| lights | |
|---|---|
hover.id(id) / hover.role(role) / hover.style(role, lit, base) |
the questions: is the pointer on this? |
hover.row_style() |
a list row: a selection_bg band, background only, so a status dot or a match highlight keeps its colour. Skip it on the selected row, which wears the stronger bar |
hover.button_style() |
a pill or chip: inverted_fg on accent_bright, the pair the kernel's own buttons use |
hover.border(focused, id) |
a field's border: border_focused when focused, text_primary when hovered, else border_unfocused |
ui.list, widgets.list (pass hover_style), ui.row():button,
modal.footer's pills and textinput.node given an id already do this. A row
widgets.list draws with hover = false stays unlit, for a heading that has an
id only so a press on it resolves to nothing.
10_sessions.lua and 80_restore.lua are the two worked examples — a
full-height pane and a float.
Which pane has the keys is said three ways at once, so it survives losing any one of them:
| cue | focused | unfocused | survives no colour |
|---|---|---|---|
| border glyphs | thick ┏━┓┃ |
thin, rounded ╭─╮│ |
yes |
| title | ▸ Title, bold, on a filled badge |
Title, plain |
yes (the mark and bold) |
| colour | border_focused |
border_unfocused |
no |
A terminal surface adds a fourth: its cursor block is painted only on the surface the keys go to — the focused pane's first live one. The kernel does that; the pane does nothing.
To opt in, pass focused = ctx.focused to ui.panel. A pane that builds its
own frame gets the same result from lib/chrome: chrome.frame(title, chrome.level(ctx.focused)) for the whole thing, or the parts — chrome.level,
chrome.border_type, chrome.border_style, chrome.title_style,
chrome.label (the padded title, marked when focused) and chrome.rule (the
horizontal glyph, for runs a pane paints over its own top border). Never pick
the colours yourself: they are the theme's two border roles, so every palette
restyles every pane.
Every field on screen — search, rename, the new-session filters — edits through
textinput.key(field, key), so these keys work in all of them and in yours:
| keys | does |
|---|---|
left/right, home/end, ctrl+b/ctrl+f, ctrl+a/ctrl+e |
move a character, or to either end |
alt+left/alt+right, ctrl+left/ctrl+right, alt+b/alt+f |
move a word |
backspace/delete, ctrl+d |
delete a character |
alt+backspace, ctrl+backspace, ctrl+w |
delete the word before the caret |
alt+delete, ctrl+delete, alt+d |
delete the word after the caret |
ctrl+u/ctrl+k |
delete to the start / to the end |
Most terminals send ctrl+backspace as ctrl+h, which the kernel keeps for
moving focus, so it deletes a word only under the kitty keyboard protocol. A
macOS terminal sends option+arrow as alt+b/alt+f, which is why both are
here. Any other alt chord is left for your pane; any other ctrl+letter is
swallowed, never typed. A paste into a focused field arrives as typing, one
character at a time, and never as your pane's declared chords.
The whole environment. Anything not in this list does not exist here.
Lua standard library: string, table, math, coroutine, utf8, plus
the base functions (pairs, ipairs, type, tostring, tonumber, select,
next, assert, error, pcall, xpcall, setmetatable, getmetatable,
raw*).
Injected globals:
Each plugin has globals of its own: one you assign is invisible to the next
plugin, require hands you your own copy of a module, and string, table,
math, coroutine, utf8, text and everything under thurbox are
read-only — copy a published list before you sort it. Share through store.
Borders: a node's frame is the whole border vocabulary — title (styled
runs, not a bare string), title_align, border_type (rounded or square),
border_style, padding, and overlay. The overlay paints runs onto the
frame's own border cells after the block draws them —
top_left/top_right/bottom_left/bottom_right along the horizontal borders
and right_column one run per inner row down the right one — so a status strip,
a scroll count or a scrollbar costs no content cell. Slots clip between the
corners, which are never painted over.
Clickable spans: a run inside a line takes id/role of its own and becomes
a target over the columns it is laid out at, so a chip needs no node with a
hand-computed len. Adjacent runs carrying the same identity coalesce into one
hitbox — which is how ◀ F9, two runs because a run has one style, stays one
button. It applies to overlay runs too: that is what makes the terminal pane's
scrollbar one target the length of its column.
Dragging: a node with role = "drag" takes hold of the pointer — the press
arms no text selection and every move until release arrives as a further
on_click with hit.dragging = true, clamped to the rect it was pressed in.
That is how the terminal pane's scrollbar is grabbable. hit.w/hit.h carry
the node's size, so a pure pane can resolve the coordinate without stashing
geometry in render.
The wheel: on_scroll(wheel) is offered a tick over your pane — wheel.up,
plus wheel.x/wheel.y inside your rect — before the kernel turns it into an
up/down keystroke for you. Decline it and you keep the keystroke, which is
what a pane that already declares those keys wants. It exists for the pane that
cannot declare them: one taking input = "session" hands every unclaimed key to
the agent.
Speaking and acting outside your pane: command("message", { text = …, level = "info"|"success"|"error" }) puts a sentence in the message band, which
stays kernel-drawn — a pane contributes to it as it contributes a pill or a
binding, rather than spending a row of its own on a message line.
command("action", { text = "help.open" }) runs a declared action exactly as its
chord or a click on it would, which is how a key handler reaches help,
settings, themes or the palette. Before these two, both were reachable only by
painting a node with role = "action:…" and waiting for a click. It reaches the
plugin that declared the action — as a key or as a palette row in commands — so
it is also how one pane asks another to act, with any argument left in store
first (the search strip scrolls the terminal pane this way, via terminal.reveal).
Events: declare events = { "session.status", … } and an on_event(name, payload) and the kernel calls you once per change, with the same environment a
render has. command("emit", { text = "x", … }) reaches every plugin subscribed
to user.x. commands = { { action, desc } } puts an action in the Ctrl+P
palette with no chord. The list of events is thurbox-cli plugin events.
Interactive hooks: on_key, on_action, on_click, on_context,
on_outside (a float's, for a press that missed it), on_scroll,
on_focus_cycle (the topmost open float's native Ctrl+H/Ctrl+L cycle), and
on_event. Return true from on_focus_cycle("next"|"previous") after moving
your selected item; return false to keep the pane cycle. See
the plugin reference.
A menu at the pointer: from on_context, leave store.menu holding
at = { x = hit.screen_x, y = hit.screen_y } and
items = { { label = "…", action = "…" }, "sep", … }, and the bundled 64_menu
float draws it there and runs the chosen entry's action.
Adding to the sessions menu: at your file's top level or from a handler, add your entries under your own name, keeping everyone else's:
local extra = store["sessions.menu_extra"] or {}
extra["mine"] = { { label = "Do mine", action = "mine.do" } }
store["sessions.menu_extra"] = extraThe sessions column appends them to a row's menu. An entry whose action no
plugin declares (in keys or commands) is dropped. Your on_action reads the
pressed row from the action's args.session_id. The full contract is in
docs/PLUGINS.md → The right button.
thurbox.granted tells you which capabilities this file has been granted
(granted.run, granted.program, granted.sessions, granted.files). It exists
because not every capability can be withheld by absence: run and files are
globals, so if not run then is the check, but an interactive program pane, and
the verbs that drive an agent or lose something for good (sessions), are asked for
through command, which every plugin has.
Deliberately absent: os, io, debug, package, print, dofile,
load, loadstring, rawset, require of anything outside lib/. They are not blocked,
they are missing — os.time() is an attempt to index a nil value, not a
permission error. selene catches this at lint time via thurbox.yml, which is
the sandbox written down; run selene ui if it is installed. It names fields
as well as tables, so thurbox.granted.program and thurbox.platform.os are
checked reads and a misspelt one is an error. The path stops being checked at the
first […], which is why a list like thurbox.sessions is declared only as far
as the list.
The other half of the API fails quietly rather than loudly: convert.rs drops a
node key it does not know (that is how you carry your own bookkeeping on the node
table), command reads a fixed list of option names, and an undefined theme role
is nil. Each of those draws something plausible and reports nothing.
lib/thurbox.d.lua is what turns those into findings. Point an editor at the
repository (it reads .luarc.json) or run the checker over your own directory:
lua-language-server --check . --checklevel=WarningNo config to write: lib/thurbox.d.lua ships beside the panes, and the checker
loads a ---@meta file that is simply in the directory it is checking.
Two habits make it earn its keep:
- Annotate the node you build:
---@type thurbox.TextNodeabove a table is what letstxet = "…"read back as "missing required fieldtext". Without an annotation there is no type to check against. - Name a verb's options as the verb spells them.
command("open", { url = u })is the classic: the url goes intext, andurlis collected and ignored.
Extra keys in a table are never reported — lua-language-server does not flag them — which is why each kind and each verb declares the field it cannot work without, so a misspelling reads as that field missing.
These are the ones that cost real time.
-
Choose the run target.
runrequires a nonempty stringopts.sessionby default. For a machine-level readout without a session, use{ machine = "local" }or{ machine = "host:exact-name" }; both run in the target home directory. Configured hosts need no session or sharing. Invalid session options fail the call before queueing. Use stable per-target keys andrun_forget(key)for retired keys; forgetting discards queued work and late answers, but does not cancel active commands. See Running a program. -
Asking every frame is correct.
run(...)and thestore.want_*reads are designed to be called on every render: a fresh answer is a table lookup, not a process. Do not try to call them "only once" — you will get a pane that never updates. -
Measure in columns, not characters.
#sis bytes andutf8.len(s)is codepoints; a terminal budget is neither, and a CJK name is one codepoint over two columns.text.width(s)is the measure the painter itself uses, andtext.truncate/text.pad(or thewidgetshelpers that forward to them) spend a budget in it. The exception isinput.cursor, which is a CHARACTER offset —widgets.charsis that count. -
The answer is not there yet.
runreturns nothing useful on the frame you ask. Readthurbox.runs[key]and handlenilandstate ~= "done"— a pane that assumes the answer is present renders an error on its first frame. -
runis nil until the user trusts the file. Declaringcapabilities = { "run" }does not grant it. Checkif not run thenand draw something honest; do not call it and hope. -
send,create,fork,restart,dispatch,automation,pluginand a forcedeleteneedsessions;openneedsrun. Without the grant the command is refused andcommand.failedsays why.filesis nil until the file is grantedfiles. Checkthurbox.granted.sessionsbefore offering a key that would only be refused. -
A program pane needs
focusable = true.capabilities = { "program" }plusinput = "session"gets you a terminal you can never type at, because raw input only reaches the plugin that has focus. It draws perfectly and ignores the keyboard, which is a confusing thing to debug — and checkthurbox.granted.program, sincecommandis present whether or not you may. -
Declaring a key does not outrank a global one. A plugin-scoped chord loses to a kernel chord. Check
F1after adding a key: if it is not listed, it did not bind. -
A slot needs a home in
layout.lua. A plugin whose slot appears nowhere in the arrangement loads fine and never draws. Adding a pane is two edits — the plugin and the slot — andplugin checkfails on the missing second one and prints the line to add. -
A pane sharing a
switchslot draws nothing until it is focused. The quieter sibling of the above: the slot's first occupant is shown and yours waits, andctrl+h/ctrl+lpass it over — the cycle stops once per slot. Nothing fails, so declare apills = { … }entry and the action band will offer it —plugin checkwarns when you have not. Give thatactiona key as well: the band draws a pill only when a chord resolves for it, so a pill over a palette-onlycommandsentry is dropped as silently as one naming nothing. Itspriorityis where the band puts it, highest first; do not edit another plugin's number to make room. Thepillssection ofui.jsonbeside this directory is where a different order goes:{ "pills": { "fleetqueue.toggle": 75 } }Keyed by action, so only the buttons you moved are listed and deleting an entry gives the plugin's own number back. It is read at startup, and it moves a pill rather than making one: an entry naming an action no pill declares is ignored. A value that is not a whole number is refused — that pill keeps the number its plugin declared — but the line stays in your file, and
thurbox-cli config validatenames it. -
Give that pane one key that both enters and leaves it, with
toggle:command("focus", { text = "review", toggle = true })
Pressed once it focuses your pane; pressed again it returns to whatever was focused before. Declare it
scope = "global"so it works from inside a focused terminal, and prefer an F-key — a focused terminal keeps the barectrl+<letter>chords for the program in it.The alternative is worse in a way that is not obvious: a pane that focuses itself and leaves the user to walk out with
ctrl+h/ctrl+lis a one-way door, and a pane that focuses a named sibling to get back has hard-coded the user's arrangement.toggleuses the memoryEscalready uses, so it needs no name andEsckeeps working too. -
theme.*returns roles, not colours. Ask fortheme.accentortheme.mutedso your pane looks right under all thirty-six palettes. Never hardcode a hex value. For a tinted band,theme.tint(colour, amount)mixes a role over the palette's own background, and answers nil where the palette has no#rrggbbto mix — have a fallback. -
Instructions and memory are bounded. An accidental infinite loop is killed, not hung — if a pane vanishes after an edit, check
plugin check.
You do not have to write one from scratch. plugins.toml here lists what this
interface is composed of, and the manager keeps the directory in step with it:
thurbox-cli plugin available # what installs by bare name
thurbox-cli plugin install top # fetch it, and record it in plugins.toml
thurbox-cli plugin sync # make the directory match the spec
thurbox-cli plugin remove top # file, spec entry and recordinstall prints the layout.lua line the new pane needs, since that is the edit
it cannot make for you — nothing here writes your arrangement. After editing
plugins.toml by hand, sync is the one command to run: it installs what is
missing, takes back what you removed from the spec, and leaves everything else
alone. Running it twice changes nothing.
Your edits survive all of it. A managed file you changed is reported as kept and
never overwritten, and a managed file you deleted stays deleted — that is how you
remove one.
plugins.lock beside the spec records what each entry resolved to. You edit the
spec; nothing edits the lock. Commit both and this interface reproduces elsewhere.
.bundled.json records what delivery did; ui.json records what you decided
(which files are off, which are trusted, any rebindings, and the action band's
order). Editing a shipped
file is fine — delivery stops overwriting it once you have. Deleting one is how
you remove it, and the Interface tab (Ctrl+, → ]) will r restore it from
the binary (an edited layout.lua is kept as layout.lua.bak first). So no edit
or deletion of a file thurbox ships is unrecoverable.
A file you added is the case r cannot help with — thurbox ships no version
of it to put back, and it says so instead. The way back there is space on its
row: the file stays exactly where it is and is simply not loaded, which is enough
to get a working interface while you fix it. An installed pane
(the row reads from <src>) is put back by the manager that placed it,
thurbox-cli plugin sync. The tab sorts failures to the top and shows the load
error of the selected row, so the broken file is the first thing on the list.
docs/PLUGINS.mdin the thurbox repository — writing a plugin, start to finishdocs/KERNEL.md— the kernel's shape and why it refuses thingsexamples/lua/composite.lua— a worked example that runs programs
