fux 0.12.0

A minimal trusted Bevy terminal multiplexer
fux-0.12.0 is not a library.
Visit the last successful build: fux-0.11.0

fux

A small, trusted terminal multiplexer built on Bevy 0.20 (currently the 0.20.0-rc.1 release candidate). One server owns real PTYs; terminal viewers share their processes while keeping independent layout sizes, focus, zoom and scrollback.

The API is unrestricted same-user command execution. Stock Bevy BRP is served over HTTP on a Unix domain socket, without credentials, capability tokens, component filters or a method allowlist. The socket's file permissions are the only access control. Do not expose this server to callers you do not trust.

Build and run

Rust 1.98.1 is pinned in rust-toolchain.toml and Cargo.toml declares a minimum supported version of 1.95.

Every bevy_* crate is pinned to =0.20.0-rc.1 from crates.io, the 0.20 release candidate; the 0.20.0 release should be a version bump.

cargo build --release --locked
./target/release/fux server                   # foreground, on $XDG_RUNTIME_DIR/fux/server.sock or $TMPDIR/fux/server.sock
# In another terminal:
./target/release/fux attach                   # or: attach WORKSPACE
./target/release/fux stop

Server options: --socket PATH, --config FILE. The server listens only on a Unix domain socket: --socket, else FUX_SOCKET, else $XDG_RUNTIME_DIR/fux/server.sock, else $TMPDIR/fux/server.sock (per-user on macOS). Clients (attach, rpc, stop) use FUX_SOCKET, else the same default. Paths must be absolute and fit the platform's socket-path limit (103 bytes on macOS); an empty variable or a URL is an error, never a silent fallback.

The socket's directory must be owned by you with mode 0700, and must not be reachable through a directory other users can rewrite; the default fux directory is created that way, and nothing that already exists is modified. The socket itself is created with mode 0600 before the server accepts a connection. A lock beside the socket makes one server its only owner, so a second server on the same path refuses to start, and a socket left by a killed server is replaced only after nothing answers on it. Graceful shutdown removes the socket.

Migrating from the TCP endpoint: --address and --port are gone, and a set FUX_ENDPOINT makes attach, rpc, stop and server fail with a message rather than reach whichever server owns the default socket. Use fux server --socket PATH and FUX_SOCKET=PATH; raw HTTP clients use the socket directly, for example curl --unix-socket "$FUX_SOCKET" http://fux/ -d '{"jsonrpc":"2.0","id":1,"method":"rpc.discover"}' (the host name is not contacted). SIGINT, SIGTERM and SIGHUP stop the server; graceful viewer signals restore its terminal and detach. A forcibly killed viewer cannot restore its terminal, but does not kill the shared process.

No configuration file is required: the default command is $SHELL or /bin/sh, in the server's working directory. The first pane waits for initial configuration loading to succeed or fail, so a valid configured shell applies from startup. Missing or invalid configuration is logged; usable defaults/the previous valid configuration remain active.

Controls

Press Ctrl-B to open the command column, then a configured shortcut or navigate to an action and press Enter. Ctrl-B twice sends a literal Ctrl-B; Esc cancels, and unknown shortcut keys leave the column open. There is no timeout. Every shortcut below follows the prefix, never intercepting ordinary application input.

Key after prefix Action
[ / ] Previous / next tab
{ / } Previous / next workspace (Shift-modified tab cycling)
Tab / Shift+Tab Next / previous pane
Backspace Last-focused pane
Alt+Left/Right/Up/Down Directional pane focus
t / T New tab / tab chooser
w / W New workspace / workspace chooser
p / s / S Pane / current tab / current workspace actions
h / v Split side-by-side / stacked; also open a pane in an empty tab. Refused with a notice when the pane cannot hold two panes and a separator
z Viewer-local zoom
r Rename pane
x Confirm pane close
Ctrl+Left/Right Shrink/grow width at the nearest horizontal container
Ctrl+Up/Down Grow/shrink height at the nearest vertical container
Shift+Left/Right/Up/Down Move pane in that direction
c Enter history/copy mode
y Copy visible text using OSC 52
d Detach, preserving processes

In the prefix column, choosers and action menus: Up/Down select an action and scroll it into view, PageUp/PageDown move a page, Home/End select first/last, Enter executes, and Esc cancels. Wheel scrolling moves the selection; headings and overflow indicators are skipped. Selection is reversed, unavailable actions remain dimmed, and invoking one explains why without acting. Left/Right are reserved no-ops in these vertical lists. Context menus do not dispatch prefix shortcuts behind themselves; chooser/context lists additionally accept j/k and q. The prefix column is the only help surface; the help action opens it.

Unmodified navigation keys, Enter and Esc belong to the menu before configured bindings. Custom arrow bindings remain listed and can be selected with Enter, but cannot resize panes while navigating. Nonreserved shortcuts (including modified arrows) still execute directly from the prefix column. The configured prefix itself retains doubled-prefix literal forwarding. Text prompts retain editing semantics; confirmations retain their explicit confirmation keys; pasted text never becomes menu commands.

Defaults changed deliberately; user-supplied binding lists and hot reload are preserved, not migrated or overwritten. Actions losing default shortcuts remain in context menus or available for custom bindings. Pane actions include termination, sibling reorder, swaps, history scrolling and moves to existing/new tabs/workspaces. Tab/workspace actions include rename, reorder and confirmed close. Workspace actions also include save/load layout.

Previous default Replacement
{ / ] previous/next tab [ / ]
P / w previous/next workspace { / }
c new workspace; [ copy mode w new workspace; c copy mode
n / u / ! next/previous/last pane Tab / Shift+Tab / Backspace
Bare arrows resize Ctrl+arrows; bare arrows navigate menus
Shift+Left/Right sibling reorder Pane actions; Shift+arrows now move directionally
; / ' / backtick context menus p / s / S
k terminate; p move workspace Pane actions
R rename workspace; s / l save/load Workspace actions
Prefix PageUp/PageDown history Copy mode PageUp/PageDown, or pane actions
Copy-mode Esc clears first, exits second One Esc exits; c clears without exiting

Workspaces contain ordered tabs, each containing its own split/pane tree. Viewers independently remember the selected tab per workspace and the focused/last-focused pane per tab. Next/previous pane use native tab navigation; directional focus ranks Bevy-computed pane centers by cross-axis distance, then forward distance, then entity ID, without wrapping at an edge. Switching tabs or moving panes exits zoom; zoom never changes another viewer. Removed or hidden targets get a deterministic surviving focus. Processes and history survive switches.

Choosers and menus use Up/Down or j/k, PageUp/PageDown, Home/End, Enter, and Esc/q. A selected row remains reachable even when only one overlay row fits. Pane menus act on the pane they opened on, not a later focus. Tab/workspace menus provide creation, rename, reorder, and close. Directional move nests the source beside the nearest directional pane; swap exchanges pane positions. Moving to a workspace uses its first tab. Empty split containers collapse bottom-up. New tabs/workspaces created by a move reuse the original process instead of launching a shell.

Interactive pane/tab/workspace closes require y; n or Esc cancels. Confirmations capture entity identities, revalidate them, and never retarget a disappeared item. Closing a tab/workspace removes its contained views, terminating only processes with no surviving references. Closing the last tab retains one empty tab; closing a workspace selects the first surviving workspace for its viewers, or detaches them if none remain; the next attach then recreates the initial workspace and shell. Direct API close actions are explicit and noninteractive. Natural process exit still retains the final screen.

Click to focus; click a tab to select it. Right-click a pane/tab or click the workspace name for its action menu. When an application requests mouse input, pane events remain application-owned; Shift-right-click opens fux's menu instead. Wheel and drag browse/select the pane under the pointer when the application does not request mouse input, or when Shift is held. Application events keep pane-relative coordinates, including first/last content cells. Bars, separators and modal overlays never click through to the PTY. Overlay lists are keyboard-operated, with wheel navigation; list entries are not clickable.

Copy mode: arrows or h/j/k/l move, u/d or PageUp/PageDown browse history, Home/End move within a row, Space anchors a selection, c clears it, y or Enter copies it and returns to live output, g returns to live without copying, q exits, and Esc clears the selection and exits in one press. Mouse drag selects; y/Enter copies after release. Selection is viewer-private and limited to the displayed viewport, including its wrapped rows. Wide-glyph continuations normalize to the leading cell; combining marks remain attached; wrapped rows join without an invented newline. Blank padding at hard line ends is trimmed.

clipboard is disabled by default. Set "clipboard":"write-only" to permit bounded OSC 52 writes; the outer terminal must also allow them. Copy never reads the system clipboard. Each encoded effect is at most 1 MiB, with at most 16 queued effects. A copy viewport is capped at 262144 cells. Failure and success are reported in the bar.

Selections follow fux-vt row IDs and columns. Ordinary output and scrolling preserve unchanged retained selected text; the copying viewer follows those rows while other viewers remain independent. Row versions skip unchanged data, and changed versions validate the selected spans, including every interior row and soft line break. Unrelated cells and style-only updates do not clear a selection. Overwritten text, a lost/reordered required row, reset, buffer switches, backing resize, viewport clipping changes, or explicit browsing to a different window clear it with a notice. Losing an unselected row is safe if the selection still fits in one viewport. Changing focus ends copy mode; pane removal or a replaced process (row IDs are per terminal instance) ends it with a notice. No second history or paragraph reflow is retained.

Prefix, prompts, confirmations, menus and copy mode own their input. The frontend sends a paste-start marker before buffering a fragmented bracketed paste, retaining its original owner until the end marker. Cancellation, prompt replacement, or a focus switch cannot redirect that paste to a PTY. Paste is bounded to 64 KiB; oversized content is drained and discarded. Termina still decodes ordinary keys/mouse; a lone Escape uses a 35 ms disambiguation deadline. Ordinary nonmodal keys and complete pastes go to the focused PTY.

Panes are borderless: content starts at the top-left cell, with one shared thin separator between default split siblings. Separators next to focus are bold; others are muted. The last row is always a full-width, gray-background bar: workspace and ordered tabs on the left (active tab reversed), focused process id: name/exit status on the right. Notices replace the right zone (yellow; errors red) until subsequent input clears them. Zoom/history indicators stay compact. Unfocused exited panes retain a small dim marker, not a title strip.

The command/help list grows upward from the bottom-right, directly above the bar, one configured binding per row, grouped into Panes, Focus, Tabs, Workspaces and Session while preserving configuration order within groups. Unknown custom actions are listed under Other. Unavailable commands are dimmed and explain why when invoked. A bold heading, contrasting background and minimal padding distinguish it without a border. Hidden rows are marked ▲ n more / ▼ n more when space permits; narrow labels use cell-aware ellipsis. Rename, scene-path prompts, choosers, confirmations and context menus use the same corner surface; editable text is reversed and its tail stays visible. Closing an overlay restores the pane and cursor. A one-row viewer shows only the bar; zero-sized views paint no content. The active tab remains in the overflow window; at one or two columns it takes priority over the workspace label.

A PTY has one real size: the smallest visible content height/width across its viewers, with an exact 1×1 backing minimum, including the child's real PTY geometry. Pane layout nodes independently retain their 2×2 usability minimum whatever their flex weight, so shrinking never squeezes a sibling to nothing; a viewer too small for a tab's panes overflows at the end of the axis and clips there. Tiny viewers still paint only their actual available cells, never partial wide glyphs or invented rows. Larger viewers retain their own layout and leave surplus content cells blank. Resizing follows the documented fux-vt screen/history contract, not paragraph reflow. Detached processes keep their last size. Closing the last layout reference terminates that pane; removing a layout hierarchy directly does not own or resurrect its referenced processes.

Configuration and scenes

The selected JSON file is a native Bevy asset, watched in its parent directory. Omitted fields retain defaults; supplying bindings replaces the binding list.

{
  "prefix": "ctrl-b",
  "shell": ["/bin/sh"],
  "history_lines": 10000,
  "clipboard": "write-only",
  "bindings": [
    {"key": "h", "action": "split_horizontal"},
    {"key": "d", "action": "detach"}
  ]
}

layout: "layout.scn.ron" optionally loads/watches a native DynamicWorld asset relative to the configuration directory. It replaces the workspace with the same saved name, or adds that workspace if no matching name exists. Use distinct workspace names when using this convenience path. Interactive/API save and load paths instead resolve relative to the server's working directory. Saved scenes contain the layout hierarchy and registered UI components, not PTYs, terminal history or process recipes. Borderless defaults do not rewrite loaded Nodes: native flex/grid, visibility, spacing and other registered components remain intact. Only actual one-cell gaps between visible siblings of Split containers receive separator glyphs; custom margins and wider gaps remain blank. Tabless scenes from PR #20 are wrapped into a main tab: their complete root layout Node moves into that tab under a neutral workspace wrapper, so grid tracks, padding and margins apply once. Existing child entities and process references are retained. New tabbed scenes preserve native Nodes unchanged. Tabs must be direct workspace children; viewer/runtime state is not scene content.

Scene pane references resolve to existing process entities in the same server. Explicit load_layout mapping pairs map [saved_process_entity, existing_process_entity]; all references are validated before creating layout entities. Missing processes fail without replacing the current layout. Loading never launches a process. IDs and scene formats are not stable across server runs or Bevy versions.

Unrestricted remote control

fux rpc METHOD '[JSON]' sends a stock JSON-RPC request over the server's socket and prints its result. Any HTTP client that can dial a Unix socket works the same way, for example curl --unix-socket PATH http://fux/ -d '{"jsonrpc":"2.0","id":1,"method":"rpc.discover"}'.

Access control is the socket's file permissions: only your user (and root) can open a mode-0600 socket in a mode-0700 directory. There is still no authentication step for a caller who can open it, so every same-user process remains fully trusted. The server opens no TCP or UDP socket, so a web page cannot reach it at all: browser JavaScript cannot dial a Unix socket, which removes the cross-origin "simple request" exposure of the former loopback port by construction. A TCP proxy or native bridge you put in front of the socket would reintroduce that exposure. rpc.discover lists no server URL.

There is no application authorization step:

fux rpc rpc.discover
fux rpc registry.schema
fux rpc world.query '{"data":{"components":["fux::model::Workspace"]}}'
fux rpc world.query '{"data":{"components":["fux::model::ProcessState"]}}'
fux rpc world.spawn_entity '{"components":{"bevy_ecs::name::Name":"scratch"}}'
# Substitute IDs returned by the server:
fux rpc world.insert_components '{"entity":ENTITY,"components":{"bevy_ecs::name::Name":"renamed"}}'
fux rpc world.remove_components '{"entity":ENTITY,"components":["bevy_ecs::name::Name"]}'
fux rpc world.despawn_entity '{"entity":ENTITY}'

Native extensions are only fux.attach, fux.frame and the SSE watch fux.frame+watch. fux.attach accepts {workspace?, rows, cols} and returns a viewer entity. The others accept {viewer}. Closing a frame-watch connection detaches that viewer, and only a viewer: an id that names anything else is ignored rather than despawned. A non-streaming API caller should explicitly detach it.

Requests are bounded, and passing a bound is a JSON-RPC error naming it rather than a dropped connection: a request body may be up to 4 MiB, one batch may hold up to 1024 requests, and a batch reply is capped at 8 MiB, after which the remaining requests in that batch are answered with an error instead of being run. The largest request fux itself sends is a paste, bounded by 64 KiB of payload.

Use stock world.trigger_event for fux::control::Control, fux::control::UserInput and fux::control::Shutdown. Control and UserInput are entity events: viewer names the viewer entity and the rest is one tagged command or input. A Command is an object with a kind and exactly the fields that command needs; a request whose kind or fields do not match is rejected when it deserializes, with a JSON-RPC error rather than a notice. The full shape is registry.schema for fux::control::Command. Subjects are explicit: {"pane":ID}, {"tab":ID} or {"workspace":ID}. A command that names an entity of the wrong kind reports a notice; a command that names a missing entity reports "target no longer exists". Close commands are noninteractive over the API; interactive bindings add confirmation. Do not use raw hierarchy despawn as a substitute for a close: native hierarchy removal does not own shared processes. split runs /bin/sh -lc PROGRAM when program is a string and the configured command when it is null. menu and choose open the same interactive lists a binding would.

Tab/workspace operations share scope: "tab" | "workspace": select {scope, entity}, next {scope}, previous {scope} and reorder {scope, order}. order is "previous" or "next"; reorder_pane {order} reorders the focused pane instead. scroll {order} moves the focused pane's history by half the viewer's rows: previous shows older output, next newer, clamped to the retained history. The reflected Viewer.scrollback is the resulting offset in lines above the live bottom; zero is live output. All close scopes use close {subject}. Pane moves use move {to}, where to is {"kind":"tab","tab":ID}, {"kind":"workspace","workspace":ID}, {"kind":"new_tab","name":null|"…"} or {"kind":"new_workspace","name":null|"…"}. The former paired command kinds (tab_select, workspace_next, move_to_tab, and so on) are no longer accepted; configured action names such as tab_next are unchanged.

fux rpc fux.attach '{"rows":24,"cols":80}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"split","axis":"horizontal","program":"exec /bin/sh"}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"close","subject":{"pane":PANE_VIEW}}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"rename","subject":{"tab":TAB},"name":"logs"}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"focus","pane":PANE_VIEW}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"select","scope":"tab","entity":TAB}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"next","scope":"workspace"}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"previous","scope":"tab"}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"reorder","scope":"tab","order":"next"}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"reorder_pane","order":"previous"}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"scroll","order":"previous"}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"move","to":{"kind":"workspace","workspace":WORKSPACE}}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"load_layout","workspace":WORKSPACE,"path":"layout.scn.ron","mapping":[[OLD_PANE,LIVE_PANE]]}}}'
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"detach"}}}'
fux rpc world.trigger_event '{"event":"fux::control::UserInput","value":{"viewer":VIEWER,"input":{"kind":"key","key":"enter","ctrl":false,"alt":false,"shift":false}}}'

The uppercase IDs above are placeholders to substitute, not literal JSON values. UserInput accepts key {key,ctrl,alt,shift} where key is a single character or one of enter, tab, escape, backspace, delete, insert, left, right, up, down, home, end, pageup, pagedown, f1..f12; paste_begin followed by paste {text} (ownership-preserving fragmented paste); atomic paste {text}; resize {rows,cols}; and mouse {action,button,x,y,ctrl,alt,shift} with action one of press, release, move, scroll_up, scroll_down and button one of left, middle, right, none. Mouse coordinates are zero-based viewer cells. Control and file-operation errors appear in the reflected Viewer.notice, an object {text, error} or null; asynchronous scene completion changes that notice.

The layout hierarchy itself is Bevy's own bevy_ecs::hierarchy::ChildOf (a pane view's or tab's parent, serialized as the bare entity ID) and bevy_ecs::hierarchy::Children (an ordered array of entity IDs); read them by those full paths, for example world.get_components '{"entity":PANE_VIEW,"components":["bevy_ecs::hierarchy::ChildOf"]}'. A viewer's place in the layout is three relationship components on the viewer entity, each serialized as the bare entity ID: fux::model::Viewing (its workspace), fux::model::OnTab (its tab) and fux::model::Focused (its pane view). Bevy removes a relationship when its target despawns and fux then restores it from viewer memory or the first available entity, so these never dangle. Viewer itself holds only rows, cols, zoom, scrollback and notice. The existing fux::interaction::Prefix and fux::interaction::Overlay components are also reflected: Prefix.scroll is the selected command-column row; Overlay contains its serial, captured Target, and Mode. Mode is a native reflected enum: {"List":{"title":…, "entries":…, "selected":…}}, {"Text":{"action":…, "buffer":…}} or {"Confirm":{"command":…}}. Entries contain a label and Run (Action or Command). Native reflected action variants use Rust names such as RenamePane; nested Command retains its existing tagged JSON shape. These are the actual interaction components, not snapshots. Their presence/absence tracks opening/closing; the serial is paste-ownership bookkeeping, not a durable operation receipt.

# Substitute a viewer ID returned by fux.attach for VIEWER:
fux rpc world.trigger_event '{"event":"fux::control::Control","value":{"viewer":VIEWER,"command":{"kind":"help"}}}'
fux rpc world.get_components '{"entity":VIEWER,"components":["fux::interaction::Prefix","fux::interaction::Overlay"]}'
fux rpc world.query '{"data":{"components":["fux::interaction::Overlay"]}}'
fux rpc world.list_components '{"entity":VIEWER}'
fux rpc registry.schema

Default non-strict world.get_components returns separate components and errors maps, so an absent overlay is not a failed request. Stock world.get_components+watch supports component change/removal observation through the native watching transport. No fux-specific inspection method is added.

Exposure boundary: workspace/tab/pane/process state, viewer relationships, settings, controls and input were already reflected. This additionally registers Prefix, Overlay, Mode, Entry, Run and actions::Target. Copy-mode Selection remains unreflected because it owns a captured cell grid and emulator row identities; reflecting that storage would expose a large buffer and its invariants merely to obtain metadata. Component listing can identify its presence; painted frames remain the copy-mode display. Viewer memory, paste ownership, presentation/layout caches, tasks, PTY handles and the emulator runtime remain unreflected. BRP exposes registered reflection, not arbitrary Rust methods such as Terminal::copy_text; fux.frame remains the terminal-display interface. No second history or observation state is maintained.

Use Control and UserInput for normal interactions, validation and side effects. Raw component insertion/mutation/removal is trusted low-level access, not a safe interaction protocol: it can bypass normal transitions and paste bookkeeping. Out-of-range list increments saturate and entry lookup is checked, but raw edits do not gain human-action semantics. Runtime Prefix/Overlay components are rejected by fux layout save/load validation even if attached to a layout entity; they never become supported scene content.

For exact argv/cwd, stock-spawn a fux::model::Launch component, then a PaneView referring to its returned entity, and reparent that view under a tab using world.reparent_entities. Launch is a creation recipe (argv, cwd, history_lines), not an automatic restart controller. Its required ProcessState reports dimensions, a revision and one status: {"kind":"starting"}, {"kind":"running","pid":N,"error":null|"…"}, {"kind":"exited","code":N} or {"kind":"failed","error":"…"}; reflected dimension edits resize the real PTY. Despawning the process or removing Launch terminates it. Configured bindings map a key token such as ctrl-b or shift-tab to an action name; an unknown name is kept, listed under Other in help, and reports itself when pressed. All registered operational and UI components remain available to stock inspection/mutation; resource/schedule/event/schema methods are not filtered. Two stock methods are guarded rather than filtered: world.mutate_components answers entity_not_found for an entity that is not alive, where the stock handler panics, and world.despawn_entity refuses an entity that holds a Bevy resource, since despawning one leaves the ECS inconsistent. Each guard then hands the request to the stock handler unchanged.

Architecture and dependency boundary

  • actions.rs names the bindable actions with their labels, groups and availability, and turns a bound action into the Command it means for the viewer's current workspace, tab and pane. control.rs is the wire: Command is one tagged enum whose variants carry only their own fields, and Control/UserInput are entity events targeting the viewer. interaction.rs stores overlays (confirmations, prompts, choosers, menus) and the open prefix column as components on the viewer; a completed overlay produces a Command. selection.rs stores only one bounded viewport per copying viewer, never a second history. protocol.rs types every key, modifier, mouse action and direction; unsupported names cannot be constructed. Prefix/overlay state and its nested action types are reflected for stock BRP inspection; copy-mode storage remains unreflected. Runtime interaction state is excluded from scenes.
  • model.rs: reflected ECS components; ChildOf/Children own layout hierarchies. Workspace, Tab, PaneView and Split require their default layout Node; explicit scene/spawn Nodes take precedence, including column-split overrides. The native PaneView/PaneViews relationship references a separate process entity without linked despawn. A viewer's workspace, tab and focused pane are the Viewing, OnTab and Focused relationships; their targets (Viewers, TabViewers, FocusedBy) are unreflected bookkeeping on layout entities that the layout cache ignores. Status is one process lifecycle and Notice one bar message, so neither can be half-set.
  • server.rs sets Bevy's fallback error handler to log: a command that fails reports it rather than ending the process, because one request must not be able to end every session. navigation.rs: hierarchy normalization and viewer memory. Component hooks record the tab per workspace and the focused and previously focused pane per tab as relationships change, and queue repair on insertion/removal. Server-scoped observers still prune dead memory entries and normalize hierarchy changes: a workspace that loses its last tab gets one back, a child placed directly under a workspace is wrapped into a tab, a ChildOf that points into the entity's own subtree is rejected like self-parenting rather than left as a cycle, a Viewer inserted onto a layout node (Workspace, Tab, Split or PaneView), or a layout component inserted onto a viewer, loses the Viewer and its viewer-only state because the layout role wins, and a tab unlinked from its workspace or parented elsewhere has its viewers repaired onto a listed tab. Passes over viewers select only entities with Viewer and no layout component. A repair pass queues no further repair for its own relationship insertions; a request made while it runs is answered by another pass, at most sixteen in all. Normalization/pruning observers are not installed in inert presentation worlds. Nothing scans for dangling IDs on a schedule.
  • server.rs: direct typed Bevy observers, queries/resources and scheduled systems implement operations. execute is one match over Command that either changes the world or calls the module that owns that part of the model; there is no forwarding between layers. Scene preparation, sizing and ordinary frame formatting use exclusive world access; synchronization snapshots Viewer inputs before borrowing its presentation, and painting copies rectangle values before mutating terminals. Independent Presentation components own inert Worlds and retain native focus isolation; a viewer whose workspace cannot be projected, which only raw hierarchy edits produce, gets a frame whose bar names the failure rather than a failed request. A causal settling update lets stock RemoteLast mutations reach lifecycle/layout systems even when idle. Workspace-owned layout caches follow arbitrary component change ticks, component-set changes and native hierarchy membership, not a component allowlist. Unrelated workspace projections remain cached; removing a Viewer also removes its presentation context. A scene save or load is a Task<CommandQueue> component on the requesting viewer, polled once per update with check_ready and dropped with the viewer; the queue applies the completion on the ECS thread. Pane geometry is bevy_math::URect.
  • presentation.rs: one inert native scene projection per viewer, sharing the type registry. After plugin setup, its extracted World runs Main and clears trackers; the viewer component also owns paint throttling and pending clipboard delivery. UiPlugin, native flex/grid layout, visibility, InputFocus, tab navigation and ui_focus_system own geometry/focus/picking. No hand-written layout solver, OS window or GPU renderer is installed. The cell painter renders borderless pane surfaces and computed shared separators, not a general Bevy image/text/shader renderer. chrome.rs paints the bottom bar and content-sized corner overlays; modal wheel input follows the selected list, without cached overlay hit-test bounds or a second picking system.
  • assets.rs: AssetServer, native file watchers, asset events, DynamicWorld serialization and native entity maps. File reads/writes run on Bevy's I/O task pool. No parallel configuration or persistence engine.
  • terminal.rs: each authoritative process entity owns an unreflected Terminal runtime component, accessed through typed queries rather than a separate entity-keyed registry; only shared wake/coalescing state is a resource. Its runtime is a two-state type, Live with the PTY, child, waiter and I/O tasks or Stopped, so input after exit is a type error rather than a check on optional fields; only the draining reader outlives the process. Scheduled Launch removal publishes final status before removing the runtime, while entity despawn and explicit pre-join shutdown drop it. Inert scenes never construct or serialize runtimes. portable-pty owns native PTYs/processes; the workspace-owned fux-vt owns its DEC ANSI parser, bounded row-major grids/history, row identities and non-destructive change tracking. Its only production dependency is unicode-width; the independent fux-fuzz harness retains the upstream oracle. Immutable history windows never change parser state. Per-process row extraction is keyed by row ID/version and visible width, not terminal revision or a single viewer's offset. The cache holds at most 4096 entries / 4 MiB of serialized rows, plus the current explicitly sized frame's row references; a row too large for the cache is rendered without retaining a cache entry. Multiple widths/windows can coexist. A bounded index of the eight most recently served windows, keyed by the emulator's non-destructive change mark plus offset/height/width, lets an unchanged window skip per-row lookups; viewers do not evict each other's windows, and any emulator change (including cursor-only) falls back to row reuse. Every frame still emits ALL its relocatable SGR-only rows; cursor and modes come directly from the emulator. Bounded DSR/CPR/DA replies go synchronously to the existing nonblocking input queue, with saturation reported as an I/O error. Readiness-driven async-io tasks on Bevy's I/O pool handle bounded I/O (16 × 8 KiB output slots; 64 KiB consumed per pane/update; 16 input slots, each bounded to the largest accepted paste plus its bracketed-paste envelope). One blocking native waiter per child preserves its unreaped PID while cleanup signals its group. Shutdown first allows 100 ms for shell hangup propagation, then closes the master, hard-kills the owned original group and reaps its leader. Closing the master before the blocking reap also releases a dying writer's queued PTY output on macOS. A final drain is bounded to 128 KiB.
  • transport.rs: the BRP serving loop, which fux owns because bevy_remote binds its HTTP server to a TCP listener and offers no way to supply another. It is the stock loop with Async<UnixListener> in place of the TCP listener, and HTTP/1 on top unchanged: hyper, smol-hyper and http-body-util (already in the graph through the crate's http feature, which is no longer enabled) serve single and batch requests and +watch server-sent events, and requests enter the stock BrpSender mailbox. What stays stock is everything above the listener: the method registry, the BRP request/response/error types and the watching bookkeeping. The module also owns the socket's location, private directory and permissions, the lock that makes one server its owner, stale-socket recovery and removal on shutdown. It adds no authentication and no method filtering. A closed watch connection still closes its response channel, which detaches that viewer even when idle; the entity named by a watch is despawned only if it is a viewer, so a watch is not a despawn of the caller's choosing. Request bodies, batch sizes and batch replies are bounded (see above), and a streaming request inside a batch is refused before it is dispatched, so a refused request leaves the world alone. accept failing for want of descriptors is told apart from a peer that aborted: the condition is reported once and then at intervals rather than every 50 ms, and a reserved descriptor is spent to accept and close one waiting connection so the backlog drains and a client fails promptly. A server with no descriptors still cannot serve a new client; what it does is fail fast, say so, and recover by itself when the pressure clears.
  • viewer.rs: termina supplies key/mouse decoding, terminal modes/dimensions/restoration; paste.rs adds bounded paste-envelope detection on a readiness-driven Unix input loop, with native SIGWINCH resize handling and an explicit stop socket; Each server-sent event is read through a bound (64 MiB, about five times the densest frame a 4096x4096 viewer can produce), and passing it ends the attachment with a message rather than buffering without limit. ureq consumes the HTTP/SSE through unix_http.rs, a connector that dials only the server's Unix socket, with no DNS, proxy or redirect path back to TCP (ureq is pinned exactly because that connector uses its unversioned transport API). The maintained HTTP Agent is reused. Paints coalesce, while explicit clipboard effects are delivered separately; the server queues up to 16 pending copies per viewer and reports overflow in Viewer.notice. The stream reader does not hold its paint slot lock during terminal writes. Stock Bevy's watching bookkeeping closes full watch response channels; fux drains/coalesces frames without replacing it. signal-hook handles graceful termination. nix, parking_lot, channels, serde/RON, Base64 and Unicode cell widths cover the remaining narrow native/protocol needs.

The runner parks without an idle tick; PTY data/exit, requests, disconnections, signals and asset notifications wake it. Streamed paints coalesce behind a 16 ms minimum interval, using an on-demand one-shot I/O-pool timer; idle viewers have no recurring paint timer. Direct fux.frame snapshots are immediate. While native asset loads are pending the runner uses a 25 ms settling deadline. Scene/UI projections are caches, never process/session authorities. Transitive Bevy rendering-related types are dependencies of native UI/camera APIs; renderer plugins are not running.

Scope and verification

fux-fuzz is an unpublished, opt-in black-box harness for replayable startup, resize, paste, key round-trip, mouse forwarding, copy, history, zoom, layout lifecycle, process lifecycle, navigation, scenes, configuration reload, overlays, limits, chrome, selection, concurrency races, viewer memory, reorder, scene mapping, mouse edge cases, clipboard queueing, pane resizing, API misuse, scene fidelity, tabless scenes, configuration churn, scene process references, a repetition soak, relationship repair, terminal edge cases, frontend stream, and generated random-walk (driven over BRP and through a real frontend, under viewer resizes, exiting children, configuration rewrites and a second frontend), scale, adversarial-output, concurrent-walk, raw-mutation-walk and scene-file-fuzzing scenarios with automatic trace minimization. It runs separately from the normal tests and CI; its documentation covers resource bounds, replay and verification.

Tested on macOS arm64; see verification/keybinding-consistency.md for the current binding/menu verification, verification/interaction-restoration.md for this interaction pass and intentional differences from original main, verification/design-restoration.md for the current visual/input verification and captured renders, verification/REFINEMENT.md for historical comparable measurements and the capability audit, and verification/VERIFICATION.md for preserved baseline evidence. Linux and other Unix systems are unvalidated; this is not a Windows/mobile implementation.

Owned direct children and their original process groups are cleaned up and reaped. Ordinary interactive-shell job groups receive the shell's hangup propagation. Deliberately detached/disowned descendants, or descendants in other groups that ignore hangup, are not a process-containment guarantee; fux does not enumerate and signal potentially recycled descendant PIDs. macOS zombie-only group EPERM is distinguished by native membership inspection, not ignored for live groups.

Intentionally excluded: authentication (the transport is a Unix socket whose permissions admit only your user; no credential is checked beyond that), method filtering, remote-host catalogs/tunnels, task/provider policy, crash recovery, process resurrection, automatic restart, plugin installation, durable input receipts, graphics protocols, IME and broad editor/dashboard features. No cross-version API or saved-scene compatibility promise.