TUI layout, mouse, and status bar
The reference TUI's consumer-facing product surface: subcommands, keybinds, status bar, layout, hooks, recording.
Full source summary
The reference TUI's consumer-facing product surface: subcommands, keybinds, status bar, layout, hooks, recording. The TUI is the wedge — the daily-driver adoption surface — and its differentiator is the wire: attach/detach, remoting, and a human and their agents sharing the same live terminals. It is held a pure consumer with no protocol privilege by ADR-0017. What's normative lives in ../spec/; this file is the human-facing reference for the tmux-shaped consumer that ships in tree.
6. Layout
6.1 The tree
A window’s layout is a binary split tree: each interior node is a
split (horizontal or vertical) with a single ratio in (0, 1) and
exactly two children; leaves are panes. Three-way and N-way splits
are represented as nested binary splits. See
ADR-0012 for the closed
decision behind this shape and the wire form in
../spec/L3.md §3.2.
window: split(vertical, ratio = 0.5)
├── pane #0
└── split(horizontal, ratio = 0.33)
├── pane #1
└── pane #2
(The first ratio gives pane #0 the top half of the window; the second gives pane #1 the left third of the bottom half.)
Tabbed layout nodes are reserved for the v0.2 wire spec (see
../spec/CHANGELOG.md).
The client-side rendering surface for this tree — multi-pane tiling,
borders, focus chrome, input routing to the focused pane, layout
persistence in L3 metadata under phux.tui.layout/v1, and the
keybind-action wiring — is settled by
ADR-0019 and tracked under
the phux-4li epic.
6.1.1 The pane grid: shared rules, one weight, titles on the rail
Panes share their rules. A split costs exactly one cell of chrome, not
two adjacent borders, so a 2x2 window is one │ column and one ─ row
crossing at a ┼. Junctions are resolved per cell from the lines that
actually meet there (│ ─ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼).
Above the pane area sits the rail: one row that closes the grid at the
top and holds each top-row pane’s title, tee-ing into ┬ wherever a
vertical rule drops out of it. It costs one row of the viewport and is
always reserved, so splitting a window never moves the panes you were
already looking at. A viewport with only one usable row keeps it for the
pane.
Every rule is drawn in the light box-drawing set. Focus is carried by
colour — divider_focus plus bold on the focused pane’s own rules and its
title, divider everywhere else — not by a heavier stroke. Heavy rules
force the mixed-weight junction glyphs (╅ ╆ ╈ ╉ ╂ ┿) at every
crossing, and most terminal fonts either lack them outright or draw
strokes that miss their light neighbours, so an emphasised grid read as a
broken one.
A pane’s title is its own OSC-2 terminal title — what the program in
the pane says it is: the shell’s directory, an editor’s filename, an agent
harness’s banner. It is inset one cell into the rule above the pane,
wrapped in single spaces, and clipped by DISPLAY WIDTH with the shared …
so a CJK or emoji title cannot overrun the rule that closes it. A pane
whose program never set a title gets no label: phux does not invent a name
for a pane it did not name.
A title is whatever the pane says it is, so it is untrusted text on a path
that writes escape sequences. Control characters and explicit bidi
formatting overrides (U+202A-U+202E, U+2066-U+2069, U+061C,
U+200E, U+200F) are dropped from every chrome label — pane titles,
window tabs, sidebar rows, status-bar text. The overrides are zero-width,
so they cost no budget and no width check notices them, but they reorder
everything drawn after them: a pane could make its own label read as its
neighbour’s. Right-to-left text is unaffected, because a terminal derives
direction from the letters themselves rather than from the overrides.
A pane that has asked for a human (ADR-0035)
badges with a filled ● in the attention tone ahead of its title — the
same glyph and tone the sidebar’s row for that pane uses, because a pane’s
state must not read differently depending on where you look at it.
6.2 Resize behavior
Status: Viewport-driven reflow ships. Automatic minimum-size freezing now also ships (phux-foz.3): proportional re-flow and freezing are implemented in the layout walk itself, so paint, reflow (
TERMINAL_RESIZEsizing), and mouse hit-testing all read the same frozen tiling.
When the client viewport (or server-aggregated viewport for multi-client
sessions) resizes, split ratios are preserved and dimensions are
redistributed proportionally. A leaf that hits its minimum size
(min_cols = 2, min_rows = 1 for the inner content; chrome is per
client) freezes; remaining space redistributes among non-frozen leaves.
This mirrors tmux’s resize behavior.
Below the layout’s aggregate minimums (every leaf at its floor plus one cell per interior divider) freezing disengages and pure proportional tiling resumes: panes degrade to sub-viable rectangles rather than disappearing, and the exact-tiling invariant (no gaps, no overlaps) holds at every viewport size.
6.3 Resize commands
Status: Keyboard
resize-paneactions and mouse divider dragging ship (ADR-0048, phux-foz.3).resize-panedispatches through the single-dispatch action registry,C-a H/J/K/Lare the default bindings (see §5.3), the command palette offers a resize row, and drag-on-divider (§7) commits through the same ratio math.
resize-pane direction=right amount=5 moves the boundary between the
focused pane and its right neighbor by 5 columns toward the right,
giving the focused pane more width. Negative amounts shrink.
Resize commands modify the relevant interior node’s ratio (not
absolute sizes). After a subsequent window resize, the new ratio is
preserved.
A resize that would push either side of the boundary below 2 cells on
the resize axis is a bell-no-op (ADR-0019 decision 5). The gate measures
the ratio’s proportional tiling — what the ratio asks for — not the
frozen tiling of §6.2, so a command cannot silently bank ratio behind a
frozen divider that the layout would snap to on the next viewport grow.
The new layout broadcasts to other attached clients via SET_METADATA
(phux.tui.layout/v1), like every other layout mutation.
6.4 Window sidebar
Status: Shipped (
phux-4h5a; herdr-shaped byphux-p4vp; interactive perphux-fce4; sectioned + agent-aware perphux-foz.9; three-zone attention inbox perphux-k0cw/ ADR-0089).
[sidebar] docks a vertical strip on the left (default) or right edge.
It is on by default; toggle-sidebar (C-a b) flips it at runtime,
and so does clicking the collapse chevron in the strip’s bottom corner.
That runtime choice is client-local chrome: it persists across
switch-session for the life of the attach, and [sidebar] enabled
seeds it at attach only. Panes tile into the remaining content rect, so
the strip never overlaps content.
The default width = 0 enables automatic sizing: a quarter of the viewport,
bounded to 28–40 columns. That gives an 80-column terminal a 28-column
strip, a 144-column terminal 36 columns, and a 160-column terminal 40 columns.
A positive [sidebar] width keeps that exact width. Automatic width depends
only on viewport size, so changing titles and agent states never reflow work.
At the default pane floor the strip yields below 68 columns (§4.5).
One-cell gutters separate text from the edge and divider. Icons, labels, branch context, and footer actions share the same column grid. The active window’s two-row block gets a quiet selection fill; only its name is bold. Unchanged frames emit no bytes, and changed frames repaint only changed rows.
The strip runs the full height of the terminal, and the status bar
yields its columns rather than spanning underneath it (phux-qtw8): with
the sidebar open, the bar — window tabs included — starts beside the
strip. The three regions tile the viewport without overlap, and a click
in the strip’s columns is the strip’s, on every row.
The strip is three zones, headed by muted lowercase headers (the
sidebar_section theme slot). They are ordered by how much each row wants
a human, not by where the row lives:
needs you — the cross-session attention queue: every agent
wanting a human, on this server, worst first. Rows carry the same glyph
and state word as the agents section they replace. When a row is too
narrow for both state and agent name, the glyph carries state and the name
stays visible so two agents in one session remain distinguishable. A row from
another session is labelled by its session rather than its window (a
window name out of its session’s context locates nothing). Committing a
row runs select-window for a local agent and
switch-session { name, window, pane } for a peer’s — a single Enter or
click lands on the pane that wants you.
The queue is capped (five rows) with a +N more row that opens the
agent-fleet dashboard, and it contributes zero rows when nothing wants
a human — no header, no gap, no placeholder. The strip shrinks when the
fleet is calm; that is the point of it, not an optimization.
Two limits are structural rather than temporary. A peer row never
becomes seen (visiting a pane is what marks it, and visiting a peer’s
pane means switching there), so a peer’s finished-and-unread agent stays
on its rung until someone looks. And peer rows have no last-change clock,
so equal-rank peers hold the session graph’s order.
here — the focused session’s windows: one fixed two-row block
per window, top to bottom in select-window index order:
- Name row. A status dot (filled +
accentfor the active window, hollow +dimotherwise,attentionyellow when a pane in the window is waiting on a human) followed by the window’s display label (bold only when active) (agent record, OSC title, or stored name — same resolution as the status-bar tab strip), plus the §8.6 attention!. - Branch row. The VCS branch of the window’s focused pane, dim and
nested under the label (
main, awave2/...branch, or a short commit hash for a detached HEAD). Blank when the pane’s working directory is not inside a git repository.
A queue row’s agent identity, per pane, comes from one of two sources in
preference order (colored by the agent_idle / agent_working /
agent_blocked / agent_done theme slots; an undeclared state renders in
dim):
- The structured
phux.agent/v1record (ADR-0040). The server derives and writes this record for a pane it owns (ADR-0046), so the four lifecycle states are live for a recognized agent CLI with no integration on the agent’s side:workingwhile it runs,blockedwhen it is waiting on a human,idleotherwise. An explicitphux agent setoutranks the derivation for whatever fields it supplies, so a wrapper or hook that declares its own state still wins — but only while that agent still occupies the pane. When the server has positive evidence the declared occupant is gone or has changed, it withdraws the declaration tounknown(keeping the declared name and kind) and the derivation resumes, so a killed agent no longer leaves the row paintedworkingfor the life of the pane. - The OSC-title identity heuristic — the compatibility path for a
pane the server did not recognize (an unknown agent, or a platform
where process introspection is unavailable). The name comes from the
title token; the state is
blockedwhile the pane’s §8.6 asked flag is up, elseidle. Screen text is never scanned on the render path. Title changes refresh the chrome directly: the client diffs each pane’s title as content frames apply, so the row appears when the agent sets its title and disappears when the shell resets it on exit.
Rows are ordered by how much they want a human, not by session or window index:
blocked > done (unvisited) > working > done/idle (visited) > unknown
“Finished, and you have not looked at it yet” therefore sorts above “still working” — the whole point of the zone is to answer “which of my agents needs me?” without reading it top to bottom. Ties break by most recent state change, then by declaration order. A pane is seen once you focus it; a new state landing on a pane you are not looking at marks it unseen again, so an agent that finishes in a background window rises back to the top rather than staying quietly settled from an hour ago.
Panes matching neither source produce no row — the zone lists agents, not
shells. A peer pane that raised an ADR-0035 ask but declares no record
still earns a row, labelled unnamed agent: it is blocked on a human by
definition, and the strip can say what happened without claiming to know
who.
spaces — one rolled-up line per other session: a status dot
taking that session’s worst rung, its name, and a compact histogram
(!1 *2 — one blocked, two working). A dot says what; the count says
how much. Committing a row runs switch-session { name }.
The roster is deliberately not capped the way the queue is: it answers
“which sessions are on the line?”, and a truncated list answers that
wrongly. It is bounded only by the strip’s height, with its own +N more
overflow row. With no other sessions it contributes zero rows, so a
single-session user never reads an empty section.
A satellite session shows a pane count and an explicitly unknown dot
(?4). Its per-Terminal metadata is not subscribable from here
(docs/spec/L3.md), so its state is unknowable and must not render as a
calm zero — an attention surface that reports 0 blocked for something it
cannot see is worse than one that says it cannot see it.
Zone 2 keeps a floor of a header plus one window block, so a blocked fleet
can never squeeze the session you are working in off its own strip. When
the focused session somehow has no windows, zone 2 shows a quiet
no windows placeholder. Empty-state and overflow lines are inert as
click targets except the overflow rows, which open the fleet dashboard.
(A short strip that cannot fit a zone’s gap + header + one row drops that
zone whole, rather than leaving a dangling header.)
Two environment knobs govern the server-side derivation (the client has no switch of its own; it renders whatever the record says):
| Variable | Effect |
|---|---|
PHUX_AGENT_DETECT=0 | Disable detection entirely. Rows fall back to the OSC-title heuristic, exactly as before ADR-0046. |
PHUX_AGENT_RULES_DIR=<dir> | Load agent rule manifests from <dir> instead of $XDG_CONFIG_HOME/phux/agent-rules. A manifest replaces the built-in of the same kind. |
See ../operations.md for what the detector reads and
how a bad manifest surfaces.
Branch inference is client-local and read-only: the pane’s working
directory (carried by the ATTACHED snapshot) is walked up to the
enclosing .git, worktree gitfiles (gitdir: ...) are resolved, and
HEAD is read directly — one cached file read, never a git
subprocess, and nothing added to the wire. The cache re-validates on a
short TTL keyed by HEAD’s mtime, so a git switch shows up on the
next chrome refresh without stat storms.
The strip’s last two rows are the bottom-anchored interactive
affordances (phux-fce4), with the collapse chevron in the bottom
corner cell; window blocks and agent rows are click targets too. Every
sidebar click commits the same ResolvedAction a keybinding or palette
row would — one run_action dispatch path, no bespoke click semantics:
| Target | Committed action |
|---|---|
| A window block (either row) | select-window { index } |
A needs you row (local) | select-window { index } for the window holding the agent’s pane |
A needs you row (peer) | switch-session { name, window, pane } |
A spaces roster row | switch-session { name } |
Either +N more row | agent-fleet |
+ new | new-window |
= menu | command-palette (the session/plugin menu; new-session lives in its Session group) |
| The collapse chevron | toggle-sidebar |
Pointer events over the strip never leak into pane routing: presses on
section headers, blank rows, or the separator column are consumed and
dropped. The same targets stay keyboard-reachable through their actions
(C-a c, C-a :, C-a b, C-a 0–9).
7. Mouse
Status: Shipped (ADR-0048; per-pane opt-out in phux-npb3). Click-to-focus, drag-on-divider to resize, and default outer-terminal mouse capture are implemented. The client enables its own mouse tracking on attach so divider drags work without an inner program turning mouse mode on. Opt-outs: the global
mouse = falseconfig, and the per-paneset-pane mouse offaction described below.
Mouse handling is enabled by default. On attach the client emits DECSET
?1002h (button-event tracking) + ?1006h (SGR coordinates) for the
outer terminal and restores them on detach. That capture is what makes
drag-to-resize work in a plain shell: without it the client is deaf to
the pointer over a divider whenever the inner program has no mouse mode.
| Event | Action |
|---|---|
| Click in pane | Focus the pane, then forward to it |
| Press on a divider | Grab the boundary for a resize drag |
| Drag a divider | Resize the boundary (tracks pointer) |
| Release | Commit the new layout (broadcast L3) |
| Scroll wheel in pane | Layered: an inner program that |
| enabled mouse mode gets the wheel | |
| forwarded; otherwise on the primary | |
| screen the wheel scrolls the pane’s | |
| client-local scrollback viewport, and | |
| on the alt screen it becomes arrow | |
| keys (xterm alternate scroll, DECSET | |
| 1007 — on by default, apps opt out | |
with ?1007l). In copy-mode it | |
| scrolls the focused pane’s local | |
| viewport | |
| Right-click in pane | Opens the pane context menu (§7.1); |
| forwarded to the inner program | |
| instead when that program has mouse | |
| tracking on | |
| Click on status bar row | A windows-widget tab selects that |
window (select-window { index }, | |
| phux-foz.12); every other cell on the | |
| row is consumed as chrome (no-op) | |
| Right-click on the bar | A tab selects its window and opens |
| the window menu; elsewhere on the row | |
| opens the session menu (§7.1) | |
| Click on a sidebar row | Select that window (window blocks and |
agent rows); + new / = menu / the | |
| collapse chevron run their actions | |
| (§6.4) | |
| Right-click the sidebar | A window or agent row selects that |
| window and opens the window menu; | |
| every other cell opens the session | |
| menu (§7.1) |
Only divider cells change meaning. Every event inside a pane’s rectangle is forwarded to that pane with pane-local coordinates, so an inner TUI (vim, htop) that turns mouse tracking on still receives its mouse events — the server’s per-pane encoder produces empty bytes for a pane whose inner app has no mouse mode, so forwarding is harmless either way.
Native selection. Enabling outer capture suppresses the host
terminal’s click-drag text selection inside the phux viewport. Hold
Shift to bypass application mouse reporting and use native selection
(a near-universal terminal convention; phux relies on it but does not
enforce it). A host that does not honour Shift-bypass needs
mouse = false for easy selection.
Escape hatches. mouse = false in [defaults] skips the DECSET
entirely and reverts to pass-through-only (the client only sees mouse
when an inner program enables it).
Per-pane (phux-npb3): the set-pane action with mouse = "on",
"off", or "toggle" (bindable, and offered by the command palette as
a toggle) opts the focused pane out of client mouse handling without
touching its siblings. The state is client-local and capture follows
focus: while an opted-out pane is focused the client drops its own
mouse-tracking DECSET, so the host terminal’s raw handling (native
click-drag selection and friends) returns for that pane; focusing any
opted-in pane re-enables capture and drag-to-resize. While capture is
on (another pane focused), a click on the opted-out pane still focuses
it — that is the mouse path back in — but the client never synthesizes
INPUT_MOUSE (or the local wheel viewport scroll) for an opted-out
pane. Nothing crosses the wire; a pane’s opt-out ends when it closes.
We do not ship copy-mode mouse drag selection — see §11.
7.1 Context menus
Status: Shipped (ADR-0058).
The right button opens a menu anchored at the pointer, listing the actions that apply to what you clicked. Three menus, one per target:
| Right-click on | Menu | Rows |
|---|---|---|
| A pane | pane | Split right, Split down, Zoom / |
| Unzoom, Copy mode, All commands…, | ||
| Close pane | ||
| A status-bar tab or a | window | New window, Rename window…, Pick |
| sidebar window/agent row | window…, All commands…, Close | |
| window | ||
| Any other chrome cell | session | New window, Pick window…, Pick |
| session…, Rename session…, Agent | ||
| fleet, Toggle sidebar, All | ||
| commands…, Detach |
A menu row commits the same ResolvedAction a keybinding would, so it
runs through one dispatch path and nothing is menu-only. Each row shows
the chord bound to it, when there is one.
Right-clicking a window’s tab or sidebar row selects that window first, then opens the menu for it — the menu acts on what you pointed at, not on whatever was active.
Driving one. Both idioms work, with no mode to choose:
- Press, drag onto a row, release — the row under the pointer is picked.
- Press and release, move, then click a row — a left or right press on a row picks it.
The pointer hovers rows as it moves (the client raises ?1003h
any-motion reporting for as long as a menu is open, and drops it again
on close). Arrow keys, j / k, C-n / C-p, Home / End move the
selection; Enter picks; Esc, q, or a click outside dismisses. A
click on the menu’s border or on a separator does nothing.
Panes that own the mouse. An inner program with mouse tracking on
(vim, htop, an agent TUI, anything with its own right-click menu) keeps
every button, so no menu opens over it — the same boundary drag-to-copy
respects. Bind the context-menu action to open the pane menu from the
keyboard there, or use the command palette. A pane opted out via
set-pane mouse off has no menu either, by the same logic.
The menu never covers the sidebar or the status-bar row: it is clamped into the pane content rect, flipping left and up at the edges, so a click on a bottom-docked bar opens the box upward over the panes.
Resizing closes it. A menu is pinned to the cell you clicked, against the viewport that existed then, so a terminal resize invalidates its box and the client drops it (phux-fsb). Every other overlay — the palette, the pickers, help, prompts — lays itself out fresh on each paint and reflows into the new size instead.
8. Status bar
8.1 Architecture: widget-first from day one
The status bar is rendered entirely client-side. A GUI client may
ignore it and render its own chrome; the TUI client composes it from
widgets and draws it on one reserved row of the outer terminal — the
bottom row by default, or the top row with position = "top".
Every slot’s contents are a list of widgets. A widget is a typed thing that produces styled text. The default config looks short because a bare string is shorthand for a no-parameters widget:
[status]
left = ["session-name"] # → [{ kind = "session-name" }]
center = []
right = [{ kind = "time", format = " %H:%M" }]
position = "bottom" # or "top"; default "bottom"
position moves the whole reserved row: with "top" the bar draws on
the outer terminal’s first row and the panes shift down one row, so
nothing ever underlaps the bar. The sidebar strip is the exception — it
is full-height in both positions, and the bar insets out of its columns
instead (§6.4). Everything else — widgets, styling, refresh — is
identical in both positions.
There are three categories of widgets:
- Server facts. The server already publishes session names, window
lists, focused pane, cwd (via OSC 7), last command exit (via OSC
133). These are widget kinds (
session-name,windows,cwd,exit, etc.) backed by data the server pushes anyway. - Client-local widgets. Things derivable on the client without
server help:
time, and anything expressible as anexecwidget. execwidgets. The client runs the named program on the configured interval and renders its stdout (parsed for SGR if it contains ANSI). These run per-client; a clipboard daemon, a battery percentage, etc.
right = [
{ kind = "exec", command = "~/.local/bin/battery", interval = "30s" },
{ kind = "time", format = "%H:%M" },
]
8.2 Why widget-first
The scoping decision in CONTRIBUTING.md is that we will not ship a
status bar DSL — no if/else mini-language, no format-template
expression engine. The widget system gets us extensibility without
becoming a template interpreter: arbitrary logic lives in exec
widgets, which are real programs in real languages, supervised by the
client. The widget contract itself is small and typed.
This shape costs us almost nothing on day one (the default config is three names in three lists), and means we never have to do an architectural revision to grow a status bar plugin story.
8.3 Built-in widget kinds
The widget catalog is a generated reference:
docs/reference/widgets.md lists every
registered widget kind with the exact options and defaults its factory
accepts, plus the universal style table and its precedence contract.
It renders from spec consts the factories themselves validate options
against, test-pinned to the registry, so a kind or option is listed
there exactly when the binary accepts it; regenerate with
just docs-gen after changing the widget surface.
Widget options are a closed surface: every factory rejects an
option outside its documented set, naming the widget and suggesting the
nearest valid spelling (“unknown option formt (did you mean
format?)”). phux config check runs the same build path over
[status], so a typo’d kind, a typo’d option, or a bad style table
surfaces as a located finding instead of parsing clean and doing
nothing.
Plugin manifests can contribute additional widget entries via
[[widgets]] (section 4.2’s manifest contract): each contribution is a
widget table plus a slot, appended after the user’s own widgets, and a
contribution that fails validation is dropped with a logged warning
rather than degrading the bar.
Data feeds behind the server-fact widgets: cwd renders the focused
pane’s live directory from cwd_changed events (the server queries the
PTY child’s kernel cwd at OSC-133 prompt boundaries and on output
settle; the ATTACHED snapshot’s spawn cwd seeds it), and exit
renders command_finished.exit_code (the OSC-133 D-mark code, so it
requires shell integration). exec widgets never run on the render
path: the client runs the command per interval as a bounded
kill_on_drop child process (10s hard cap) and folds captured stdout —
first line only — into a cached strip the widget renders; a failed or
timed-out run keeps the last good output.
8.4 Refresh and ordering
- Server-fact widgets re-render on the relevant server event (window rename, focus change, OSC 7/133).
- Client-local widgets with no interval re-render only on event.
clockre-renders every minute by default;intervaloverrides. execwidgets re-render everyinterval. The client batches re-renders to once per frame (max ~60 Hz).- Slot contents render left-to-right with no implicit separator. Use
textwidgets for separators, andspacerwidgets for gaps that grow with the terminal (§8.4.2).
8.4.1 How the bar narrows
The row is always exactly the terminal’s width — never short (which would strand cells from the previous paint) and never long (which would wrap onto the pane above). When the three slots want more than that, the bar resolves them in priority order rather than cutting cells off the end:
- Right takes what it needs, up to half the row. The cap keeps a long session name from pushing the window tabs off a narrow bar.
- Left gets everything the right slot did not take. It holds the tab strip — the chrome you navigate by — so it is the last to lose room.
- Center gets whatever gap survives, less one blank column on each side. Under 8 columns the gap is not worth filling and the center slot renders nothing.
Within a slot, later widgets yield first: slot order is a statement
of priority, so right = ["session-name", { time }] loses its clock
before it loses the session name.
Each widget then decides how to spend what it is given, and the rule throughout is drop whole units, never fragments:
windowsdrops whole tabs. It anchors on the active tab and grows outward while neighbours fit, standing in for the hidden ones with a‹/›mark. A strip clipped mid-label (0:alpha 1:) reads as a window named1:, hides that others exist, and leaves a click target pointing at a name you cannot see. Below the width of even the active tab, its label clips — the leading{index}survives longest, because that is what you need to typeprefix <n>.help-hintsdrops whole hints, then disappears. Hints exist to be read by someone who does not know the keys yet, and? he…fails at that in a way that showing one fewer hint does not.switchrenders whole or not at all: a clipped chip is a smaller target claiming the same columns.- Everything else clips with a trailing
…, so a shortened value never passes for a complete one.
Widgets can also be hidden outright by terminal width, via the
universal min-cols / max-cols options (see the generated widget
reference). A hidden widget costs no width at all, so the widgets that
remain get the columns it would have taken. The shipped lineup uses this
to change shape rather than merely shrink: above 64 columns the right
slot carries the session name and clock; at or below it, those give way
to a clickable switch chip that opens the fleet dashboard — the same
overlay prefix A opens, which on a small terminal opens full-screen
(§4.4.1).
8.4.2 Elastic space: the spacer widget
Every other widget is sized by what it has to say. A spacer is the
exception: it has no content, takes no width of its own, and then
absorbs the columns nothing else claimed.
[status]
left = [{ kind = "windows" }, { kind = "spacer" }, "session-name"]
That puts the tab strip hard left and the session name hard right, at every terminal width, without using a second slot.
Three rules, worth knowing before you build a bar around one:
- Slack is row-wide, not slot-wide. Every spacer in the bar — left, center, or right slot — splits the same leftover width evenly, in reading order, with the odd column going to the earlier ones. There is no such thing as “the left slot’s own width” for a spacer to expand into; slots are placed against the row, not sized.
- A bar with a spacer has no room left for the center slot. The
spacers eat the gap the center slot is centered in. If you want
something centered, use
center— that is what it is for. - Spacers yield first. They are paid out of slack, and a row that overflows has none, so on a narrow terminal every spacer renders zero cells and §8.4.1’s narrowing runs on your real widgets untouched. A spacer can never push content off the screen, which is what makes it safe to leave in a config you also use over SSH from a phone.
A spacer takes no options. Give it a style table to paint the gap
({ kind = "spacer", style = { bg = "#1e1e2e" } }) instead of leaving
it blank, and min-cols / max-cols to gate it by terminal width like
any other widget — a gated-out spacer claims no share of the slack. For
a gap that does not grow, use a text widget of spaces.
8.5 What the status bar is not
- Not multi-row. One row — bottom of the outer terminal by default,
top with
position = "top"(§8.1). If you need more, dedicate a pane. - Not themable via a styling engine. Per-widget
styletables only. - Not server-rendered. Every client owns its chrome. This is what enables a future GUI client with native chrome to coexist with the TUI client trivially.
8.6 Agent attention (the asked chrome)
When an agent in a pane blocks for a human answer, the server emits
AgentEvent::Asked on the subscribed event stream
(ADR-0035; detection sources in
ADR-0036). The interactive
TUI folds that event into per-pane state (the same fold as the
ADR-0033 TerminalControl badge) and renders it on every chrome
surface that names windows, colored by the attention theme slot
(§4.4):
- Window tab marker. The asking pane’s window gets a
!suffix on its tab, in both the sidebar strip and the status bar’swindowswidget — including for a background window, so the question is findable from anywhere. (The sidebar marker is themed; thewindowswidget marker rides the segment’s own style, like the zoomZ.) - Status-bar hint. A right-aligned
[ ASK ]chip on the bar row ([ ASK xN ]when several panes are asking), sitting left of the ADR-0033 supervisory badge when one is up.
Jump and return. C-a q (next-attention) jumps to the next asking
pane in deterministic window order, then depth-first leaf order, wrapping at
the end. The first jump saves the pane you came from; further cycling does
not overwrite it. C-a Q (return-from-attention) returns there once and
consumes the saved origin. If no pane is asking, no origin was saved, or the
origin closed, the action bells without moving focus. Both actions are
client-local: they send no frame and write no layout metadata or shared focus.
C-a A remains the full agent-fleet dashboard (§5.6).
Clearing rule. Attention clears when the client forwards key or
paste input to the asking pane — i.e. you focused it and typed
(presumably answering). Merely focusing or clicking the pane does
not clear it: looking at a question is not answering it. A repeated
Asked for a still-flagged pane changes nothing; the flag re-raises
on the next Asked after input cleared it. The flag is client-local
and per-attach — it does not persist across detach/reattach (a
re-emitted Asked from the ADR-0036 detector re-raises it).
Implementation provenance. The attention channel tracked as
phux-oih5.15 is already present: the wire AgentEvent::Asked, explicit ask
hook, server detector/state, and TUI asked fold shipped in commits bdb64f6,
2e59992, 23c7bca, and 28f0b34. No replacement wire is introduced here.
The shared/directed-focus proposals tracked as phux-oih5.10 and
phux-oih5.17 are superseded by accepted ADR-0049: topology may be shared,
but focus authority and advisory attention navigation remain client-local.
8.7 Transient notices
Some lifecycle events deserve a moment of visibility but no persistent chrome. The bar has a single transient notice slot for them: a full-row message (reverse video; warnings additionally bold) that takes the bar row over from the widgets for about 7 seconds, then expires on the bar’s existing 1-second refresh tick and the widget row returns. The slot is newest-wins — a fresh notice replaces the current one and restarts the clock; nothing queues.
Current producers:
- Input-authority handovers (ADR-0033). When the focused pane’s
input lease moves — another client takes or releases the wheel — an
info notice calls out the transition (
input: c9 took the wheel,input: wheel released). The persistentWHEEL:*badge (same client-id spelling) keeps showing the steady state; the notice marks the moment it changed. The lease state the server re-states at attach time is not a transition and raises no notice, and neither do handovers on unfocused panes. - Degraded federation. When a hub announces that a satellite became
unreachable (a spontaneous, uncorrelated
ERROR { SATELLITE_UNREACHABLE }), a warn notice reports it (federation degraded: ...). This is the in-TUI view of the same statephux statusreports on the CLI. - Pane death (phux-i0e8.2.2). When a pane’s process dies and other
panes survive the layout fold, a warn notice names the dead pane and
its exit shape:
pane 3: exited 137, orpane 3: killed (signal or unknown)whenTERMINAL_CLOSEDcarried no exit code (a signal kill). Two deaths are deliberately silent: a clean exit 0 (the user typedexit; nothing is wrong) and a close this client itself requested viakill-pane/kill-window(the kill dispatch marks its targets as expected, and the matching close consumes the marker — so a later spontaneous death of a reused pane id still notifies). - Server restart recovery (phux-i0e8.2.3). When the client rides out
a server restart (§8.8), the first bar paint of the new attach shows
an info notice —
re-attached after server restart— so the recovery is announced inside the live TUI. (A cooked-terminal line would be replaced by the alt screen within milliseconds of being printed.)
When the last pane dies there is no bar left to notice on: the
client’s consumer-owned detach policy (phux-4r1) tears the TUI down.
Since phux-i0e8.2.2 that exit is explained: after the alt screen is
gone and the terminal is cooked again, the client prints one line to
stderr — phux: session ended: the last pane exited 137 (or
... killed (signal or unknown)) — so an OOM-killed shell no longer
looks like a phux crash. A detach the user asked for prints nothing, and
the process exit code stays 0 in every case: the attach succeeded; the
ending just gets words. (Internally the run_* attach entry points
return an AttachEnd — Detached { reason } vs
LastPaneClosed { exit_status } — that the CLI callers format.)
A detach the user did not ask for is explained the same way
(phux-l83x). DETACHED carries an optional DetachReason
(proto.md §7.2), and any reason other than
REQUESTED prints one stderr line after teardown — for example
phux: detached: the server is shutting down or
phux: detached: another client took over this attach. A server that
states no reason (one predating 0.7.0-draft.7, or a bare disconnect
with no frame at all) prints nothing, exactly as before: the client
never invents a reason it was not told.
Precedence and degradation:
- The persistent error line (a
[status]config that failed to load, §4.2.1) always outranks the slot: while it holds the row, a notice is refused and degrades to a log line. - No bar row, no notice. An empty
[status]config reserves no row, so notices degrade to log lines (tracing) instead of painting. This is a documented limitation: configure at least one widget to see transient notices. - Notices are client-local and never persist: nothing crosses the wire, and a detach/reattach clears the slot.
8.8 The reconnect window
When the server vanishes mid-session (the graceful-upgrade blink of ADR-0032, or a crash), the TUI tears down to the cooked primary screen and waits for the server to come back — visibly, not as a blank terminal (phux-i0e8.2.3):
phux: lost the server connection; waiting up to 10s for it to come back
phux: reconnecting… 7s left (Ctrl-C to give up)
The second line is overwritten in place once per second.
How long it waits, and how hard it retries, depends on the lane. On
the local Unix socket the thing that vanished is a process: the
ADR-0032 re-exec keeps the socket bound and is back in well under a
second, so the client polls flat every 100ms for 10 seconds and the
blink is nearly invisible. On the remote lanes (--ws, --quic) the
thing that vanished is usually the client’s own network — a laptop
moving between wifi and cellular, or waking on a different AP — and the
server never went anywhere. Association, DHCP, DNS, and an overlay
network re-establishing its path routinely run past ten seconds, so
those lanes wait 60 seconds and back off exponentially from 500ms to
8s between attempts rather than re-running a TLS handshake ten times a
second on battery.
On the remote lanes, entering the window at all depends on noticing
the drop: a stalled TCP connection produces no FIN and no RST, so the
wss:// lane carries RFC 6455 ping/pong liveness (10s probe, 30s
timeout, matching the QUIC lane’s transport-level contract) and reports
a peer that stops answering as a disconnect. See
transport.md.
Three endings:
- The server comes back (a graceful upgrade re-execs in well under
a second):
phux: server is back; re-attaching…, the TUI returns with its state replayed, and the status bar shows there-attached after server restartnotice (§8.7). - The socket file is gone: the server shut down cleanly (a clean
shutdown unlinks its socket) and is not restarting. The client stops
waiting immediately and says so, naming the restart commands and
phux doctor. - The window elapses with the socket present but never accepting:
the server likely crashed or hung. The failure names the server log
(where the reason lives) and
phux doctor.
Either failure exits non-zero. The distinction matters: a gone socket is an ordinary shutdown you can restart your way out of; a timeout is a server that needs its log read.
What happens to input caught in the drop. Keystrokes are
fire-and-forget by design and are not replayed — ADR-0053 explains why
replaying them risks duplicates. A paste is different: on the remote
lanes, against a server that advertises ACKNOWLEDGED_INPUT, the TUI
delivers each bracketed paste as an acknowledged APPLY_INPUT operation
under a client-generated idempotent id. If the connection drops before
the receipt arrives, the re-attach resends the same operation id —
same server incarnation only, within a ten-minute horizon — and the
server’s dedupe cache answers instead of writing the paste twice. A
paste that cannot be honestly replayed (the server restarted, the
horizon passed, the window closed without a server) is reported in the
status bar — or on the cooked terminal at exit — as either delivery
unknown, read the pane before retyping or not delivered, safe to
retype, never silently dropped or doubled. On the local socket the
lane is not armed: the ADR-0032 blink is process-local, and a restarted
server is a new incarnation with an empty dedupe cache anyway.