Skip to main content

max / alloy

Make ? open the keymap Action::Help was classified and dropped: the only two mentions in the console were test assertions confirming it passed through untouched. So the one key a lost user will press did nothing, while CONSOLE.md advertised it. It now routes through Reserved::Help, gated on text_entry for the same reason q is and against the same bug: ? is a character someone types into a hostname or a filter, and a help screen opening mid-word is the classifier's documented caller obligation going unmet. While the overlay is open it owns every key, as a modal does. A user who opened it to find out what a key does should not learn the answer by having it happen behind the overlay. Esc, ? and q all close it: ? so the key that opened it also dismisses it, and q closes the overlay rather than the console, matching the modal rule. It draws over the whole frame rather than the body. A confirm is about the command on the log's last line and wants it visible; a keymap is about the console itself and wants the room. View::keys() defaults to one group derived from hints(), so all eight views answer ? today without being touched, and a view overrides it to say more than a footer has room for. View::unanswered() dims reserved keys a view does not implement, so a tabless pane stops advertising l and h as live without hiding that they exist. net.rs is converted as the exemplar. Its footer still shows only live keys, which is right for one row, while ? shows the whole vocabulary with connect and wifi dimmed and explained rather than absent. mesh, audio and install still ride the derived default and are the next ones.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 01:31 UTC
Signed with PGP, not checked
Commit: f3e1016c8716621ce4662ba201611f3fdda40e7f
Parent: 2ec336c
2 files changed, +185 insertions, -10 deletions
@@ -6,7 +6,11 @@
6 6 //! and demoed on a machine whose real network state you would rather not
7 7 //! touch.
8 8
9 - use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text};
9 + use alloy_tui::keys::Action;
10 + use alloy_tui::{
11 + AlloyBlock, AlloyList, Cursor, Hint, KeyGroup, Severity, Theme, binding, hint, text,
12 + unavailable,
13 + };
10 14 use anyhow::Result;
11 15 use ratatui::Frame;
12 16 use ratatui::crossterm::event::{KeyCode, KeyEvent};
@@ -371,6 +375,13 @@
371 375 view
372 376 }
373 377
378 + /// Whether the selected interface has a connect action behind it, which on
379 + /// the mock and on loopback it does not.
380 + fn can_connect(&self) -> bool {
381 + self.selected()
382 + .is_some_and(|iface| self.backend.connect(iface).is_some())
383 + }
384 +
374 385 fn selected(&self) -> Option<&Interface> {
375 386 self.interfaces.get(self.cursor.selected()?)
376 387 }
@@ -480,12 +491,9 @@
480 491
481 492 fn hints(&self) -> Vec<Hint> {
482 493 let mut hints = vec![hint("j/k", "select")];
483 - // The connect key is offered only where there is something behind it,
484 - // which on the mock and on loopback is nothing.
485 - if self
486 - .selected()
487 - .is_some_and(|iface| self.backend.connect(iface).is_some())
488 - {
494 + // The footer has one row and shows what is live. What the pane *can* do,
495 + // including the parts it cannot do right now, is `?`'s job: see `keys`.
496 + if self.can_connect() {
489 497 hints.push(hint("s", "connect/disconnect"));
490 498 }
491 499 if self.wifi.is_some() {
@@ -495,6 +503,40 @@
495 503 hints
496 504 }
497 505
506 + /// Every key this pane has, including the ones that are unavailable on the
507 + /// current selection.
508 + ///
509 + /// The footer drops those; this must not. A key that vanishes takes its own
510 + /// existence with it, so a user on loopback never learns the pane can
511 + /// connect anything at all, and the rows they *can* use shift under them
512 + /// each time the selection moves.
513 + fn keys(&self) -> Vec<KeyGroup<'static>> {
514 + let connect = if self.can_connect() {
515 + binding("s", "connect/disconnect")
516 + } else {
517 + unavailable("s", "connect/disconnect", "nothing to connect here")
518 + };
519 + let wifi = if self.wifi.is_some() {
520 + binding("w", "wifi radio")
521 + } else {
522 + unavailable("w", "wifi radio", "no wifi device")
523 + };
524 + vec![KeyGroup::new(
525 + "this pane",
526 + vec![
527 + binding("j/k", "select"),
528 + connect,
529 + wifi,
530 + binding("r", "refresh"),
531 + ],
532 + )]
533 + }
534 +
535 + /// One screen, no tabs.
536 + fn unanswered(&self) -> &'static [Action] {
537 + &[Action::NextTab, Action::PrevTab]
538 + }
539 +
498 540 fn status(&self) -> Option<(Severity, String)> {
499 541 if let Some(message) = &self.error {
500 542 return Some((Severity::Error, message.clone()));
@@ -10,7 +10,10 @@
10 10 use std::time::Duration;
11 11
12 12 use alloy_tui::keys::{Action, classify};
13 - use alloy_tui::{AlloyLog, AlloyModal, AlloyStatusBar, Hint, Severity, Theme, hint, layout};
13 + use alloy_tui::{
14 + AlloyKeymap, AlloyLog, AlloyModal, AlloyStatusBar, Hint, KeyGroup, Severity, Theme, binding,
15 + hint, layout,
16 + };
14 17 use anyhow::Result;
15 18 use ratatui::Frame;
16 19 use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind};
@@ -74,6 +77,30 @@
74 77 /// so a view lists only its own keys.
75 78 fn hints(&self) -> Vec<Hint>;
76 79
80 + /// Everything this view can do, for the `?` overlay.
81 + ///
82 + /// The default derives one group from [`hints`](View::hints), so every view
83 + /// answers `?` with something true the day it is written. Override it to say
84 + /// more than a footer has room for: a binding the footer omits for space, or
85 + /// one that is real but unavailable right now and should be shown dimmed
86 + /// with a reason rather than left out.
87 + fn keys(&self) -> Vec<KeyGroup<'static>> {
88 + vec![KeyGroup::new(
89 + "this pane",
90 + self.hints()
91 + .into_iter()
92 + .map(|h| binding(h.key, h.label))
93 + .collect(),
94 + )]
95 + }
96 +
97 + /// Reserved actions this view does not answer, shown dimmed in the overlay
98 + /// rather than promising something that will not happen. A view with no tabs
99 + /// names the tab movers here.
100 + fn unanswered(&self) -> &'static [Action] {
101 + &[]
102 + }
103 +
77 104 /// Transient status for the right end of the footer, if any.
78 105 fn status(&self) -> Option<(Severity, String)> {
79 106 None
@@ -192,9 +219,12 @@
192 219 // confirm is a design smell, and an unbounded stack turns Esc into "how
193 220 // many times do I press this" rather than "back out".
194 221 let mut modal: Option<Confirm> = None;
222 + // The `?` overlay. A flag rather than a slot: it carries no state of its
223 + // own, being rebuilt from the view every frame like the rest of the chrome.
224 + let mut help = false;
195 225
196 226 loop {
197 - terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref()))?;
227 + terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref(), help))?;
198 228
199 229 // Poll rather than block, so a view can refresh state that changes
200 230 // without the user. `poll` returns as soon as an event arrives, so
@@ -238,11 +268,26 @@
238 268 continue;
239 269 }
240 270
271 + // The overlay owns every key while it is open, for the reason the modal
272 + // does: a user who opened it to find out what a key does should not
273 + // discover the answer by having it happen behind the overlay. It sits
274 + // after the modal because a confirm raised before it still outranks it.
275 + if help {
276 + if help_key(action) == HelpOutcome::Close {
277 + help = false;
278 + }
279 + continue;
280 + }
281 +
241 282 // Both reserved claims are the view's to decline, so the routing is
242 283 // decided first and the flow it produces is handled in one place.
243 284 let flow = match reserved(action, view.text_entry()) {
244 285 Reserved::Cancel => view.cancel(),
245 286 Reserved::Quit => view.quit(),
287 + Reserved::Help => {
288 + help = true;
289 + Flow::Continue
290 + }
246 291 Reserved::Pass => view.handle(key, log),
247 292 };
248 293
@@ -262,6 +307,8 @@
262 307 Cancel,
263 308 /// `q` with no text field open: close the view.
264 309 Quit,
310 + /// `?` with no text field open: open the keymap overlay.
311 + Help,
265 312 /// Not the shell's, hand it to the view.
266 313 Pass,
267 314 }
@@ -280,10 +327,36 @@
280 327 match action {
281 328 Action::Cancel => Reserved::Cancel,
282 329 Action::Quit if !text_entry => Reserved::Quit,
330 + // Gated on text entry for the same reason `q` is, and it is the same
331 + // bug: `?` is a character someone types into a hostname or a filter,
332 + // and a help screen opening mid-word is the classifier's documented
333 + // caller obligation going unmet.
334 + Action::Help if !text_entry => Reserved::Help,
283 335 _ => Reserved::Pass,
284 336 }
285 337 }
286 338
339 + /// What a key does to the open keymap overlay.
340 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
341 + enum HelpOutcome {
342 + Close,
343 + Ignored,
344 + }
345 +
346 + /// Route a reserved action against the open overlay.
347 + ///
348 + /// Everything that means "get me out of here" closes it, and nothing else does
349 + /// anything. `?` closes it too, so the key that opened it is also the key that
350 + /// dismisses it and a user who pressed it by accident does not have to guess.
351 + /// `q` closes the overlay rather than the console, matching the modal: while
352 + /// something is over the view, leaving means leaving that.
353 + const fn help_key(action: Action) -> HelpOutcome {
354 + match action {
355 + Action::Cancel | Action::Help | Action::Quit => HelpOutcome::Close,
356 + _ => HelpOutcome::Ignored,
357 + }
358 + }
359 +
287 360 /// What a key does to an open modal.
288 361 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
289 362 enum ModalOutcome {
@@ -351,6 +424,7 @@
351 424 view: &dyn View,
352 425 log: &mut CommandLog,
353 426 modal: Option<&Confirm>,
427 + help: bool,
354 428 ) {
355 429 let areas = layout::console(frame.area());
356 430
@@ -375,6 +449,17 @@
375 449 area,
376 450 );
377 451 }
452 +
453 + // Over the whole frame rather than the body, unlike the modal. A confirm is
454 + // about the thing on the log's last line and wants it visible; the keymap is
455 + // about the console itself and wants the room.
456 + if help {
457 + let area = layout::centered(frame.area(), HELP_WIDTH, HELP_HEIGHT);
458 + frame.render_widget(
459 + AlloyKeymap::new(theme, &view.title(), view.keys()).unavailable(view.unanswered()),
460 + area,
461 + );
462 + }
378 463 }
379 464
380 465 /// Modal box size. Wide enough for a package name plus a sentence about what
@@ -382,6 +467,12 @@
382 467 const MODAL_WIDTH: u16 = 54;
383 468 const MODAL_HEIGHT: u16 = 7;
384 469
470 + /// Keymap overlay size. Tall enough for a view's own keys plus the eleven
471 + /// reserved ones without truncating on a standard terminal, and `centered`
472 + /// clamps it on anything smaller.
473 + const HELP_WIDTH: u16 = 52;
474 + const HELP_HEIGHT: u16 = 24;
475 +
385 476 /// Title text for a view's body block, padded so it does not sit flush against
386 477 /// the border corner.
387 478 pub(crate) fn block_title(title: &str) -> String {
@@ -590,7 +681,6 @@
590 681 Action::Save,
591 682 Action::Filter,
592 683 Action::Command,
593 - Action::Help,
594 684 Action::Passthrough,
595 685 ] {
596 686 assert_eq!(reserved(action, false), Reserved::Pass, "{action:?}");
@@ -598,6 +688,49 @@
598 688 }
599 689 }
600 690
691 + // `?` is a character, so it belongs to a text field that is open, exactly
692 + // like `q`. Without this a user typing a hostname with a question mark in it
693 + // gets a help screen mid-word.
694 + #[test]
695 + fn question_mark_opens_help_unless_something_is_being_typed() {
696 + assert_eq!(reserved(Action::Help, false), Reserved::Help);
697 + assert_eq!(reserved(Action::Help, true), Reserved::Pass);
698 + }
699 +
700 + // Whatever means "get me out" closes the overlay, including the key that
701 + // opened it, so an accidental `?` does not need a guess to undo.
702 + #[test]
703 + fn any_way_out_closes_the_keymap_overlay() {
704 + assert_eq!(help_key(Action::Cancel), HelpOutcome::Close);
705 + assert_eq!(help_key(Action::Help), HelpOutcome::Close);
706 + assert_eq!(help_key(Action::Quit), HelpOutcome::Close);
707 + }
708 +
709 + // And q closes the overlay rather than the console, matching the modal.
710 + // Quitting the whole console from behind an overlay the user opened to read
711 + // is the one outcome this must not have.
712 + #[test]
713 + fn q_closes_the_overlay_rather_than_the_console() {
714 + assert_eq!(help_key(Action::Quit), HelpOutcome::Close);
715 + }
716 +
717 + #[test]
718 + fn the_overlay_swallows_everything_that_is_not_a_way_out() {
719 + for action in [
720 + Action::NextFocus,
721 + Action::PrevFocus,
722 + Action::NextTab,
723 + Action::PrevTab,
724 + Action::Activate,
725 + Action::Save,
726 + Action::Filter,
727 + Action::Command,
728 + Action::Passthrough,
729 + ] {
730 + assert_eq!(help_key(action), HelpOutcome::Ignored, "{action:?}");
731 + }
732 + }
733 +
601 734 // No shipped view takes text input, so the default must leave all four of
602 735 // them on exactly the reserved map they were written against.
603 736 #[test]