# 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, connect and radio [shipped] alloy audio # audio: pactl front, streams and devices [shipped] alloy bluetooth # bluetooth: bluetoothctl front, all five state fields [shipped] alloy mesh # mesh VPN: tailscale front (alias: tail) [shipped] alloy display # outputs: swaymsg front, scale and enable [shipped] alloy pkg install # what is layered, and where a package belongs alloy pkg box # containers: the isolation dial [shipped] alloy disk # storage: lsblk front, udisks mounts and partitions [shipped] alloy usb # attachments and Type-C connectors, straight from sysfs [shipped] alloy update # system updates: rpm-ostree front alloy sync # syncthing front (see CONTINUITY.md) [shipped] alloy setup # the first-boot offer: mesh and sync [shipped] alloy settings # system settings and app configs: two tabs over one form alloy config # one config file, opened directly, without the tab chrome alloy status --bar # the swaybar status line, in JSON, not a view [shipped] alloy theme apply # put the chosen day/night skeleton in place [shipped] alloy theme # swap the runtime theme; reads makeover's themes/*.toml # or ~/.config/alloy/themes/*.toml via makeover ``` `alloy status --bar` is the other verb that draws nothing, and it is the only one whose output is read by another program rather than by a person. sway's `bar { status_command }` runs it and reads swaybar's JSON protocol off its stdout: battery, network, volume, clock, one block each, colored from the theme's intent tokens because that protocol takes hex and nothing else. It reuses the `audio` and `net` backends rather than shelling out a second time, which is the whole argument for it being a console verb instead of a script in `usr/bin/` alongside `alloy-shot` and `alloy-menu`. The `--bar` flag is required: a bare `alloy status` should not fill a terminal with JSON because someone was guessing at verbs, and it leaves room for a human-readable one-shot later. Both it and `theme apply` are handled before the theme load in `main`, since neither needs a ratatui palette and the bar must survive a theme directory that will not load. `alloy theme apply` is the one view-less verb that also writes. It reads `~/.config/alloy/mode`, which the settings theme row writes, and copies the matching render of every themed config out of the image: `/etc/skel` for `day`, `/usr/share/alloy/skel-night` for `night`. `usr/bin/alloy-session` runs it before exec'ing sway, and greetd's `--cmd` points at that wrapper, so a theme chosen in the console reaches sway, mako, shop and the rest at the next login. A file whose contents match neither render is one the user edited, and it is kept and named rather than replaced; `--force` is the only way past that, and it leaves a `.alloy-bak`. `alloy mesh` is the generic name, 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` is an alias for it. 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. `alloy pkg install`, `alloy pkg box`, and `alloy update` are three verbs onto one view, each landing on its own tab: installed, boxes, system. They are one view because the first and third share an object rather than a data source. `rpm-ostree install` does not touch the running system, it stages a deployment, and that staged deployment is what the system tab shows. `alloy update` keeps its top-level name. Design in the wiki note `alloy-package-ux`. The system tab carries one thing the other two do not: `u` checks what is behind. Baking the curated stack into the image is affordable only if the machine says when it has drifted (wiki note `alloy-packaging-policy`), so the tab diffs the installed packages against the repos the image was built from and reports the result in two halves — a curated watchlist of high-exposure packages, named with versions, and a bare count for everything else. Merging them is the failure mode: "247 behind" every week is a screen people learn to dismiss. It also says what the machine's layers are and what they cost, because since the component flip every Alloy machine carries `alloy` and `shop` as layers for as long as it exists. That has one consequence a user otherwise meets as an error message: `bootc upgrade` refuses outright on a machine with local modifications, and `bootc switch` is the verb that works. The note sorts the layers into ours and the user's own, reading which are ours from `/usr/share/alloy/components` rather than assuming, and off an Alloy image it attributes nothing. It also warns about the second boot: a switch discards the layers and the first-boot unit lays the new image's copies down, so adopting a rebuild boots twice. See docs/STACK.md, "Hotfixes". Three properties of that check are constraints rather than choices. It is a keypress, never a timer or a poll, because an installed machine reaching out on its own would undo what `alloy@85ee0e6` established. It names each disabled build-time repo explicitly on the command line, because the Containerfile turns terra, tailscale and the COPRs off post-install and dnf would otherwise diff against a fraction of where the stack came from. And it never claims to be a security check: a version diff says a newer build exists, not that the installed one is vulnerable, and real advisory tracking is a much larger commitment than this. The remedy is printed, not run. Whether the console should drive the rebuild and the `bootc switch` itself belongs to the builder TUI, which is not designed yet. The boxes tab does not front a single CLI. A box's isolation level picks its backend (`host` to distrobox, `workspace` to podman directly, `sandboxed` to flatpak), so the user chooses isolation and Alloy chooses the implementation. That keeps the level as the stable interface and leaves the backend swappable. Each backend builds argv and runs nothing; the view executes through the command log, which makes "every action shows its invocation" structural rather than remembered. `alloy image` is the builder, and it is Alloy's whole distribution mechanism: nobody downloads an Alloy image, they build one (wiki `alloy-distribution`). It is a form rather than a wizard — six choices that are read together before either action is taken, where `alloy install`'s wizard shape is for a fixed sequence ending in a destructive act. It exposes only the choices Alloy deliberately declines to make: the profile (client or server), the browser, which language toolchains to carry, the artifact, and machine identity. Theme, terminal, editor and shell stay fixed and are not on it. Two properties of that screen are constraints rather than decisions. **It does not write the disk.** `build/build-image.sh --write` refuses partitions, refuses anything mounted, and verifies with `cmp` against a negative control; re-deriving that behind a progress bar is how a disk-eating bug gets written. So the console owns the choice of device and then hands the terminal to the script through the same suspend `distrobox enter` uses, where it asks its own confirmation and applies its own guards. **And the artifact holds no secrets:** the hostname and the ssh public key it bakes in are both public, which is what lets the medium be kept, copied or rebuilt without care. The profile also decides which verbs exist. A `server` image has no compositor and no bar, so `alloy display` and `alloy status --bar` are dropped from `--help` there and refuse to run, both off the same table so the two cannot drift. That reads a marker the build wrote rather than probing for a compositor: a probe answers "no" on a client machine sitting at the greeter or reached over SSH, which would make a verb appear and disappear on one machine depending on where it was typed. `alloy net` writes as well as reads: connect, disconnect, and the wifi radio. Those three need no privilege at all, which is a fact rather than a design: NetworkManager's shipped policy grants `network-control` and `enable-disable-wifi` to an active session outright. Joining a *new* network is `settings.modify.system`, which the shipped rule deliberately does not grant, so it needs an answer every time. The screen asks three questions in three modes — which device, which network in range, what is the passphrase — and Esc walks back through them. The passphrase is never an argument: `nmcli --ask` prompts for it and reads the prompt from stdin, so it does not appear in `ps` for every user on the machine the way `device wifi connect SSID password PW` would. That one action is what forced tier 3 of wiki `alloy-privilege`, because tier 2 cannot reach it. Tier 2 answers polkit by suspending the console and running the command under `pkttyagent`; a suspended child inherits the terminal's stdio, so there is no pipe left to carry the passphrase on, and a secret and a suspend cannot both be had. So the join runs beside the event loop with an authentication agent the console registers for its own process, and polkit's question arrives as a modal in Akari naming the action. The PAM conversation is still polkit's own setuid helper — Alloy supplies the cookie, draws the prompt, and hands back what was typed, and decides nothing about whether it was right. `alloy disk` is the one pane that rewrites a partition table, and the only place in the console where a keypress can destroy data that was not already being overwritten by something the user asked for by name. Four operations sit on the volume tab beside mount, unmount and eject: `n` adds a partition, `d` deletes one, `f` writes a filesystem, `z` resizes. Three constraints hold them: - **The disk the system boots from is refused, not confirmed.** The check is drive-wide rather than volume-wide, which is the part worth reading twice: the ESP is usually unmounted and a spare partition beside it is idle by every test the mount guard applies, so a volume-level check would offer to delete a partition whose removal stops the machine booting. One system mount anywhere on a disk takes that whole disk out of reach, and the keys render dimmed with the reason rather than erroring when pressed. - **Every partition write confirms, and the confirm carries the facts.** `disk`'s standing rule is that a confirmation appears only where a mistake costs something, so that agreeing does not become reflex. Every partition write costs something, so the rule is restated rather than reused: the prompt names the device, its size, its label and the filesystem about to be destroyed. One function builds that sentence, so four prompts cannot describe the same volume differently. - **Nothing escalates.** udisks2 answers a session user through polkit, which is the reason it was the right tool for mounting and is the reason it stays the right one here. The operations do not exist in `udisksctl` (its whole verb set is mount, unmount, unlock, lock, loop-setup, loop-delete, power-off, smart-simulate), so they go over D-Bus as `busctl call`, which keeps them argv and keeps them in the command log like everything else. The alternative was `sfdisk` under `run0`, which would turn the verb that exists so plugging in a stick works into one that asks for the root password. A confirmation is answered by a person, so the disks are re-read and the refusals re-run between the answer and the command. The volume can have been mounted from another terminal or unplugged entirely while the prompt was up, and the answer given was about the state on screen when it was raised. 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 usb`: the view before the policy Filed as the front end of USBGuard, then reordered: the view is the point and enforcement sits behind it. That ordering is not manners. A device-authorization prompt that fires on plug-in has nowhere to render in a sway session with no opinion about modal trust, and a silent default-deny is how a machine loses its keyboard. Making an attachment legible first means the policy, when it arrives, is turned on from a screen that already shows what it will act on. So the shipped half reads sysfs and nothing else. There is no usbguard dependency, and the screen works on an image that has never heard of it. It is also the one view that fronts no CLI: `lsusb` reads the same files and drops most of them, so the reads go straight to `/sys` the way `alloy display` does, and every parse is a pure function over a string. That stays true even though the image carries usbguard. The package is installed on both profiles and its daemon is deliberately not enabled, so the screen still has nothing to depend on and still reads the same files on a machine where the daemon never starts. The acting half fronts `usbguard list-devices`, `allow-device` and `block-device` the way `alloy bluetooth` fronts `bluetoothctl`, which keeps the log pane's promise intact: the plug-then-prompt hook is `usbguard watch --exec`, a CLI with an argv, rather than the D-Bus interface the stock applets use. See docs/STACK.md for why the daemon ships disarmed. **The rule half of that has landed, and it needs no daemon.** Measured on the image: `usbguard allow-device` takes `( | | )`, so a rule composed from sysfs is an accepted argument and not only the device id the daemon hands out, and `usbguard generate-policy` runs with no daemon at all and emits rules keyed on the same fields this screen already parses. So the detail pane shows the rule for the selected device wherever usbguard is installed, keyed on vendor and product id plus serial and never on the port, since `DeviceRulesWithPort=false` is the shipped default and a device that moves sockets has to keep matching. A device reporting no usable serial gets the model-wide rule under a label that says so, because a permanent rule keyed on sixteen `0xFF` bytes matches every device that ships the same filler. What still wants a machine is applying a rule, and what it wants there is a writable `/sys` rather than a daemon that has run before: with `/sys` read-only the daemon starts and then rejects every device with `SysFSDevice: authorized: Permission denied`. The `hash` and `parent-hash` a generated rule carries are over the USB descriptor blob and are not sysfs, so the composed form omits them, which is a rule usbguard takes because a rule matches on the attributes it states. **Two tabs, because a charger and a keyboard are answered by different subsystems.** The bus knows what enumerated and what each interface claims to be; the Type-C connectors know which way power is flowing, whether the partner speaks Power Delivery, and whether any alternate mode came up. Neither list answers the other's question. Measured on fw13: port 3 reads `drawing power` while the other three read `supplying power` or `nothing attached`, which is the charger being visibly a different row without any policy existing. **The two lists are deliberately not joined.** The kernel can expose a `connector` symlink from a USB port to the Type-C port under it, which would let a row say which connector a device is on. No such link exists anywhere under `/sys/bus/usb/devices` on fw13 (measured on 6.17.9), so the join is unavailable on the hardware Alloy runs on. Inferring it from port numbering would be a guess that reads as a fact, so the lists stay separate. Three things the screen surfaces that a person would otherwise need a spec to see. Device class `0x11` is **billboard**, which exists so a Type-C thing whose alternate mode failed can enumerate and say so. fw13 has one, a Digital AV adapter sitting at 12 Mb/s. A **serial of filler bytes is refused** rather than shown, because a permanent allow rule keyed on sixteen `0xFF` bytes matches every device that ships the same filler. And alternate modes are read from the **partner**, not the port: the port advertises what it supports and marks it all active, so reading that side reports DisplayPort on empty connectors. ## `alloy settings`: two tabs over one form engine The thesis at the top of this document, built. One view, two tabs, and the same form under both: - **System**, first, because it is the one a user goes looking for. General settings held as live state: time (zone, NTP), hostname, locale and keymap, theme, and whether gopass has an age identity. Time, hostname and locale are built, fronting `timedatectl`, `hostnamectl` and `localectl`; theme is the one left. Each writing row runs exactly one command, and every one of those is in the five actions the shipped polkit rule grants, so none of them prompts and none can reach an action deliberately left prompting. The grant is for an active *local* session in `wheel`, which an ssh login is not, so those same rows do prompt over ssh — correctly, since "the person sitting at the machine" is the whole of the argument for granting them. The console answers that with polkit's own text agent rather than by widening the grant: a setter that comes back saying interactive authentication is required is run again with `pkttyagent` alive beside it, which means the screen tears down, polkit asks in the terminal's own colors, and the console rebuilds. It is the honest fallback and looks like one. A row whose front did not answer, or whose vocabulary came back empty, is shown and not settable and says why: hiding it would answer "where do I set this" with silence, and offering it would promise a command that is not there. A vocabulary of one closes the row on the same grounds, since the single entry is whatever is already in force and picking it changes nothing. The locale row is the case that reaches users: the image carries no glibc langpacks, so `localectl` has one locale to list, and the row names it and says that adding a langpack adds a choice. The gate is the count rather than a langpack probe, so the row reopens by itself once one is layered in. The secrets row applies the same idiom to something absent rather than to a front that stayed quiet: Alloy ships gopass and provisions no age identity for it, so the row names the directory gopass will look in and says the store will not decrypt until a key is placed there. It is shown and closed in both states, because writing an identity from a settings form is exactly what the provisioning decision ruled out, and it reports whether a file is there without ever reading it. Its rows commit as they are edited rather than at Ctrl-S, and the form reads that from the bind rather than from which tab it is on. Each is a handful of rows over one front with machine-readable output, which is the bar `net` (`nmcli`) and `audio` (`pactl -f json`) were already held to. - **Applications**, the adopted stack's config files, grouped behind the app each one configures rather than presented as file paths. The user picks **rio**, not `~/.config/rio/config.toml`; left pane lists the apps a schema ships for, right pane is the form for the selected one, reusing `audio`'s two-pane routing layout. An app with no *form* (sway, whose i3-style syntax is neither TOML nor KDL) still appears in the list and opens the text-edit fallback, because someone looking for "where do I configure sway" should find an answer rather than an absence. It reaches the list through a header-only schema declaring its syntax, so there is one catalog and one search path rather than a second registry for the apps the first one cannot describe. Display and power/idle are deliberately not here. `alloy display` is a verb of its own and `alloy power` is another; idle behaviour belongs with power rather than with display. Folding any of them into settings would duplicate a screen rather than unify one. `alloy config ` stays as the direct-open escape hatch: one file's form, no tab chrome, which is what a script or a `helix` sidecar wants. Same view, entered with a path instead of a tab, and same code under the chrome — a second render path for one file is how the two would start to disagree. The schema is found by the path it declares as its target rather than by the file's name, since two tools can both keep a `config.toml`, and a file no schema targets is refused by name instead of opening as an empty form. ### The config half 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. ### View architecture Settled with the crate/binary split the rest of the console already follows (see [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md)): `alloy_tui` renders dumb themed field widgets and form chrome, and the binary owns the schema parser, the `toml_edit` document, validation, and save. The pieces: - **The two tabs meet at one seam, `Bind`.** Everything above it is shared: the rows, the chrome, the modal edit, the validation. Below it, a tab differs in three things only. Where a value is read from (a `DocumentMut`, or a `timedatectl show` style key=value front). What committing one does. And where the field list comes from: parsed from a schema file on the Applications side, a hand-written Rust table on the System side, because there are a dozen System rows fronting different CLIs and ten schemas and growing. Neither side knows the other exists. - **Committing returns effects rather than performing them**, which is the pattern every backend in the console already follows and is what keeps both binds testable on a machine with none of the tools installed. It also settles what dirty means per tab without either tab being told which kind it is: a file bind holds its edits in the document and emits one `Effect::Write` at save, and a command front emits its setter at commit and has nothing to save. The form asks the bind whether anything is pending and shows the save affordance accordingly. So Ctrl-S is a file thing, because a half-edited TOML is not valid TOML, and live state has no such constraint: `timedatectl set-timezone` is atomic on its own. - **Values live in a `toml_edit::DocumentMut`**, loaded from the target file. That document is the single source of truth and the thing edits are written into in place, which is what makes the roundtrip-safe promise structural rather than remembered. Fields display their schema `default` when the file omits the key, but the key is not materialized on disk until the user edits it, so a minimal config stays minimal. - **Editing is modal per field.** `mode: Navigate | Editing { row, buffer, original }`. In Navigate the reserved keymap holds (Tab / j-k move, Enter activates, Space folds a section or flips a bool, Ctrl-S saves). Enter on a string, number, or color field opens Editing, where the field owns every key. This is the case the classifier already documents: a view holding an active text input must not treat `q`, `/`, or `:` as reserved. Esc discards the buffer, Enter validates and commits; an invalid value stays in edit with its diagnostic shown rather than being written. Enums do not free-type: Enter opens a pick overlay (the `AlloyModal` + `AlloyList` idiom) listing each value's label and description and committing the raw value. Bools toggle in place. - **The pick overlay filters as you type.** It was first specced against enums the size of rio's `cursor.shape`, three values. The System tab's zone row is an enum over `timedatectl list-timezones`, which is about 600 entries, and locales are worse. So the overlay owns a `TextField` and matches on a plain substring. No fuzzy match in v1: zone names are already terse and hierarchical, and a ranker over 600 strings is a scoring function to tune for no gain a substring does not give. Typing `den` and pressing enter is also fully offline, needs no database and no license, and cannot be confused by a VPN, which is what retired the idea of geolocating the installer's timezone. - **Sections are collapsible.** One pane per schema `[[section]]`, folded with Space on its header, so the 29-slot colors group defaults to a single collapsed row rather than dominating the form. The binary flattens the visible rows each frame (section headers plus the fields of open sections); the `Cursor` rides that. - **Save and quit.** Ctrl-S serializes the document back to the target path, logged as `write ` (the same `Effect::Write` shape the `alloy pkg` export wrapper established). Quitting with unsaved edits confirms through `AlloyModal`, reusing the Cancel-that-is-not-Quit machinery `alloy pkg` forced into the shell. - **List-of-tables opens the file.** `type = "list"` records (rio's `bindings.keys`) show as a count on their form row, and Enter on that row swaps the pane from the form to the text-edit fallback over the same file. Esc comes back. Reading and editing arrive together and neither needs `AlloyTable`, which still renders nothing and would need a cursor, an offset and a selection before it could. The route is on the field's *declared* kind, not on the value, so an empty list opens too — which is when a user most wants the file, to add the first record. The dirty-state answer is that the file is the handoff in both directions: the form writes its pending edits before the text pane opens, the form is rebuilt from the file on the way back, and Esc refuses to leave a dirty buffer rather than discarding it. - **Fallback.** A file the form engine cannot render opens in the syntax-highlighted text-edit pane instead, and a diagnostic heads the pane when there is one to show. Two routes into it are built: a schema declaring a syntax with no bind behind it (`sway`, `kdl`, `text`), and a target the `toml_edit` bind refused to parse. The second matters because a config that fails to parse is exactly the one a user needs to open and fix. - **The syntax is declared, not inferred.** `syntax = ` in the header names the file's language, defaults to `toml`, and answers both questions at once: what to highlight, and whether a form is possible at all. Declaring the language rather than declaring "no form" is what lets a file change tiers without being rewritten — the day the roundtrip-safe-KDL question below resolves, every `syntax = "kdl"` schema starts rendering a form on its own. A schema whose syntax has no form engine must be a header alone, and must name a `target_path`: fields nobody will draw are an authoring error worth reporting, and a schema that opens no file has no reason to be in the catalog. - **The pane is not an editor.** No undo, no selection, no search, no clipboard. The image ships `helix`, and a fallback that grew those would be a worse copy of it living inside a settings screen. Its modality matches the form's, for the reason `classify` documents: in Navigate the reserved keymap holds, so `q` and Tab still work; Enter takes the buffer; Esc leaves it *keeping* what was typed, because discarding a file's worth of edits is not the same act as discarding one retyped value. Highlighting is line by line with no state carried between lines, so a TOML multi-line string has its body colored as code — a deliberate trade against re-scanning from the top of the file to draw any line, and sway, the syntax this was built for, has no multi-line construct at all. ## 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 config.** Alloy ships `schemas/shop.toml.schema`, and `schemas/yazi.toml.schema`, `schemas/mako.schema` and the rest follow. **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 [`crates/alloy/testdata/rio.toml.schema`](../crates/alloy/testdata/rio.toml.schema). The design pass against rio's real config surface shook out five affordances beyond a naive field list: sections, groups, presets, format hints, and an unknown-key policy. Presets did not survive the build; see below. That worked example is a test fixture now rather than a shipped schema, since shop replaced rio in the image (see [STACK.md](STACK.md#terminal)). It stays the format's reference because nothing shipped exercises the DSL as widely: `schemas/shop.toml.schema`, the only catalog entry today, is three plain fields, which is what a terminal needs when it resolves its palette from a theme id instead of from ninety transcribed keys. **Header.** Every schema declares its target file, where that file lives, the tool it targets, a target-version semver range, the schema-DSL version, and an unknown-key policy. `target_path` carries `~` and `$XDG_CONFIG_HOME` unexpanded, so parsing a schema does not depend on the environment it is parsed in; whoever opens the file resolves them. It is optional, because `alloy config ` names the file itself, but a schema without one describes a shape and not a location and so cannot be listed as an app. 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_path = "$XDG_CONFIG_HOME/rio/config.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 29 palette slots, all colors, all hex. A `[[group]]` with `path = "colors"`, `type = "color"`, `format = "hex"`, and 29 `entries` expands to 29 fields at `colors.` without 29 near-identical `[[field]]` blocks. Groups are DSL sugar over fields, not a runtime concept: the editor materializes them into the same form widgets. **There are no presets in the DSL.** A schema declaring `[[preset]]` is an unknown key and takes the fallback. Presets are not on the `Bind` trait, so exposing one needs a mode of its own rather than a keybinding, and no schema the image ships wants bundles. **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 are the sugar that keeps 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. 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. - `AlloyTabs` and `AlloyModal`, forced in by `alloy pkg`'s three-tab view and its first destructive-action confirm. - `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. Shipped with `alloy settings`, as `alloy_tui` 1.2: - `AlloyForm`, the form chrome, over rows that are one line each without exception. That is what lets it scroll through the same stateless `list_offset` an `AlloyList` does, and it is why the focused row's help and diagnostic render on a reserved line at the foot of the form rather than under the row. Sections are header rows and an indent, not nested boxes: a box inside a pane spends two columns a side per level to say what the fold marker already says. - A single `AlloyField` widget carrying a `FieldKind` value-cell enum (Toggle / Text / Number / Enum / Color), rather than five separate field widgets. They would differ only in how the value cell paints once state and validation live in the binary, and `AlloyForm`'s heterogeneous row list forces an enum regardless; the rationale is worked in full in [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md). It carries an `unset` flag, which is what lets a row show the difference between a key set to 12 and a key absent from a file that defaults to 12. - `AlloyPicker`, the filterable overlay an enum opens, and a display-only `AlloyTable`. Nothing renders through the table: list-of-tables records open in the text pane instead. If a read-only table view is ever wanted it is a separate decision, against the cost of giving `AlloyTable` a cursor and an offset it does not have. - `TextField` promoted from the console binary's `field.rs` as the caret buffer both the field and the picker's filter use. Not in `alloy_tui`: 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 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 live-state views come first. 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 settings` 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`, `alloy setup`, and all three `alloy pkg` tabs (`box`, plus `install` and `update` fronting `rpm-ostree status --json`). Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. `alloy pkg` forced three more pieces into it: `AlloyTabs`, a Cancel that views see before the shell claims it (a confirm needs a cancel that is not "exit the app"), and terminal suspend, so entering a box can hand the TTY to another interactive program. - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback, and `schemas/sway.schema` is the header that declares it). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, **shipped**, which is also what puts a schema-less app in the list at all: the catalog is built from `.schema` files, so sway reaches it through a header-only schema declaring `syntax = "sway"` rather than through a second catalog; (10) `AlloyTable` read-only, on the path to nothing: list-of-tables routes to the text pane. - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. Neither `wlr-randr` nor kanshi is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode). - **Shipped, and the only screen that is not a question.** `alloy install`'s credits page. It names each project Alloy ships, its SPDX identifier and its URL, off `crates/alloy/credits.toml` embedded in the binary. The install starts from this screen rather than from the summary: the last screen before a disk is repartitioned should be the one that says whose work is about to be written to it. Curated rather than generated, so it drifts by design and keeping it current is a release step (see [IMAGE.md](IMAGE.md)). Not a source offer and not a license-text viewer: the full texts stay in `/usr/share/licenses` on the installed system. - **Shipped as a view, with the applying half of enforcement still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. usbguard itself is in both profiles and its daemon is not enabled, which is the deliberate state rather than an oversight: the package's stock policy is an empty rule file plus `ImplicitPolicyTarget=block`, so arming it before the policy exists deauthorizes the keyboard at boot. The keyboard gate is the part that makes deny-unknown safe: `usr/bin/alloy-usb-gate` drops enforcement whenever the machine has zero usable keyboards, continuously rather than at boot, on the client profile only. It counts `ID_INPUT_KEYBOARD` and not `ID_INPUT_KEY`, which is what keeps a power button from reading as a keyboard, and it reads the input subsystem rather than the USB bus, which is what makes the Framework's i8042 keyboard count without a special case. The rule half of enforcement has landed: `alloy usb` composes the usbguard rule for the selected device, keyed on vendor and product id plus serial and never on the port, wherever usbguard is installed. What is left is applying it and the policy itself: deny-unknown, with an activation the user performs from this screen. - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). The first-boot flow (see [CONTINUITY.md](CONTINUITY.md)) shipped as `alloy setup`: two rows over the same `mesh` and `sync` backend seams, so there is no third enrollment path to keep in agreement with them. It survives the teardown mesh enrollment costs by refreshing on the first tick after a suspend rather than on its poll counter. The shell's post-suspend refresh arrives as one `tick` call, and a counter would swallow four out of five of them, leaving the row the user just enrolled reading "not enrolled" for several seconds. The screen appears once: the session autostart runs `alloy setup --if-first-boot`, and the console records that it asked in the same config store the theme lives in. - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. - **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. The console is the wedge. ## Non-goals - **Not a shell replacement.** Users still live in rio + nu + 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 store.** The console never browses or searches a catalog. `alloy pkg` shows what is installed, where it lives, and what each surface costs; it does not help you find software you have not named. Browsing is what this line protects against; explaining where a package belongs is MANIFESTO principle 6, and the two are separable. ## Open questions - [x] **Schema format finalized: schema-format v1.** Worked example at [`crates/alloy/testdata/rio.toml.schema`](../crates/alloy/testdata/rio.toml.schema); DSL reference in the section above. - [ ] 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: `alloy settings` is the entry point, `alloy config ` opens one file.** There is no `alloy edit` alias: the "config = editor" mental model works for KDL too, since these are structured machine-editable formats. A path is the wrong thing to have to know first. Someone looking for the time or the hostname has no path in mind, and someone looking for rio's config wants rio, not `~/.config/rio/config.toml`. So the verb people use is `alloy settings`, and `alloy config ` is the direct-open form for scripts and sidecars. Same treatment `alloy update` gets against `alloy pkg`. - [x] **Dirty and apply differ per tab, and neither tab knows it.** A file needs Ctrl-S because a half-edited TOML is not valid TOML. Live state has no such constraint, and `timedatectl set-timezone` is atomic on its own, so a System row commits on edit. The two behaving differently is a real cost, taken deliberately: it falls out of the `Bind` seam returning effects instead of performing them, so the form reads whether anything is pending from the bind rather than from which tab it is on.