phux
guidesinteractive TUI

TUI layout, mouse, and status bar

The reference TUI's consumer-facing product surface: subcommands, keybinds, status bar, layout, hooks, recording.

evolving document
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_RESIZE sizing), 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-pane actions and mouse divider dragging ship (ADR-0048, phux-foz.3). resize-pane dispatches through the single-dispatch action registry, C-a H/J/K/L are 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 by phux-p4vp; interactive per phux-fce4; sectioned + agent-aware per phux-foz.9; three-zone attention inbox per phux-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 + accent for the active window, hollow + dim otherwise, attention yellow 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, a wave2/... 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):

  1. The structured phux.agent/v1 record (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: working while it runs, blocked when it is waiting on a human, idle otherwise. An explicit phux agent set outranks 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 to unknown (keeping the declared name and kind) and the derivation resumes, so a killed agent no longer leaves the row painted working for the life of the pane.
  2. 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 blocked while the pane’s §8.6 asked flag is up, else idle. 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):

VariableEffect
PHUX_AGENT_DETECT=0Disable 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:

TargetCommitted 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 rowswitch-session { name }
Either +N more rowagent-fleet
+ newnew-window
= menucommand-palette (the session/plugin menu; new-session lives in its Session group)
The collapse chevrontoggle-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 09).


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 = false config, and the per-pane set-pane mouse off action 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.

EventAction
Click in paneFocus the pane, then forward to it
Press on a dividerGrab the boundary for a resize drag
Drag a dividerResize the boundary (tracks pointer)
ReleaseCommit the new layout (broadcast L3)
Scroll wheel in paneLayered: 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 paneOpens the pane context menu (§7.1);
forwarded to the inner program
instead when that program has mouse
tracking on
Click on status bar rowA 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 barA tab selects its window and opens
the window menu; elsewhere on the row
opens the session menu (§7.1)
Click on a sidebar rowSelect that window (window blocks and
agent rows); + new / = menu / the
collapse chevron run their actions
(§6.4)
Right-click the sidebarA 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 onMenuRows
A panepaneSplit right, Split down, Zoom /
Unzoom, Copy mode, All commands…,
Close pane
A status-bar tab or awindowNew window, Rename window…, Pick
sidebar window/agent rowwindow…, All commands…, Close
window
Any other chrome cellsessionNew 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:

  1. 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.
  2. Client-local widgets. Things derivable on the client without server help: time, and anything expressible as an exec widget.
  3. exec widgets. 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. clock re-renders every minute by default; interval overrides.
  • exec widgets re-render every interval. The client batches re-renders to once per frame (max ~60 Hz).
  • Slot contents render left-to-right with no implicit separator. Use text widgets for separators, and spacer widgets 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:

  1. 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.
  2. 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.
  3. 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:

  • windows drops 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 named 1:, 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 type prefix <n>.
  • help-hints drops 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.
  • switch renders 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:

  1. 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.
  2. 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.
  3. 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 style tables 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’s windows widget — including for a background window, so the question is findable from anywhere. (The sidebar marker is themed; the windows widget marker rides the segment’s own style, like the zoom Z.)
  • 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 persistent WHEEL:* 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 state phux status reports 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, or pane 3: killed (signal or unknown) when TERMINAL_CLOSED carried no exit code (a signal kill). Two deaths are deliberately silent: a clean exit 0 (the user typed exit; nothing is wrong) and a close this client itself requested via kill-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 AttachEndDetached { 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 the re-attached after server restart notice (§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.


View exact source