phux
guidesinteractive TUI

TUI configuration and keybindings

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.

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 a SPAWN_TERMINAL whose wire frame carries no command; it is also the shell that wraps spawn-on-attach and --seed-command via <shell> -c. The server resolves it once at startup: defaults.shell when set, else $SHELL, else /bin/sh. A wire command always wins over this default (phux-i0e8.4.1).

  • term (string, default "xterm-256color") is the TERM the server advertises to the inner program of every spawned pane. The resolution order for one spawn, lowest to highest: compiled-in baseline → defaults.term → the SPAWN_TERMINAL.term wire field → a TERM entry in SPAWN_TERMINAL.env (spec L1 §3.1). The default is deliberately the safe xterm baseline rather than ghostty: ghostty’s terminfo advertises the fullkbd capability, which ncurses apps read as “kitty keyboard protocol available” and push CSI > N u — and at least htop then fails to parse the CSI-u key reports it asked for, so its q quit 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 under TERM=ghostty and 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. Set term = "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’s SPAWN_INITIAL_SIZE capability). Against a server without that capability the pane starts at 80x24 and the reflow TERMINAL_RESIZE that 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 a SPAWN_TERMINAL leaves cwd unset (an explicit cwd always 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-focused and home are wired server-side (phux-cs6): inherit-focused reads the focused pane’s live PTY working directory via a kernel query (/proc/<pid>/cwd on Linux, proc_pidinfo on macOS), so it tracks cd without any shell OSC 7 setup. session-root and last-cwd-per-window are 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 command phux spawns when it auto-creates a session on attach. Unset ⇒ honor defaults.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’s window-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 by phux resize).

    The three view-derived values decide only among views. An explicit phux resize TARGET COLSxROWS is 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 resize reads 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 — see agents.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 referencetoken-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:

ExitMeaning
0clean, or no config file at all (the shipped defaults apply)
1findings — the config loads nothing, or loads wrong
2the 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-config action — 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 reload from 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 key phux.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:

SlotDefaultUsed for
accent#bef264Modal titles, query caret, active focus
chord#86efacKeybinding chords in the help table
actionterminal fgAction labels
dim#9aa4b2Footer hints, inactive tabs, branch context, empty states
border#7c8696Modal borders + the sidebar separator rule
title#bef264Titles that diverge from accent
section_header#9aa4b2Section headings inside help and pickers
error#f87171Error / alarm text
text#f4f7fbBody copy on a filled surface panel
surface#171b23Sidebar and modal background ("reset" = transparent)
shadow#090b0fModal drop shadow
selection_fg#f4f7fbSelected sidebar/list row and copy-mode foreground
selection_bg#293628Selected sidebar/list row and copy-mode background
attention#fde047Agent-attention chrome (asked marker/hint, fleet-dashboard hot rows)
sidebar_section#9aa4b2Sidebar needs you / here / spaces zone headers
agent_idle#9aa4b2Sidebar agent row in the idle state
agent_working#86efacSidebar agent row in the working state
agent_blocked#fde047Sidebar agent row in the blocked state
agent_done#bef264Sidebar agent row in the done state
divider#7c8696Pane rules off the focused pane’s frame
divider_focus#bef264The focused pane’s own rules (also bold)
pane_title#9aa4b2An unfocused pane’s label on its top rule
pane_title_focus#bef264The 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:

RungSlotRatio
structure (rules, modal borders)border / dividerat least 4.5:1
recessive text (hints, sub-lines)dim and its trackersbrighter than rules
what you are looking ataccent (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:

  • title tracks accent: chrome that names something is one hue.
  • sidebar_section and agent_idle track dim: “not what you are looking at” reads the same everywhere.
  • agent_blocked tracks attention: a blocked agent and an attention marker are one fact seen from two places.
  • agent_working tracks chord: the green of live progress.
  • divider tracks border: every rule in the chrome — modal frames, the sidebar’s edge, the pane grid — is one material.
  • divider_focus and pane_title_focus track accent: a focused pane’s frame and its label are the same statement made twice.
  • pane_title tracks dim: an unfocused label recedes like every other unfocused affordance.
  • text tracks selection_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 switch chip. The bar’s own shape change is not driven by the breakpoint below — it is the per-widget min-cols / max-cols in 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-b rings 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 using super, hyper, or meta modifiers.
[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:

ChordAction
C-a "split-pane horizontal (stacked panes)
C-a %split-pane vertical (side-by-side panes)
C-a xkill-pane
C-a Xkill-window
C-a h/j/k/lfocus-direction left/down/up/right
C-a onext-pane
C-a ;previous-pane
C-a =last-pane (jump back; repeat to toggle)
C-a ztoggle-zoom
C-a btoggle-sidebar
C-a [copy-mode
C-a cnew-window
C-a n/pnext-window / previous-window
C-a 09select-window by index
C-a wwindow-picker (grouped: sessions, windows nested)
C-a ssession-picker (C-a a is a kept alias)
C-a Aagent-fleet (fleet dashboard — §5.6)
C-a qnext-attention (cycle asking panes, window + DFS order)
C-a Qreturn-from-attention (consume the saved local origin)
C-a Cnew-session
C-a ,rename-window (interactive prompt)
C-a $rename-session (interactive prompt)
C-a H/J/K/Lresize-pane left/down/up/right by 5
C-a :command-palette
C-a ddetach
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), like split-pane.
  • tab — a new window named after the pane’s title.
  • zoomed — a split whose new pane opens zoomed to fill the window; toggle-zoom reveals it tiled beside the anchor pane.
  • overlaynot 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/v1 record (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 attention slot (§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/HEAD read 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):

KeyGrab
wword under the cursor (select_word)
vwhole line under the cursor (select_line)
Vline bounded by semantic-prompt (OSC-133) state changes
Aall 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).


View exact source