Skip to main content

max / alloy

shell: let a view claim Esc and the character keys Groundwork for `alloy install`, which is the first console screen that is not a surface onto live state. An installer is a fixed sequence of questions answered in order, and two of the shell's reserved-key claims are wrong for that shape. Esc closed the view unconditionally, so a wizard had no way to spell "back". `View::cancel` now answers it, defaulting to Flow::Exit — every shipped view is unchanged byte for byte — and a wizard overrides it to step back, returning Exit only from the first step. "Esc backs out until there is nothing left to back out of, then it leaves" falls out of the default rather than being special-cased in the loop. `q` is a letter in a hostname. alloy_tui's classify() documents ignoring the character actions during text entry as an obligation of whoever calls it, and the shell is that caller for every view, so it honors it here rather than leaving each view to remember. `View::text_entry` gates only the quit claim; Esc, Ctrl-S and the focus movers are not characters and keep working mid-word. Both claims route through a pure `reserved()`, for the reason the file already gives for modal_key: this is where the consequences are, and a rule that wants testing does not belong somewhere that needs a live terminal to reach. One test pins today's Esc behavior so the hooks cannot quietly change what the four shipped screens do. Flow::Exit loses its allow(dead_code) and the comment saying it had no production consumer. The shell produces it now. wizard.rs holds Steps, the linear step cursor — a third type beside FocusRing and Cursor rather than a reuse of either. A focus ring wraps, because Tab past the last pane means the first; a list cursor clamps over rows that come and go on refresh; a step sequence has a fixed length and must not wrap, since one Enter too many would restart the installer instead of running it. It knows nothing about what a step is, so validation stays in the view and the navigation model tests without a terminal, a form, or a disk. `furthest` is tracked apart from `current` for the summary step: reading the summary, stepping back to fix a hostname and advancing again must not flicker the steps ahead between answered and pending. It carries a module-level allow(dead_code) until install.rs constructs the first one. The model landed ahead of its consumer on purpose, so that what Esc and Enter mean in a wizard could be settled and tested on its own. Two things found and left alone. The repo is not rustfmt-clean and was not before this — six files differ, mostly rustfmt's 60-column fn_call_width against code written to 100. Either a rustfmt.toml is missing or the decision was not to run it; worth settling, but not underneath this diff. And CONSOLE.md:159 still claims `alloy pkg` forced "a Cancel that views see before the shell claims it", which the code never did. The mechanism exists now, but pkg does not use it, so the sentence remains an overstatement. 150 tests pass, 6 ignored, clippy clean.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 00:13 UTC
Signed with PGP, not checked
Commit: d3b637cc9eead70162578f60501df6d13b624d4c
Parent: 9bee8a5
3 files changed, +368 insertions, -13 deletions
@@ -13,6 +13,7 @@
13 13 mod pkg;
14 14 mod shell;
15 15 mod theme;
16 + mod wizard;
16 17
17 18 use anyhow::Result;
18 19 use clap::{Parser, Subcommand};
@@ -24,14 +24,12 @@
24 24 /// rather than compare.
25 25 ///
26 26 /// `Confirm` and `Suspend` are `alloy pkg`'s: removing a box raises the first,
27 - /// entering one raises the second. `Exit` still has no production consumer —
28 - /// every view so far leaves through the shell's own quit key — and is kept
29 - /// because a view that needs to close itself should not have to add a variant
30 - /// to do it. Covered by tests in this module.
27 + /// entering one raises the second. `Exit` is the shell's own answer to Esc,
28 + /// via [`View::cancel`]'s default, and is what a view returns from that hook
29 + /// when it has nothing left to back out of.
31 30 #[derive(Debug)]
32 31 pub enum Flow {
33 32 Continue,
34 - #[allow(dead_code)]
35 33 Exit,
36 34 /// Open a confirmation modal. The view keeps whatever it was about to do
37 35 /// and performs it in [`View::confirmed`] if the user agrees.
@@ -87,6 +85,34 @@
87 85 /// Handle a key the shell did not claim.
88 86 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow;
89 87
88 + /// The user pressed Esc outside a modal.
89 + ///
90 + /// Default is to close the view, which is what Esc has always meant in the
91 + /// console and what every list-shaped view still wants. The hook exists for
92 + /// the shape that reads Esc as "back": a wizard steps to the previous
93 + /// question and returns [`Flow::Continue`], and returns [`Flow::Exit`] only
94 + /// once it is on the first step. "Esc backs out until there is nothing left
95 + /// to back out of, then it leaves" then falls out of the default rather
96 + /// than being special-cased in the loop.
97 + fn cancel(&mut self) -> Flow {
98 + Flow::Exit
99 + }
100 +
101 + /// The view has an active text field, so the character keys in the reserved
102 + /// map are letters rather than commands.
103 + ///
104 + /// [`classify`](alloy_tui::keys::classify) documents this as an obligation
105 + /// of whoever calls it: `q`, `/`, `:`, `h`, and `l` are all characters a
106 + /// user types into a field. The shell is that caller for every view, so it
107 + /// honors the obligation here rather than leaving each view to remember it.
108 + /// Only the quit claim is affected. Esc, Ctrl-S, and the focus movers are
109 + /// not characters and keep working mid-word.
110 + ///
111 + /// Default is false, since no shipped view takes text input yet.
112 + fn text_entry(&self) -> bool {
113 + false
114 + }
115 +
90 116 /// The user confirmed the modal this view raised with [`Flow::Confirm`].
91 117 ///
92 118 /// Default is nothing, so a view with no destructive actions ignores the
@@ -179,18 +205,52 @@
179 205 continue;
180 206 }
181 207
182 - match action {
183 - Action::Quit | Action::Cancel => return Ok(()),
184 - _ => match view.handle(key, log) {
185 - Flow::Exit => return Ok(()),
186 - Flow::Continue => {}
187 - Flow::Confirm(confirm) => modal = Some(confirm),
188 - Flow::Suspend(command) => suspend(terminal, view, log, command)?,
189 - },
208 + // Both reserved claims are the view's to decline, so the routing is
209 + // decided first and the flow it produces is handled in one place.
210 + let flow = match reserved(action, view.text_entry()) {
211 + Reserved::Cancel => view.cancel(),
212 + Reserved::Quit => Flow::Exit,
213 + Reserved::Pass => view.handle(key, log),
214 + };
215 +
216 + match flow {
217 + Flow::Exit => return Ok(()),
218 + Flow::Continue => {}
219 + Flow::Confirm(confirm) => modal = Some(confirm),
220 + Flow::Suspend(command) => suspend(terminal, view, log, command)?,
190 221 }
191 222 }
192 223 }
193 224
225 + /// What the shell does with a key before the view sees it.
226 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
227 + enum Reserved {
228 + /// Esc: ask the view, whose default is to close.
229 + Cancel,
230 + /// `q` with no text field open: close the view.
231 + Quit,
232 + /// Not the shell's, hand it to the view.
233 + Pass,
234 + }
235 +
236 + /// Route a classified action against the shell's two reserved claims.
237 + ///
238 + /// Pure and separate from the loop for the same reason [`modal_key`] is: this
239 + /// is where the consequences live. It decides whether `q` closes the console or
240 + /// lands in the hostname field the user is typing, and a rule that wants
241 + /// testing does not belong somewhere that needs a live terminal to reach.
242 + ///
243 + /// Esc is routed to the view unconditionally rather than gated on
244 + /// `text_entry`. It is not a character, so a text field has no claim on it, and
245 + /// a wizard needs it as "back" on every step including the ones it types on.
246 + const fn reserved(action: Action, text_entry: bool) -> Reserved {
247 + match action {
248 + Action::Cancel => Reserved::Cancel,
249 + Action::Quit if !text_entry => Reserved::Quit,
250 + _ => Reserved::Pass,
251 + }
252 + }
253 +
194 254 /// What a key does to an open modal.
195 255 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
196 256 enum ModalOutcome {
@@ -414,6 +474,94 @@
414 474 Bare.cancelled();
415 475 }
416 476
477 + // The behavior every shipped view relies on, pinned so the wizard hooks
478 + // cannot quietly change what Esc means for the four screens already out.
479 + #[test]
480 + fn esc_closes_a_view_that_does_not_claim_it() {
481 + assert_eq!(reserved(Action::Cancel, false), Reserved::Cancel);
482 + assert!(matches!(StubView::default().cancel(), Flow::Exit));
483 + }
484 +
485 + // A wizard reads Esc as "back" and stays open, which is the whole reason
486 + // the hook is a `Flow` rather than the loop returning directly.
487 + #[test]
488 + fn a_view_can_decline_esc_and_stay_open() {
489 + struct Backs {
490 + depth: usize,
491 + }
492 + impl View for Backs {
493 + fn title(&self) -> String {
494 + "backs".into()
495 + }
496 + fn hints(&self) -> Vec<Hint> {
497 + Vec::new()
498 + }
499 + fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {}
500 + fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow {
501 + Flow::Continue
502 + }
503 + fn cancel(&mut self) -> Flow {
504 + match self.depth.checked_sub(1) {
505 + Some(next) => {
506 + self.depth = next;
507 + Flow::Continue
508 + }
509 + None => Flow::Exit,
510 + }
511 + }
512 + }
513 +
514 + let mut view = Backs { depth: 2 };
515 + assert!(matches!(view.cancel(), Flow::Continue));
516 + assert!(matches!(view.cancel(), Flow::Continue));
517 + assert!(
518 + matches!(view.cancel(), Flow::Exit),
519 + "backing out of the first step leaves"
520 + );
521 + }
522 +
523 + // `q` is the console's quit key everywhere except inside a text field,
524 + // where it is a letter. Without this the first user to type a hostname
525 + // containing `q` loses the installer mid-word.
526 + #[test]
527 + fn q_quits_unless_a_text_field_is_open() {
528 + assert_eq!(reserved(Action::Quit, false), Reserved::Quit);
529 + assert_eq!(reserved(Action::Quit, true), Reserved::Pass);
530 + }
531 +
532 + // Esc is not a character, so a text field has no claim on it: a wizard
533 + // needs "back" on the steps it types on as much as the ones it does not.
534 + #[test]
535 + fn text_entry_does_not_swallow_esc() {
536 + assert_eq!(reserved(Action::Cancel, true), Reserved::Cancel);
537 + }
538 +
539 + #[test]
540 + fn everything_else_reaches_the_view() {
541 + for action in [
542 + Action::NextFocus,
543 + Action::PrevFocus,
544 + Action::NextTab,
545 + Action::PrevTab,
546 + Action::Activate,
547 + Action::Save,
548 + Action::Filter,
549 + Action::Command,
550 + Action::Help,
551 + Action::Passthrough,
552 + ] {
553 + assert_eq!(reserved(action, false), Reserved::Pass, "{action:?}");
554 + assert_eq!(reserved(action, true), Reserved::Pass, "{action:?}");
555 + }
556 + }
557 +
558 + // No shipped view takes text input, so the default must leave all four of
559 + // them on exactly the reserved map they were written against.
560 + #[test]
561 + fn views_default_to_letting_the_shell_claim_q() {
562 + assert!(!StubView::default().text_entry());
563 + }
564 +
417 565 #[test]
418 566 fn a_destructive_confirm_carries_the_error_accent() {
419 567 let confirm = Confirm::destructive("remove", "Remove tailscale?");
@@ -1,0 +1,206 @@
1 + //! A linear step sequence: the navigation model behind `alloy install`.
2 + //!
3 + //! Every console view so far is a surface onto live state, where the user
4 + //! moves between panes and acts on rows. An installer is the other shape: a
5 + //! fixed sequence of questions, answered in order, with the destructive act at
6 + //! the end. [`Steps`] is that shape's cursor.
7 + //!
8 + //! Deliberately parallel to [`Cursor`](alloy_tui::Cursor) and
9 + //! [`FocusRing`](alloy_tui::FocusRing), and deliberately a third type rather
10 + //! than a reuse of either. A focus ring wraps, because Tab past the last pane
11 + //! means the first. A list cursor clamps over rows that appear and disappear
12 + //! on refresh. A step sequence does neither: its length is fixed at
13 + //! construction, it never wraps (wrapping from the last step to the first
14 + //! would turn "one more Enter" into "start over"), and advancing is gated on
15 + //! the current step being answerable, which is a question neither of the other
16 + //! two has to ask.
17 + //!
18 + //! Lives in the console binary rather than in `alloy_tui`. The design-system
19 + //! crate is published separately, so promoting a type into it is a release;
20 + //! this one waits until a second consumer wants it, which would be
21 + //! `alloy config`'s multi-page forms if those land.
22 +
23 + // The navigation model landed before the view that drives it, so that the
24 + // question of what Esc and Enter mean in a wizard could be settled and tested
25 + // on its own. Comes off with `install.rs`, which constructs the first one.
26 + #![allow(dead_code)]
27 +
28 + /// A position in a fixed sequence of steps.
29 + ///
30 + /// Knows nothing about what a step *is*. Whether the current step is answered
31 + /// is the view's question, so [`advance`](Self::advance) is called only once
32 + /// the view has decided the answer holds. Keeping validation out here is what
33 + /// lets the whole navigation model be tested without a terminal, a form, or a
34 + /// disk.
35 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 + pub struct Steps {
37 + index: usize,
38 + len: usize,
39 + /// The furthest step reached, which is not always the current one.
40 + ///
41 + /// The summary step is the case this exists for. A user reads the summary,
42 + /// steps back to fix a hostname, and advances again; without this, the
43 + /// step indicator would show the steps ahead of them flipping from done to
44 + /// pending and back on the way through. What a user has answered stays
45 + /// answered when they step behind it.
46 + furthest: usize,
47 + }
48 +
49 + impl Steps {
50 + /// A sequence of `len` steps, positioned on the first.
51 + ///
52 + /// A zero-step sequence is inert rather than a panic: every mover no-ops
53 + /// and both ends report true. A wizard with no steps is a programming
54 + /// error, but it is not one worth taking the process down for, and
55 + /// [`Cursor`](alloy_tui::Cursor) sets the precedent of staying total over
56 + /// an empty range.
57 + pub const fn new(len: usize) -> Self {
58 + Self {
59 + index: 0,
60 + len,
61 + furthest: 0,
62 + }
63 + }
64 +
65 + pub const fn current(&self) -> usize {
66 + self.index
67 + }
68 +
69 + pub const fn len(&self) -> usize {
70 + self.len
71 + }
72 +
73 + /// The furthest step the user has reached, current or behind them.
74 + ///
75 + /// For the step indicator: steps at or below this are answered, the rest
76 + /// are not yet visited.
77 + pub const fn furthest(&self) -> usize {
78 + self.furthest
79 + }
80 +
81 + /// On the first step, so Esc leaves the installer rather than stepping
82 + /// back. Also true of an empty sequence, which is nowhere.
83 + pub const fn is_first(&self) -> bool {
84 + self.index == 0
85 + }
86 +
87 + /// On the last step, so Enter runs the install rather than advancing.
88 + ///
89 + /// The `len == 0` guard matters: without it an empty sequence reports
90 + /// "not on the last step" while having no steps to advance through, and a
91 + /// view driving off that would offer a next that never arrives.
92 + pub const fn is_last(&self) -> bool {
93 + self.len == 0 || self.index + 1 == self.len
94 + }
95 +
96 + /// Move to the next step, reporting whether there was one.
97 + ///
98 + /// Returning `bool` rather than nothing is what lets a view spell the last
99 + /// step's Enter as "advance, and if it did not move, run the install"
100 + /// without asking [`is_last`](Self::is_last) separately and racing its own
101 + /// state.
102 + pub const fn advance(&mut self) -> bool {
103 + if self.is_last() {
104 + return false;
105 + }
106 + self.index += 1;
107 + if self.index > self.furthest {
108 + self.furthest = self.index;
109 + }
110 + true
111 + }
112 +
113 + /// Move to the previous step, reporting whether there was one.
114 + ///
115 + /// `false` on the first step is the shell's cue to close the view: Esc
116 + /// backs out until there is nothing left to back out of, then it leaves.
117 + pub const fn back(&mut self) -> bool {
118 + if self.is_first() {
119 + return false;
120 + }
121 + self.index -= 1;
122 + true
123 + }
124 + }
125 +
126 + #[cfg(test)]
127 + mod tests {
128 + use super::*;
129 +
130 + #[test]
131 + fn starts_on_the_first_step() {
132 + let steps = Steps::new(4);
133 + assert_eq!(steps.current(), 0);
134 + assert!(steps.is_first());
135 + assert!(!steps.is_last());
136 + }
137 +
138 + // The property that separates this from a focus ring. Wrapping from the
139 + // last step to the first would make one Enter too many restart the
140 + // installer instead of running it.
141 + #[test]
142 + fn neither_end_wraps() {
143 + let mut steps = Steps::new(3);
144 + assert!(!steps.back(), "no step before the first");
145 + assert_eq!(steps.current(), 0);
146 +
147 + while steps.advance() {}
148 + assert_eq!(steps.current(), 2);
149 + assert!(!steps.advance(), "no step after the last");
150 + assert_eq!(steps.current(), 2);
151 + }
152 +
153 + // Esc backs out until there is nothing left to back out of; the `false` is
154 + // what the shell turns into "close the view".
155 + #[test]
156 + fn back_from_the_first_step_reports_nothing_to_go_back_to() {
157 + let mut steps = Steps::new(2);
158 + steps.advance();
159 + assert!(steps.back());
160 + assert!(steps.is_first());
161 + assert!(!steps.back());
162 + }
163 +
164 + // The case `furthest` exists for: reading the summary, stepping back to
165 + // fix an answer, and finding the steps ahead still marked answered.
166 + #[test]
167 + fn stepping_back_leaves_the_furthest_step_reached_alone() {
168 + let mut steps = Steps::new(4);
169 + steps.advance();
170 + steps.advance();
171 + steps.advance();
172 + assert_eq!(steps.furthest(), 3);
173 +
174 + steps.back();
175 + steps.back();
176 + assert_eq!(steps.current(), 1);
177 + assert_eq!(steps.furthest(), 3, "answered steps stay answered");
178 +
179 + steps.advance();
180 + assert_eq!(steps.furthest(), 3, "re-advancing does not double-count");
181 + }
182 +
183 + // A single-step wizard is on the last step from the start: its Enter runs
184 + // the install rather than looking for a step that is not there.
185 + #[test]
186 + fn one_step_is_both_ends_at_once() {
187 + let mut steps = Steps::new(1);
188 + assert!(steps.is_first());
189 + assert!(steps.is_last());
190 + assert!(!steps.advance());
191 + assert!(!steps.back());
192 + }
193 +
194 + // A programming error, but not one worth a panic. Every mover no-ops and
195 + // `is_last` is true, so a view driving off it offers no next step rather
196 + // than one that never arrives.
197 + #[test]
198 + fn an_empty_sequence_is_inert() {
199 + let mut steps = Steps::new(0);
200 + assert!(steps.is_first());
201 + assert!(steps.is_last());
202 + assert!(!steps.advance());
203 + assert!(!steps.back());
204 + assert_eq!(steps.current(), 0);
205 + }
206 + }