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
Commit: 071c59adc5ac18a7e478a3e9842fc64a1e4968f6
Parent: bb1f524
7 files changed, +709 insertions, -4 deletions
A src/focus.rs +103
@@ -0,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 + }
A src/keys.rs +93
@@ -0,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 + }
@@ -0,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 + }
M src/lib.rs +16
@@ -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::*;
@@ -0,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 + }
A src/text.rs +42
@@ -0,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 + }
M src/widgets.rs +324 -4
@@ -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 @@ impl Severity {
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 + }