Skip to main content

max / alloy_tui

console: revive sysop-tui as alloy_tui, add the alloy binary and net The 2026-07-17 pivot made the Console the wedge, but alloy_tui was still a seed (theme.rs plus AlloyBlock and Severity) and the alloy binary did not exist. This lands both halves against one working subcommand rather than authoring the widget roster first, so the shell chrome is designed against a screen that actually renders. alloy_tui gains the two things ratatui leaves to the app and every Alloy TUI has to agree on. keys.rs is sysop-tui's classifier widened from six actions to the reserved set COMPONENT-LIBRARY.md specifies; it accepts both encodings of Shift-Tab, since terminals split between BackTab and Tab+SHIFT and honoring one makes focus one-directional on the other. focus.rs is the focus ring, inert at zero length so a view whose panes have not loaded does not modulo by zero. The rest is sysop-tui retinted. Its palette was const; Alloy loads the palette at runtime, so text.rs and selection.rs take the Theme instead. Selection fills with surface.raised rather than an accent, which keeps DESIGN-LANGUAGE.md's rule that color stays off chrome and leaves a Severity color on a row readable while that row is selected. layout.rs adds the command-log pane sysop had no equivalent of, and drops it below 12 rows: on a short terminal the body is worth more than four rows of log border. AlloyList scrolls statelessly, deriving its offset from the selection each frame instead of carrying a ListState. That is what keeps it immediate-mode, and it costs centered scrolling instead of minimal. In the binary, the log pane's contract is structural. Every CLI call goes through Invocation, which records into CommandLog as it runs, so the pane cannot display a command that was not run or run one it does not display. CONSOLE.md's teaching claim depends on that staying true as subcommands are added, and it should not rest on remembering to log. Probes are the deliberate exception: they run before the user asked for anything. net fronts nmcli, adapting sysop's ip-on-Alpine backend, and carries over its mock-or-real detection. The probe runs nmcli rather than checking for the binary, because an nmcli that cannot reach a daemon (a container, a live ISO mid-boot) is worse than no nmcli and only running it reveals that. The parser was first written assuming terse mode escapes colons as \:, with a fixture and an unescape to match, and it passed. Real output disagrees: device show emits IPv6 unescaped. Escaping applies to the tabular form, where fields are colon-separated and it is forced. The first-colon split was right either way since keys carry no colons, but unescape was dead code that would have corrupted any value holding a real backslash. The fixture is now captured output, including the nested parens in "100 (connected (externally))", an empty trailing connection, and a bare ::1/128. Two of those the invented fixture did not have. No hard-coded palette fallback in theme.rs. TOKENS.md says no hex values live in Rust, so a missing or malformed theme is an error the user sees rather than something papered over with colors from no theme file. Not eyeballed. The parser is checked against real nmcli output and the pieces are unit-tested, but no one has looked at the rendered frame yet. CONSOLE.md's roadmap also still orders alloy config at v0.5 ahead of alloy net at v1, which this inverts; the doc is left alone for now.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 17:27 UTC
Signed with PGP, not checked
Commit: d2a79c80a8415fd9ce24ae27a2e63c4c1e183636
Parent: f9af46e
14 files changed, +1804 insertions, -4 deletions
M Cargo.lock +119
@@ -17,6 +17,17 @@
17 17 source = "registry+https://github.com/rust-lang/crates.io-index"
18 18 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19 19
20 + [[package]]
21 + name = "alloy"
22 + version = "0.0.0"
23 + dependencies = [
24 + "alloy_tui",
25 + "anyhow",
26 + "clap",
27 + "ratatui",
28 + "theme-common",
29 + ]
30 +
20 31 [[package]]
21 32 name = "alloy_tui"
22 33 version = "0.0.0"
@@ -25,6 +36,56 @@
25 36 "theme-common",
26 37 ]
27 38
39 + [[package]]
40 + name = "anstream"
41 + version = "1.0.0"
42 + source = "registry+https://github.com/rust-lang/crates.io-index"
43 + checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
44 + dependencies = [
45 + "anstyle",
46 + "anstyle-parse",
47 + "anstyle-query",
48 + "anstyle-wincon",
49 + "colorchoice",
50 + "is_terminal_polyfill",
51 + "utf8parse",
52 + ]
53 +
54 + [[package]]
55 + name = "anstyle"
56 + version = "1.0.14"
57 + source = "registry+https://github.com/rust-lang/crates.io-index"
58 + checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
59 +
60 + [[package]]
61 + name = "anstyle-parse"
62 + version = "1.0.0"
63 + source = "registry+https://github.com/rust-lang/crates.io-index"
64 + checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
65 + dependencies = [
66 + "utf8parse",
67 + ]
68 +
69 + [[package]]
70 + name = "anstyle-query"
71 + version = "1.1.5"
72 + source = "registry+https://github.com/rust-lang/crates.io-index"
73 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
74 + dependencies = [
75 + "windows-sys",
76 + ]
77 +
78 + [[package]]
79 + name = "anstyle-wincon"
80 + version = "3.0.11"
81 + source = "registry+https://github.com/rust-lang/crates.io-index"
82 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
83 + dependencies = [
84 + "anstyle",
85 + "once_cell_polyfill",
86 + "windows-sys",
87 + ]
88 +
28 89 [[package]]
29 90 name = "anyhow"
30 91 version = "1.0.103"
@@ -121,6 +182,52 @@
121 182 source = "registry+https://github.com/rust-lang/crates.io-index"
122 183 checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
123 184
185 + [[package]]
186 + name = "clap"
187 + version = "4.6.2"
188 + source = "registry+https://github.com/rust-lang/crates.io-index"
189 + checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
190 + dependencies = [
191 + "clap_builder",
192 + "clap_derive",
193 + ]
194 +
195 + [[package]]
196 + name = "clap_builder"
197 + version = "4.6.2"
198 + source = "registry+https://github.com/rust-lang/crates.io-index"
199 + checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
200 + dependencies = [
201 + "anstream",
202 + "anstyle",
203 + "clap_lex",
204 + "strsim",
205 + ]
206 +
207 + [[package]]
208 + name = "clap_derive"
209 + version = "4.6.1"
210 + source = "registry+https://github.com/rust-lang/crates.io-index"
211 + checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
212 + dependencies = [
213 + "heck",
214 + "proc-macro2",
215 + "quote",
216 + "syn 2.0.118",
217 + ]
218 +
219 + [[package]]
220 + name = "clap_lex"
221 + version = "1.1.0"
222 + source = "registry+https://github.com/rust-lang/crates.io-index"
223 + checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
224 +
225 + [[package]]
226 + name = "colorchoice"
227 + version = "1.0.5"
228 + source = "registry+https://github.com/rust-lang/crates.io-index"
229 + checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
230 +
124 231 [[package]]
125 232 name = "compact_str"
126 233 version = "0.9.1"
@@ -491,6 +598,12 @@
491 598 "syn 2.0.118",
492 599 ]
493 600
601 + [[package]]
602 + name = "is_terminal_polyfill"
603 + version = "1.70.2"
604 + source = "registry+https://github.com/rust-lang/crates.io-index"
605 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
606 +
494 607 [[package]]
495 608 name = "itertools"
496 609 version = "0.14.0"
@@ -704,6 +817,12 @@
704 817 source = "registry+https://github.com/rust-lang/crates.io-index"
705 818 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
706 819
820 + [[package]]
821 + name = "once_cell_polyfill"
822 + version = "1.70.2"
823 + source = "registry+https://github.com/rust-lang/crates.io-index"
824 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
825 +
707 826 [[package]]
708 827 name = "ordered-float"
709 828 version = "4.6.0"
@@ -8,9 +8,25 @@
8 8 //! targets, and `docs/DESIGN-LANGUAGE.md` for the color-is-information rule
9 9 //! enforced here: chrome is tinted-greyscale, accents live on text via
10 10 //! `Severity`.
11 + //!
12 + //! Beyond the widgets, the crate carries the two things ratatui leaves to the
13 + //! app and every Alloy TUI must agree on: the reserved keymap ([`keys`]) and
14 + //! the focus ring ([`focus`]). Both descend from mountaineer-sysop's
15 + //! `sysop-tui`, retinted from a const palette to the runtime [`Theme`].
16 + //!
17 + //! <!-- wiki: alloy-console -->
11 18
19 + pub mod focus;
20 + pub mod keys;
21 + pub mod layout;
22 + pub mod selection;
23 + pub mod text;
12 24 pub mod theme;
13 25 pub mod widgets;
14 26
27 + pub use focus::FocusRing;
28 + pub use keys::{Action, classify};
29 + pub use layout::{ConsoleAreas, console};
30 + pub use selection::{MARKER, selected_style};
15 31 pub use theme::{Mode, Theme, ThemeError};
16 32 pub use widgets::*;
@@ -2,13 +2,19 @@
2 2 //!
3 3 //! v1 target per docs/CONSOLE.md: `AlloyBlock`, `AlloyList`, `AlloyForm`,
4 4 //! `AlloyTable`, `AlloyStatusBar`, `AlloyLog`, plus form-field widgets driven
5 - //! by the config schema. This module seeds the crate with `AlloyBlock` and the
6 - //! `Severity` accent — the two pieces every other widget composes with — and
7 - //! grows from there.
5 + //! by the config schema. This module carries the four the console shell needs
6 + //! to render a screen end to end — block, list, log pane, status bar — plus
7 + //! the `Severity` accent they all compose with. `AlloyForm`, `AlloyTable`, and
8 + //! the schema-driven fields land with `alloy config`.
8 9
10 + use ratatui::buffer::Buffer;
11 + use ratatui::layout::Rect;
9 12 use ratatui::style::{Color, Style};
10 - use ratatui::widgets::{Block, Borders};
13 + use ratatui::text::{Line, Span};
14 + use ratatui::widgets::{Block, Borders, Paragraph, Widget};
11 15
16 + use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style};
17 + use crate::text;
12 18 use crate::theme::Theme;
13 19
14 20 /// Themed `Block`: default borders + palette chrome. Wraps `ratatui::widgets::Block`
@@ -86,3 +92,317 @@
86 92 Style::default().fg(self.color(theme))
87 93 }
88 94 }
95 +
96 + /// A footer key hint: the key, and what it does.
97 + pub struct Hint {
98 + pub key: &'static str,
99 + pub label: &'static str,
100 + }
101 +
102 + /// Terse constructor for a [`Hint`], so hint lists read as data at the call
103 + /// site: `[hint("Tab", "focus"), hint("q", "quit")]`.
104 + pub const fn hint(key: &'static str, label: &'static str) -> Hint {
105 + Hint { key, label }
106 + }
107 +
108 + /// The one-row footer: key hints on the left, transient status on the right.
109 + ///
110 + /// This is docs/CONSOLE.md's "common status area" and
111 + /// docs/COMPONENT-LIBRARY.md's footer chrome in one widget — they occupy the
112 + /// same row, and splitting them into two widgets would mean two things
113 + /// competing for it. Descended from sysop-tui's `Footer`, with the status
114 + /// slot added.
115 + pub struct AlloyStatusBar<'a> {
116 + theme: &'a Theme,
117 + hints: Vec<Hint>,
118 + status: Option<(Severity, String)>,
119 + }
120 +
121 + impl<'a> AlloyStatusBar<'a> {
122 + pub fn new(theme: &'a Theme, hints: impl IntoIterator<Item = Hint>) -> Self {
123 + Self {
124 + theme,
125 + hints: hints.into_iter().collect(),
126 + status: None,
127 + }
128 + }
129 +
130 + /// Attach a transient status message (busy, error, dirty) to the right end.
131 + pub fn status(mut self, severity: Severity, message: impl Into<String>) -> Self {
132 + self.status = Some((severity, message.into()));
133 + self
134 + }
135 + }
136 +
137 + impl Widget for AlloyStatusBar<'_> {
138 + fn render(self, area: Rect, buf: &mut Buffer) {
139 + let base = Style::default().bg(self.theme.surface_sunken);
140 + Paragraph::new("").style(base).render(area, buf);
141 +
142 + let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 3);
143 + for (i, h) in self.hints.iter().enumerate() {
144 + if i > 0 {
145 + spans.push(Span::raw(" "));
146 + }
147 + spans.push(text::action(self.theme, h.key));
148 + spans.push(Span::raw(" "));
149 + spans.push(text::muted(self.theme, h.label));
150 + }
151 + Paragraph::new(Line::from(spans))
152 + .style(base)
153 + .render(area, buf);
154 +
155 + // The status sits on the same row, right-aligned. Rendering it as a
156 + // second pass into a right-hand slice means a long hint list is
157 + // overwritten by the status rather than pushing it off-screen — the
158 + // status is the more urgent of the two.
159 + if let Some((severity, message)) = self.status {
160 + let text_width = message.chars().count() as u16 + 1;
161 + let width = text_width.min(area.width);
162 + let slot = Rect {
163 + x: area.x + area.width - width,
164 + width,
165 + ..area
166 + };
167 + Paragraph::new(Line::from(Span::styled(
168 + message,
169 + severity.style(self.theme).patch(base),
170 + )))
171 + .style(base)
172 + .right_aligned()
173 + .render(slot, buf);
174 + }
175 + }
176 + }
177 +
178 + /// Themed selectable list.
179 + ///
180 + /// Rows are pre-composed `Line`s so callers keep control of their own content
181 + /// styling (a `Severity` span in a row survives selection); this widget owns
182 + /// only the gutter marker, the row style, and scrolling.
183 + pub struct AlloyList<'a> {
184 + theme: &'a Theme,
185 + items: Vec<Line<'a>>,
186 + selected: Option<usize>,
187 + }
188 +
189 + impl<'a> AlloyList<'a> {
190 + pub fn new(theme: &'a Theme, items: impl IntoIterator<Item = Line<'a>>) -> Self {
191 + Self {
192 + theme,
193 + items: items.into_iter().collect(),
194 + selected: None,
195 + }
196 + }
197 +
198 + pub fn selected(mut self, selected: Option<usize>) -> Self {
199 + self.selected = selected;
200 + self
201 + }
202 +
203 + /// First visible row for a viewport of `height` rows.
204 + ///
205 + /// Stateless by design: the offset is derived from the selection each
206 + /// frame rather than carried between frames, which is what lets the whole
207 + /// widget stay immediate-mode. The cost is that scrolling centers the
208 + /// selection instead of scrolling by the minimum amount; the benefit is
209 + /// that no caller has to own and thread a `ListState`.
210 + fn offset(&self, height: usize) -> usize {
211 + let (Some(selected), true) = (self.selected, self.items.len() > height) else {
212 + return 0;
213 + };
214 + let max_offset = self.items.len() - height;
215 + selected.saturating_sub(height / 2).min(max_offset)
216 + }
217 + }
218 +
219 + impl Widget for AlloyList<'_> {
220 + fn render(self, area: Rect, buf: &mut Buffer) {
221 + if area.height == 0 || area.width == 0 {
222 + return;
223 + }
224 +
225 + let height = area.height as usize;
226 + let offset = self.offset(height);
227 +
228 + for (row, (index, item)) in self
229 + .items
230 + .iter()
231 + .enumerate()
232 + .skip(offset)
233 + .take(height)
234 + .enumerate()
235 + {
236 + let is_selected = self.selected == Some(index);
237 + let style = if is_selected {
238 + selected_style(self.theme)
239 + } else {
240 + unselected_style(self.theme)
241 + };
242 + let marker = if is_selected { MARKER } else { MARKER_BLANK };
243 +
244 + let mut spans = vec![Span::styled(format!("{marker} "), style)];
245 + spans.extend(item.spans.iter().cloned());
246 +
247 + let line_area = Rect {
248 + y: area.y + row as u16,
249 + height: 1,
250 + ..area
251 + };
252 + Paragraph::new(Line::from(spans))
253 + .style(style)
254 + .render(line_area, buf);
255 + }
256 + }
257 + }
258 +
259 + /// One line of the command log: the CLI invocation that was run, and how it
260 + /// went.
261 + ///
262 + /// The console fronts CLIs rather than hiding them (docs/CONSOLE.md), so
263 + /// `command` holds the actual argv the console executed — verbatim, so a user
264 + /// can copy it into a shell and get the same result.
265 + #[derive(Debug, Clone)]
266 + pub struct LogEntry {
267 + pub command: String,
268 + pub outcome: Severity,
269 + }
270 +
271 + impl LogEntry {
272 + pub fn new(command: impl Into<String>, outcome: Severity) -> Self {
273 + Self {
274 + command: command.into(),
275 + outcome,
276 + }
277 + }
278 + }
279 +
280 + /// The always-on command-log pane.
281 + ///
282 + /// Renders the tail of the log — the most recent invocation on the bottom row,
283 + /// terminal-transcript order, so the pane reads the way a shell scrollback
284 + /// does.
285 + pub struct AlloyLog<'a> {
286 + theme: &'a Theme,
287 + entries: &'a [LogEntry],
288 + }
289 +
290 + impl<'a> AlloyLog<'a> {
291 + pub fn new(theme: &'a Theme, entries: &'a [LogEntry]) -> Self {
292 + Self { theme, entries }
293 + }
294 + }
295 +
296 + impl Widget for AlloyLog<'_> {
297 + fn render(self, area: Rect, buf: &mut Buffer) {
298 + if area.height == 0 || area.width == 0 {
299 + return;
300 + }
301 +
302 + let block = AlloyBlock::new(self.theme).build().title(" commands ");
303 + let inner = block.inner(area);
304 + block.render(area, buf);
305 +
306 + if inner.height == 0 {
307 + return;
308 + }
309 +
310 + let visible = inner.height as usize;
311 + let tail = self.entries.len().saturating_sub(visible);
312 + let lines: Vec<Line> = self.entries[tail..]
313 + .iter()
314 + .map(|entry| {
315 + Line::from(vec![
316 + Span::styled("$ ", entry.outcome.style(self.theme)),
317 + text::secondary(self.theme, entry.command.clone()),
318 + ])
319 + })
320 + .collect();
321 +
322 + Paragraph::new(lines)
323 + .style(Style::default().bg(self.theme.surface_page))
324 + .render(inner, buf);
325 + }
326 + }
327 +
328 + #[cfg(test)]
329 + mod tests {
330 + use super::*;
331 + use ratatui::style::Color;
332 +
333 + fn theme() -> Theme {
334 + Theme {
335 + mode: crate::theme::Mode::Dark,
336 + surface_page: Color::Rgb(0, 0, 0),
337 + surface_raised: Color::Rgb(1, 1, 1),
338 + surface_sunken: Color::Rgb(2, 2, 2),
339 + surface_overlay: Color::Rgb(3, 3, 3),
340 + content_primary: Color::Rgb(4, 4, 4),
341 + content_secondary: Color::Rgb(5, 5, 5),
342 + content_muted: Color::Rgb(6, 6, 6),
343 + action_primary: Color::Rgb(7, 7, 7),
344 + status_danger: Color::Rgb(8, 8, 8),
345 + status_success: Color::Rgb(9, 9, 9),
346 + status_warning: Color::Rgb(10, 10, 10),
347 + status_info: Color::Rgb(11, 11, 11),
348 + line_border: Color::Rgb(12, 12, 12),
349 + border_subtle: Color::Rgb(13, 13, 13),
350 + border_strong: Color::Rgb(14, 14, 14),
351 + category: [Color::Rgb(15, 15, 15); 6],
352 + }
353 + }
354 +
355 + fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> {
356 + // Leaked so the test list can hold a 'static theme reference; the
357 + // widget borrows rather than owns, and these are per-test one-offs.
358 + let theme: &'static Theme = Box::leak(Box::new(theme()));
359 + let items: Vec<Line<'static>> = (0..n).map(|i| Line::from(format!("row {i}"))).collect();
360 + AlloyList::new(theme, items).selected(selected)
361 + }
362 +
363 + #[test]
364 + fn short_list_never_scrolls() {
365 + assert_eq!(list_of(3, Some(2)).offset(10), 0);
366 + }
367 +
368 + // Selection near the top must not scroll past the start of the list — a
369 + // naive `selected - height/2` underflows or shows blank rows above row 0.
370 + #[test]
371 + fn offset_clamps_at_the_top() {
372 + assert_eq!(list_of(50, Some(0)).offset(10), 0);
373 + assert_eq!(list_of(50, Some(2)).offset(10), 0);
374 + }
375 +
376 + // Selection at the end must land the last row on the last visible line,
377 + // not scroll into empty space past the end of the list.
378 + #[test]
379 + fn offset_clamps_at_the_bottom() {
380 + assert_eq!(list_of(50, Some(49)).offset(10), 40);
381 + }
382 +
383 + #[test]
384 + fn offset_centers_a_midlist_selection() {
385 + assert_eq!(list_of(50, Some(25)).offset(10), 20);
386 + }
387 +
388 + // A log longer than its pane shows the newest entries. Showing the head
389 + // instead would freeze the pane on startup noise and never display the
390 + // command the user just triggered.
391 + #[test]
392 + fn log_renders_the_newest_entries() {
393 + let theme = theme();
394 + let entries: Vec<LogEntry> = (0..10)
395 + .map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy))
396 + .collect();
397 + let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4));
398 + AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf);
399 +
400 + let rendered = buf
401 + .content()
402 + .iter()
403 + .map(|cell| cell.symbol())
404 + .collect::<String>();
405 + assert!(rendered.contains("nmcli run 9"), "newest entry must be visible");
406 + assert!(!rendered.contains("nmcli run 0"), "oldest entry must have scrolled off");
407 + }
408 + }
@@ -1,0 +1,20 @@
1 + [package]
2 + name = "alloy"
3 + version = "0.0.0"
4 + description = "The Alloy Console: a ratatui control surface fronting the system CLIs."
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + license.workspace = true
8 + repository.workspace = true
9 + authors.workspace = true
10 +
11 + [[bin]]
12 + name = "alloy"
13 + path = "src/main.rs"
14 +
15 + [dependencies]
16 + alloy_tui = { path = "../alloy_tui" }
17 + anyhow = "1"
18 + clap = { version = "4", features = ["derive"] }
19 + ratatui.workspace = true
20 + theme-common.workspace = true
@@ -1,0 +1,194 @@
1 + //! Invoking the CLIs the console fronts, and recording what was invoked.
2 + //!
3 + //! Every command the console runs passes through here, which is what makes the
4 + //! log pane's promise honest: the pane cannot show a command the console did
5 + //! not run, and it cannot run one it does not show. docs/CONSOLE.md — "the
6 + //! console is not trying to hide the CLI, it's trying to make the CLI
7 + //! approachable" — is enforced structurally rather than by remembering to log.
8 +
9 + use std::collections::VecDeque;
10 + use std::process::Command;
11 +
12 + use alloy_tui::{LogEntry, Severity};
13 + use anyhow::{Context, Result, bail};
14 +
15 + /// How many invocations the log keeps. The pane shows a couple of rows; the
16 + /// rest is scrollback for a future `alloy log` or a scroll binding.
17 + const LOG_CAPACITY: usize = 256;
18 +
19 + /// The command log — the console's transcript of what it actually ran.
20 + #[derive(Debug, Default)]
21 + pub struct CommandLog {
22 + entries: VecDeque<LogEntry>,
23 + }
24 +
25 + impl CommandLog {
26 + pub fn new() -> Self {
27 + Self::default()
28 + }
29 +
30 + pub fn record(&mut self, command: impl Into<String>, outcome: Severity) {
31 + if self.entries.len() == LOG_CAPACITY {
32 + self.entries.pop_front();
33 + }
34 + self.entries.push_back(LogEntry::new(command, outcome));
35 + }
36 +
37 + /// Entries oldest-first, for [`AlloyLog`](alloy_tui::AlloyLog).
38 + ///
39 + /// `VecDeque` is not contiguous, so the slice view needs the ring
40 + /// straightened first; this is called once per frame, and after the first
41 + /// call the deque is already contiguous.
42 + pub fn entries(&mut self) -> &[LogEntry] {
43 + self.entries.make_contiguous();
44 + self.entries.as_slices().0
45 + }
46 + }
47 +
48 + /// A command line, held as argv rather than a string so it is executed exactly
49 + /// as displayed — no shell, no quoting round-trip, no injection surface.
50 + #[derive(Debug, Clone)]
51 + pub struct Invocation {
52 + program: String,
53 + args: Vec<String>,
54 + }
55 +
56 + impl Invocation {
57 + pub fn new(program: impl Into<String>) -> Self {
58 + Self {
59 + program: program.into(),
60 + args: Vec::new(),
61 + }
62 + }
63 +
64 + pub fn arg(mut self, arg: impl Into<String>) -> Self {
65 + self.args.push(arg.into());
66 + self
67 + }
68 +
69 + pub fn args<I, S>(mut self, args: I) -> Self
70 + where
71 + I: IntoIterator<Item = S>,
72 + S: Into<String>,
73 + {
74 + self.args.extend(args.into_iter().map(Into::into));
75 + self
76 + }
77 +
78 + /// The command as a user would type it. Arguments containing whitespace are
79 + /// quoted so the displayed line is copy-pasteable into a shell and means
80 + /// the same thing there as it did here.
81 + pub fn display(&self) -> String {
82 + let mut out = String::from(&self.program);
83 + for arg in &self.args {
84 + out.push(' ');
85 + if arg.contains(char::is_whitespace) {
86 + out.push('\'');
87 + out.push_str(arg);
88 + out.push('\'');
89 + } else {
90 + out.push_str(arg);
91 + }
92 + }
93 + out
94 + }
95 +
96 + /// Run the command and return its stdout, recording the invocation and its
97 + /// outcome in `log`.
98 + pub fn run(&self, log: &mut CommandLog) -> Result<String> {
99 + let result = self.capture();
100 + log.record(
101 + self.display(),
102 + if result.is_ok() { Severity::Healthy } else { Severity::Error },
103 + );
104 + result
105 + }
106 +
107 + /// Run without logging — for probes, which run before the user has asked
108 + /// for anything and would otherwise fill the pane with noise the user did
109 + /// not trigger.
110 + pub fn probe(&self) -> bool {
111 + self.capture().is_ok()
112 + }
113 +
114 + fn capture(&self) -> Result<String> {
115 + let output = Command::new(&self.program)
116 + .args(&self.args)
117 + .output()
118 + .with_context(|| format!("failed to invoke `{}`", self.display()))?;
119 +
120 + if !output.status.success() {
121 + let stderr = String::from_utf8_lossy(&output.stderr);
122 + let detail = stderr.trim();
123 + // A nonzero exit with nothing on stderr is common enough (nmcli
124 + // does it for "no such device") that reporting an empty message
125 + // would leave the user with no idea what happened.
126 + if detail.is_empty() {
127 + bail!("`{}` exited with {}", self.display(), output.status);
128 + }
129 + bail!("`{}`: {detail}", self.display());
130 + }
131 +
132 + String::from_utf8(output.stdout)
133 + .with_context(|| format!("`{}` emitted non-UTF-8 output", self.display()))
134 + }
135 + }
136 +
137 + #[cfg(test)]
138 + mod tests {
139 + use super::*;
140 +
141 + #[test]
142 + fn display_round_trips_a_plain_command() {
143 + let inv = Invocation::new("nmcli").args(["-t", "-f", "DEVICE,TYPE", "device", "status"]);
144 + assert_eq!(inv.display(), "nmcli -t -f DEVICE,TYPE device status");
145 + }
146 +
147 + // An SSID with a space is the common case that breaks a naive join. The
148 + // displayed line is advertised as copy-pasteable, so it has to survive one.
149 + #[test]
150 + fn display_quotes_arguments_containing_whitespace() {
151 + let inv = Invocation::new("nmcli").args(["connection", "up", "Coffee Shop Wifi"]);
152 + assert_eq!(inv.display(), "nmcli connection up 'Coffee Shop Wifi'");
153 + }
154 +
155 + #[test]
156 + fn log_keeps_insertion_order() {
157 + let mut log = CommandLog::new();
158 + log.record("first", Severity::Healthy);
159 + log.record("second", Severity::Error);
160 + let entries = log.entries();
161 + assert_eq!(entries[0].command, "first");
162 + assert_eq!(entries[1].command, "second");
163 + assert_eq!(entries[1].outcome, Severity::Error);
164 + }
165 +
166 + // The ring must drop the oldest rather than grow without bound or, worse,
167 + // silently stop recording once it is full.
168 + #[test]
169 + fn log_evicts_oldest_at_capacity() {
170 + let mut log = CommandLog::new();
171 + for i in 0..LOG_CAPACITY + 10 {
172 + log.record(format!("cmd {i}"), Severity::Healthy);
173 + }
174 + let entries = log.entries();
175 + assert_eq!(entries.len(), LOG_CAPACITY);
176 + assert_eq!(entries[0].command, "cmd 10", "oldest entries were evicted");
177 + assert_eq!(entries[LOG_CAPACITY - 1].command, format!("cmd {}", LOG_CAPACITY + 9));
178 + }
179 +
180 + // `entries()` straightens the deque; a wrapped ring must still read back in
181 + // order, or the pane shows the transcript spliced at the wrap point.
182 + #[test]
183 + fn entries_are_contiguous_after_wrapping() {
184 + let mut log = CommandLog::new();
185 + for i in 0..LOG_CAPACITY * 2 {
186 + log.record(format!("cmd {i}"), Severity::Healthy);
187 + }
188 + let entries = log.entries();
189 + assert_eq!(entries.len(), LOG_CAPACITY, "no entries lost to the wrap");
190 + for (offset, entry) in entries.iter().enumerate() {
191 + assert_eq!(entry.command, format!("cmd {}", LOG_CAPACITY + offset));
192 + }
193 + }
194 + }
@@ -1,0 +1,47 @@
1 + //! `alloy` — the Alloy Console.
2 + //!
3 + //! One binary, one subcommand per system surface, all sharing the `alloy_tui`
4 + //! design system and the shell in [`shell`]. See docs/CONSOLE.md for the
5 + //! subcommand roster and the roadmap; `net` is the first of them.
6 + //!
7 + //! <!-- wiki: alloy-console -->
8 +
9 + mod cli;
10 + mod net;
11 + mod shell;
12 + mod theme;
13 +
14 + use anyhow::Result;
15 + use clap::{Parser, Subcommand};
16 +
17 + use crate::cli::CommandLog;
18 +
19 + #[derive(Parser)]
20 + #[command(name = "alloy", about = "Alloy Console", version)]
21 + struct Cli {
22 + /// Theme id to render in (default: Akari, matched to the terminal background)
23 + #[arg(long, global = true)]
24 + theme: Option<String>,
25 +
26 + #[command(subcommand)]
27 + command: Command,
28 + }
29 +
30 + #[derive(Subcommand)]
31 + enum Command {
32 + /// Network interfaces and connections
33 + Net,
34 + }
35 +
36 + fn main() -> Result<()> {
37 + let cli = Cli::parse();
38 + let theme = theme::load(cli.theme.as_deref())?;
39 + let mut log = CommandLog::new();
40 +
41 + match cli.command {
42 + Command::Net => {
43 + let mut view = net::NetView::new(&mut log);
44 + shell::run(&theme, &mut view, &mut log)
45 + }
46 + }
47 + }
@@ -1,0 +1,539 @@
1 + //! `alloy net` — a NetworkManager front.
2 + //!
3 + //! Descended from sysop's `net.rs`, which fronted `ip` on Alpine. Alloy is
4 + //! Fedora, so the backend is `nmcli`; the mock-or-real detection pattern is
5 + //! carried over unchanged, because it is what lets the console be developed
6 + //! and demoed on a machine whose real network state you would rather not
7 + //! touch.
8 +
9 + use alloy_tui::{AlloyBlock, AlloyList, Hint, Severity, Theme, hint, text};
10 + use anyhow::Result;
11 + use ratatui::Frame;
12 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
13 + use ratatui::layout::Rect;
14 + use ratatui::text::{Line, Span};
15 +
16 + use crate::cli::{CommandLog, Invocation};
17 + use crate::shell::{Flow, View, block_title};
18 +
19 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20 + pub enum Kind {
21 + Wired,
22 + Wireless,
23 + Loopback,
24 + Other,
25 + }
26 +
27 + impl Kind {
28 + /// Map NetworkManager's device type. NM's vocabulary is open-ended
29 + /// (`bridge`, `tun`, `wireguard`, `bond`, ...); everything Alloy does not
30 + /// name specifically is `Other` and still listed, because hiding an
31 + /// interface the user can see in `nmcli` would make the console look
32 + /// broken.
33 + fn from_nm(raw: &str) -> Self {
34 + match raw {
35 + "ethernet" => Kind::Wired,
36 + "wifi" => Kind::Wireless,
37 + "loopback" => Kind::Loopback,
38 + _ => Kind::Other,
39 + }
40 + }
41 +
42 + const fn label(self) -> &'static str {
43 + match self {
44 + Kind::Wired => "wired",
45 + Kind::Wireless => "wireless",
46 + Kind::Loopback => "loopback",
47 + Kind::Other => "other",
48 + }
49 + }
50 + }
51 +
52 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
53 + pub enum State {
54 + Connected,
55 + Disconnected,
56 + Unavailable,
57 + Unmanaged,
58 + }
59 +
60 + impl State {
61 + /// NM reports `GENERAL.STATE` as `"100 (connected)"`. The numeric code is
62 + /// the stable part — the parenthesized text is localized — so parse the
63 + /// number and ignore the rest.
64 + fn from_nm(raw: &str) -> Self {
65 + let code = raw
66 + .split_whitespace()
67 + .next()
68 + .and_then(|n| n.parse::<u16>().ok())
69 + .unwrap_or(0);
70 + match code {
71 + 100 => State::Connected,
72 + 30 => State::Disconnected,
73 + 20 => State::Unavailable,
74 + _ => State::Unmanaged,
75 + }
76 + }
77 +
78 + const fn label(self) -> &'static str {
79 + match self {
80 + State::Connected => "connected",
81 + State::Disconnected => "disconnected",
82 + State::Unavailable => "unavailable",
83 + State::Unmanaged => "unmanaged",
84 + }
85 + }
86 +
87 + const fn severity(self) -> Severity {
88 + match self {
89 + State::Connected => Severity::Healthy,
90 + State::Disconnected => Severity::Warn,
91 + State::Unavailable | State::Unmanaged => Severity::Info,
92 + }
93 + }
94 + }
95 +
96 + #[derive(Debug, Clone)]
97 + pub struct Interface {
98 + pub name: String,
99 + pub kind: Kind,
100 + pub state: State,
101 + pub connection: Option<String>,
102 + pub addresses: Vec<String>,
103 + }
104 +
105 + /// A source of interface state.
106 + pub trait Backend {
107 + fn name(&self) -> &'static str;
108 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>>;
109 + }
110 +
111 + /// Pick a backend: the real one when `nmcli` answers, the mock otherwise.
112 + ///
113 + /// The probe is a real invocation rather than a `which` check — an `nmcli`
114 + /// binary that cannot reach a NetworkManager daemon (a container, a live ISO
115 + /// mid-boot) is worse than no `nmcli` at all, and only running it reveals that.
116 + pub fn detect() -> Box<dyn Backend> {
117 + if Invocation::new("nmcli").arg("--version").probe() {
118 + Box::new(NmCli)
119 + } else {
120 + Box::new(Mock)
121 + }
122 + }
123 +
124 + pub struct NmCli;
125 +
126 + impl NmCli {
127 + /// One invocation for the whole device table. `nmcli device show` with no
128 + /// device dumps every device, which keeps the log pane to a single
129 + /// copy-pasteable line instead of one per interface.
130 + fn invocation() -> Invocation {
131 + Invocation::new("nmcli").args([
132 + "-t",
133 + "-f",
134 + "GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS",
135 + "device",
136 + "show",
137 + ])
138 + }
139 + }
140 +
141 + impl Backend for NmCli {
142 + fn name(&self) -> &'static str {
143 + "nmcli"
144 + }
145 +
146 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> {
147 + Ok(parse_device_show(&Self::invocation().run(log)?))
148 + }
149 + }
150 +
151 + /// Fixed sample state, for machines without NetworkManager.
152 + pub struct Mock;
153 +
154 + impl Backend for Mock {
155 + fn name(&self) -> &'static str {
156 + "mock"
157 + }
158 +
159 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> {
160 + // Logged as a comment rather than a command: the pane's contract is
161 + // that every line is something you could run, and there is nothing to
162 + // run here. The `#` marks it as commentary in the same way a shell
163 + // would.
164 + log.record("# no NetworkManager; showing mock interfaces", Severity::Warn);
165 + Ok(vec![
166 + Interface {
167 + name: "wlp1s0".into(),
168 + kind: Kind::Wireless,
169 + state: State::Connected,
170 + connection: Some("Example Network".into()),
171 + addresses: vec!["192.168.1.42/24".into()],
172 + },
173 + Interface {
174 + name: "enp2s0".into(),
175 + kind: Kind::Wired,
176 + state: State::Disconnected,
177 + connection: None,
178 + addresses: vec![],
179 + },
180 + Interface {
181 + name: "lo".into(),
182 + kind: Kind::Loopback,
183 + state: State::Unmanaged,
184 + connection: None,
185 + addresses: vec!["127.0.0.1/8".into()],
186 + },
187 + ])
188 + }
189 + }
190 +
191 + /// Parse `nmcli -t -f ... device show` output.
192 + ///
193 + /// Terse mode emits `KEY:value` per line with devices separated by blank
194 + /// lines. Keys never contain a colon, so splitting on the first one is
195 + /// unambiguous and values are taken verbatim.
196 + ///
197 + /// That verbatim part is worth stating, because nmcli's terse *tabular* output
198 + /// (`device status`) does escape colons as `\:` — it has to, since its fields
199 + /// are colon-separated. Multiline output does not, and an IPv6 address here
200 + /// arrives as plain `fe80::1`. Unescaping it anyway would corrupt any value
201 + /// containing a legitimate backslash.
202 + fn parse_device_show(raw: &str) -> Vec<Interface> {
203 + let mut interfaces = Vec::new();
204 + let mut current: Option<Interface> = None;
205 +
206 + for line in raw.lines() {
207 + let line = line.trim_end();
208 + if line.is_empty() {
209 + continue;
210 + }
211 + let Some((key, value)) = line.split_once(':') else {
212 + continue;
213 + };
214 + let value = value.to_string();
215 +
216 + // A device block starts at GENERAL.DEVICE. Keying off that rather than
217 + // the blank-line separator means a missing separator merges nothing:
218 + // the next DEVICE always opens a new record.
219 + if key == "GENERAL.DEVICE" {
220 + if let Some(iface) = current.take() {
221 + interfaces.push(iface);
222 + }
223 + current = Some(Interface {
224 + name: value,
225 + kind: Kind::Other,
226 + state: State::Unmanaged,
227 + connection: None,
228 + addresses: Vec::new(),
229 + });
230 + continue;
231 + }
232 +
233 + let Some(iface) = current.as_mut() else {
234 + continue;
235 + };
236 +
237 + match key {
238 + "GENERAL.TYPE" => iface.kind = Kind::from_nm(&value),
239 + "GENERAL.STATE" => iface.state = State::from_nm(&value),
240 + // NM writes `--` for an absent connection, which would otherwise
241 + // render as a connection literally named "--".
242 + "GENERAL.CONNECTION" if value != "--" && !value.is_empty() => {
243 + iface.connection = Some(value);
244 + }
245 + // Address keys are indexed: IP4.ADDRESS[1], IP6.ADDRESS[2], ...
246 + _ if !value.is_empty()
247 + && (key.starts_with("IP4.ADDRESS") || key.starts_with("IP6.ADDRESS")) =>
248 + {
249 + iface.addresses.push(value);
250 + }
251 + _ => {}
252 + }
253 + }
254 +
255 + interfaces.extend(current);
256 + interfaces
257 + }
258 +
259 + /// The `alloy net` screen.
260 + pub struct NetView {
261 + backend: Box<dyn Backend>,
262 + interfaces: Vec<Interface>,
263 + selected: usize,
264 + error: Option<String>,
265 + }
266 +
267 + impl NetView {
268 + pub fn new(log: &mut CommandLog) -> Self {
269 + let mut view = Self {
270 + backend: detect(),
271 + interfaces: Vec::new(),
272 + selected: 0,
273 + error: None,
274 + };
275 + view.refresh(log);
276 + view
277 + }
278 +
279 + fn refresh(&mut self, log: &mut CommandLog) {
280 + match self.backend.list(log) {
281 + Ok(interfaces) => {
282 + self.interfaces = interfaces;
283 + // Refresh can shrink the list (an interface went away); keep
284 + // the cursor on a row that exists.
285 + self.selected = self.selected.min(self.interfaces.len().saturating_sub(1));
286 + self.error = None;
287 + }
288 + Err(err) => self.error = Some(err.to_string()),
289 + }
290 + }
291 +
292 + fn move_selection(&mut self, delta: isize) {
293 + if self.interfaces.is_empty() {
294 + return;
295 + }
296 + let last = self.interfaces.len() - 1;
297 + self.selected = match delta {
298 + d if d < 0 => self.selected.saturating_sub(d.unsigned_abs()),
299 + d => (self.selected + d as usize).min(last),
300 + };
301 + }
302 +
303 + fn row<'a>(&self, theme: &Theme, iface: &'a Interface) -> Line<'a> {
304 + let address = iface
305 + .addresses
306 + .first()
307 + .cloned()
308 + .or_else(|| iface.connection.clone())
309 + .unwrap_or_default();
310 +
311 + Line::from(vec![
312 + text::bold(theme, format!("{:<12}", iface.name)),
313 + text::muted(theme, format!("{:<10}", iface.kind.label())),
314 + Span::styled(
315 + format!("{:<14}", iface.state.label()),
316 + iface.state.severity().style(theme),
317 + ),
318 + text::secondary(theme, address),
319 + ])
320 + }
321 + }
322 +
323 + impl View for NetView {
324 + fn title(&self) -> String {
325 + format!("network ({})", self.backend.name())
326 + }
327 +
328 + fn hints(&self) -> Vec<Hint> {
329 + vec![hint("j/k", "select"), hint("r", "refresh")]
330 + }
331 +
332 + fn status(&self) -> Option<(Severity, String)> {
333 + self.error
334 + .as_ref()
335 + .map(|message| (Severity::Error, message.clone()))
336 + }
337 +
338 + fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
339 + let block = AlloyBlock::new(theme)
340 + .focused(true)
341 + .build()
342 + .title(block_title(&self.title()));
343 + let inner = block.inner(area);
344 + frame.render_widget(block, area);
345 +
346 + if self.interfaces.is_empty() {
347 + frame.render_widget(
348 + Line::from(text::muted(theme, "no interfaces")),
349 + inner,
350 + );
351 + return;
352 + }
353 +
354 + let rows: Vec<Line> = self
355 + .interfaces
356 + .iter()
357 + .map(|iface| self.row(theme, iface))
358 + .collect();
359 + frame.render_widget(AlloyList::new(theme, rows).selected(Some(self.selected)), inner);
360 + }
361 +
362 + fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
363 + match key.code {
364 + KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
365 + KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
366 + KeyCode::Char('r') => self.refresh(log),
367 + _ => {}
368 + }
369 + Flow::Continue
370 + }
371 + }
372 +
373 + #[cfg(test)]
374 + mod tests {
375 + use super::*;
376 +
377 + // Captured verbatim from `nmcli -t -f GENERAL.DEVICE,GENERAL.TYPE,
378 + // GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS device show` on
379 + // a NetworkManager 1.5x box, hostname and SSID aside. Kept real rather
380 + // than tidied: the awkward parts below (nested parens in the state, an
381 + // empty trailing connection, a bare `::1`) are all things nmcli actually
382 + // emits, and a hand-written fixture is where a parser goes to pass tests
383 + // it would fail in production.
384 + const SAMPLE: &str = "\
385 + GENERAL.DEVICE:wlp192s0
386 + GENERAL.TYPE:wifi
387 + GENERAL.STATE:100 (connected)
388 + GENERAL.CONNECTION:Example Network
389 + IP4.ADDRESS[1]:192.168.0.16/24
390 + IP6.ADDRESS[1]:fe80::59a3:bc22:d95f:c06b/64
391 +
392 + GENERAL.DEVICE:tailscale0
393 + GENERAL.TYPE:tun
394 + GENERAL.STATE:100 (connected (externally))
395 + GENERAL.CONNECTION:tailscale0
396 + IP4.ADDRESS[1]:100.103.89.95/32
397 + IP6.ADDRESS[1]:fd7a:115c:a1e0::af3b:595f/128
398 + IP6.ADDRESS[2]:fe80::ccae:60fc:a1c5:3b13/64
399 +
400 + GENERAL.DEVICE:lo
401 + GENERAL.TYPE:loopback
402 + GENERAL.STATE:100 (connected (externally))
403 + GENERAL.CONNECTION:lo
404 + IP4.ADDRESS[1]:127.0.0.1/8
405 + IP6.ADDRESS[1]:::1/128
406 +
407 + GENERAL.DEVICE:p2p-dev-wlp192s0
408 + GENERAL.TYPE:wifi-p2p
409 + GENERAL.STATE:30 (disconnected)
410 + GENERAL.CONNECTION:
411 + ";
412 +
413 + #[test]
414 + fn parses_every_device_block() {
415 + let ifaces = parse_device_show(SAMPLE);
416 + assert_eq!(ifaces.len(), 4);
417 + assert_eq!(ifaces[0].name, "wlp192s0");
418 + assert_eq!(ifaces[0].kind, Kind::Wireless);
419 + assert_eq!(ifaces[0].state, State::Connected);
420 + assert_eq!(ifaces[0].connection.as_deref(), Some("Example Network"));
421 + assert_eq!(
422 + ifaces[3].name, "p2p-dev-wlp192s0",
423 + "the last block is not dropped for want of a trailing blank line"
424 + );
425 + }
426 +
427 + // IPv6 values carry colons and arrive unescaped, so the split has to be on
428 + // the *first* colon only. Splitting on every colon shows `fe80` as the
429 + // address; `::1/128` is the case that breaks a naive rsplit as well.
430 + #[test]
431 + fn ipv6_addresses_survive_the_key_value_split() {
432 + let ifaces = parse_device_show(SAMPLE);
433 + assert_eq!(
434 + ifaces[0].addresses,
435 + vec!["192.168.0.16/24", "fe80::59a3:bc22:d95f:c06b/64"]
436 + );
437 + assert_eq!(
438 + ifaces[1].addresses,
439 + vec![
440 + "100.103.89.95/32",
441 + "fd7a:115c:a1e0::af3b:595f/128",
442 + "fe80::ccae:60fc:a1c5:3b13/64",
443 + ],
444 + "every indexed address is collected, not just the first"
445 + );
446 + assert_eq!(ifaces[2].addresses[1], "::1/128");
447 + }
448 +
449 + // NM leaves the connection field empty for a device with no active
450 + // connection. Empty must read as absent, not as a connection named "".
451 + #[test]
452 + fn treats_an_empty_connection_as_absent() {
453 + let ifaces = parse_device_show(SAMPLE);
454 + assert_eq!(ifaces[3].connection, None);
455 + assert!(ifaces[3].addresses.is_empty());
456 + }
457 +
458 + // `--` is NM's other placeholder for "none", used where a field is
459 + // tabulated rather than left blank.
460 + #[test]
461 + fn treats_double_dash_connection_as_absent() {
462 + let raw = "GENERAL.DEVICE:enp2s0\nGENERAL.TYPE:ethernet\nGENERAL.CONNECTION:--\n";
463 + assert_eq!(parse_device_show(raw)[0].connection, None);
464 + }
465 +
466 + // The state field nests parentheses: "100 (connected (externally))". Only
467 + // the leading numeric code is stable across locales, so that is what is
468 + // parsed; anything reading the text would misclassify this as unmanaged.
469 + #[test]
470 + fn parses_state_from_the_numeric_code_not_the_text() {
471 + let ifaces = parse_device_show(SAMPLE);
472 + assert_eq!(ifaces[1].state, State::Connected);
473 + assert_eq!(ifaces[3].state, State::Disconnected);
474 + }
475 +
476 + #[test]
477 + fn empty_output_yields_no_interfaces() {
478 + assert!(parse_device_show("").is_empty());
479 + }
480 +
481 + // NM's device-type vocabulary is open-ended; an unknown type must still
482 + // list rather than vanish.
483 + #[test]
484 + fn unknown_device_types_are_listed_as_other() {
485 + let raw = "GENERAL.DEVICE:wg0\nGENERAL.TYPE:wireguard\nGENERAL.STATE:100 (connected)\n";
486 + let ifaces = parse_device_show(raw);
487 + assert_eq!(ifaces.len(), 1);
488 + assert_eq!(ifaces[0].kind, Kind::Other);
489 + assert_eq!(ifaces[0].state, State::Connected);
490 + }
491 +
492 + // Selection must survive the list shrinking under it — an interface going
493 + // away while the cursor sits on the last row.
494 + #[test]
495 + fn selection_clamps_when_the_list_shrinks() {
496 + let mut view = NetView {
497 + backend: Box::new(Mock),
498 + interfaces: Vec::new(),
499 + selected: 0,
500 + error: None,
Lines truncated
@@ -1,0 +1,106 @@
1 + //! The console shell: the frame, the event loop, and the reserved-key handling
2 + //! every `alloy` subcommand shares.
3 + //!
4 + //! docs/CONSOLE.md commits each subcommand to the same navigation model, the
5 + //! same status area, and the same command-log pane. Those live here rather
6 + //! than in each view, so a new subcommand supplies only its body and its
7 + //! hints and inherits the rest.
8 +
9 + use alloy_tui::keys::{Action, classify};
10 + use alloy_tui::{AlloyLog, AlloyStatusBar, Hint, Severity, Theme, hint, layout};
11 + use anyhow::Result;
12 + use ratatui::Frame;
13 + use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind};
14 + use ratatui::layout::Rect;
15 +
16 + use crate::cli::CommandLog;
17 +
18 + /// What a view wants the shell to do after handling a key.
19 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20 + pub enum Flow {
21 + Continue,
22 + Exit,
23 + }
24 +
25 + /// A console screen. Views own their data and their body; the shell owns the
26 + /// frame around it.
27 + pub trait View {
28 + /// Title for the body block.
29 + fn title(&self) -> String;
30 +
31 + /// Key hints for the footer. The shell appends the reserved global hints,
32 + /// so a view lists only its own keys.
33 + fn hints(&self) -> Vec<Hint>;
34 +
35 + /// Transient status for the right end of the footer, if any.
36 + fn status(&self) -> Option<(Severity, String)> {
37 + None
38 + }
39 +
40 + /// Draw the body into `area`.
41 + fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme);
42 +
43 + /// Handle a key the shell did not claim.
44 + fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow;
45 + }
46 +
47 + /// Run a view to completion: set up the terminal, loop, and restore.
48 + ///
49 + /// The terminal is restored even when the loop fails, so a backend error does
50 + /// not strand the user in raw mode with no echo.
51 + pub fn run(theme: &Theme, view: &mut dyn View, log: &mut CommandLog) -> Result<()> {
52 + let mut terminal = ratatui::init();
53 + let result = event_loop(&mut terminal, theme, view, log);
54 + ratatui::restore();
55 + result
56 + }
57 +
58 + fn event_loop(
59 + terminal: &mut ratatui::DefaultTerminal,
60 + theme: &Theme,
61 + view: &mut dyn View,
62 + log: &mut CommandLog,
63 + ) -> Result<()> {
64 + loop {
65 + terminal.draw(|frame| draw(frame, theme, view, log))?;
66 +
67 + let Event::Key(key) = event::read()? else {
68 + continue;
69 + };
70 + // Windows terminals report press *and* release for every key; acting on
71 + // both runs each action twice.
72 + if key.kind != KeyEventKind::Press {
73 + continue;
74 + }
75 +
76 + match classify(key) {
77 + Action::Quit | Action::Cancel => return Ok(()),
78 + _ => {
79 + if view.handle(key, log) == Flow::Exit {
80 + return Ok(());
81 + }
82 + }
83 + }
84 + }
85 + }
86 +
87 + fn draw(frame: &mut Frame, theme: &Theme, view: &dyn View, log: &mut CommandLog) {
88 + let areas = layout::console(frame.area());
89 +
90 + view.render(frame, areas.body, theme);
91 + frame.render_widget(AlloyLog::new(theme, log.entries()), areas.log);
92 +
93 + let mut hints = view.hints();
94 + hints.push(hint("q", "quit"));
95 + let mut status_bar = AlloyStatusBar::new(theme, hints);
96 + if let Some((severity, message)) = view.status() {
97 + status_bar = status_bar.status(severity, message);
98 + }
99 + frame.render_widget(status_bar, areas.footer);
100 + }
101 +
102 + /// Title text for a view's body block, padded so it does not sit flush against
103 + /// the border corner.
104 + pub fn block_title(title: &str) -> String {
105 + format!(" {title} ")
106 + }
@@ -1,0 +1,109 @@
1 + //! Resolving which theme the console renders in, and where it loads from.
2 + //!
3 + //! docs/TOKENS.md: no hex values are hard-coded in Rust. There is deliberately
4 + //! no built-in fallback palette here — a missing or malformed theme is an
5 + //! error the user sees, not something the console papers over by rendering in
6 + //! colors that exist nowhere in the theme files.
7 +
8 + use std::path::PathBuf;
9 +
10 + use alloy_tui::Theme;
11 + use anyhow::{Context, Result};
12 +
13 + /// Default light theme (docs/TOKENS.md).
14 + pub const DEFAULT_LIGHT: &str = "akari-dawn";
15 +
16 + /// Default dark theme (docs/TOKENS.md).
17 + pub const DEFAULT_DARK: &str = "akari-night";
18 +
19 + /// Theme search path, highest precedence first: the user's own themes, then
20 + /// the ones the image ships, then the in-repo checkout when running from a dev
21 + /// tree. The `bool` is theme-common's is-custom flag.
22 + fn search_path() -> Vec<(PathBuf, bool)> {
23 + let mut dirs = Vec::new();
24 +
25 + if let Some(config) = dirs_config_home() {
26 + dirs.push((config.join("alloy").join("themes"), true));
27 + }
28 + dirs.push((PathBuf::from("/usr/share/alloy/themes"), false));
29 +
30 + // Dev fallback: crates/alloy -> alloy -> Code, then MNW/shared/themes.
31 + // Only resolves when that tree exists, so it is inert on an installed
32 + // system.
33 + if let Some(dev) = theme_common::dev_themes_dir(env!("CARGO_MANIFEST_DIR").as_ref(), 3) {
34 + dirs.push((dev, false));
35 + }
36 +
37 + dirs
38 + }
39 +
40 + fn dirs_config_home() -> Option<PathBuf> {
41 + // XDG_CONFIG_HOME wins when set and absolute; the spec says a relative
42 + // value is invalid and must be ignored rather than resolved against cwd.
43 + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
44 + let path = PathBuf::from(xdg);
45 + if path.is_absolute() {
46 + return Some(path);
47 + }
48 + }
49 + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
50 + }
51 +
52 + /// Load a theme by id, or the mode-appropriate default when `id` is `None`.
53 + pub fn load(id: Option<&str>) -> Result<Theme> {
54 + let id = id.map(str::to_string).unwrap_or_else(default_theme_id);
55 + let dirs = search_path();
56 +
57 + let colors = theme_common::load_theme(&dirs, &id)
58 + .map_err(anyhow::Error::msg)
59 + .with_context(|| {
60 + let searched: Vec<String> = dirs
61 + .iter()
62 + .map(|(path, _)| path.display().to_string())
63 + .collect();
64 + format!("loading theme `{id}` (searched: {})", searched.join(", "))
65 + })?;
66 +
67 + Theme::from_theme(&colors).with_context(|| format!("theme `{id}` is incomplete"))
68 + }
69 +
70 + /// Guess whether the terminal is dark, and pick the matching Akari default.
71 + ///
72 + /// `COLORFGBG` is the only signal available without writing an OSC query to
73 + /// the terminal and waiting on a reply, which is not worth doing before the
74 + /// first frame. Its background field is a color index: 0-6 and 8 are the dark
75 + /// ones. When the variable is absent or unparseable, light is the documented
76 + /// default.
77 + fn default_theme_id() -> String {
78 + let dark = std::env::var("COLORFGBG")
79 + .ok()
80 + .and_then(|value| {
81 + value
82 + .rsplit(';')
83 + .next()
84 + .and_then(|bg| bg.trim().parse::<u8>().ok())
85 + })
86 + .is_some_and(|bg| bg <= 6 || bg == 8);
87 +
88 + if dark { DEFAULT_DARK.into() } else { DEFAULT_LIGHT.into() }
89 + }
90 +
91 + #[cfg(test)]
92 + mod tests {
93 + use super::*;
94 +
95 + // The console ships against these two ids; a rename in MNW/shared/themes
96 + // that misses this crate should fail here rather than at first launch.
97 + #[test]
98 + fn shipped_defaults_load_and_resolve() {
99 + for id in [DEFAULT_LIGHT, DEFAULT_DARK] {
100 + let theme = load(Some(id));
101 + assert!(theme.is_ok(), "default theme `{id}` failed to load: {theme:?}");
102 + }
103 + }
104 +
105 + #[test]
106 + fn a_missing_theme_is_an_error_not_a_fallback() {
107 + assert!(load(Some("no-such-theme")).is_err());
108 + }
109 + }
@@ -1,0 +1,103 @@
1 + //! The focus ring — the one piece ratatui does not provide.
2 + //!
3 + //! Rendering is immediate-mode, but input is event-driven, so something has to
4 + //! remember which pane the next keystroke belongs to. Per
5 + //! docs/COMPONENT-LIBRARY.md that model lives here, and it is deliberately
6 + //! minimal: an index into a fixed number of focusable slots, wrapping in both
7 + //! directions. Apps map their own pane enum onto the index.
8 +
9 + /// A wrapping cursor over `len` focusable slots.
10 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 + pub struct FocusRing {
12 + len: usize,
13 + index: usize,
14 + }
15 +
16 + impl FocusRing {
17 + /// A ring over `len` slots, focused on the first.
18 + ///
19 + /// A zero-length ring is legal and inert: `current()` reports 0 and the
20 + /// movers do nothing, so a view that has not yet loaded its panes does not
21 + /// have to special-case navigation.
22 + pub const fn new(len: usize) -> Self {
23 + Self { len, index: 0 }
24 + }
25 +
26 + pub const fn current(&self) -> usize {
27 + self.index
28 + }
29 +
30 + pub const fn is_focused(&self, slot: usize) -> bool {
31 + self.len > 0 && self.index == slot
32 + }
33 +
34 + pub const fn next(&mut self) {
35 + if self.len > 0 {
36 + self.index = (self.index + 1) % self.len;
37 + }
38 + }
39 +
40 + pub const fn prev(&mut self) {
41 + if self.len > 0 {
42 + self.index = (self.index + self.len - 1) % self.len;
43 + }
44 + }
45 +
46 + /// Focus a specific slot. Out-of-range indices are ignored rather than
47 + /// clamped — silently landing on a neighbouring pane is worse than not
48 + /// moving.
49 + pub const fn focus(&mut self, slot: usize) {
50 + if slot < self.len {
51 + self.index = slot;
52 + }
53 + }
54 +
55 + /// Resize the ring, keeping focus in range. Used when a view's pane count
56 + /// changes (a detail pane appearing, a section collapsing).
57 + pub const fn resize(&mut self, len: usize) {
58 + self.len = len;
59 + if self.index >= len {
60 + self.index = if len == 0 { 0 } else { len - 1 };
61 + }
62 + }
63 + }
64 +
65 + #[cfg(test)]
66 + mod tests {
67 + use super::*;
68 +
69 + #[test]
70 + fn wraps_in_both_directions() {
71 + let mut ring = FocusRing::new(3);
72 + ring.prev();
73 + assert_eq!(ring.current(), 2, "prev from the first slot wraps to the last");
74 + ring.next();
75 + assert_eq!(ring.current(), 0, "next from the last slot wraps to the first");
76 + }
77 +
78 + // An empty ring is what a view has before its panes load. The movers must be
79 + // no-ops rather than panicking on a modulo by zero.
80 + #[test]
81 + fn empty_ring_is_inert() {
82 + let mut ring = FocusRing::new(0);
83 + ring.next();
84 + ring.prev();
85 + assert_eq!(ring.current(), 0);
86 + assert!(!ring.is_focused(0), "nothing is focused when there are no slots");
87 + }
88 +
89 + #[test]
90 + fn resize_pulls_focus_back_into_range() {
91 + let mut ring = FocusRing::new(4);
92 + ring.focus(3);
93 + ring.resize(2);
94 + assert_eq!(ring.current(), 1, "focus clamps to the new last slot");
95 + }
96 +
97 + #[test]
98 + fn focus_ignores_out_of_range_slots() {
99 + let mut ring = FocusRing::new(2);
100 + ring.focus(5);
101 + assert_eq!(ring.current(), 0, "an out-of-range focus leaves the ring where it was");
102 + }
103 + }
@@ -1,0 +1,93 @@
1 + //! Reserved keymap: the constants every Alloy TUI navigates by, and the
2 + //! classifier that turns a raw key event into one of them.
3 + //!
4 + //! Per docs/COMPONENT-LIBRARY.md the reserved keys live in exactly one place so
5 + //! apps match against `Action` rather than hardcoding keycodes: `Tab` /
6 + //! `Shift-Tab` move focus, `Enter` activates, `Esc` cancels, `Ctrl-S` saves,
7 + //! `q` quits, `?` opens help, `/` filters, `:` opens the command line.
8 + //!
9 + //! Descended from sysop-tui's `keys.rs`, widened from that crate's six actions
10 + //! to the full reserved set the console's form surfaces need.
11 +
12 + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13 +
14 + /// A reserved key's meaning. `Passthrough` means the key is not reserved and
15 + /// belongs to whatever view currently holds focus.
16 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17 + pub enum Action {
18 + NextFocus,
19 + PrevFocus,
20 + Activate,
21 + Cancel,
22 + Save,
23 + Help,
24 + Quit,
25 + Filter,
26 + Command,
27 + Passthrough,
28 + }
29 +
30 + /// Classify a key event against the reserved keymap.
31 + ///
32 + /// This is a pure classifier with no notion of mode: it reports what a key
33 + /// *means* in the reserved map, not whether the app should honor it. Two
34 + /// caller obligations follow from that:
35 + ///
36 + /// - **Filter to `KeyEventKind::Press` first.** Windows terminals deliver both
37 + /// press and release for every key, so an unfiltered event loop performs
38 + /// each action twice.
39 + /// - **Ignore the character actions while text entry has focus.** `q`, `/`,
40 + /// and `:` are literal characters a user types into a field; a view holding
41 + /// an active text input should route keys to the input and consult this
42 + /// classifier only for `Cancel`, `Save`, and the focus movers.
43 + pub fn classify(key: KeyEvent) -> Action {
44 + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
45 + let shift = key.modifiers.contains(KeyModifiers::SHIFT);
46 +
47 + match key.code {
48 + KeyCode::Char('s') | KeyCode::Char('S') if ctrl => Action::Save,
49 + KeyCode::BackTab => Action::PrevFocus,
50 + KeyCode::Tab if shift => Action::PrevFocus,
51 + KeyCode::Tab => Action::NextFocus,
52 + KeyCode::Enter => Action::Activate,
53 + KeyCode::Esc => Action::Cancel,
54 + KeyCode::Char('?') => Action::Help,
55 + KeyCode::Char('q') => Action::Quit,
56 + KeyCode::Char('/') => Action::Filter,
57 + KeyCode::Char(':') => Action::Command,
58 + _ => Action::Passthrough,
59 + }
60 + }
61 +
62 + #[cfg(test)]
63 + mod tests {
64 + use super::*;
65 +
66 + fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
67 + KeyEvent::new(code, mods)
68 + }
69 +
70 + #[test]
71 + fn ctrl_s_saves_but_bare_s_does_not() {
72 + assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)), Action::Save);
73 + assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::NONE)), Action::Passthrough);
74 + }
75 +
76 + // Terminals disagree on how they report Shift-Tab: some send BackTab with no
77 + // modifier, others send Tab with SHIFT. Both must reach PrevFocus, or focus
78 + // navigation silently becomes one-directional on half the terminal emulators
79 + // in the stack.
80 + #[test]
81 + fn both_shift_tab_encodings_move_focus_backward() {
82 + assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus);
83 + assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)), Action::PrevFocus);
84 + assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::SHIFT)), Action::PrevFocus);
85 + assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus);
86 + }
87 +
88 + #[test]
89 + fn unreserved_keys_pass_through() {
90 + assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough);
91 + assert_eq!(classify(key(KeyCode::Down, KeyModifiers::NONE)), Action::Passthrough);
92 + }
93 + }
@@ -1,0 +1,95 @@
1 + //! The console frame: body, command-log pane, footer.
2 + //!
3 + //! sysop-tui split a screen into body plus a one-row footer. Alloy adds the
4 + //! command-log pane between them, which docs/CONSOLE.md settled as always-on —
5 + //! the "console teaches its own primitives" claim only lands if the log of
6 + //! underlying CLI invocations is visible without being asked for.
7 +
8 + use ratatui::layout::{Constraint, Layout, Rect};
9 +
10 + /// Default height of the command-log pane, borders included: a two-row window
11 + /// onto the log plus its box. Enough to show the command just run and the one
12 + /// before it, which is what makes the pane read as a running transcript rather
13 + /// than a status line.
14 + pub const LOG_HEIGHT: u16 = 4;
15 +
16 + /// Below this total height the log pane is dropped entirely. The body needs
17 + /// room to be worth drawing; on a short terminal the log is the first thing
18 + /// that should go, and it degrades to nothing rather than to a sliver of
19 + /// borders with no content between them.
20 + const MIN_HEIGHT_FOR_LOG: u16 = 12;
21 +
22 + /// The three regions of a console screen. `log` is empty (zero height) when
23 + /// the terminal is too short to carry it — callers can render into it
24 + /// unconditionally, since ratatui clips a zero-area render.
25 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
26 + pub struct ConsoleAreas {
27 + pub body: Rect,
28 + pub log: Rect,
29 + pub footer: Rect,
30 + }
31 +
32 + /// Split a full-screen area into body, log pane, and footer.
33 + pub fn console(area: Rect) -> ConsoleAreas {
34 + console_with_log_height(area, LOG_HEIGHT)
35 + }
36 +
37 + /// As [`console`], with an explicit log-pane height.
38 + pub fn console_with_log_height(area: Rect, log_height: u16) -> ConsoleAreas {
39 + if area.height < MIN_HEIGHT_FOR_LOG || log_height == 0 {
40 + let [body, footer] =
41 + Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area);
42 + return ConsoleAreas {
43 + body,
44 + log: Rect { height: 0, ..body },
45 + footer,
46 + };
47 + }
48 +
49 + let [body, log, footer] = Layout::vertical([
50 + Constraint::Min(0),
51 + Constraint::Length(log_height),
52 + Constraint::Length(1),
53 + ])
54 + .areas(area);
55 +
56 + ConsoleAreas { body, log, footer }
57 + }
58 +
59 + #[cfg(test)]
60 + mod tests {
61 + use super::*;
62 +
63 + #[test]
64 + fn full_height_gets_all_three_regions() {
65 + let areas = console(Rect::new(0, 0, 80, 24));
66 + assert_eq!(areas.body.height, 24 - LOG_HEIGHT - 1);
67 + assert_eq!(areas.log.height, LOG_HEIGHT);
68 + assert_eq!(areas.footer.height, 1);
69 + assert_eq!(
70 + areas.body.height + areas.log.height + areas.footer.height,
71 + 24,
72 + "the three regions must tile the screen exactly, with no dead row"
73 + );
74 + }
75 +
76 + // A short terminal drops the log rather than starving the body. Without this
77 + // the layout hands the body 1-2 rows and spends the rest on log borders.
78 + #[test]
79 + fn short_terminal_drops_the_log_pane() {
80 + let areas = console(Rect::new(0, 0, 80, 10));
81 + assert_eq!(areas.log.height, 0, "log is dropped below the minimum height");
82 + assert_eq!(areas.body.height, 9);
83 + assert_eq!(areas.footer.height, 1);
84 + }
85 +
86 + // The degenerate case: a terminal so short there is only the footer. This
87 + // must not panic or produce a negative-height body.
88 + #[test]
89 + fn single_row_terminal_yields_footer_only() {
90 + let areas = console(Rect::new(0, 0, 80, 1));
91 + assert_eq!(areas.body.height, 0);
92 + assert_eq!(areas.log.height, 0);
93 + assert_eq!(areas.footer.height, 1);
94 + }
95 + }
@@ -1,0 +1,36 @@
1 + //! Selection chrome: the marker glyph and the selected-row style.
2 + //!
3 + //! Ported from sysop-tui's `selection.rs`, retinted from the theme. The marker
4 + //! stays a plain triangle rather than a Nerd Font glyph so selection survives
5 + //! a console without the patched font — the TTY before the session starts, a
6 + //! remote shell, `alloy` over SSH. Per docs/ICONOGRAPHY.md, Nerd Font glyphs
7 + //! decorate; they never carry state on their own.
8 +
9 + use ratatui::style::{Modifier, Style};
10 +
11 + use crate::theme::Theme;
12 +
13 + /// The selected-row marker. Rendered in the gutter, one cell plus a space.
14 + pub const MARKER: &str = "▶";
15 +
16 + /// Blank gutter for unselected rows — the same width as [`MARKER`], so rows do
17 + /// not shift horizontally as selection moves.
18 + pub const MARKER_BLANK: &str = " ";
19 +
20 + /// Style for the selected row.
21 + ///
22 + /// Uses `surface.raised` as the selection field rather than an accent fill:
23 + /// DESIGN-LANGUAGE.md keeps color off chrome, so selection reads as a *raised*
24 + /// surface plus weight, and any `Severity` color already on the row survives
25 + /// unchanged instead of being drowned by an accent background.
26 + pub fn selected_style(theme: &Theme) -> Style {
27 + Style::default()
28 + .bg(theme.surface_raised)
29 + .fg(theme.content_primary)
30 + .add_modifier(Modifier::BOLD)
31 + }
32 +
33 + /// Style for an unselected row.
34 + pub fn unselected_style(theme: &Theme) -> Style {
35 + Style::default().fg(theme.content_secondary)
36 + }
@@ -1,0 +1,42 @@
1 + //! Themed span helpers.
2 + //!
3 + //! sysop-tui's `style.rs` offered the same set (`fg`, `dim`, `bold`, `ok`,
4 + //! `warn`, `fault`) against a const palette. Alloy's palette is loaded at
5 + //! runtime, so each helper takes the `&Theme` instead. The status-colored
6 + //! variants are gone: they collapse into [`Severity`](crate::Severity), which
7 + //! already owns the mapping from intent to `status.*` color.
8 +
9 + use ratatui::style::{Modifier, Style};
10 + use ratatui::text::Span;
11 +
12 + use crate::theme::Theme;
13 +
14 + /// Body text in the primary content color.
15 + pub fn primary(theme: &Theme, s: impl Into<String>) -> Span<'static> {
16 + Span::styled(s.into(), Style::default().fg(theme.content_primary))
17 + }
18 +
19 + /// De-emphasized text — labels, units, inactive rows.
20 + pub fn muted(theme: &Theme, s: impl Into<String>) -> Span<'static> {
21 + Span::styled(s.into(), Style::default().fg(theme.content_muted))
22 + }
23 +
24 + /// Supporting text: dimmer than primary, louder than muted.
25 + pub fn secondary(theme: &Theme, s: impl Into<String>) -> Span<'static> {
26 + Span::styled(s.into(), Style::default().fg(theme.content_secondary))
27 + }
28 +
29 + /// Emphasized body text.
30 + pub fn bold(theme: &Theme, s: impl Into<String>) -> Span<'static> {
31 + Span::styled(
32 + s.into(),
33 + Style::default().fg(theme.content_primary).add_modifier(Modifier::BOLD),
34 + )
35 + }
36 +
37 + /// A key hint or other interactive affordance. The one accent-on-text use that
38 + /// is not a `Severity` — DESIGN-LANGUAGE.md allows the action color here
39 + /// because a key hint *is* the actionable element, not decoration.
40 + pub fn action(theme: &Theme, s: impl Into<String>) -> Span<'static> {
41 + Span::styled(s.into(), Style::default().fg(theme.action_primary))
42 + }