Skip to main content

max / alloy

Let the run screen scroll, which its footer has always claimed The install output pane showed the tail and nothing else: `handle` returned early on any run, so every movement key was swallowed while the footer sat underneath advertising `j/k scroll`. It also advertised `enter install` and `esc back`, both inherited from the credits step still underneath and neither of which does anything during a run. Found the hard way. An encrypted install failed on fw12, bootc's error had scrolled off the top, and the keys the screen named were inert, so the one piece of evidence that explains the failure could not be read on the machine that produced it. The offset counts back from the newest line, so zero is the tail and the screen behaves exactly as it did until something asks it not to. Home and End still mean oldest and newest, because the direction the offset counts is nobody's business but this function's. The buffer grows under a live run, so the maximum is recomputed against the output that exists rather than remembered, and the render clamps again for the frame that lands between a keypress and the next line.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 22:56 UTC
Signed with PGP, not checked
Commit: 61c194cc23e207aed816130d50bac44a6e240915
Parent: e832f97
1 file changed, +206 insertions, -7 deletions
@@ -2948,6 +2948,19 @@
2948 2948 /// Zero until the page has been drawn once, which is why every reader
2949 2949 /// treats zero as "one screen" rather than as a real measurement.
2950 2950 credits_viewport: std::cell::Cell<usize>,
2951 + /// How far back from the newest line the run screen is scrolled.
2952 + ///
2953 + /// Counted from the bottom rather than the top, because the tail is where
2954 + /// this screen lives: zero is pinned to the newest output, which is the
2955 + /// behaviour it has always had and the right default while a command is
2956 + /// still producing lines. The cost is that scrolling back during a live run
2957 + /// keeps your distance from the end rather than your place in the text, so
2958 + /// the lines drift while new ones arrive. Accepted: the case this exists
2959 + /// for is reading a failure, and a failed run produces nothing further.
2960 + run_scroll: usize,
2961 + /// How many lines of run output last fitted on screen, for the same reason
2962 + /// [`credits_viewport`](Self::credits_viewport) is recorded.
2963 + run_viewport: std::cell::Cell<usize>,
2951 2964 answers: Answers,
2952 2965 error: Option<String>,
2953 2966 /// The install, once it has been confirmed and started.
@@ -2993,6 +3006,8 @@
2993 3006 disk_intact: Arc::new(AtomicBool::new(false)),
2994 3007 encrypt: true,
2995 3008 credits_scroll: 0,
3009 + run_scroll: 0,
3010 + run_viewport: std::cell::Cell::new(0),
2996 3011 credits_viewport: std::cell::Cell::new(0),
2997 3012 answers: Answers::default(),
2998 3013 error: None,
@@ -3703,6 +3718,37 @@
3703 3718 );
3704 3719 }
3705 3720
3721 + /// Keys for the run screen: scrolling back through the output, and nothing
3722 + /// else.
3723 + ///
3724 + /// The offset counts lines back from the newest, so `j` walks toward the
3725 + /// end and `k` away from it, and zero is the tail. Clamped against the
3726 + /// output that exists rather than against a remembered maximum: the buffer
3727 + /// grows under this while a command runs.
3728 + fn scroll_run(&mut self, key: KeyEvent) -> Flow {
3729 + let Some(sequence) = self.running.as_ref() else {
3730 + return Flow::Continue;
3731 + };
3732 + let viewport = self.run_viewport.get().max(1);
3733 + let max = sequence.output().len().saturating_sub(viewport);
3734 + let page = viewport.max(2) - 1;
3735 +
3736 + self.run_scroll = match key.code {
3737 + KeyCode::Char('j') | KeyCode::Down => self.run_scroll.saturating_sub(1),
3738 + KeyCode::Char('k') | KeyCode::Up => self.run_scroll.saturating_add(1),
3739 + KeyCode::PageDown => self.run_scroll.saturating_sub(page),
3740 + KeyCode::PageUp => self.run_scroll.saturating_add(page),
3741 + // Home is the oldest line and End the newest, which is the way
3742 + // round a pager does it. The tail being zero here is an
3743 + // implementation detail and should not leak into the keys.
3744 + KeyCode::Home => max,
3745 + KeyCode::End => 0,
3746 + _ => self.run_scroll,
3747 + }
3748 + .min(max);
3749 + Flow::Continue
3750 + }
3751 +
3706 3752 /// Keys for the credits page: scrolling, and nothing else.
3707 3753 ///
3708 3754 /// Enter is not here. It starts the install, which is [`handle`](Self::handle)'s
@@ -3908,11 +3954,19 @@
3908 3954
3909 3955 // The tail rather than the head. A long-running command's interesting
3910 3956 // output is always its most recent, and a failure's explanation is its
3911 - // last line.
3957 + // last line. [`scroll_run`](Self::scroll_run) moves the window back
3958 + // from there; at rest it sits on the newest line.
3912 3959 let body = area.height.saturating_sub(lines.len() as u16) as usize;
3960 + self.run_viewport.set(body);
3913 3961 let output = sequence.output();
3914 - let from = output.len().saturating_sub(body);
3915 - for line in &output[from..] {
3962 + // Clamped here as well as in the key path, because the buffer can grow
3963 + // between a keypress and this frame and a stale offset would slice past
3964 + // the end.
3965 + let end = output
3966 + .len()
3967 + .saturating_sub(self.run_scroll.min(output.len()));
3968 + let from = end.saturating_sub(body);
3969 + for line in &output[from..end] {
3916 3970 lines.push(Line::from(text::secondary(theme, line.clone())));
3917 3971 }
3918 3972
@@ -4137,6 +4191,20 @@
4137 4191 return vec![hint("enter", "confirm")];
4138 4192 }
4139 4193
4194 + // A run on screen owns the footer, whatever step raised it. Otherwise
4195 + // the credits step's hints stay up through the install and past the end
4196 + // of it, promising an `enter install` that is refused and an `esc back`
4197 + // that goes nowhere, next to the `j/k scroll` that is the one thing
4198 + // here that does work.
4199 + if let Some(sequence) = self.running.as_ref() {
4200 + let mut hints = vec![hint("j/k", "scroll")];
4201 + // Esc leaves, but only once there is nothing left to interrupt.
4202 + if sequence.is_done() {
4203 + hints.push(hint("esc", "done"));
4204 + }
4205 + return hints;
4206 + }
4207 +
4140 4208 // Esc is listed from the second step on, where it means "back". On the
4141 4209 // first it closes the installer, which is the shell's own `q`.
4142 4210 let mut hints = match self.step() {
@@ -4235,11 +4303,15 @@
4235 4303 return self.edit_recovery(key);
4236 4304 }
4237 4305
4238 - // A running install answers no questions. The only key that means
4239 - // anything is the one that leaves once it has finished, and the shell's
4240 - // own `q` already does that.
4306 + // A running install answers no questions, but it does have a screenful
4307 + // of output that is longer than the screen, and reading it is the whole
4308 + // job when one has just failed. So the movement keys work and nothing
4309 + // else does. They were advertised in the footer long before they did
4310 + // anything, which is how this was found: an install failed on fw12,
4311 + // bootc's error had scrolled off, and the keys the screen named were
4312 + // inert.
4241 4313 if self.running.is_some() {
4242 - return Flow::Continue;
4314 + return self.scroll_run(key);
4243 4315 }
4244 4316
4245 4317 match self.step() {
@@ -4634,6 +4706,8 @@
4634 4706 recovery_ack: false,
4635 4707 encrypt: true,
4636 4708 credits_scroll: 0,
4709 + run_scroll: 0,
4710 + run_viewport: std::cell::Cell::new(0),
4637 4711 credits_viewport: std::cell::Cell::new(0),
4638 4712 answers: Answers::default(),
4639 4713 error: None,
@@ -5315,6 +5389,131 @@
5315 5389 assert_eq!(view.step(), Step::Encryption);
5316 5390 }
5317 5391
5392 + // ---- scrolling the run screen ----
5393 +
5394 + /// A view sitting on a run whose output is longer than the screen.
5395 + ///
5396 + /// `seq` prints its lines and exits, so the sequence reaches a real
5397 + /// finished state with real output rather than one poked into place.
5398 + fn at_run_output(lines: usize, viewport: usize) -> (InstallView, CommandLog) {
5399 + let (mut view, mut log) = at_summary();
5400 + view.running = Some(Sequence::new(vec![Stage::Run(
5401 + Invocation::new("seq").arg(lines.to_string()),
5402 + )]));
5403 + // Ticked until the child has exited and every line has been drained,
5404 + // rather than a fixed count: how many polls that takes is the
5405 + // scheduler's business and a fixed number is how this goes flaky.
5406 + for _ in 0..2000 {
5407 + if view.running.as_ref().is_some_and(Sequence::is_done) {
5408 + break;
5409 + }
5410 + view.tick(&mut log);
5411 + std::thread::sleep(std::time::Duration::from_millis(1));
5412 + }
5413 + assert!(
5414 + view.running.as_ref().is_some_and(Sequence::is_done),
5415 + "the fixture did not finish"
5416 + );
5417 + assert!(
5418 + view.running
5419 + .as_ref()
5420 + .is_some_and(|s| s.output().len() >= lines),
5421 + "the fixture produced no output"
5422 + );
5423 + view.run_viewport.set(viewport);
5424 + (view, log)
5425 + }
5426 +
5427 + fn press(view: &mut InstallView, code: KeyCode) {
5428 + let mut log = CommandLog::new();
5429 + view.handle(KeyEvent::from(code), &mut log);
5430 + }
5431 +
5432 + // The keys the footer has always named. They did nothing at all until
5433 + // 2026-08-09: `handle` returned early on any run, so an install that failed
5434 + // with its explanation scrolled off the top could not be read at the
5435 + // machine it failed on.
5436 + #[test]
5437 + fn the_run_screen_scrolls_back_through_its_output() {
5438 + let (mut view, _log) = at_run_output(100, 10);
5439 +
5440 + assert_eq!(view.run_scroll, 0, "a fresh run is not pinned to the tail");
5441 +
5442 + press(&mut view, KeyCode::Char('k'));
5443 + assert_eq!(
5444 + view.run_scroll, 1,
5445 + "k did not move away from the newest line"
5446 + );
5447 +
5448 + press(&mut view, KeyCode::Char('j'));
5449 + assert_eq!(view.run_scroll, 0, "j did not walk back toward the newest");
5450 +
5451 + // Down at the tail stays at the tail rather than wrapping.
5452 + press(&mut view, KeyCode::Char('j'));
5453 + assert_eq!(view.run_scroll, 0);
5454 + }
5455 +
5456 + // Home is the oldest line and End the newest, whichever way the offset
5457 + // happens to be counted underneath.
5458 + #[test]
5459 + fn home_and_end_reach_both_ends_of_the_run_output() {
5460 + let (mut view, _log) = at_run_output(100, 10);
5461 + let len = view.running.as_ref().unwrap().output().len();
5462 +
5463 + press(&mut view, KeyCode::Home);
5464 + assert_eq!(
5465 + view.run_scroll,
5466 + len - 10,
5467 + "Home did not reach the oldest line"
5468 + );
5469 +
5470 + press(&mut view, KeyCode::End);
5471 + assert_eq!(view.run_scroll, 0, "End did not return to the newest");
5472 + }
5473 +
5474 + // Scrolling stops at the oldest line. Running off the top would leave the
5475 + // stored offset past the end, and the first few presses back would move
5476 + // nothing while the number came down.
5477 + #[test]
5478 + fn the_run_screen_does_not_scroll_past_its_oldest_line() {
5479 + let (mut view, _log) = at_run_output(100, 10);
5480 + let len = view.running.as_ref().unwrap().output().len();
5481 +
5482 + for _ in 0..(len + 50) {
5483 + press(&mut view, KeyCode::Char('k'));
5484 + }
5485 +
5486 + assert_eq!(view.run_scroll, len - 10);
5487 + }
5488 +
5489 + // Output shorter than the screen cannot be scrolled: there is nowhere to
5490 + // go, and an offset would scroll the text off into blank space.
5491 + #[test]
5492 + fn a_run_shorter_than_the_screen_does_not_scroll() {
5493 + let (mut view, _log) = at_run_output(3, 40);
5494 +
5495 + press(&mut view, KeyCode::Char('k'));
5496 + press(&mut view, KeyCode::Home);
5497 +
5498 + assert_eq!(view.run_scroll, 0);
5499 + }
5500 +
5501 + // The footer stops promising what a run refuses. `enter install` and
5502 + // `esc back` both belong to the credits step, which is the step still
5503 + // underneath when the run screen is drawn.
5504 + #[test]
5505 + fn a_running_install_advertises_only_the_keys_that_work() {
5506 + let (view, _log) = at_run_output(100, 10);
5507 + let hints: Vec<String> = view
5508 + .hints()
5509 + .iter()
5510 + .map(|hint| hint.key.to_string())
5511 + .collect();
5512 +
5513 + assert!(hints.iter().any(|key| key == "j/k"), "{hints:?}");
5514 + assert!(!hints.iter().any(|key| key == "enter"), "{hints:?}");
5515 + }
5516 +
5318 5517 // ---- the recovery phrase pane ----
5319 5518
5320 5519 /// A view sitting on a finished install with a phrase waiting to be copied.