Skip to main content

max / alloy

config: open one file's form directly, as CONSOLE.md has always said `alloy config <path>` was documented in three places and in the manual, and was not a verb. It is now the same view as `alloy settings` entered with a file instead of a tab: Surface::File draws the form and neither the tab bar nor the app list, since both would be one-item lists over a choice the command line already made. Everything under the chrome is the code the tabs use — a second render path for one file is how the two would start to disagree. The schema is resolved by the path it declares as its target, not by the file's name: two tools can both keep a config.toml. A file no schema targets is refused by name, saying where it looked, rather than opening as an empty form or quietly becoming an editor. Also corrects the manual, which promised two things that are not built: the text-edit fallback for schema-less apps like sway, and settings rows that never prompt for a password (they do over ssh, which is the point of the tier-2 work in b8940e3).
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 22:20 UTC
Signed with PGP, not checked
Commit: e02475d65b338a93172d4fb12e26ac4084aa266b
Parent: b8940e3
5 files changed, +281 insertions, -25 deletions
M README.md +1 -1
@@ -38,7 +38,7 @@
38 38 - [`docs/COMPONENT-LIBRARY.md`](docs/COMPONENT-LIBRARY.md) — `alloy_tui` crate idioms and how themes reach the widgets.
39 39 - [`docs/RESEARCH-IMMEDIATE-MODE-DESIGN.md`](docs/RESEARCH-IMMEDIATE-MODE-DESIGN.md) — late-term research sketch: what does Figma look like for immediate-mode UI? How do designers and engineers collaborate without designers writing Rust?
40 40 - [`docs/DESIGN-LANGUAGE.md`](docs/DESIGN-LANGUAGE.md) — tinted-greyscale chrome, color reserved for information.
41 - - [`docs/CONSOLE.md`](docs/CONSOLE.md) — Alloy Console: the ratatui unified control surface (`alloy net`, `alloy audio`, `alloy mesh` shipped; `alloy config <path>` schema-driven editor next). Replaces the "graphical settings app" question.
41 + - [`docs/CONSOLE.md`](docs/CONSOLE.md) — Alloy Console: the ratatui unified control surface (`alloy net`, `alloy audio`, `alloy mesh`, `alloy settings` and the `alloy config <path>` schema-driven editor all shipped). Replaces the "graphical settings app" question.
42 42 - [`docs/CONTINUITY.md`](docs/CONTINUITY.md) — a mesh VPN and file sync as base-image citizens: an Alloy install should be rejoinable, not just installable.
43 43 - [`docs/SHELL.md`](docs/SHELL.md) — Nushell as the login shell; bash unchanged as `/bin/sh` and `/bin/bash`. Architecture, interop rules, accepted costs.
44 44 - [`docs/HARDWARE-FW12.md`](docs/HARDWARE-FW12.md) — Framework Laptop 12 as the first hardware target: 2-in-1 stance, touch/tablet/rotation/OSK/fingerprint decisions.
M docs/CONSOLE.md +1 -1
@@ -161,7 +161,7 @@
161 161
162 162 Display and power/idle are deliberately not here. `alloy display` is a verb of its own, now shipped, and idle behaviour belongs with it; folding them in would duplicate a screen rather than unify one.
163 163
164 - `alloy config <path>` 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.
164 + `alloy config <path>` 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.
165 165
166 166 ### The config half
167 167
@@ -38,6 +38,10 @@
38 38 atomic on its own. Each writing row runs exactly one command, and each of those
39 39 is narrowly granted so it does not prompt for a password.
40 40
41 + That grant is for the session sitting at the machine. Over ssh the same rows do
42 + prompt, and the console tears its screen down so polkit can ask in the terminal,
43 + then comes back where you were.
44 +
41 45 The clock is shown and never settable. Moving a system clock invalidates
42 46 certificates and reorders logs; the row is there so you can see what changing
43 47 the timezone did.
@@ -49,9 +53,14 @@
49 53
50 54 File-backed forms save with `Ctrl+S`. Live-state rows have nothing to save.
51 55
52 - An app with no schema still appears in the list and opens a plain text editor
53 - with syntax highlighting, so looking for "where do I configure sway" finds an
54 - answer rather than a blank.
56 + The list holds the apps a schema ships for, which today is `shop`. Configs with
57 + no schema are not in it yet, sway's among them: its i3-style syntax is neither
58 + TOML nor KDL, so it takes a text-edit pane the console has not built. Edit
59 + `~/.config/sway/config` in your editor for now.
60 +
61 + `alloy config <path>` opens one file's form directly, with no tabs and no app
62 + list. The path has to be one a schema names as its target; anything else is
63 + refused rather than opened as an empty form.
55 64
56 65 ## A row that says it cannot do something
57 66
@@ -114,6 +114,15 @@
114 114 Image,
115 115 /// System settings and application configs
116 116 Settings,
117 + // The direct-open form of the tab above, kept rather than deprecated when
118 + // `alloy settings` became the entry point: a path is the wrong thing to
119 + // have to know first, and exactly the right thing for a script or a helix
120 + // sidecar that already holds one. docs/CONSOLE.md settles both names.
121 + /// One config file, opened directly, without the tab chrome
122 + Config {
123 + /// The file to edit. A schema must declare it as its target.
124 + path: std::path::PathBuf,
125 + },
117 126 /// Synchronized folders and the devices they reach
118 127 Sync {
119 128 #[command(subcommand)]
@@ -299,6 +308,10 @@
299 308 let mut view = settings::SettingsView::new(settings::Tab::System, &mut log);
300 309 shell::run(&theme, &mut view, &mut log)
301 310 }
311 + Command::Config { path } => {
312 + let mut view = settings::SettingsView::config(&path)?;
313 + shell::run(&theme, &mut view, &mut log)
314 + }
302 315 Command::Sync { surface } => {
303 316 let tab = match surface {
304 317 Some(SyncSurface::Devices) => sync::Tab::Devices,
@@ -17,6 +17,19 @@
17 17 //! an Alloy install, and that is the honest thing to tell someone looking at
18 18 //! one.
19 19 //!
20 + //! ## Two verbs, one view
21 + //!
22 + //! `alloy config <path>` is the same view entered with a file instead of a tab:
23 + //! [`Surface::File`], which draws the form and neither the tab bar nor the app
24 + //! list, since both would be one-item lists over a choice the command line
25 + //! already made. Everything below the chrome — the rows, the pick overlay, the
26 + //! editing, the save — is the code the tabs use, because a second render path
27 + //! for one file is how the two would start to disagree.
28 + //!
29 + //! It resolves the schema by the path the schema declares, not by the file's
30 + //! name: two tools can both keep a `config.toml`. A file no schema targets is
31 + //! refused by name rather than opened as an empty form.
32 + //!
20 33 //! ## What a schema catalog is
21 34 //!
22 35 //! One `.schema` file per adopted config, found on a search path the same shape
@@ -37,6 +50,7 @@
37 50 FieldKind, FocusRing, FormRow, Hint, PickRow, Severity, TextField, Theme, hint, layout,
38 51 list_row_y, text,
39 52 };
53 + use anyhow::Context as _;
40 54 use ratatui::Frame;
41 55 use ratatui::crossterm::event::{KeyCode, KeyEvent};
42 56 use ratatui::layout::{Constraint, Layout, Rect};
@@ -144,6 +158,13 @@
144 158 /// The `.schema` file this came from, named in the diagnostic when it is
145 159 /// the thing that is broken.
146 160 source: PathBuf,
161 + /// The file this app's form edits, resolved.
162 + ///
163 + /// `None` when the schema names no `target_path`, or names one that does
164 + /// not resolve on this machine — the same two cases that leave `state` an
165 + /// error, kept as a field of its own because `alloy config <path>` matches
166 + /// on it and cannot reach into a `Form` that was never built.
167 + target: Option<PathBuf>,
147 168 state: Result<Form, String>,
148 169 }
149 170
@@ -253,25 +274,26 @@
253 274 return Self {
254 275 name: fallback,
255 276 source: source.to_path_buf(),
277 + target: None,
256 278 state: Err(format!("{error:#}")),
257 279 };
258 280 }
259 281 };
260 282
261 283 let name = schema.header.target_tool.clone();
262 - let state = match schema.header.target_path.as_deref() {
263 - None => Err("the schema does not say where the file lives".to_string()),
264 - Some(target) => match expand(target) {
265 - None => Err(format!("cannot resolve `{target}`")),
266 - Some(path) => FileBind::open(schema, &path).map_or_else(
267 - |error| Err(format!("{error:#}")),
268 - |bind| Ok(Form::new(Box::new(bind))),
269 - ),
270 - },
284 + let target = schema.header.target_path.as_deref().and_then(expand);
285 + let state = match (schema.header.target_path.as_deref(), &target) {
286 + (None, _) => Err("the schema does not say where the file lives".to_string()),
287 + (Some(declared), None) => Err(format!("cannot resolve `{declared}`")),
288 + (Some(_), Some(path)) => FileBind::open(schema, path).map_or_else(
289 + |error| Err(format!("{error:#}")),
290 + |bind| Ok(Form::new(Box::new(bind))),
291 + ),
271 292 };
272 293 Self {
273 294 name,
274 295 source: source.to_path_buf(),
296 + target,
275 297 state,
276 298 }
277 299 }
@@ -869,7 +891,24 @@
869 891 const SYSTEM_POLL_TICKS: u64 = 5;
870 892
871 893 /// The `alloy settings` screen.
894 + /// What the view is a surface onto.
895 + ///
896 + /// One view and two entry points, which is the whole of the difference between
897 + /// the verbs: `alloy settings` is the one someone goes looking for and
898 + /// `alloy config <path>` is the one a script or a `helix` sidecar names. The
899 + /// form, the keys, the editing and the saving are the same code under both, so
900 + /// what this selects is chrome and nothing else.
901 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
902 + pub(crate) enum Surface {
903 + /// Two tabs: the System front, and the catalog of apps a schema ships for.
904 + Console,
905 + /// One file, named by the caller. No tab bar and no app list, because both
906 + /// would be one-item lists over a choice the command line already made.
907 + File,
908 + }
909 +
872 910 pub(crate) struct SettingsView {
911 + surface: Surface,
873 912 tabs: FocusRing,
874 913 panes: FocusRing,
875 914 apps: Vec<App>,
@@ -894,6 +933,7 @@
894 933 tabs.focus(tab.slot());
895 934
896 935 Self {
936 + surface: Surface::Console,
897 937 tabs,
898 938 panes: FocusRing::new(2),
899 939 apps,
@@ -904,6 +944,72 @@
904 944 }
905 945 }
906 946
947 + /// `alloy config <path>`: one file's form, opened directly.
948 + ///
949 + /// The schema is found by where it says its target lives, not by the file's
950 + /// name: two tools can both keep a `config.toml`, and the path is the thing
951 + /// the caller actually named. A schema with no `target_path` describes a
952 + /// shape and not a location, so it cannot answer this question and is not
953 + /// consulted — the same reason it cannot be listed as an app.
954 + ///
955 + /// Failing rather than falling back to an editor. Handing the file to
956 + /// `$EDITOR` would be a second, silent answer to `alloy config` that looks
957 + /// like the first until it is not a form, and the text-edit fallback the
958 + /// Applications tab wants is its own decision to make. So this says which
959 + /// file it could not find a schema for and where it looked.
960 + pub(crate) fn config(path: &Path) -> anyhow::Result<Self> {
961 + let wanted =
962 + std::path::absolute(path).with_context(|| format!("resolving {}", path.display()))?;
963 +
964 + let app = App::catalog()
965 + .into_iter()
966 + .find(|app| app.target.as_deref() == Some(wanted.as_path()))
967 + .with_context(|| {
968 + format!(
969 + "no schema targets {} (searched {})",
970 + contract_home(&wanted),
971 + search_path()
972 + .iter()
973 + .map(|dir| contract_home(dir))
974 + .collect::<Vec<_>>()
975 + .join(", ")
976 + )
977 + })?;
978 +
979 + Ok(Self::file(app))
980 + }
981 +
982 + /// The one-file surface, over an app already resolved.
983 + ///
984 + /// Split from [`config`](Self::config) so the chrome can be tested without
985 + /// a schema on the machine's search path and a file at the place it names.
986 + fn file(app: App) -> Self {
987 + let mut cursor = Cursor::new();
988 + cursor.resize(1);
989 + Self {
990 + surface: Surface::File,
991 + // Focused on Applications so every accessor that asks which tab it
992 + // is on finds the one holding a file. The bar is not drawn and the
993 + // movers are declined, so the focus is a fact about the state
994 + // rather than something the user can move.
995 + tabs: {
996 + let mut tabs = FocusRing::new(Tab::ALL.len());
997 + tabs.focus(Tab::Applications.slot());
998 + tabs
999 + },
1000 + panes: {
1001 + let mut panes = FocusRing::new(2);
1002 + panes.focus(PANE_FORM);
1003 + panes
1004 + },
1005 + apps: vec![app],
1006 + cursor,
1007 + system: None,
1008 + ticks: 0,
1009 + error: None,
1010 + }
1011 + }
1012 +
907 1013 /// The form the keys act on: the System tab's, or the selected app's.
908 1014 ///
909 1015 /// One accessor rather than two paths through `handle`, which is what makes
@@ -937,9 +1043,11 @@
937 1043 ///
938 1044 /// Always, on the System tab: there is no list beside it to move through.
939 1045 fn on_form(&self) -> bool {
940 - match self.tab() {
941 - Tab::System => true,
942 - Tab::Applications => self.panes.is_focused(PANE_FORM),
1046 + match (self.surface, self.tab()) {
1047 + // One file and no list beside it, so the keys have nowhere else to
1048 + // go — the same reason the System tab is always on its form.
1049 + (Surface::File, _) | (_, Tab::System) => true,
1050 + (Surface::Console, Tab::Applications) => self.panes.is_focused(PANE_FORM),
943 1051 }
944 1052 }
945 1053
@@ -1208,7 +1316,17 @@
1208 1316
1209 1317 impl View for SettingsView {
1210 1318 fn title(&self) -> String {
1211 - "settings".to_string()
1319 + match self.surface {
1320 + Surface::Console => "settings".to_string(),
1321 + // The file, because it is what the caller named and what the view
1322 + // is: there is no tab bar under this title to say which of two
1323 + // things is on screen.
1324 + Surface::File => self
1325 + .apps
1326 + .first()
1327 + .and_then(|app| app.target.as_deref())
1328 + .map_or_else(|| "config".to_string(), contract_home),
1329 + }
1212 1330 }
1213 1331
1214 1332 fn hints(&self) -> Vec<Hint> {
@@ -1223,9 +1341,15 @@
1223 1341 return hints;
1224 1342 }
1225 1343
1226 - let mut hints = vec![hint("h/l", "tab")];
1227 - if self.tab() == Tab::Applications {
1228 - hints.push(hint("tab", "pane"));
1344 + // A one-file surface has neither a second tab to move to nor a second
1345 + // pane to focus, and a footer that offered both would be advertising
1346 + // keys that do nothing.
1347 + let mut hints = Vec::new();
1348 + if self.surface == Surface::Console {
1349 + hints.push(hint("h/l", "tab"));
1350 + if self.tab() == Tab::Applications {
1351 + hints.push(hint("tab", "pane"));
1352 + }
1229 1353 }
1230 1354 hints.push(hint("j/k", "select"));
1231 1355 if self.on_form() {
@@ -1240,6 +1364,21 @@
1240 1364 hints
1241 1365 }
1242 1366
1367 + /// A one-file surface has no second tab and no second pane, so the overlay
1368 + /// shows those movers dimmed rather than promising a move that cannot
1369 + /// happen.
1370 + fn unanswered(&self) -> &'static [Action] {
1371 + match self.surface {
1372 + Surface::Console => &[],
1373 + Surface::File => &[
1374 + Action::NextTab,
1375 + Action::PrevTab,
1376 + Action::NextFocus,
1377 + Action::PrevFocus,
1378 + ],
1379 + }
1380 + }
1381 +
1243 1382 fn status(&self) -> Option<(Severity, String)> {
1244 1383 if let Some(error) = &self.error {
1245 1384 return Some((Severity::Error, error.clone()));
@@ -1269,6 +1408,26 @@
1269 1408 let inner = block.inner(area);
1270 1409 frame.render_widget(block, area);
1271 1410
1411 + // A one-file surface draws the form against the block it is already
1412 + // in: a tab bar of one tab and a list of one app are chrome that says
1413 + // nothing, and the title already names the file.
1414 + if self.surface == Surface::File {
1415 + match self.apps.first().map(|app| &app.state) {
1416 + Some(Ok(form)) => Self::render_form(frame, inner, theme, form),
1417 + Some(Err(reason)) => frame.render_widget(
1418 + Paragraph::new(Line::from(Span::styled(
1419 + reason.clone(),
1420 + Severity::Error.style(theme),
1421 + )))
1422 + .wrap(ratatui::widgets::Wrap { trim: true }),
1423 + inner,
1424 + ),
1425 + None => {}
1426 + }
1427 + self.render_pick(frame, area, theme);
1428 + return;
1429 + }
1430 +
1272 1431 // One blank row under the bar, matching `alloy pkg`: the tabs read as
1273 1432 // chrome over the body rather than as its first line.
1274 1433 let [bar, _gap, body] = Layout::vertical([
@@ -1407,20 +1566,25 @@
1407 1566 return self.authorize_flow(log);
1408 1567 }
1409 1568
1569 + let chrome = self.surface == Surface::Console;
1410 1570 match classify(key) {
1411 - Action::NextTab => {
1571 + // Inert on a one-file surface, and declined in the overlay to
1572 + // match: moving to the System tab there would land on a tab this
1573 + // view was not built with, and moving panes would leave the form
1574 + // for an app list of one.
1575 + Action::NextTab if chrome => {
1412 1576 self.tabs.next();
1413 1577 return Flow::Continue;
1414 1578 }
1415 - Action::PrevTab => {
1579 + Action::PrevTab if chrome => {
1416 1580 self.tabs.prev();
1417 1581 return Flow::Continue;
1418 1582 }
1419 - Action::NextFocus => {
1583 + Action::NextFocus if chrome => {
1420 1584 self.panes.next();
1421 1585 return Flow::Continue;
1422 1586 }
1423 - Action::PrevFocus => {
1587 + Action::PrevFocus if chrome => {
1424 1588 self.panes.prev();
1425 1589 return Flow::Continue;
1426 1590 }
@@ -2024,6 +2188,76 @@
2024 2188 std::fs::remove_file(&path).ok();
2025 2189 }
2026 2190
2191 + // ---- `alloy config <path>`: the one-file surface ----
2192 +
2193 + /// The view `alloy config` opens, over a form the test built rather than
2194 + /// over whatever schemas the machine running this happens to carry.
2195 + fn config_view() -> SettingsView {
2196 + let schema = Schema::parse(SCHEMA).expect("the test schema parses");
2197 + let bind = FileBind::new(schema, PathBuf::from("/tmp/rio.toml"), "")
2198 + .expect("an empty file is a valid document");
2199 + SettingsView::file(App {
2200 + name: "rio".into(),
2201 + source: PathBuf::from("rio.toml.schema"),
2202 + target: Some(PathBuf::from("/tmp/rio.toml")),
2203 + state: Ok(Form::new(Box::new(bind))),
2204 + })
2205 + }
2206 +
2207 + // The title carries the file, because nothing else on the screen does:
2208 + // there is no tab bar under it saying which of two things is showing.
2209 + #[test]
2210 + fn the_one_file_surface_is_titled_with_the_file() {
2211 + assert_eq!(config_view().title(), "/tmp/rio.toml");
2212 + }
2213 +
2214 + // A footer that offered `h/l` and `tab` here would be advertising keys with
2215 + // nowhere to go, and the overlay says the same thing the other way round.
2216 + #[test]
2217 + fn a_one_file_surface_offers_neither_tabs_nor_panes() {
2218 + let view = config_view();
2219 + let keys: Vec<&str> = view.hints().iter().map(|hint| hint.key).collect();
2220 + assert!(!keys.contains(&"h/l"), "got: {keys:?}");
2221 + assert!(!keys.contains(&"tab"), "got: {keys:?}");
2222 + assert!(keys.contains(&"j/k") && keys.contains(&"enter"), "{keys:?}");
2223 + assert_eq!(
2224 + view.unanswered(),
2225 + [
2226 + Action::NextTab,
2227 + Action::PrevTab,
2228 + Action::NextFocus,
2229 + Action::PrevFocus
2230 + ]
2231 + );
2232 + }
2233 +
2234 + // Declining a key in the overlay and still acting on it would be worse than
2235 + // either. `h` must not move to a System tab this view was never built with:
2236 + // `system` is `None` there, so the screen would be the absence message.
2237 + #[test]
2238 + fn the_tab_movers_do_nothing_on_a_one_file_surface() {
2239 + let mut view = config_view();
2240 + let mut log = CommandLog::new();
2241 + for code in [KeyCode::Char('h'), KeyCode::Char('l'), KeyCode::Tab] {
2242 + view.handle(KeyEvent::from(code), &mut log);
2243 + assert_eq!(view.tab(), Tab::Applications);
2244 + assert!(view.on_form(), "the keys stay on the form");
2245 + }
2246 + }
2247 +
2248 + // The path is what is matched, not the file name: two tools can both keep a
2249 + // `config.toml`. A file no schema claims is an error that says so and names
2250 + // where it looked, rather than an empty form or a silent editor.
2251 + #[test]
2252 + fn a_file_no_schema_targets_is_refused_by_name() {
2253 + let Err(error) = SettingsView::config(Path::new("/tmp/alloy-no-such-schema.toml")) else {
2254 + panic!("nothing targets that path");
2255 + };
2256 + let error = error.to_string();
2257 + assert!(error.contains("/tmp/alloy-no-such-schema.toml"), "{error}");
2258 + assert!(error.contains("searched"), "{error}");
2259 + }
2260 +
2027 2261 // ---- privilege: tier 2, the fallback when the grant does not cover us ----
2028 2262
2029 2263 /// A one-row command front whose setter fails with whatever `stderr` says.