# Alloy Console A ratatui-based unified control surface for Alloy: the one place where system-level tasks (network, audio, display, updates, hinge daemon) and configuration editing happen. Companion to [STACK.md](STACK.md) and [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md). Binary: `alloy` (single binary, subcommands). The *component* is called Alloy Console; the *invocation* is `alloy `. ## Thesis Distros grow graphical settings apps because Unix's underlying CLIs are excellent primitives but a terrible surface for casual use. Nobody wants to look up `wpctl set-sink-volume @DEFAULT_AUDIO_SINK@ 0.5+` at 3am. GNOME's answer is a large graphical settings app. Alloy's answer is a small ratatui console that fronts those same CLIs with a shared visual identity and keymap. The elegant part is that "controlling the system" and "editing a config file" are the same design problem with two different data sources. Once the console has schema-driven form widgets for editing live state (interface toggles, sink volumes, output modes), pointing those same widgets at a TOML file gives a config editor for free. This kills the "graphical settings app" question permanently: Alloy has one settings surface, it's the console, and it's ratatui. ## Subcommand shape ``` alloy net # network: nmcli front [shipped] alloy audio # audio: pactl front, streams and devices [shipped] alloy mesh # mesh VPN: tailscale front (alias: tail) [shipped] alloy display # outputs: swaymsg output / wlr-randr front alloy update # system updates: rpm-ostree front alloy sync # syncthing front (see CONTINUITY.md) alloy config # schema-driven TOML/KDL editor alloy theme # swap the runtime theme; reads makeover's themes/*.toml # or ~/.config/alloy/themes/*.toml via makeover ``` `alloy mesh` was named `alloy tail` when this document was written. It is generic now, for two reasons. Someone who has never heard of Tailscale should still find the screen that lists the machines they can reach, and Headscale is a self-hosted control server driving the same client, so a vendor-named verb would misdescribe half its users. `tail` remains as an alias. The backend name stays visible in the view title (`mesh (tailscale)`), and a self-hosted control plane is named there too (`mesh (tailscale via hs.example.org)`), read once at startup from `tailscale debug prefs`. That is a `debug` interface and therefore unstable, so the lookup degrades to showing nothing rather than failing. Backend picks that differ from the sketch above: `net` fronts `nmcli` alone, since Alloy is Fedora and NetworkManager is what is there. `audio` fronts `pactl` rather than `wpctl`, because `pactl -f json` is a documented contract while `wpctl status` renders a box-drawing tree meant for human eyes. Each subcommand is a small ratatui adapter over an existing CLI. The subcommands share: - The `alloy_tui` design-system crate (colors, typography, layout, form widgets). - A common navigation model (Tab across sections, Enter to activate, Esc to cancel; Alloy's keymap conventions live in one place). - A common status area (busy/error/dirty indicators). - A common command-log pane (every action shows the underlying CLI invocation, so users learn the primitive, not the wrapper). That last point is deliberate. Alloy Console is not trying to hide the CLI, it is trying to make the CLI *approachable*. Every action taken through the console prints the equivalent shell command in the log pane. Users graduate from console to CLI as they get comfortable; the tool teaches its own primitives. **What the pane records is what the user asked for**, which is narrower than everything the console runs. Three kinds of invocation are the console's own bookkeeping and stay out of it: capability probes, which run before the user has asked for anything; background polls on the shell tick, which exist so a stream appearing or a peer going offline shows up without a keypress; and the re-read that confirms what an action did. Without that split a single volume keypress writes its action plus a four-command re-read into a two-row pane, and the command the user pressed a key for scrolls off before it can be read. An explicit refresh is a user action and does log. Every command is executed as argv rather than through a shell, and displayed the same way it is run, quoting arguments that contain whitespace so the logged line can be pasted into a shell and mean the same thing there. ## `alloy config`: schema-driven editor The config editor is the *same UI pattern* applied to file contents instead of live system state. Point it at a TOML or KDL file, get a navigable form. Save writes the file back. Behavior: - **Roundtrip-safe.** The editor preserves comments, formatting, and key order. It edits values in place, not "read, serialize, write." Uses `toml_edit` for TOML and a KDL equivalent for KDL. - **Schema-required.** No schema, no form: the editor refuses to render a config it doesn't have a schema for, rather than falling back to value-inferred forms that guess wrong. Falls back to a plain text edit pane (with syntax highlighting) as an explicit escape. - **Live validation.** Type errors, out-of-range values, missing required keys, and unknown keys all surface in the form as inline diagnostics rather than on save. - **Documentation inline.** Schema entries carry a description string that shows in the form as help text next to the field. ## Schema strategy Three options considered. Committing to option 2 with 3 as opportunistic input. 1. **Value inference.** Read the file, infer form from value types (bool to toggle, string to input, table to subsection). Cheap. Loses everything about validation, enums, docs, defaults, sections. **Rejected**, because it produces "editable but not useful" forms, which is worse than no form. 2. **Explicit schema files, one per adopted config.** Alloy ships `schemas/rio.toml.schema`, `schemas/yazi.toml.schema`, `schemas/mako.schema`, etc. **Picked.** Alloy's whole thesis is "curated stack with authored defaults", and schemas are the natural extension of that. Bounded catalog: ~10 schemas for the adopted v0 stack. (The sway config's i3-style syntax is neither TOML nor KDL, so it takes the text-edit fallback, not a schema form.) 3. **Upstream schemas (JSON Schema, KDL schema) where they exist.** Some tools ship JSON Schema; most don't. **Adopted opportunistically**: where an upstream schema exists and is maintained, Alloy imports/converts it rather than authoring its own. Falls back to option 2 for the rest. ### The schema format Alloy authors a small TOML dialect for describing TOML (and a parallel one for KDL). Not JSON Schema, which is powerful but verbose and awkward for humans. Not TOML's own type system, which is insufficient (no enums, no docs, no cross-field constraints). Schema-format v1, worked in full at [`schemas/rio.toml.schema`](../schemas/rio.toml.schema). The design pass against rio's real config surface shaked out five affordances beyond a naive field list: sections, groups, presets, format hints, and an unknown-key policy. **Header.** Every schema declares its target file, the tool it targets, a target-version semver range, the schema-DSL version, and an unknown-key policy. Unknown keys default to `preserve` so that a rio release adding a new field never bricks the editor: the field survives the edit round-trip and surfaces as an info diagnostic. ```toml [schema] target = "rio.toml" target_tool = "rio" target_version = ">=0.2" schema_version = "1" unknown_keys = "preserve" # {preserve | error}; default preserve ``` **Sections** (`[[section]]`) declare UI-level grouping: one collapsible pane per section, ordered as declared. Fields fall into their section by path prefix. Without sections the form would be a flat list ordered by field-declaration; sections make headers and per-section descriptions explicit. **Fields** (`[[field]]`) carry the atomic edit unit. Every field has `path` (dotted TOML path; hyphens are literal key names), `type`, `description`, and `default`. Types: `bool`, `int`, `float`, `string`, `color`, `path`, `enum`, `list`, `table`. Constraints: `range` (int/float), `pattern` (string regex), `values` (enum), `required` (default false). Format hints: `color format = "hex" | "hex-alpha" | "css"`, `path format = "file" | "dir" | "any"` + optional `must_exist`. `enum` values may be flat (`["a", "b"]`) or structured (`[{value, label, description}, ...]`) when the raw value is unfriendly (rio's `"Disabled"` / `"Transparent"` gets a friendlier label). **Groups** (`[[group]]`) collapse repetition. Rio's `[colors]` has ~25 palette slots, all colors, all hex. A `[[group]]` with `path = "colors"`, `type = "color"`, `format = "hex"`, and 25 `entries` expands to 25 fields at `colors.` without 25 near-identical `[[field]]` blocks. Groups are DSL sugar over fields, not a runtime concept: the editor materializes them into the same form widgets. **Presets** (`[[preset]]`) give palette-heavy configs the UX they need: one action swaps a bundle. Each preset declares `name`, `description`, and a `values` map of `path` to value. Applied as one atomic edit: single dirty state, one undo entry. Presets are the answer to "how does a user pick a theme without editing 25 hex codes." Optional per schema. **List-of-tables** (`type = "list"`, `element = {type = "table", fields = [...]}`) covers repeating records like `[[bindings.keys]]`. The element sub-schema is inlined for authoring simplicity, with no cross-file references to chase. **Cross-field constraints deliberately excluded.** JSON Schema showed how much complexity `if`/`then`/`allOf`/`oneOf` buys. The editor renders every field; the target tool ignores the irrelevant ones. If a specific "hide field B when field A is off" case shows up in daily use, `enabled_when` can land in schema-DSL v2. Properties the format has to hold: - **Readable and writable by hand.** Alloy will author dozens of these. Rio's schema at ~250 lines is the size baseline; if a schema takes a day to write, the catalog is months of work. If it takes an hour, it's a weekend project. Groups and presets are the sugar that keep the hour number honest. - **Extensible without breaking editors.** New field types and constraints will get added over time. The editor treats unknown types as text-edit fallback rather than refusing to open the file; `schema_version` on the header lets the editor detect an incompatible schema DSL and route to fallback cleanly. The KDL parallel dialect follows the same shape; only the `path` grammar and the roundtrip-safe editing library differ (see the KDL open question below). ## `alloy_tui`: the ratatui design system `alloy_tui` **is** Alloy's design system. The pivot dropped the egui `alloy_ui` sibling, so there is no GUI counterpart, and this crate carries the whole authored visual identity. It consumes makeover `.toml` theme files at runtime (see [TOKENS.md](TOKENS.md)); palette and semantic colors render as terminal chrome. Contents (v1). Shipped: - Themed `ratatui` widget wrappers: `AlloyBlock`, `AlloyList`, `AlloyStatusBar`, `AlloyLog`. - `AlloyConnector`, which draws the link between two panes. Added for `alloy audio`'s stream-to-device pairing and not in the original roster. - `keys`: the reserved keymap (Tab, Shift-Tab, Enter, Esc, Ctrl-S, q, plus `?`, `/`, `:`) and the classifier apps match against. - `FocusRing` for moving between panes, and `Cursor` for a selection over a list whose length changes underneath it. Two types rather than one: a focus ring wraps, because that is what Tab means, while a list cursor clamps and re-clamps when a refresh returns fewer rows. - Footer chrome and the theme layer, ported from mountaineer-sysop's `sysop-tui` and retinted from a runtime theme rather than a const palette. Still to come, with `alloy config`: - `AlloyForm`, `AlloyTable`. - Form-field widgets driven by the schema types above (`ColorField`, `EnumField`, `RangedNumberField`, etc.). Not in `alloy_tui`, contrary to earlier drafts: mock-or-real backend detection. It lives per view in the console binary, because what counts as "the real backend" is `nmcli` for one view and `pactl` for another. The pattern carries over from `sysop-tui`; the code does not. The immediate-mode model's stability property (see [STACK.md](STACK.md)) is now shipped in one place: everything Alloy authors renders through `alloy_tui`. ## Relationship to the rest of the stack - **Replaces the graphical settings app** Alloy would otherwise eventually be pressured into shipping. - **Complements yazi.** Yazi is for files; the console is for state and config. Different data, similar TUI aesthetic. - **Fronts, does not replace, the underlying CLIs.** `nmcli`, `wpctl`, `rpm-ostree`, `swaymsg` all remain the ground truth. The console is a courteous surface. - **Design-system parity.** `alloy_tui` is the single design system; the console, bottom, and tuigreet already read as one terminal-native family: same palette, same information hierarchy. ## Roadmap The ordering below is the reverse of what this document originally planned, which put `alloy config` alone at v0.5 and every live-state subcommand at v1. The live-state views went first instead. They are small enough to carve one at a time, and each one forced a piece of shared machinery into existence against something real: the shell chrome and log pane from `net`, the second list and the `Cursor` from `audio`, the two-pane layout and `AlloyConnector` from `audio`'s routing, the background tick from watching streams appear. `alloy config` needs the form widgets and the schema parser at once, and it is a better shape to build on a shell that has already carried three screens. - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`. Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. - **Next.** `alloy config`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. - **Blocked on the target machine.** `alloy display` and `alloy update` front `swaymsg`/`wlr-randr` and `rpm-ostree`, none of which exist on a non-Fedora, non-sway development box. Writing them now would mean shipping parsers checked against nothing but their own fixtures, which is exactly how the two parser bugs found so far got written. They want the QEMU image or real hardware. - **Then.** `alloy sync`, `alloy theme`. `alloy theme` swaps the runtime theme in place (makeover consumer, no re-login). First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy mesh` and `alloy sync` enrollments. - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.) - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned. Positioned right after v0 stack packaging as the primary authored work. Roughly 3-6 months at the v1 scope, broad across subcommands. With the egui marquee apps dropped, the console is the wedge. ## Non-goals - **Not a shell replacement.** Users still live in rio + zellij/tmux + helix. The console is invoked for specific tasks, then closed. - **Not a systemd control panel.** `systemctl` is fine. If a common systemd operation shows up in daily use, add a subcommand; don't build a general systemd UI. - **Not a launcher.** Launching is the shell (terminal-driven); there is no graphical launcher. The console is invoked by name, not discovered via search. - **Not a package manager.** `alloy update` fronts `rpm-ostree` for atomic upgrades and rollbacks; installing individual packages is a rare enough action on Silverblue that keeping it out of the console is honest. ## Open questions - [x] **Schema format finalized: schema-format v1.** Worked example at [`schemas/rio.toml.schema`](../schemas/rio.toml.schema); DSL reference in the section above. The rio design pass surfaced five affordances the original sketch missed (sections, groups, presets, format hints, unknown-key policy). - [ ] KDL editing story: the `kdl` crate ecosystem in Rust is less mature than `toml_edit`. Verify roundtrip-safe editing is achievable before committing to the "one editor, both formats" pitch. If not, KDL configs get the text-edit fallback until it is. - [x] **Command-log pane: always on.** The pedagogical claim ("teaches its own primitives") only lands if the log is visible. Users who dislike it can add a hide toggle later; the shipped default should teach. - [x] **Naming: one entry point, `alloy config `.** No `alloy edit` alias. Fewer names to remember; the "config = editor" mental model holds for KDL files too, since they're structured, machine-editable formats, which is what `alloy config` addresses.