0100 — The TUI is its own crate
The reference TUI moves out of phux-client into phux-tui. phux-client is the headless client library: connection, transports, the stdin parser, the replay...
Full source summary
The reference TUI moves out of phux-client into phux-tui. phux-client is the headless client library: connection, transports, the stdin parser, the replay journal, the exit vocabulary, and the agent verbs. phux-tui is everything that needs a screen: the attach driver, the libghostty replicas, the ratatui chrome, the dispatcher, the overlays. The dependency runs one way, and the tui cargo feature is gone.
Status: Accepted Date: 2026-09-08
Context
ADR-0020 confined ratatui to phux-client and moved the pane-interior
substrate into phux-client-core so that boundary was compiler-enforced.
It left a second boundary enforced only by a cargo feature: phux-client
was at once the interactive TUI (attach/driver, render/, the input
dispatcher, sixty thousand lines) and the headless library behind every
phux agent verb and the phux-mcp adapter (snapshot, send_keys,
wait, watch, state, twenty thousand lines). The tui feature, on
by default, was the only thing separating them.
That arrangement had concrete costs. phux-mcp had to remember to say
default-features = false, and a CI script existed solely to prove no
ratatui reached its graph. Doc paths and prose called phux-client “the
ratatui TUI client” while docs/consumers/sdk.md called it “the library
behind the CLI”; both were true, which is the problem. Every headless
module that the TUI happened to reach into (layout_ops, agent_meta,
perf, vcs) could stay pub(crate), so the real API the TUI consumed
from the library was never written down. Test builds unified tokio
features across the two halves, hiding a missing test-util declaration
until the halves were separated. And the settings work that follows this
ADR needs a place for TUI-owned configuration state that is neither the
config crate nor the agent library.
Decision
-
phux-tuiis a new workspace crate holding every module thetuifeature used to gate:attach/{driver, paint, render, reflow, rendered, repaint, input_dispatch, actions, action_registry, server_frame, fleet, focus, copy, context_menu, onboarding, plugin_actions, plugin_panes, sidebar_zones, record, reload, stdout_writer, terminal_probe, tty_input}and all ofrender/(chrome, overlays, theme). Its tests, benches, and insta snapshots move with it. It re-exports the headless attach vocabulary underphux_tui::attachso the driver keeps its paths and embedders can name one crate. -
phux-clientis the headless client library, and only that. It keepsattach::{connection, input, input_replay, outcome, quic, ws}and the verb modules. It links noratatui, norustix, nophux-crash, nophux-plugin, and never enables tokio’sio-std. Thetuiandnative-enginefeatures are removed;testkitstays. -
The dependency is one-way.
phux-tuidepends onphux-client; nothing inphux-clientmay namephux-tui. What the TUI needs from the library is nowpub, which is what makes it an API: the layout-key owner, the connection’s test seams under thetestkitfeature, the exit-status formatter, the named-key constructor. -
phux-client-coreis unchanged. Both crates depend on it; both re-exportlayout,multi_pane,predict. The ADR-0020 boundary between chrome and substrate stands; this ADR adds the boundary between chrome and headless control plane beside it. -
The binary depends on both.
phux attachand the renderedphux snapshotgo throughphux_tui::attach; every other verb goes throughphux_client.phux-mcpdepends onphux-clientalone.
Why
A crate boundary is the only boundary Rust checks. A feature flag protects
nothing on the default path, and the default path is the one every
contributor builds; the flag mostly documented an intent. Splitting the
crates turns “headless consumers must not link a terminal” from a script’s
assertion into a property of the dependency graph: phux-mcp cannot
compile the chrome because no edge leads there.
The split also names the interface. Forty-odd crate:: paths from the TUI
into the headless half became phux_client:: paths, and each private item
they reached became a public one with a doc comment. That list is the
contract a native or web front end would code against; before, it did not
exist as a thing.
The smaller reason is build hygiene. Each half now has exactly the dependencies it uses, its own dev-dependency graph, and its own feature set, so a change in the chrome does not re-key tokio for the agent verbs and a missing test feature fails where it is missing.
Tradeoffs
- One more crate (seventeen). The workspace already treats crates as the unit of boundary, so this follows the house pattern rather than adding a new kind of thing.
- Public surface grows. Items the TUI reached into become
pubonphux-client. They were already load-bearing; they are now visible and documented. The crate ispublish = false, so this is not a semver commitment. - Path churn.
phux_client::attach::{run_with_predict_dial, record, status_bar, action_registry, render}becomephux_tui::attach::.... Only the binary and one conformance test named them. native-enginemoves with the driver, still not optional in practice. Itscfg(not(...))fallback branches were never compiled by any gate before this ADR, andcargo check -p phux-tui --no-default-featuresdoes not build today (the fallback namesengine::ghostty, which that build configures out). The split made the dead feature visible; deleting or repairing it is follow-up work, not this decision.
Alternatives
Keep the feature flag and add a lint. A check-no-ratatui script
already existed for phux-mcp. It catches one consumer and says nothing
about the interface between the halves. Rejected as the status quo.
Move the verbs out instead (phux-sdk). Same graph, different name
for the new crate. Rejected because every existing consumer, doc, and
phux_client:: path names the headless half as phux-client; moving the
TUI churns the binary’s attach entry only.
Fold the driver into phux-client-core. Rejected: the substrate is
deliberately frontend-neutral and wasm-safe (ADR-0025’s reversal), and the
driver owns a controlling terminal.