phux docs

Development

How to build, test, and validate a change. CONTRIBUTING is the short version with the rules; architecture is where the code lives.

Choosing an environment

Two supported environments provide the toolchain. Pick one per shell.

Mise (mise.toml)Nix (flake.nix)
gives youcompilers, runtimes, the root gate toolseverything, including browser toolchains and pinned system libraries
runsjust check, once cargo-nextest is installedevery just check lane
first usesecondsa dev-shell build
choose whenyou have a working native toolchain and want the pinsyou want one command that reproduces any CI lane, or you touch the browser client
mise install     # or: nix develop
just doctor      # checks the tools for the native scope; also core, docs, integrations, web, desktop, ci

just is the command runner for this repo, not a third environment. Recipes live in just/*.just; just alone lists them.

With direnv, .envrc loads Nix. To load Mise instead, create an untracked .envrc.local containing export PHUX_ENV=mise and run direnv allow. Don’t activate Mise inside a Nix shell or the other way round; mise exec changes tool resolution but leaves Nix’s compiler variables in place.

just toolchain-check fails if mise.toml, flake.nix, rust-toolchain.toml, .config/zig-toolchain.json, the Cargo manifests, and the workflows disagree about a version. just toolchain-parity resolves both environments and compares the binaries they hand you.

cargo-nextest is not in Mise’s registry. Install the prebuilt binary; Nix provides it.

libghostty-vt

The terminal engine is Ghostty’s C library. Its build script downloads a prebuilt, checksummed libghostty-vt.a for the pinned Ghostty commit on the three release targets and needs only curl and tar. Anything else (other targets, LIBGHOSTTY_VT_SYS_FROM_SOURCE=1, offline, a failed download) builds it from source with Zig, about 200 seconds once per set of inputs, cached outside any target/ directory. GHOSTTY_SOURCE_DIR builds a local Ghostty checkout; LIBGHOSTTY_VT_SYS_LIB_DIR links one you built. just ghostty-pin-check confirms the pinned revision publishes an archive for every target.

Build cache (mbx)

Both environments run cargo through mbx. A phux build is tens of gigabytes and this repo often has several agent worktrees at once; mbx gives them one content-keyed store (a fresh worktree restores the whole dependency graph instead of compiling it), a managed target/ symlink with a disk budget, and a machine-wide compiler pool.

Plain cargo, and every just recipe, go through it. ~/.cargo/bin/cargo by absolute path does not, nor does a harness whose PATH never loaded either environment; use mise exec -- cargo ... or nix develop -c cargo ....

mbx doctor            # tools, cache access, link mode
mbx stats             # savings, cross-worktree sharing
mbx explain --last    # why the last build hit, missed, or bypassed
mbx clean "$PWD"      # this checkout's managed target only

Opt out with PHUX_NO_MBX=1 before nix develop, or [tools] rust = { version = "<pin>", mr_boxington = false } in an untracked mise.local.toml.

Two rules keep crates reusable across worktrees, both enforced by just cache-portable-check: no compile-time checkout path in library or test code (read CARGO_MANIFEST_DIR at run time), and the Zig engine build stays outside target/. Rust E0514 means the shell’s toolchain changed under a build: check rustc -V, cargo -V, mbx doctor, then mbx clean "$PWD".

Pick your work area

AreaNeedsFirst check
docsGit, Bashjust docs-check
pure Rust: domain, wire codecRust, a linkerjust core-check or just crate-check phux-protocol
server, TUI, CLI, FFIRust, Zig, platform packagesjust doctor native, then just crate-check phux-server
agent integrationsNode 24, Bunjust integration-check pi (or runtime, opencode-v2, omp, claude, grok)
browser clientRust wasm target, Node, wasm toolsjust doctor web
desktopApple silicon Mac with Xcode, or Linux with desktop librariesjust doctor desktop, then just desktop-app
everythingall of the above plus gate toolsjust doctor ci, then just check

bash scripts/doctor.sh <area> works before just is installed. It checks and prints remedies; it installs nothing.

phux-protocol’s codec is pure Rust; its server feature adds the libghostty conversions. just crate-check phux-protocol needs no Zig; just crate-check phux-protocol server runs the engine-dependent tests too.

Platform packages

Mise and Nix install compilers, not the host SDK or linker.

Ubuntu 24.04 / Debian:

sudo apt-get install -y git curl ca-certificates build-essential mold pkg-config xz-utils

.cargo/config.toml uses mold for Linux GNU builds.

macOS: Apple’s Command Line Tools are enough for pure Rust. libghostty and the FFI need full Xcode, selected:

sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
xcrun --show-sdk-path && xcrun --find nmedit
brew install pkgconf

Inside Nix none of this applies; the shell brings its own SDK and must not inherit the host Xcode’s.

just doctor links and runs a throwaway binary, so a linker that predates the SDK it points at, or an SDKROOT inherited from another shell, fails in a second instead of twenty minutes in.

Native CI

The native setup lane in ci.yml has neither Mise nor Nix. It uses the same pins through two helpers, which also work locally:

bash scripts/setup-rust.sh native      # rustup toolchain from rust-toolchain.toml
zig_bin="$(bash scripts/install-zig.sh "${XDG_DATA_HOME:-$HOME/.local/share}/phux/toolchains")"
export PATH="$zig_bin:$PATH"
just native-smoke

Browser client

Ordinary client work uses the committed clients/phux-vt-web/vendor/ghostty-vt.wasm and needs no Zig. You need the Rust wasm target (bash scripts/setup-rust.sh web), Node, wasm-pack, wasm-bindgen-cli at exactly the version the client manifests pin (just doctor web tells you), and binaryen. Nix’s shell has all of them.

just doctor web
cd clients/phux-web && wasm-pack build --target web --release --out-dir pkg
wasm-pack test --node

Browser e2e tests need Chrome and a matching chromedriver, and a server: PHUX_DEMO_PANE=cat cargo run --locked -p phux-server --example ws_demo_server from the repo root. bash scripts/build-vt-wasm.sh rebuilds the engine (--check verifies it) with the pinned Zig release, installed by scripts/install-zig.sh, never the one on PATH.

GPUI desktop

Apple silicon macOS, or x86_64/arm64 Linux with X11 or Wayland and a Vulkan driver (Mesa’s lavapipe works for tests). On Debian-family Linux:

sudo apt-get install -y libfontconfig1-dev libfreetype-dev libxkbcommon-dev \
  libxkbcommon-x11-dev libwayland-dev libx11-dev libxi-dev libxcb1-dev \
  libvulkan-dev mesa-vulkan-drivers

On macOS use Mise plus the host Xcode for the daily loop; the desktop gates refuse a Nix shell whose compiler is shadowed by Mise. The Metal compiler must run, not just exist (xcodebuild -downloadComponent MetalToolchain if not).

just doctor desktop
just desktop-app      # build the app and the CLI from this tree, start a server, open it

clients/desktop has the app’s own commands and gates.

The Windows desktop is a separate native MSVC build of that same workspace, with Direct3D 11/DirectWrite rather than Metal or Vulkan. Its coordinator and CLI remain in WSL 2. Set PHUX_WSL_DISTRO to select a distribution; --phux is a Linux executable path and --socket a Linux socket path. A development Windows app requires an explicit isolated socket and does not discover the installed server by default. Windows-native qualification must exercise the packaged .exe and real window input, not the Linux TUI or the macOS/Linux headless renderer fixtures.

Archived Zig Cockpit

The Zig Cockpit is tabled, not a supported build or install on main. Published recovery refs are branch archive/zig-cockpit and annotated tag archive/zig-cockpit-2026-10-14. The GPUI desktop is the maintained app. Use the snapshot recovery guide in a separate worktree. Its SDK, fixtures and hooks belong to that snapshot; never restore retired CI, installers or publication without an explicit revival decision and same-checkout protocol/FFI and isolated app evidence. Shared FFI, mobile bindings and Zig for libghostty remain supported.

Validating a change

Start narrow, then widen with the blast radius:

just check --dry-run              # lanes selected against origin/main
just check --preflight            # committed branch vs fetched origin/main; no builds
just check                        # run those lanes: the PR bar
just crate-check phux-server      # scoped fmt, clippy, rustdoc, tests
just test                         # cargo nextest run --workspace

just check uses the same routing and recipes as ci.yml. Guards run on every change; Rust lint, unit, e2e, browser, desktop and integration lanes run when their inputs change. There is no run-everything recipe. rust icount and native setup are CI-only; stress and real-GPU desktop qualification are local. A scoped pass is not a full CI pass.

Fetch origin before the preflight when other agents are landing changes. just check checks committed mergeability first and reports conflicting paths without changing the checkout or index. Merge origin/main into your feature worktree and resolve those paths before spending time on builds. A stale branch that merges cleanly can proceed; a base update alone does not require a rebase. Uncommitted edits count for lane routing but are not part of the merge simulation. During a merge, resolve and stage all conflicted paths, then just check can validate the working tree before you commit. The committed merge simulation is deferred until the merge commit exists; --preflight reports that explicitly.

Scoped gates for other surfaces: just integration-check <pkg>, just desktop-check, just tools-check (the Bun package under tools/, including the CI routing table), just guards (workflow syntax, pins, routing truth table, shell scripts, docs, and install contracts).

Things that fail CI and are easy to forget:

  • A CLI, config, or widget change without just docs-gen: the generated pages under docs/reference/ are byte-compared by a unit test.
  • A wire change without regenerating docs/spec/wire-schema.json (protocol).
  • A commit message that is not a conventional commit. commitlint checks every commit in the PR and the title.
  • A new crates/* directory not listed in architecture.

Real-PTY tests set PHUX_NO_UPDATE_CHECK=1 in the child environment so an update prompt cannot eat a scripted keystroke. The shared spawn path seals that opt-out after env_clear, and the variable also suppresses a cached notice.

Worktrees

Never develop on main or in the primary checkout; make a branch and a linked worktree. The gate recipes lease the worktree to the session that runs them and refuse another live session with exit 3, so one agent session per worktree. just worktree-release hands one on; PHUX_WORKTREE_TAKEOVER=1 is for a deliberate handoff.

Running the result

just build        # target/debug/phux and phux-mcp
just install-dev  # install them to Cargo's bin dir
just rebuild      # install, then hot-swap the dev server, keeping its sessions

A development build runs under the dev profile: its own socket, state, and server. It refuses to talk to the installed release’s socket. Never copy a build over the installed phux, point a build at the production socket, or phux upgrade the production server; verify against the dev server or an explicit --socket under a temp directory.

CI

One classifier, bun tools/main.ts detect-changes over tools/src/routing/routes.ts, decides which lanes a change can affect. ci.yml owns all these lanes. Unknown paths fail closed and run everything; handwritten docs normally run only guards. Docs read by tests, including docs/agents.md and generated references, also select their Rust consumers. A library change runs the tests of its dependents. The required ci aggregate rejects a failed or cancelled lane. just tools-check tests the routes.

The test lanes compile once (just test-archive) and run the unit halves and e2e from that nextest archive. Pull requests restore caches and never save them; main pushes save one entry per lane under a 10 GB repository budget, pruned by prune-build-cache. Runners are the free hosted ones: Ubuntu x64 and arm64, and macOS on the xcode-27 image, where the desktop lane shares a handful of slots with the release builds.

Workflow files carry their own comments; .github/workflows/ is the reference. just guards lints them.

Performance

just perf-icount counts instructions with callgrind on a fixed set of hot paths (VT apply, frame encode and decode, agent stream append, kernel history slice, PTY batch copy, one organization commit) and fails the perf-icount CI lane when a count rises more than five percent over benchmarks/icount-baseline.json. Run it inside nix develop on x86_64; counts from another libc or architecture are not comparable.

To accept a new baseline, run just perf-icount --update there, commit the file with the change that justifies it, and either add the trailer Perf-Baseline: update to a commit or the perf-baseline label to the PR before ci runs. The lane refuses an unauthorized baseline edit in seconds.

just timings, just llvm-lines, just bloat, and just dep-stats are the local build-observability recipes. For the server at run time, phux perf reads its counters, and the tokio-console and dhat-heap features in phux-server and phux attach the usual profilers.

phux perf trace --seconds N records every timed span in the server for N seconds and writes Chrome trace-event JSON under the server’s state directory (traces/, beside server.log); open it in https://ui.perfetto.dev. phux perf alloc on|off switches heap accounting, which then shows as heap in phux perf. A server started with PHUX_DEV=1 has heap accounting on from the start. Both use PERF_CONTROL, gated on features_ext.PERF_CONTROL. It is restricted to the ordinary owner socket, not delegated or remote grants. An abandoned server capture stops and saves after ten minutes; the server retains the newest 20 trace files. Exports use exclusive, owner-readable files so simultaneous captures cannot overwrite each other.

PHUX_DEV=1 is the dev-mode switch every phux process reads (phux_perf::dev). The desktop also has a saved Developer mode setting (Settings > Developer) that does the same without a restart. In the desktop, Developer mode also turns on the performance HUD and a dev socket, desktop-<pid>.dev.sock beside the server socket, that answers line JSON (clients/desktop/native/src/devtools/socket.rs). phux perf --desktop [--watch S] [--json] reads it, phux perf trace --desktop records the desktop and server onto one Perfetto timeline, and phux perf hud on|off toggles the HUD. Every phux_perf histogram becomes a trace span with no extra code, so a new hot path needs only a histogram in its crate’s TABLE to show in reports, traces and the HUD. The dev socket verifies the connecting UID and requires a non-symlink, owner-only parent directory; PHUX_DESKTOP_DEV_SOCKET overrides its path but does not relax those checks. Discovery checks the associated server socket, including when several server sockets share a directory. Development builds retain the production-socket guard, including explicit dev-socket overrides and desktop calls through an installed CLI; an override must identify the same permitted server before dispatch. Requests are limited to 64 KiB, 16 concurrent connections, and five-second I/O deadlines; a desktop trace records at most 60 seconds. --reset returns each snapshot before clearing its counters, including in watch mode.

Mutation testing

Regression tests belong in the ordinary suite; mutation runs are a bounded, opt-in way to find tests that would not notice a change.

just mutation-rust-install                                     # pinned cargo-mutants
just mutation-rust                                             # default scope
just mutation-rust --file crates/phux-core/src/window.rs --limit 16
just mutation-rust --in-diff origin/main --list

A caught mutant is evidence for that test; a missed one is a place to look, not necessarily a test to add. Unviable mutants and timeouts are not evidence of anything.

Demos and recordings

scripts/demo-setup.sh stages a demo session and prints the runbook. scripts/demo-record.sh records docs/assets/recording-demo.cast and renders the GIF, under a 1 MiB budget because GitHub serves the whole file before the first frame. scripts/demo-pi-setup.sh sets up the pi fleet demo. The .cast files under docs/assets/ are read by tests in phux-record and phux, so re-record them deliberately.