hexe draws no chrome of its own. The status bar, tab strip, float and pane titles and pane sprites are produced by an external program — a painter. hexe collects the state, asks for a named view, and composites what comes back.
hexe has no opinion about what that program is. It speaks one small protocol over a Unix socket; anything that can bind a socket and emit JSON qualifies. The example at the bottom of this page is a painter in twenty lines of Python.
status = {
enabled = true,
view = "status", -- which view to ask for
socket = nil, -- nil = the default path, below
command = nil, -- optional: how to start the painter
refresh_ms = 250,
},The bar can be one region or three, addressed independently:
status = {
enabled = true,
zones = {
left = { view = "status.left" },
center = { view = "status.center" },
right = { view = "status.right" },
},
shrink = { "center", "right", "left" }, -- who gives up width first
},view and zones are mutually exclusive — view keeps meaning one region
spanning the whole bar. Every zone is optional: name only left and right and
nothing is asked for the middle.
Nothing about the protocol changes. Each zone is an ordinary request for its own selector, tagged with the zone name so the three never share a cache entry. A painter needs no new capability — only three selectors instead of one.
Each zone is asked at the full bar width and composes freely; hexe places
what comes back. Left sits flush at column 0, right flush against the far edge,
center on the bar's own midpoint. When the three do not fit, zones are dropped
in shrink order — the last name in the list is the one kept longest — and a
single zone still too wide is clipped rather than dropped.
The centre never moves for a neighbour. Its position depends on the bar
width and on its own content, and on nothing else. A left zone wide enough to
reach the midpoint loses columns off its tail; a right zone loses columns off
its head and stays flush. The centre used to be pushed aside instead, which
meant a clock ticking from 9:59 to 10:00, or a spinner one cell wider on
this frame than the last, slid the whole centre sideways — and the centre is
where a pane list lives, so that read as the bar flickering every time
something else redrew.
That costs a little painter work at narrow widths, in exchange for one round of requests per frame. The alternative — measure, then re-request with real budgets — would let a painter prune itself to fit, at the price of a stale frame; hexe does not do this.
What zones buy over one flat response:
- No padding arithmetic. Alignment stops being something the painter fakes with spacers it computed from the bar width.
- Independent pruning.
shrinksays which cluster loses first, instead of the painter guessing blind from a single flat list. - Independent staleness. A zone whose painter goes quiet dims on its own while the others stay live. With one region, one silence dimmed the whole bar.
Interaction is unchanged, including the coordinates: a zone reports its
rectangles relative to its own left edge, starting at x=0, and hexe offsets
them by where it drew that zone. hover_region, press_region and
press_button go to all three zones as they are — ids belong to the painter, so
a painter that reuses one across zones is describing two things by one name.
A zone answering ok:false contributes nothing and takes no width; that is how
a painter opts out of a view it does not implement. If no zone answers, the
bar is blank and hexe logs the three selectors it asked for — the config that
introduces zones is the same one that has to point them at names the painter
actually serves.
You name it, and hexe runs it:
hexe.status = { enabled = true, exec = "pixy serve --stdio" }That is the whole of it. Every region that falls due at the same moment for the
same command shares one run: hexe starts the command and hands it the requests
one at a time, each after the answer to the one before, closing its stdin after
the last. A command of plain words is started directly; one that uses quoting,
expansion, redirection or a leading VAR=value goes through /bin/sh -c. Its
stdout is the wire and its stderr is left on the terminal that started hexe, so
a painter's diagnostics have somewhere to go. Say nothing and nothing is drawn.
A painter that answers only the first request and exits still works: the regions it left unanswered are run again, and from then on its command is run once per region.
Nothing stays running between fetches, and that is the point.
hexe used to connect to a socket — one painter every session on the machine shared, with a single accept loop serialising them, one config to restart for all of them, a slow render that was everybody's, and, because nothing owned it, a painter still running days later from a build no longer installed. Every one of those is a property of sharing.
Owning a long-lived child fixes the sharing and keeps the rest: it is still a server, still resident, still something that can wedge and has to be reaped — and one per region is a Lua VM per zone. It was only ever worth keeping because a fetch cost a process start per frame.
A filmstrip buys a whole animation cycle per fetch, so that start is paid about once a refresh instead of twenty-five times a second — and at that rate a fresh process is cheaper than the daemon it replaces, with nothing resident to leak, share, or outlive anything.
It also makes a wedged painter cost only the region that asked for it. There is no shared pipe to hold, and nothing alive to hold it.
The cost is the painter's own startup, paid per fetch. That is around 2ms
for a compiled painter and closer to 20ms for a Python one, so a painter that
animates without answering frames is bounded by its own start time and will
be asked far less often than its next_frame_ms asks for. Answer with a strip
and the question does not arise.
| View | Mode | Drawn as |
|---|---|---|
status |
run | the whole bar, including tabs |
status.left / .center / .right |
run | one zone each, when zones is set |
float.title |
run | float border titles |
container.title |
run | pane border titles |
overlay.sprite |
surface | per-pane overlay art |
Rename any of them with view, float_title_view, container_title_view,
sprite_view, or per zone with zones.<zone>.view. The names above are only
defaults — hexe asks for whatever the config says.
Dialogs are deliberately not on this list. Confirmations, choosers and notifications block the UI and take keyboard input, so hexe draws them itself. A terminal that cannot ask "really quit?" without a helper running is a broken terminal. A painter that does not implement a view simply returns an error for it, and hexe draws nothing there.
run — [{text, style}, …]. Measurable, so hexe can place and clip it.
surface — raw ANSI. The bytes are fed into a terminal sized to the region's rectangle, so the painter cannot address a cell outside it no matter what it emits.
Each message is a four-byte big-endian length followed by one UTF-8 JSON value, 1 MiB maximum. Requests go down the painter's stdin, answers come back up its stdout, and it keeps answering until stdin closes.
Nothing about the framing depends on a pipe: it was designed against a socket and is unchanged. What a painter must be is a program that reads a frame and writes a frame — which any language does in a dozen lines, with no listener, no path to agree on, and no way to be left running.
A painter that dies, or answers something unusable, is taken down and the next fetch starts a fresh one.
Request
{"version":1,"select":["status"],"mode":"run","width":120,"height":1,
"now_ms":1755271083000,
"context":{"cwd":"/home/you","home":"/home/you","jobs":0,
"exit_status":0,"duration_ms":12,
"values":{"schema":1,"session":"main","tabs":["main"],
"active_tab":0,"shell_running":false,
"alt_screen":false,"adhoc_float":false,"active":true,
"progress_state":"inactive","progress_pct":null}}}Shell state sits at the top of context; mux state sits in context.values
under "schema":1. Ignore anything you do not use.
progress_state and progress_pct carry the focused pane's OSC 9;4 progress.
The state is one of inactive, in_progress, error, indeterminate,
paused; progress_pct is 0–100 or null. hexe parses the sequence and
reports it — whether a progress indicator is drawn, and how, is the painter's
call. contrib/painter.py renders it on the right.
Response
{"version":1,"ok":true,"output":{"mode":"run",
"runs":[{"text":" 12:34 ","style":"fg:250 bg:237 bold"}],
"width":7,"next_frame_ms":null,
"regions":[{"id":"tab.1","x":10,"y":0,"width":6,"height":1,
"actions":{"left":"tab.select.1"}}]}}For mode":"surface", replace runs with "ansi":"…".
Anything malformed, or "ok":false, is discarded and the previous frame stays
on screen. A painter cannot blank the bar by crashing mid-thought.
A space-separated list: fg:<color>, bg:<color>, bold, dim, italic,
underline. <color> is a palette index 0–255, a name (red,
bright_blue), or #rrggbb. There is no reverse — swap the two colors.
Unknown tokens are ignored, so a painter may emit extras for other consumers.
regions is optional: clickable rectangles in region-local coordinates, each
with an id and per-button actions. hexe hit-tests them and runs the action
name. tab.select.<n> (one-based) switches tabs; any other name is run as a
statusbar action.
Which region is hovered or held comes back on the next request as
hover_region, press_region and press_button, so the painter styles its own
pressed and hovered states. hexe never restyles a painter's output.
next_frame_ms in a response asks to be polled sooner than refresh_ms — an
animating view returns 75 and gets asked again in 75ms. It can only shorten
the interval, never lengthen it.
hexe asks on this schedule by itself for as long as the region is on screen, and redraws only when an answer differs from the one before. A change in what it would send — a tab, the hovered region, the cwd, the size — is asked at once rather than at the next refresh.
It is a delay, never a timestamp. A painter that sends an absolute time is
asking for a frame decades away; hexe warns once and ignores it rather than
clamping it silently, because the symptom of the silent version is only that the
animation looks sluggish — it ran at refresh_ms and nothing said why.
A spinner moving every 40ms used to mean twenty-five round trips a second, even though what the bar says — the clock, the branch, the cwd — changes about once. The animation rate set the fetch rate, and the animation is the part that needs nothing from outside.
So hexe sends frames_ms with every request: how far ahead to draw. A painter
that can answer replies with a strip instead of a picture:
{"ok":true,"output":{"frames":[
{"mode":"run","runs":[…],"width":12,"next_frame_ms":40},
{"mode":"run","runs":[…],"width":12,"next_frame_ms":360}
]},"version":1}Each frame is exactly the object a single reply carries, and its
next_frame_ms is how long that frame holds. hexe plays them off its own
timer and comes back only when refresh_ms says the data underneath could have
moved. Nothing is asked in between.
Two rules make it safe:
The horizon is a promise, not a suggestion. A painter stops at frames_ms
even mid-cycle. Content that is not on a cycle at all — a clock — would
otherwise be enumerated into the future and replayed stale, which is worse than
being a frame late.
Held pictures coalesce. The pause at the end of a sweep is one frame with a longer hold, not the same cells sent nine times.
A painter that ignores frames_ms and answers with a single frame keeps
working exactly as before; a strip of one is the same thing.
The render loop never waits on a painter. Connect, write and read are all
non-blocking and advance one step per event-loop iteration. Slow, wedged or
dead costs a stale frame, never a dropped one.
scripts/smoke_region_painter.py asserts this against a painter that accepts
the connection and then answers nothing.
Last value wins. A frame stays until a newer one replaces it.
Failures back off. Consecutive failures double the retry delay from 500ms to 30s, so a painter that is not running costs one connect attempt every 30 seconds rather than one per frame.
Painter output is untrusted. Run text is stripped of escape sequences and control characters; every region is clipped to its own rectangle.
The bar is empty, titles fall back to the raw pane name, and sprites do not appear. Panes, layout, input, keybindings, dialogs and shell integration are unaffected — nothing hexe needs to function is painted externally.
#!/usr/bin/env python3
import json, struct, sys, time
while True:
head = sys.stdin.buffer.read(4)
if len(head) < 4: # hexe closed the pipe: it is gone, so are we
break
req = json.loads(sys.stdin.buffer.read(struct.unpack(">I", head)[0]))
text = " %s " % time.strftime("%H:%M:%S")
body = json.dumps({"version": 1, "ok": True, "output": {
"mode": "run",
"runs": [{"text": text, "style": "fg:250 bg:237 bold"}],
"width": len(text), "next_frame_ms": 1000}}).encode()
sys.stdout.buffer.write(struct.pack(">I", len(body)) + body)
sys.stdout.buffer.flush() # or hexe waits for a frame sitting in a bufferstatus = { enabled = true, exec = "/path/to/painter.py" }That is a working status bar. Grow it from there, in whatever language you like.
contrib/painter.py is a fuller one in the same spirit: clock, session, tabs,
knight-rider spinner, battery, truncated cwd, and clickable regions. Run it with
python3 contrib/painter.py & and start hexe.
Note what it does with the gaps between segments: it leaves them unstyled. Styling the padding paints one continuous strip across the row; leaving it bare makes the segments islands on the terminal's own background.
Placement, size, clipping, hit-testing, the frame clock, and all pane and
session state. Shell integration (hexe shell init, shell-event,
exit-intent) stays in hexe: it is how hexe learns each pane's cwd, exit
status, job count and running command — the state it then hands to the painter.