TUI configuration and keybindings
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.
4. Configuration
4.0 Philosophy and the phux config commands
phux is config-driven, in the Ghostty mold
(ADR-0023): one TOML file is the
whole source of truth, and phux never writes settings back from running
state. There is no set-option verb. The defaults you don’t override
ship inside the binary as an embedded, annotated default.toml; your
config.toml is a sparse overlay merged on top of it leaf-by-leaf. A key
you omit keeps tracking the binary’s default, so a phux upgrade that
improves a default reaches you automatically — your file is overrides,
not a frozen snapshot.
A missing config file is not an error; phux runs on the embedded defaults alone. To get a documented starting point and to inspect what’s active:
phux config path # print the resolved config path (no I/O)
phux config init # scaffold a commented starter config there;
# refuses to overwrite (use --force)
phux config init --distro herdr
# same scaffold plus one active extends line
# layering a starter distribution (bundled
# name or path); see docs/CONFIG.md
phux config show # print the effective config (defaults + your
# overrides) as canonical TOML
phux config show --default # print the shipped defaults verbatim,
# comments and all — the annotated source
phux config show --layers # provenance: which layer of the extends
# stack (ADR-0039) set each effective key;
# arrays list each element's contributor.
# --json for the stable document
# (schema_version 1)
phux config plugins --json # print configured plugin manifests as JSON
phux config agents --json # print configured plugin agent states as JSON
phux config check # every unknown key and wrong value, each
# with its full dotted path and the layer
# file that introduced it. --json for the
# stable document (schema_version 1)
phux config reload # validate, then apply the config to running
# clients in place (see 4.3)
phux plugin list --json # inspect the plugin registry
phux plugin validate # validate every configured plugin manifest
phux config init writes the shipped defaults with every line commented
out: the file documents every option next to its real default value, yet
imposes no overrides until you uncomment a line. That is what keeps the
binary’s defaults authoritative — uncommenting is the only way the file
changes behavior. The --distro flavor adds exactly one live statement —
an extends line layering a curated starter distribution (ADR-0039)
between the defaults and your file; the distro layer is referenced, never
copied, so its updates keep reaching you. Distribution mechanics and the
bundled herdr starter are documented in
docs/CONFIG.md. config show renders the merged TOML table, so it
answers “what is my effective config” rather than reproducing your file’s
comments or key order; cat the file for the latter.
For testing config changes inside a checkout without touching your real
~/.config/phux, just scaffold-config drops a starter into a
worktree-local ./.phux-xdg (gitignored); point XDG_CONFIG_HOME at it
to exercise the result.
4.0.1 First-use moments
First use is a short journey through normal work, not a setup wizard. On the
first attach for an active profile, a compact overlay explains that the session
outlives the view, how to detach, how naked phux returns, and how to open the
command palette. It renders the effective detach and command-palette
bindings instead of assuming the defaults. The first key dismisses the overlay
and still follows its normal binding or pane-input path; the guidance does not
cost a keystroke.
After the first intentional detach, once the outer terminal is reset to cooked
mode, phux prints one reassurance: the session is still running and phux
returns to it. The next attach shows a brief status-bar confirmation that this
is the session left running, without replaying the introduction. Later attaches
are quiet. Session switches inside one attach invocation do not advance or
repeat these moments.
Progress is a versioned onboarding.json file in the active profile’s state
directory (section 4.1). Missing state starts the journey. Delivery is claimed
under a profile-scoped lock and committed only after the overlay or status
notice is accepted; interrupted attaches leave a retryable pending stage.
Unreadable, corrupt, newer-version, or unwritable state fails quiet, and
onboarding state never turns a successful attach into an error. Profiles do not
share progress. The command palette’s Getting started action always reopens
the current-binding-aware introduction without changing progress.
4.1 File location
Config is read from $XDG_CONFIG_HOME/phux/config.toml (or
~/.config/phux/config.toml). Set XDG_CONFIG_HOME to isolate configuration
for a test or alternate environment; there is no global config-path flag.
The full path map — socket, config, logs, TLS material, token store, and the not-yet-implemented paths — is the generated file locations reference, rendered from the resolving functions themselves and pinned by unit tests, so it cannot drift from the code.
4.2 Format
Config is TOML. The config tree is shallow, so TOML’s idioms
([table], [[array.of.tables]], inline tables for parameterized values)
cover it without deep nesting.
A minimal config:
[defaults]
shell = "/bin/zsh" # unset: $SHELL, fallback /bin/sh
term = "xterm-256color" # TERM advertised to spawned panes
# Scrollback is bounded twice and pruned on whichever bound is reached first
# (ADR-0094). On a wide grid the BYTE bound is the one that binds, so raising
# history-limit alone buys no depth. Raising history-bytes costs attach
# latency, not just memory: ~8 ms per pane per attach at 2 MiB, ~65 ms at
# 10 MiB. Max 67108864 (64 MiB).
history-limit = 50000 # rows, per pane
history-bytes = 2097152 # 2 MiB, per pane
# Sane-default spawn knobs (phux-4li.1):
cwd-inheritance = "inherit-focused"
session-name-template = "default"
window-size = "smallest" # geometry policy for shared Terminals (ADR-0027)
[voice] # server-side transcriber behind TRANSCRIBE (docs/operations.md)
# transcriber = ["curl", "-sf", "-F", "file=@{path}", "-F", "response_format=text",
# "http://127.0.0.1:8000/v1/audio/transcriptions"]
# timeout-secs = 30
# spawn-on-attach = "/usr/bin/some-launcher" # default: defaults.shell
[keybindings]
prefix = "C-a"
# Bindings under the prefix.
# An action is either a bare string (no parameters) or an inline
# table whose `action` field names the action and remaining fields
# pass parameters.
[keybindings.prefix-table]
'"' = { action = "split-pane", direction = "horizontal" }
"%" = { action = "split-pane", direction = "vertical" }
"x" = "kill-pane"
"c" = "new-window"
"n" = "next-window"
"h" = { action = "focus-direction", direction = "left" }
"j" = { action = "focus-direction", direction = "down" }
"k" = { action = "focus-direction", direction = "up" }
"l" = { action = "focus-direction", direction = "right" }
"w" = "window-picker"
"s" = "session-picker"
"d" = "detach"
"," = "rename-window"
# Global table: bindings that fire without a prefix.
# Empty by default; opt in to hyper/super combos if your outer
# terminal forwards them.
[keybindings.global]
# "M-Enter" = "detach"
[status]
left = [{ kind = "windows" }]
center = [{ kind = "help-hints" }]
right = ["session-name", { kind = "time", format = " %H:%M" }]
# Responsive-chrome breakpoints (section 4.5). Shipped values shown.
[chrome]
compact-cols = 64
compact-rows = 18
min-pane-cols = 40
[[plugins]]
manifest = "/path/to/plugin/phux-plugin.toml"
enabled = true
[[satellites]]
name = "devbox"
endpoint = "ssh://devbox"
enabled = true
[theme]
accent = "#cdd6f4"
section_header = "yellow"
Spawn defaults under [defaults] shape what happens when a new pane
or session comes into being:
-
shell(string, default unset) is the program server-spawned panes run when nothing names a command: the seed session, attach-time session creation, and aSPAWN_TERMINALwhose wire frame carries nocommand; it is also the shell that wrapsspawn-on-attachand--seed-commandvia<shell> -c. The server resolves it once at startup:defaults.shellwhen set, else$SHELL, else/bin/sh. A wirecommandalways wins over this default (phux-i0e8.4.1). -
term(string, default"xterm-256color") is theTERMthe server advertises to the inner program of every spawned pane. The resolution order for one spawn, lowest to highest: compiled-in baseline →defaults.term→ theSPAWN_TERMINAL.termwire field → aTERMentry inSPAWN_TERMINAL.env(spec L1 §3.1). The default is deliberately the safe xterm baseline rather thanghostty: ghostty’s terminfo advertises thefullkbdcapability, which ncurses apps read as “kitty keyboard protocol available” and pushCSI > N u— and at least htop then fails to parse the CSI-u key reports it asked for, so itsqquit dies (phux-7vx). The phux stack itself round-trips the kitty protocol — the phux-0o8 harness (crates/phux-server/tests/terminal/kip_roundtrip.rs) drives real TUIs through the full wire path underTERM=ghosttyand proves nvim’s CSI-u opt-in works end-to-end, with fzf/less/vim/btop regression-free — but the canonical ncurses reproducer (htop) remains unproven, so the default stays conservative. Setterm = "ghostty"to opt into ghostty’s extended terminfo (sixel, kitty graphics advertisement, ghostty SGR extensions) once the apps you run are known to round-trip it; apps that opt into kitty mode at runtime get it under either default. -
Spawn geometry is not configurable, and deliberately so. A new pane is created at the tile the TUI has already computed for it: the split (or new window) is applied to a provisional copy of the layout before the request goes out, and the resulting rect rides along as
SPAWN_TERMINAL.initial_size(spec L1 §3.1, gated on the server’sSPAWN_INITIAL_SIZEcapability). Against a server without that capability the pane starts at 80x24 and the reflowTERMINAL_RESIZEthat follows every spawn sizes it — visually identical, but it costs the pane the engine checkpoint the server had just captured, which is why the field exists (phux-a5xj). -
cwd-inheritance(string enum, default"inherit-focused") controls how a freshly-spawned pane picks its working directory when aSPAWN_TERMINALleavescwdunset (an explicitcwdalways wins). Values:"inherit-focused"(match the focused pane’s CWD — tmux’s default),"home"(always$HOME),"session-root"(the directory the session was created in),"last-cwd-per-window"(remember per window).inherit-focusedandhomeare wired server-side (phux-cs6):inherit-focusedreads the focused pane’s live PTY working directory via a kernel query (/proc/<pid>/cwdon Linux,proc_pidinfoon macOS), so it trackscdwithout any shell OSC 7 setup.session-rootandlast-cwd-per-windoware accepted but not yet resolved server-side (they fall back to no override); completing them is a phux-cs6 follow-up. -
spawn-on-attach(string, default unset) is the commandphuxspawns when it auto-creates a session on attach. Unset ⇒ honordefaults.shell(which honors$SHELL). -
session-name-template(string, default"default") names auto-created sessions. Supports${cwd-basename}substitution against the client’s working directory at session-create time. Unknown placeholders pass through verbatim. -
window-size(string enum, default"smallest") picks one geometry when concurrent views of a single Terminal disagree on size. A Terminal is one PTY + one libghostty grid (ADR-0027), so it has exactly one authoritative(cols, rows); mirrored panes or multiple attached clients share it, and a view that wants a different size letterboxes rather than reflowing the shared grid. The vocabulary mirrors tmux’swindow-size:"smallest"(use the smallest view — nothing is ever cropped; larger views letterbox),"largest"(use the largest view; smaller views may crop),"latest"(track the most-recently-resized view),"manual"(hold a fixed size, set byphux resize).The three view-derived values decide only among views. An explicit
phux resize TARGET COLSxROWSis not a view: it names a size no viewport reported, and the server applies it immediately whether or not anyone is attached. What it does not do is win permanently. Under"smallest","largest", and"latest"the next view event — an attach, a detach, or an attached client’s window resize — recomputes the Terminal’s geometry from the views and supersedes it. Under"manual"no view event ever recomputes, so an explicit resize is the only thing that sets the size and it holds. That is the setting to reach for when a pane must stay at a scripted geometry (ADR-0062).phux resizereads the server’s real geometry back before exiting and fails loudly rather than silently, so a script never has to infer which of these applied — seeagents.md§4.15.
Experimental knobs live under [experimental]. Today the only key
is predictive-echo (boolean, unset by default — the transport
decides), which controls Mosh-class predictive local echo in phux attach — a client-side guess for the next keystroke, rendered with an
underline, that is reconciled when the server’s authoritative output
arrives. With the key unset, prediction is on for a remote attach
(--remote, --quic, --ws) and off over the local Unix socket. Set
it explicitly to override that in either direction: true predicts
everywhere including UDS, false never predicts. The TOML key is parsed by
phux-config and wired into the attach driver as PredictiveConfig.
The prediction set is the conservative mosh-proven subset
(single-grapheme inserts, end-of-line backspace, Ctrl-U at a known prompt
boundary, Enter, left/right arrows over known cells); a wrong guess is
stomped by the next authoritative frame, and repeated contradictions
turn the display tentative (the overlay hides until clean confirmations
prove typing has normalized). Leave it unset or set it to false to keep
echo strictly authoritative; set it to true to opt in. Anything under
[experimental] may be renamed or removed without a SemVer bump.
What it helps, and what it does not. Predictive echo hides latency for
shell-prompt typing over a slow link — the characters you type appear
immediately instead of waiting a round trip for the server to echo them.
In full-screen app mode — vim/nvim, pagers (less), and agent TUIs
(Claude Code, codex) switch to the alternate screen — the display is
confirmation-gated
(ADR-0090):
predictions queue and reconcile but paint nothing until the app proves it
echoes typed text. Apps that never echo (htop, less, vim normal mode)
never show a guess; apps people type into (vim insert mode, an agent
prompt) get their echo back after one confirmed keystroke per screen
session. The win
also shrinks toward zero as the round trip shrinks: over the local UDS
transport the server echo is already near-instant, so the benefit is most
visible on the higher-latency remote transport
(ADR-0007).
Why the dial is the default. Two main-screen cases the client cannot
detect are why prediction is not simply on everywhere: readline vi
command-mode at the prompt (set -o vi), where normal-mode keys are
mispredicted as inserts until the tentative lock hides the overlay (a
brief underlined flicker), and no-echo prompts (sudo/ssh
passwords), where echo is suppressed by the server PTY’s termios —
invisible to the client — so a predicted insert momentarily renders the
typed characters, bounded by the one-second display timeout.
The gate is an RTT gate in coarse form — predict only when there is a round trip worth hiding — and it has the advantage of being known before the first keystroke rather than estimated from one. A same-machine attach echoes in hundreds of microseconds, where a prediction can only cost the two cases above and buy nothing; a dial that crosses a network pays a full round trip per key, which is exactly the latency the predictor exists to hide. A finer SRTT-derived gate can refine this later without changing the key’s meaning.
“Remote” means the dial actually leaves the machine, not that the
transport could. A --quic or --ws dial to 127.0.0.1 or localhost
— the shape the browser client uses against a local server — counts as
local and does not predict: the transport is a network transport but the
round trip is not. For QUIC the test is the resolved address, so a name
that resolves to loopback is loopback.
A config.toml that fails to load leaves prediction off, on every
transport, rather than falling back to the per-transport default. The
default answers “the user has not said”; a file that will not parse is not
silence, and may well be a file that said false one line above a typo.
The password case above is the risk this default accepts, and its scope is
worth stating exactly: the guessed characters are painted on your own
screen only. The overlay never reaches the wire, the server, another
client, or a phux rec recording — it is a local paint, so the exposure
is someone reading over your shoulder, not a disclosure to anyone else.
See ADR-0090’s Amendment for why no client-side heuristic closes it and
what would.
[experimental]
# Unset: predict when the dial leaves the machine; not on the local
# socket, and not on a loopback --quic / --ws dial.
# true: predict on every transport.
# false: never predict.
predictive-echo = false
Plugin manifests live under [[plugins]]. This is an external package
contract, not an in-process plugin host: phux validates and inspects local
phux-plugin.toml manifests, executes declared actions as child processes, and
keeps terminal/session state in first-party CLI surfaces. manifest is an
absolute path, or a path relative to config.toml; enabled defaults to
true.
[[plugins]]
manifest = "./plugins/agent-tools/phux-plugin.toml"
enabled = true
A manifest declares package metadata and argv entrypoints:
id = "example.agent-tools"
name = "Agent Tools"
version = "0.1.0"
min_phux_version = "0.0.2"
platforms = ["linux", "macos"]
[[build]]
command = ["cargo", "build", "--release"]
[[actions]]
id = "summarize"
title = "Summarize pane"
contexts = ["pane"]
command = ["python3", "summarize.py"]
# Optional: contribute a prefix-table keybinding for this action
# (chord syntax per section 5.1, e.g. "g" or "g s"). The TUI merges it
# at attach; a chord that conflicts with the user's own [keybindings]
# (exact chord or ambiguous prefix) is dropped with a logged warning —
# user config always wins. Plugin actions also always appear in the
# command palette (section 5.5) whether or not keys is set.
keys = "g"
[[events]]
id = "idle"
title = "Pane idle"
on = "pane.idle"
command = ["sh", "-c", "printf idle"]
# Optional: contribute status-bar widgets (section 8.3). Each entry is a
# widget table (kind + kind-specific options) plus a plugin-local id and
# the bar slot ("left" | "center" | "right", default "right") to append
# to. Contributions never displace user config: the TUI appends them
# after the user's own [status] widgets, and an entry whose spec fails
# widget validation is dropped with a logged warning.
[[widgets]]
id = "battery"
slot = "right"
kind = "exec"
command = "./battery.sh"
interval = "30s"
[[agents]]
id = "codex"
label = "Codex"
state = "working"
attention = "normal"
contexts = ["workspace", "pane"]
# A pane the TUI can open as a real server-side Terminal running this
# command (section 5.5). `placement` routes where it opens: "split"
# (beside the focused pane), "tab" (a new window named after `title`),
# or "zoomed" (a split that opens filling the window). "overlay" is
# accepted by the schema but NOT hosted yet — a floating live-terminal
# surface is deferred; overlay entries are skipped with a logged
# warning and do not appear in the palette.
[[panes]]
id = "board"
title = "Agent Board"
placement = "split"
command = ["agent-board"]
[[links]]
id = "ticket"
title = "Open ticket"
contexts = ["pane"]
patterns = ["https://linear.app/*"]
command = ["agent-ticket", "{url}"]
[[workspaces]]
id = "agent-bench"
title = "Agent Bench"
contexts = ["workspace"]
agents = ["codex"]
actions = ["summarize"]
events = ["idle"]
[[workspaces.panes]]
id = "board"
pane = "board"
role = "monitor"
phux plugin list --json is the stable lifecycle inspection surface for
agents and scripts; phux config plugins --json remains a compatibility
read path for the same configured manifests. The plugin verbs load the
user config, resolve every configured manifest, validate ids and
non-empty command argv values, reject duplicate provider ids, and emit
schema_version = 1 JSON documents that enumerate actions, events,
panes, and links. Invalid manifests are hard failures: they are never
silently skipped, because a future runtime host should not execute a package
the config surface could not validate.
The lifecycle verbs edit [[plugins]] in config.toml without starting
a server:
phux plugin install https://example.com/agent-tools.git
phux plugin install ./plugins/agent-tools # local dir or .tar/.tar.gz/.tgz
phux plugin update [example.agent-tools]
phux plugin link ./plugins/agent-tools/phux-plugin.toml
phux plugin list --json
phux plugin disable example.agent-tools
phux plugin enable example.agent-tools
phux plugin unlink example.agent-tools
Manifest validation includes the min_phux_version gate: a manifest whose
floor is newer than the running phux is rejected at link, install, and load
time with an error naming both versions (best-effort batch consumers such
as the attach TUI skip the gated plugin with a logged warning instead of
failing wholesale).
phux plugin install REF fetches a whole plugin package into the managed
plugins directory — $XDG_DATA_HOME/phux/plugins, else
~/.local/share/phux/plugins. REF is a git URL (https://, git@,
file://; cloned shallow with the system git, --rev BRANCH_OR_TAG picks
a ref), a local plugin directory (copied, .git excluded), or a local
tarball (.tar, .tar.gz, .tgz; extracted with the system tar). After
the fetch, the manifest’s [[build]] steps for the current platform run as
child processes from the plugin root with a five-minute per-step timeout and
captured output; a failing or timed-out build aborts the install with the
step’s stdout/stderr and leaves nothing linked. The validated package is
then linked into [[plugins]] exactly like phux plugin link (pass
--disabled to link it disabled), and its provenance — source kind, ref,
requested branch, and the resolved commit for git sources — is recorded in
the managed directory’s plugins.lock. With --json, the result is a
schema_version = 1 document under an installed key with id, version,
dir, source, ref, branch, rev, and enabled.
phux plugin update [NAME] re-fetches from the lockfile’s recorded sources
(every entry, or just NAME), reruns the build steps, revalidates the
manifest (id changes are refused), swaps the managed copy, and records the
new resolved commit. config.toml is untouched because the linked manifest
path does not move. With --json, the result is a schema_version = 1
document whose updated array carries id, version, and rev per
plugin.
phux config agents --json [--socket PATH] projects [[agents]] entries
into a flat schema_version = 2 document with plugin_id, id, label,
state, attention, source, declared, runtime, and contexts, so
consumers can render unknown/idle/working/blocked/done state without knowing
every plugin entrypoint. The projection is live (phux-r82.10): when a server
answers on the socket, per-pane phux.agent/v1 records (ADR-0040) and asked
state override the declared manifest baseline; without a server the declared
values are reported with source = "manifest". See
docs/consumers/agents.md §4.6 for the normative shape.
The config/plugin commands load the user config, resolve every configured
manifest, and validate ids and non-empty command argv values. Invalid manifests
are hard failures: they are never silently skipped, because the runtime host
should not execute a package the config surface could not validate.
phux config run PLUGIN ACTION [--json] executes one enabled action declared
by an inspected manifest. The runtime executes the manifest’s argv directly
from the plugin root, captures stdout/stderr/exit status/duration, and kills
the child on --timeout SECS with wrapper exit code 125. With --json, the
result is a schema_version = 1 document containing plugin_id, action_id,
command, cwd, outcome, exit_code, stdout, stderr, and
duration_ms. There is no implicit shell; a plugin opts into shell behavior by
declaring ["sh", "-c", "..."].
phux workspace save [--socket PATH] [--output PATH] captures the running phux
workspace as a JSON archive. The archive records sessions, windows, pane
titles/cwds, focus, nullable commands, and layout orientation. It does not
pretend dead processes survive. phux workspace restore ARCHIVE [--socket PATH]
recreates missing sessions from that archive, using saved/authored cwd and
command fields where available. External packages compose this surface today:
the checked-in continuum demo autosaves/restores profile archives, and the
agent-tools demo launches and drives an agent-bench profile through
phux config run.
Federation satellites live under [[satellites]]. This is the
hub-side registry for remote phux servers; the registry name is the host
token that appears in TerminalId::Satellite.host — the address every
satellite-routed frame carries. endpoint is an opaque URI string in the
registry CRUD so ssh://devbox, quic://host:8788, and wss://host:8787
can share one control-plane shape; enabled defaults to true.
A server started with phux server --hub consumes this registry: at
startup it validates every enabled entry’s endpoint by scheme (quic://
requires an explicit host:port; ssh:// takes [user@]host[:port]
with a strict charset — the parts become ssh argv, so anything that
could read as an option or smuggle arguments is rejected) into a runtime
satellite table keyed by the registry name, and refuses to start on a
malformed enabled endpoint or a duplicate name. Disabled entries are
skipped. The hub then dials each table entry with capped exponential
backoff reconnect and routes satellite-tagged traffic over the
established links (SPEC L1 §9.1): per-terminal commands, input, and
subscribed streams relay both directions with ids re-tagged at the hub;
phux ls / GET_STATE on the hub aggregates every satellite’s terminals
next to the local ones (an unreachable satellite degrades to an
un-correlated typed error, never a failed list — the CLI surfaces that as
a stderr warning and, under --json, as the unreachable list; a verb
that resolves a target refuses with exit 3 rather than claiming the
pane is gone, see agents.md §5.2); and
phux spawn --satellite NAME creates a terminal on the satellite,
returning a satellite-tagged id that routes through the hub immediately.
Without --hub the server ignores the registry entirely and refuses
satellite-tagged traffic with the typed UnsupportedSatelliteRoute.
For quic:// and wss:// endpoints the hub authenticates to a satellite
as an ordinary remote consumer (ADR-0038): a pairing bearer token plus a
TLS certificate-fingerprint pin, both produced by running phux pair on
the satellite host. The token is stored by reference — token-file is
an absolute path to an owner-only file holding the hex token (the same
shape as the server’s token store); the secret never appears in
config.toml and is never printed by the lifecycle verbs.
cert-fingerprint is the satellite certificate’s SHA-256 pin (64 hex
digits, optionally colon-separated; not a secret, stored inline). Routable
endpoints without both are refused, fail closed, without dialing.
ssh:// endpoints take neither (ADR-0038 addendum): the hub spawns the
system ssh binary (override with $PHUX_SSH) running
phux stdio-bridge on the satellite host, which splices the connection
into the satellite server’s local Unix socket. SSH authenticates and
encrypts the channel — use BatchMode-compatible key material (the hub
never answers a prompt) — and the bridge inherits the satellite UDS’s
owner-only local trust, so token-file / cert-fingerprint on an
ssh:// entry are ignored. The satellite host needs phux on the
non-interactive PATH of the SSH login.
[[satellites]]
name = "devbox"
endpoint = "quic://devbox.example:8788"
enabled = true
token-file = "/home/me/.local/state/phux/satellites/devbox.token"
cert-fingerprint = "AB:CD:..."
The lifecycle verbs edit [[satellites]] in config.toml without
starting a server:
The normal path is one capture-free command per box. Run it on the hub:
phux service install --hub
phux host enroll --role satellite user@devbox
host enroll --role satellite verifies the satellite’s phux, installs its
always-on service, mints and stores its credentials, and writes the complete
registry entry. It prefers pinned QUIC on a detected overlay address and
falls back to ssh://user@devbox; --ssh-only selects that fallback
without probing. The lower-level add form remains available for
externally provisioned credentials:
phux host add --role satellite devbox quic://devbox.example:8788 \
--token-file /home/me/.local/state/phux/satellites/devbox.token \
--cert-fingerprint AB:CD:...
phux host ls --role satellite --json
phux host rm --role satellite devbox
add is add-or-update and replaces the whole entry, so repeat the auth
flags when re-adding a name; omitting them clears the stored auth material.
Outbound relay connectors live under [[connector]]. Each entry names
the self-hosted reference relay endpoint this server dials and holds as a
reverse tunnel (ADR-0051/ADR-0052):
[[connector]]
relay = "relay.example:4433"
token-file = "/home/me/.local/state/phux/relay-studio.token"
cert-fingerprint = "AB:CD:..."
token-file contains the route token printed by phux relay pair --route ROUTE; it must be owner-only and is re-read on every dial attempt.
cert-fingerprint pins the relay leaf certificate. Both are mandatory for
a routable relay and optional only on loopback for development. Unknown
keys, malformed HOST:PORT values, and incomplete routable entries fail
server startup before the local socket binds.
phux server supervises every entry independently with capped exponential
backoff. phux server --connect HOST:PORT selects the exact matching entry
and reuses its credentials; an endpoint not present in config is accepted
only when it is loopback. The relay token authorizes the tunnel, not a
consumer: each bridged consumer must still present a token from the server’s
ordinary phux pair token store. See
Remote access, Path D for
the complete enrollment and rotation flow.
4.2.1 Validating: phux config check
The loader already refuses an unknown key — Config
carries deny_unknown_fields, so a typo is a hard error, not a silent
no-op. What the loader is not is locatable. It reports:
config.toml: unknown field `enabledd`, expected one of `enabled`, `width`, `position`
Three things are wrong with that. It names only the leaf field, and
enabledd does not say which table it is in — several tables have an
enabled, a width, and a position. It carries no position, because what
is being deserialized is the merged layer stack, not your file (the loader
used to fabricate a 1:1 here; it now reports no position rather than a
confidently wrong one). And it stops at the first problem, so a config with
four typos takes four edit-run cycles.
phux config check fixes all three:
$ phux config check
keybindings.which-key: bad value: invalid type: string "yes", expected a boolean
keybindings.wich-key: unknown key: unknown field `wich-key`, expected one of `prefix`, `prefix-table`, `global`, `which-key`, `which-key-delay-ms`
sidebar.enabledd: unknown key: unknown field `enabledd`, expected one of `enabled`, `width`, `position`
from /etc/phux/team-baseline.toml
3 problems
The dotted paths come from the schema walk itself, so they cannot drift the
way a hand-maintained key list would. The from line appears only when the
key came from somewhere other than the file you named — with extends
(ADR-0039) in play, “is this typo mine or the distro’s?” is the question you
actually have, and a line number in your own file would not answer it.
Once the stack deserializes, a semantic pass validates the keybindings —
the mistakes that load fine and then silently do nothing: every chord
string must parse under the chord grammar (§5.1), every action name must be
one the dispatcher actually handles (an unknown name comes with a
did-you-mean suggestion, e.g. unknown action `kill-pain` (did you mean `kill-pane`?)), and no binding’s sequence may shadow another’s as an
ambiguous prefix. Parameterized action arguments are deliberately not
validated here — argument schemas belong to the dispatcher, not the loader.
Faults are classified because they have different fixes: an unknown key
is a typo or a key removed in a later version; a bad value is a real key
with the wrong type; a bad chord is a binding key that does not parse
(or clashes with another binding); an unknown name is an action no
dispatcher arm handles. The same labels appear in the --json findings.
Exit codes are three-way so a dotfiles CI job can react differently to each:
| Exit | Meaning |
|---|---|
| 0 | clean, or no config file at all (the shipped defaults apply) |
| 1 | findings — the config loads nothing, or loads wrong |
| 2 | the check could not run: unreadable file, malformed TOML, cyclic extends |
A missing file reports no config file (shipped defaults apply) rather than
ok, because a bare “ok” would hide the common case of checking the wrong
path.
4.3 Reloading
Config reloads are explicit, never automatic (phux-foz.5). Two surfaces trigger the same in-place reload of a running client:
- The
reload-configaction — a command-palette row (“Reload the config file”), also bindable to any chord:R = "reload-config"in[keybindings.prefix-table]. It ships unbound by default. phux config reloadfrom any shell. The CLI validates the config locally first — a broken file fails right there with the parse error and signals nothing — then rings a reload doorbell on the server (the conventional L3 keyphux.config.reload/v1, spec §3.8 of../spec/L3.md) so every attached client re-reads its own config file. The config bytes never cross the wire.
A reload re-runs the full layered loader — extends stacks and -append
array merges resolve exactly as at startup — and rebuilds, atomically:
keybindings (prefix, both tables, plugin-contributed chords, the
which-key knobs), the theme, the status-bar composition (widgets,
plugin [[widgets]] contributions, and [status] position), and the
plugin action rows in the palette. Failure semantics are all-or-nothing: on any
parse or validation error the client keeps the previous config fully
in effect and surfaces the error as a dismissable toast — never a crash,
never a half-applied mix of old and new. This is deliberately stricter
than attach-time keybinding resolution, which degrades per binding with
a status-bar diagnostic (§5.1): a reload has a known-good previous
config to fall back on; attach does not.
Not covered by a reload (restart the client, or detach and re-attach):
pane-behavior settings read once at attach, such as [predict],
[sidebar] geometry, and [defaults] (which the server owns anyway).
The file is deliberately not watched: watch-reload introduces a class of “saved-mid-edit, now my keybindings are gone” papercuts, and an explicit verb keeps a broken intermediate save inert until you ask for it. This was the design intent recorded here before the verb shipped; it is now the shipped behavior.
4.4 Theme color slots
[theme] is a free-form slot = color map. The renderer recognizes a
fixed set of named slots that color the chrome (status bar, dividers) and
overlays (help, prompt modals). Unknown slot keys are ignored; an
unparseable color keeps that slot’s default. Both cases are logged at
warn rather than failing the load. Colors accept named values
("cyan"), hex ("#cdd6f4"), and ANSI indices ("12").
Recognized slots:
| Slot | Default | Used for |
|---|---|---|
accent | #bef264 | Modal titles, query caret, active focus |
chord | #86efac | Keybinding chords in the help table |
action | terminal fg | Action labels |
dim | #9aa4b2 | Footer hints, inactive tabs, branch context, empty states |
border | #7c8696 | Modal borders + the sidebar separator rule |
title | #bef264 | Titles that diverge from accent |
section_header | #9aa4b2 | Section headings inside help and pickers |
error | #f87171 | Error / alarm text |
text | #f4f7fb | Body copy on a filled surface panel |
surface | #171b23 | Sidebar and modal background ("reset" = transparent) |
shadow | #090b0f | Modal drop shadow |
selection_fg | #f4f7fb | Selected sidebar/list row and copy-mode foreground |
selection_bg | #293628 | Selected sidebar/list row and copy-mode background |
attention | #fde047 | Agent-attention chrome (asked marker/hint, fleet-dashboard hot rows) |
sidebar_section | #9aa4b2 | Sidebar needs you / here / spaces zone headers |
agent_idle | #9aa4b2 | Sidebar agent row in the idle state |
agent_working | #86efac | Sidebar agent row in the working state |
agent_blocked | #fde047 | Sidebar agent row in the blocked state |
agent_done | #bef264 | Sidebar agent row in the done state |
divider | #7c8696 | Pane rules off the focused pane’s frame |
divider_focus | #bef264 | The focused pane’s own rules (also bold) |
pane_title | #9aa4b2 | An unfocused pane’s label on its top rule |
pane_title_focus | #bef264 | The focused pane’s label (also bold) |
The shipped palette is deliberately muted-chrome / bright-content: the
always-on chrome (sidebar headers, branch sub-lines, affordances, the
separator rule, inactive tabs, empty-state placeholders) sits in one
cohesive slate register (#7c8696 → #9aa4b2), so pane content and
the lime accent carry the eye. Selection is a quiet filled row with a
bold label, rather than host-dependent reverse video.
Recessive is a relationship, not a licence to be invisible. Every slot
that paints text or a rule clears 4.5:1 (WCAG AA) against surface,
in three ordered rungs:
| Rung | Slot | Ratio |
|---|---|---|
| structure (rules, modal borders) | border / divider | at least 4.5:1 |
| recessive text (hints, sub-lines) | dim and its trackers | brighter than rules |
| what you are looking at | accent (plus bold) | brighter than secondary text |
Focus is separated from the rest by three things at once — a brighter
tone, a saturated hue against desaturated slate, and bold — so the
hierarchy survives a terminal that flattens any one of them. The floor is
asserted by a test (contrast_floor_is_met), so a retune cannot quietly
drop below it. The sidebar and overlays own their background and foreground
together, including selected rows, so their contrast does not depend on the
host terminal palette. Pane rules still sit on the host background;
[theme] provides overrides for light-terminal rules.
It is also a system, not a bag of colors — several slots share a tone on purpose, and a retint should keep them in step:
titletracksaccent: chrome that names something is one hue.sidebar_sectionandagent_idletrackdim: “not what you are looking at” reads the same everywhere.agent_blockedtracksattention: a blocked agent and an attention marker are one fact seen from two places.agent_workingtrackschord: the green of live progress.dividertracksborder: every rule in the chrome — modal frames, the sidebar’s edge, the pane grid — is one material.divider_focusandpane_title_focustrackaccent: a focused pane’s frame and its label are the same statement made twice.pane_titletracksdim: an unfocused label recedes like every other unfocused affordance.texttracksselection_fg: a selected row is the same text on a different bed, not a different text.
action and text are deliberately not the same slot. action is
reset for actions drawn on the HOST background. Sidebar and modal
panels supply their own background (surface), so their body copy has to
supply its own foreground or it inverts into unreadability on a light
terminal.
Every value is a slot, so a theme retints the whole chrome by overriding a handful of keys.
[theme]
accent = "#7aa2f7"
chord = "#9ece6a"
border = "#7c86a6"
dim = "#8a93ab"
sidebar_section = "#8a93ab"
shadow = "#16161e"
4.5 Small terminals
phux is used in places that are not a full-screen terminal on a big display: a bottom-docked editor split, a phone over SSH, a tiling pane that got narrow. The chrome adapts rather than assuming room it does not have, around one shared breakpoint — a viewport is compact on an axis when it is at most 64 columns or at most 18 rows. The two axes are judged independently, because a short wide terminal and a narrow tall one want opposite things.
Both numbers come from content, not from round figures. A floating modal
takes 60% of the viewport, so in 64 columns it is 38 wide; less its two
border columns and a nested row’s two-column indent, 34 remain — under
the width at which a session/window pair plus its branch stays legible.
In 18 rows the same box is 10 tall, and the shared modal chrome (border,
query line and its blank, footer and its blank) spends 6 of them, leaving
four rows of actual list.
What changes:
- Overlays go full-bleed on the starved axis. Help, the command palette, the window and session pickers, the fleet dashboard, which-key and toasts float as centered boxes when there is room — that is what makes an overlay feel like it is over your work rather than instead of it — and take the whole axis when there is not. Full-bleed means “fills the rect it was given”: beside a docked sidebar, an overlay still stops at the sidebar’s edge.
- The status bar changes shape. See §8.4.1: the tab strip collapses
around the active tab, hints drop whole, and the shipped lineup trades
the session name and clock for a clickable
switchchip. The bar’s own shape change is not driven by the breakpoint below — it is the per-widgetmin-cols/max-colsin the shipped[status], which is ordinary config you own and edit. They happen to be set to 64/65 so the two agree out of the box; if you move[chrome] compact-cols, move them too. - List rows are laid out to the exact interior width. A row’s
secondary column (a branch, a cwd, a bound chord) yields before its
label does, and text that does not fit is cut with a trailing
…rather than left to run through the modal border. - The sidebar yields. Below the resolved sidebar width + 40 columns the strip
is not reserved at all: it costs its width off every pane permanently,
and a strip that leaves 30 columns of actual work is costing you the
panes it exists to help you move between.
prefix-brings the bell at those widths rather than flipping a flag with no visible effect — turning the strip off is always allowed, so shrinking a terminal never traps you. The fleet switcher is the navigation surface there.
Moving the breakpoints: [chrome]
The three numbers above are defaults, not laws. “Legible” depends on your terminal, your font, and what you are willing to trade, so each is a key:
[chrome]
compact-cols = 64 # at or below this width, overlays go full-bleed
compact-rows = 18 # at or below this height, overlays go full-bleed
min-pane-cols = 40 # narrowest pane area worth tiling into; the
# sidebar is not reserved below
# resolved sidebar width + this
Raise compact-cols if you want full-bleed pickers on a terminal phux
considers roomy; lower it if you would rather keep floating modals on a
small one. Lower min-pane-cols to keep the sidebar on a narrower
terminal — the strip still costs its columns, you are just saying you
would rather have it than them.
All three are plain counts with no reserved values. 0 disables a
threshold (nothing is ever compact on that axis; the sidebar never
yields), and a very large one pins the opposite. Both are legitimate, so
neither is an error. The axes stay independent whatever you set: a
viewport is compact on width and height separately.
[chrome] governs the overlay geometry and the sidebar yield. It does
not reach into [status]: the shipped bar’s shape change at 64
columns is per-widget min-cols / max-cols in your own config (§8.4.1),
deliberately, because a status bar is a lineup you compose rather than a
behaviour phux imposes. Changing compact-cols without editing those
leaves the bar switching shape at the old width.
[chrome] is read once per attach and swapped whole by phux config reload (§4.3), including for a modal that is already open — it reflows
on its next paint rather than keeping the thresholds it was born with.
5. Keybindings
5.1 The model
We support two binding tables, both always present:
- Prefix table (
[keybindings.prefix-table]): bindings that fire after the prefix key has been pressed. This is tmux’s familiar model. - Global table (
[keybindings.global]): bindings that fire any time. Reserved for combinations unlikely to conflict with inner programs — in practice, ones usingsuper,hyper, ormetamodifiers.
[keybindings]
prefix = "C-a"
[keybindings.global]
"hyper+left" = { action = "focus-direction", direction = "left" }
"hyper+right" = { action = "focus-direction", direction = "right" }
[keybindings.prefix-table]
'"' = { action = "split-pane", direction = "horizontal" }
# ...
The global table is empty by default — no global bindings ship out of the box because we cannot assume the user’s outer terminal forwards hyper/super at all. Users on Ghostty can opt in.
A bad binding disables exactly that binding, visibly. At attach,
keybinding resolution is lenient per binding: a chord that fails to
parse, or a binding whose sequence is a strict prefix of another’s (the
later one, in table-key order, loses), is skipped — every other
binding, including detach, keeps working. Each skipped binding is
reported on the status-bar row as an error line naming the offending
chord and pointing at phux config check; when several bindings are
disabled, the line names the first and counts the rest. A prefix
string that fails to parse falls back to the default C-a so the
prefix table stays reachable. Config reload is the deliberate
exception to this leniency: it stays all-or-nothing (§4.3) — at attach
there is no known-good previous config to keep, but a reload has one,
so any bad binding keeps the previous config fully in effect instead of
applying a partial one.
5.2 The dispatcher
Bindings invoke actions: named identifiers with typed parameters, not
shell strings. Every action in §5.4 routes through one run_action
dispatch path — the command palette and the pickers commit the same
ResolvedAction a keybinding produces, so there is a single source of
truth for what each name does (see
action_registry.rs).
5.3 Defaults
The defaults ship with prefix = "C-a" (tmux-shaped). Override it in one
line of config. The shipped prefix-table bindings:
| Chord | Action |
|---|---|
C-a " | split-pane horizontal (stacked panes) |
C-a % | split-pane vertical (side-by-side panes) |
C-a x | kill-pane |
C-a X | kill-window |
C-a h/j/k/l | focus-direction left/down/up/right |
C-a o | next-pane |
C-a ; | previous-pane |
C-a = | last-pane (jump back; repeat to toggle) |
C-a z | toggle-zoom |
C-a b | toggle-sidebar |
C-a [ | copy-mode |
C-a c | new-window |
C-a n/p | next-window / previous-window |
C-a 0–9 | select-window by index |
C-a w | window-picker (grouped: sessions, windows nested) |
C-a s | session-picker (C-a a is a kept alias) |
C-a A | agent-fleet (fleet dashboard — §5.6) |
C-a q | next-attention (cycle asking panes, window + DFS order) |
C-a Q | return-from-attention (consume the saved local origin) |
C-a C | new-session |
C-a , | rename-window (interactive prompt) |
C-a $ | rename-session (interactive prompt) |
C-a H/J/K/L | resize-pane left/down/up/right by 5 |
C-a : | command-palette |
C-a d | detach |
C-a ? | show-help |
5.4 Action catalog
The action catalog is a generated reference:
docs/reference/actions.md lists every
action the dispatcher handles — parameter surface, description, and
where the command palette offers it (with the reason for each deliberate
palette omission). It renders from the same in-code inventories the
dispatcher and the palette are test-pinned to, so it cannot drift from
the binary; regenerate with just docs-gen after changing the action
surface.
5.5 Commands, help, and pickers
command-palette (C-a :) and show-help (C-a ?) are two entry aliases
for one filterable commands & help overlay. There is no separate help
modal to choose or learn: either chord opens the executable action catalog,
with every action annotated by its currently-bound chord. The two entry
actions are omitted from inside the finder because selecting either would
only reopen the surface already in front of you. Rows are grouped
under dim category headers — Pane, Window, Session, View —
when the query is empty; as you type, the headers fall away and the
matches are ranked best-first by a scored fuzzy match (contiguous runs,
word-boundary hits, and earliness all raise a row’s rank), so typing sp
floats split-pane to the top. Enter commits the selected row through the
same run_action path a keybinding takes.
The rows are a scroll viewport, not the whole list: a palette (or
picker) with more rows than fit the box shows a window onto them, always
kept around the selection, and paints a scrollbar in the right border
column whose thumb shows how much list there is and where you are in it.
Navigate with arrows / C-n / C-p (j / k too while the query is
empty), PageUp / PageDown for a screenful, Home / End for the ends,
or the mouse wheel. Every list overlay shares this — the pickers and the
agent dashboard (§5.6) as much as the palette.
Enabled plugins’ manifest [[actions]] appear under a trailing
Plugin header, one namespaced row per action
(plugin: <plugin-name>: <action title>). Committing one runs
plugin-action { plugin, action }, which executes the manifest’s argv
through the same child-process runtime as phux config run PLUGIN ACTION — spawned off the input loop, so a slow plugin never freezes the
TUI. A failed run (non-zero exit, timeout, or spawn error) pops a
dismissable toast showing the captured output; successes only log. A
manifest action may also declare keys = "..." to contribute a
prefix-table binding (see the plugin-manifest block in §4.2); user
config always wins on conflict, and the palette row shows whichever
chord actually ended up bound.
Manifest [[panes]] share the same Plugin header, one row per
hostable pane (plugin pane: <plugin-name>: <pane title>). Committing
one runs plugin-pane { plugin, pane }, which opens a real server-side
Terminal running the pane’s argv through the same SPAWN_TERMINAL verb
split-pane / new-window use — no plugin-privileged wire surface
(ADR-0017); any consumer could do the same. The spawn’s working
directory is the plugin root, and the child sees PHUX_PLUGIN_ID,
PHUX_PLUGIN_PANE_ID, and PHUX_PLUGIN_ROOT on top of the server’s
environment (the pane counterpart of the action runtime’s identity
variables). The manifest’s placement routes where it opens:
split— beside the focused pane (side-by-side), likesplit-pane.tab— a new window named after the pane’stitle.zoomed— a split whose new pane opens zoomed to fill the window;toggle-zoomreveals it tiled beside the anchor pane.overlay— not hosted yet. A floating live-terminal overlay is a larger chrome surface than the current overlay stack (modal select lists and prompts) supports; entries declaring it are skipped with a logged warning and never listed. The declaration remains valid manifest schema so packages can ship it ahead of the host.
Unlike [[actions]], panes contribute no keybindings today; a user can
still bind one manually with a parameterized action
({ action = "plugin-pane", plugin = "...", pane = "..." }).
Disabled plugins (enabled = false) contribute no rows.
The session picker (session-picker, C-a s, alias C-a a) lists the
server’s other sessions; choosing one re-attaches this client to it
in-process (switch-session). A trailing ”+ New session” row creates one.
The window picker (window-picker, C-a w) is hierarchical: every
session is a section header with its windows nested beneath it. Choosing a
window in the current session switches to it directly
(select-window { index }). Other sessions’ windows are one-step
jumps: the client fetches each peer session’s persisted layout right
after attach, so the picker lists their windows (index:name, pane
count) too, and choosing one commits switch-session { name, window } —
a single Enter re-attaches to that session and selects that window once
its layout loads. A peer session with nothing persisted yet (or one
created after this client attached) falls back to a single “switch to
this session” row; its own picker then lists its windows. The cached
foreign layouts are an attach-time snapshot: if a peer rearranged its
windows since, the jump still switches sessions and the stale window
index degrades to the session’s own remembered focus (logged, no bell).
5.6 Agent-fleet dashboard
The agent-fleet dashboard (agent-fleet, C-a A) is the one-view
answer to “which of my agents needs me?”: a filterable overlay listing
every pane of the attached session, grouped under session headers, each
row carrying
- the agent’s name and kind from its structured
phux.agent/v1record (ADR-0040) when one is present — declared by an agent or derived by the server (ADR-0046, so the state glyph below is live for a recognized agent CLI rather than permanently?) — falling back to the pane’s OSC title otherwise (the record outranks the title); - a one-character state glyph:
!blocked,*working,-idle,.done,?unknown (also used when no record is declared); - an attention highlight — the row’s label paints in the theme’s
attentionslot (§4.4, the same amber as the sidebar marker and the status-bar asked hint) when the pane has a pending ADR-0035 question or its record declares/derives high attention; - the pane’s branch or cwd in the dimmed right column, next to the
state word (
working - main), from the same client-local.git/HEADread as the sidebar branch line.
Enter focuses the chosen pane: current-session rows commit
focus-pane { window, pane } through the single dispatch path (switching
the window and moving its client-local focus in one step). Rows under
other sessions are one-step cross-session pane focus (phux-jpqd):
each pane of a peer session with a cached persisted layout commits
switch-session { name, window, pane }, so a single Enter re-attaches to
that session, selects the window, and focuses that pane — with the peer’s
agent glyph and state already shown on the row (blocked, working, idle,
done, or ?). A peer session with nothing persisted yet (or created after
this client attached) falls back to a single “switch to this session” row
as before. The dashboard grows no wire surface for this (ADR-0030): it
reuses the same lazy per-pane L3 reads the window picker uses (phux-foz.8,
ADR-0018) — the peer’s persisted phux.tui.layout/v1 workspace for the
pane tree, plus a one-shot GET_METADATA on each foreign pane’s
phux.agent/v1 record for its identity. Foreign rows therefore carry no
asked flag or branch/cwd — those need a live per-pane subscription, so the
record’s declared state is the honest maximum until you attach there. The
phux agent list CLI remains the exhaustive cross-session projection.
The dashboard is live: while it is open, agent-record changes, asked
events, pane spawns/closes, and layout changes rebuild its rows in place
(push, not poll) without disturbing your query or selection. It shares
the palette’s fuzzy filter, j/k / arrows / C-n/C-p navigation,
and Esc dismissal. No new theme slots: headers use section_header,
secondaries dim, hot rows attention.
5.7 Which-key popup
Press the prefix and hesitate, and a small floating panel lists every
prefix-table continuation — key on the left, action on the right — built
from your live bindings (rebinds included; it is the same config snapshot
the action finder reads). The numeric window-jump keys collapse into a
single 0-9 row.
The popup is display-only and never captures input:
- Any key dismisses it and executes its binding exactly as if the popup had never appeared. A continuation typed before the delay elapses suppresses the popup entirely — it can never eat or delay a chord.
- Esc dismisses it and cancels the pending prefix (nothing is sent to the pane).
Configured under [keybindings]:
[keybindings]
which-key = true # default; false disables the popup
which-key-delay-ms = 400 # default; hesitation before it appears
400 ms is deliberately snappier than the tmux-ish 600: the popup is the primary discovery surface for the prefix table, so it should feel like a hint that arrives while you hesitate rather than a timeout you wait out.
5.8 Copy-mode
C-a [ enters copy-mode on the focused pane. Copy-mode is client-local:
it is a projection over the pane’s own libghostty engine, and nothing about a
selection touches the wire — the client extracts the selected text from its own
Terminal and writes it to the host clipboard via OSC 52. This is
ADR-0045 applied on top of
ADR-0030;
there is no server round-trip, no selection frame, and no clipboard verb on the
protocol.
Movement and viewport:
- Arrow keys move the selection cursor; hold Shift to extend the selection from its anchor instead of moving both ends.
- An arrow past the top or bottom edge, and PageUp / PageDown, scroll the pane’s client-local viewport into mirrored scrollback. Selection is bounded by the scrollback the client already holds, not the server’s full history.
Selection modes — a two-corner rectangle interpreted as one of:
- Char (default): linear, text-flow selection — full interior rows, partial first and last rows.
- Line: whole lines.
- Rect: rectangular (block/columnar) selection — the column band on every
row in the span. Tab (in copy-mode) rotates Char → Line → Rect. The
on-screen highlight and the extracted text are computed from the same
SelectionRect, so a block selection copies exactly the band it highlights.
One-shot grabs resolve against the engine at the cursor and copy-and-exit immediately (tmux-style):
| Key | Grab |
|---|---|
w | word under the cursor (select_word) |
v | whole line under the cursor (select_line) |
V | line bounded by semantic-prompt (OSC-133) state changes |
A | all selectable content (select_all) |
] | the command-output span under the cursor (select_output); a no-op when the pane has no OSC-133 zones |
- Enter copies the current two-corner selection to the host clipboard and exits.
- Esc exits copy-mode without copying.
Mouse: a left-button drag inside the pane selects and, on release, copies and exits; the wheel scrolls the client-local viewport. A click with no drag simply exits, so a mouse-initiated entry can never trap the keyboard. See §11 for the scope boundary — phux does not reimplement selection boundaries or a clipboard format path; it delegates both to libghostty and the host terminal.
Resizing the terminal keeps copy-mode open — it is a selection over the live pane, not a box pinned to the screen, so a resize must not discard a selection you are still building (the context menu is the opposite case, §7.1). The selection adopts the pane’s new size instead: a shrink pulls both corners back inside the pane, a grow makes the newly revealed rows and columns reachable, and a Line-mode selection re-spans to the new width (phux-d26y).