diff --git a/README.md b/README.md index 9609573..7d0deea 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Every task runs in its own pseudo-terminal, emulated with `vt100`. The same scre ### Input fidelity -Attached input follows the child's terminal state. Modified Enter is sent as `ESC CR` when reported, and paste uses bracketed-paste markers only when enabled by the child. The client captures the mouse only for attached children using a mouse protocol; elsewhere, native selection remains available and the wheel uses alternate scroll for full-screen children. Details are in [`docs/commands.md`](docs/commands.md). +Attached input follows the child's terminal state. Modified Enter is sent as `ESC CR` when reported, and paste uses bracketed-paste markers only when enabled by the child. Mouse-protocol children receive mouse events, and full-screen children use alternate scroll. Tasks retain 2,000 lines of scrollback: for inline children, wheel-up over output enters scrollback; `Shift+PageUp` also enters it, paging keys navigate, and `Esc` or typing returns to live output. Details are in [`docs/commands.md`](docs/commands.md). ### Jobs outlive the UI diff --git a/docs/commands.md b/docs/commands.md index e913ce7..a62498d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -59,7 +59,11 @@ Three inputs are richer than a keypress, and each is routed by state rather than - Shift+Enter and Alt+Enter are sent as `ESC CR`, which is distinct from plain Enter. Shift requires a terminal that reports modified keys; terminals that do not report it send plain `CR`. - Paste travels as one message. Bracketed-paste-aware children receive paste markers with embedded terminators removed; other children receive line endings as `CR`. Fleetcom text fields strip control characters. -- The client captures the mouse only while the attached child requests a mouse protocol, then forwards clicks, drags, releases, and wheel events in the negotiated encoding. In that mode, use the terminal's selection override chord. Otherwise, native click-drag selection remains available; the wheel scrolls full-screen children and moves dashboard selection. It is disabled for attached inline children so arrow keys are not sent to their standard input. +- Mouse-protocol children receive clicks, drags, releases, and wheel events in the negotiated encoding. Full-screen children without a mouse protocol use alternate scroll. For inline children without a mouse protocol, wheel-up enters Fleetcom's scrollback view. When Fleetcom captures the mouse (for mouse-protocol children, inline children, or scrollback), terminal selection requires the terminal's selection-override modifier. Otherwise, drag selects normally. + +#### Scrollback + +Tasks retain 2,000 lines of scrollback. While attached to an inline child, wheel-up over its output enters scrollback; `Shift+PageUp` also enters it (`Ctrl+PageUp` and `Alt+PageUp` work when Shift is intercepted). The status bar shows `[scroll ↑N]`. The wheel scrolls, `PageUp`/`PageDown` move by pages, `↑`/`↓` by lines, and `Home` jumps to the oldest row. `Esc`, `Enter`, `q`, `End`, or reaching the bottom returns to live output. Typing also returns to live and forwards the key. Detaching or switching tasks resets the view. #### Destroy is Shift-gated diff --git a/src/app.rs b/src/app.rs index e4de4cc..e47bbe8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use crossterm::event::{ use crossterm::{execute, style::Print}; use crate::path; -use crate::protocol::{Command, Event, MouseBtn, MouseKind, ScreenView, TaskView}; +use crate::protocol::{Command, Event, MouseBtn, MouseKind, ScreenView, ScrollAction, TaskView}; use crate::session; use crate::supervisor::Supervisor; use crate::task::Lifecycle; @@ -154,17 +154,24 @@ pub struct App { mouse_captured: bool, /// The alternate-scroll state last applied to the terminal. alt_scroll: bool, + /// Whether the attached task is displaying scrollback. + view_scroll: bool, } /// Return `(mouse_capture, alt_scroll)` for the attached child's screen. -/// Without an attached screen, preserve native selection and enable wheel -/// navigation through alternate scroll. -fn desired_input_modes(attached: Option<&ScreenView>) -> (bool, bool) { +/// Scrollback and inline children capture the mouse; mouse-aware children +/// receive events, while full-screen children use alternate scroll. +fn desired_input_modes(attached: Option<&ScreenView>, view_scroll: bool) -> (bool, bool) { + if view_scroll { + return (true, true); + } match attached { // Capture and forward mouse events requested by the child. Some(s) if s.wants_mouse => (true, true), - // Scroll full-screen children; disable wheel-generated arrows inline. - Some(s) => (false, s.alt_screen), + // Scroll full-screen children through alternate-scroll arrows. + Some(s) if s.alt_screen => (false, true), + // Capture wheel-up to enter scrollback for inline children. + Some(_) => (true, true), None => (false, true), } } @@ -283,6 +290,7 @@ impl App { should_quit: false, mouse_captured: false, alt_scroll: true, + view_scroll: false, exit_intent: ExitIntent::Disconnect, } } @@ -460,7 +468,15 @@ impl App { // The handshake is handled before the transport is created. Event::HelloOk => {} Event::Tasks(v) => self.views = v, - Event::Screen(s) => self.focused_screen = Some(s), + Event::Screen(s) => { + // Exit only after a nonzero offset returns to live, so a + // pre-entry screen update cannot immediately exit the view. + let prev = self.focused_screen.as_ref().map_or(0, |p| p.scrollback); + if self.view_scroll && prev > 0 && s.scrollback == 0 { + self.view_scroll = false; + } + self.focused_screen = Some(s); + } Event::Status(s) => self.status = Some(s), } } @@ -861,6 +877,8 @@ impl App { if detach { self.mode = Mode::Dashboard; self.focused_id = None; + // The watch change resets the task viewport. + self.view_scroll = false; // Repaint from scratch next tick; wipe the child's screen now. use crossterm::{ cursor::MoveTo, @@ -870,6 +888,41 @@ impl App { let _ = execute!(out, Clear(ClearType::All), MoveTo(0, 0)); return Ok(()); } + // Keep one row of overlap between pages. + let page = self.pane_rows().saturating_sub(1).max(1); + if self.view_scroll { + // Scrollback navigation is not forwarded to the child. + match k.code { + KeyCode::PageUp => self.send_scrollback(ScrollAction::Up(page)), + KeyCode::PageDown => self.send_scrollback(ScrollAction::Down(page)), + KeyCode::Up => self.send_scrollback(ScrollAction::Up(1)), + KeyCode::Down => self.send_scrollback(ScrollAction::Down(1)), + KeyCode::Home => self.send_scrollback(ScrollAction::Top), + KeyCode::End | KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { + self.view_scroll = false; + self.send_scrollback(ScrollAction::Live); + } + // Other input returns to live and is forwarded immediately. + _ => { + self.view_scroll = false; + if let Some(id) = self.focused_id + && let Some(bytes) = key_to_bytes(k.code, k.modifiers) + { + self.transport.send(Command::Input { id, bytes }); + } + } + } + return Ok(()); + } + // Ctrl/Alt provide alternatives when the terminal intercepts Shift. + if k.code == KeyCode::PageUp + && k.modifiers + .intersects(KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT) + { + self.view_scroll = true; + self.send_scrollback(ScrollAction::Up(page)); + return Ok(()); + } if let Some(id) = self.focused_id && let Some(bytes) = key_to_bytes(k.code, k.modifiers) { @@ -938,7 +991,27 @@ impl App { _ => {} }, Mode::Attached => { + // The wheel navigates scrollback instead of the child. + if self.view_scroll { + match kind { + MouseKind::WheelUp => self.send_scrollback(ScrollAction::Up(3)), + MouseKind::WheelDown => self.send_scrollback(ScrollAction::Down(3)), + _ => {} + } + return; + } if let Some(id) = self.focused_id { + // Wheel-up enters scrollback for inline children that do + // not receive mouse events. + let inline = matches!( + self.screen_for(id), + Some(s) if !s.wants_mouse && !s.alt_screen + ); + if inline && kind == MouseKind::WheelUp { + self.view_scroll = true; + self.send_scrollback(ScrollAction::Up(3)); + return; + } // Keep the pointer coordinate within the child pane: the // bottom row is fleetcom's status bar, not the child's. let row = m.row.min(self.pane_rows().saturating_sub(1)); @@ -950,13 +1023,21 @@ impl App { } } + /// Move the attached task's scrollback viewport. + fn send_scrollback(&mut self, action: ScrollAction) { + if let Some(id) = self.focused_id { + self.transport.send(Command::Scrollback { id, action }); + } + } + /// Apply input-mode changes for the current focus. fn sync_input_modes(&mut self, out: &mut Stdout) -> io::Result<()> { let attached = match self.mode { Mode::Attached => self.focused_id.and_then(|id| self.screen_for(id)), _ => None, }; - let (capture, alt_scroll) = desired_input_modes(attached); + let view = self.mode == Mode::Attached && self.view_scroll; + let (capture, alt_scroll) = desired_input_modes(attached, view); if capture != self.mouse_captured { if capture { execute!(out, EnableMouseCapture)?; @@ -984,6 +1065,7 @@ impl App { // sent from the run loop next tick. self.focused_id = Some(self.views[i].id); self.mode = Mode::Attached; + self.view_scroll = false; } } @@ -1495,10 +1577,9 @@ mod tests { assert!(app.mode == Mode::Spawn, "paste must not submit"); } - /// Capture mouse events only for children that request them; disable - /// alternate scroll for attached inline children. + /// Select capture and alternate-scroll modes by screen type. #[test] - fn capture_only_for_mouse_hungry_children() { + fn input_modes_match_screen_type() { let screen = |wants_mouse, alt_screen| ScreenView { id: 1, lines: Vec::new(), @@ -1507,25 +1588,106 @@ mod tests { hide_cursor: false, wants_mouse, alt_screen, + scrollback: 0, }; // No attached screen: keep native selection available. - assert_eq!(desired_input_modes(None), (false, true)); + assert_eq!(desired_input_modes(None, false), (false, true)); // Mouse-aware child: capture. - assert_eq!(desired_input_modes(Some(&screen(true, true))), (true, true)); assert_eq!( - desired_input_modes(Some(&screen(true, false))), + desired_input_modes(Some(&screen(true, true)), false), + (true, true) + ); + assert_eq!( + desired_input_modes(Some(&screen(true, false)), false), (true, true) ); // Full-screen child without mouse mode: alternate scroll. assert_eq!( - desired_input_modes(Some(&screen(false, true))), + desired_input_modes(Some(&screen(false, true)), false), (false, true) ); - // Inline child: wheel silenced, selection native. + // Inline child: capture wheel-up to enter scrollback. + assert_eq!( + desired_input_modes(Some(&screen(false, false)), false), + (true, true) + ); + // The scroll view overrides everything: the wheel must scroll it. assert_eq!( - desired_input_modes(Some(&screen(false, false))), - (false, false) + desired_input_modes(Some(&screen(false, false)), true), + (true, true) ); + assert_eq!(desired_input_modes(None, true), (true, true)); + } + + /// Wheel-up enters scrollback for inline children, but forwards for + /// mouse-aware children. + #[test] + fn wheel_up_enters_scroll_view_for_inline_children() { + let mut app = App::new_local(30, 100); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 5", dir); + app.pump(); + app.resolve_selection(); + app.attach(); + let id = app.focused_id.expect("attached"); + let screen = |wants_mouse| ScreenView { + id, + lines: Vec::new(), + formatted: Vec::new(), + cursor: (0, 0), + hide_cursor: false, + wants_mouse, + alt_screen: false, + scrollback: 0, + }; + let wheel_up = MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }; + + // Inline child: wheel-up enters scrollback. + app.focused_screen = Some(screen(false)); + app.on_mouse(wheel_up); + assert!(app.view_scroll, "wheel-up must open the scroll view"); + + // Mouse-aware child: wheel-up forwards instead. + app.view_scroll = false; + app.focused_screen = Some(screen(true)); + app.on_mouse(wheel_up); + assert!(!app.view_scroll, "mouse-aware children keep their wheel"); + } + + /// Scrollback opens with modified PageUp and closes on Esc or typing. + #[test] + fn scroll_view_entry_and_exit() { + let mut app = App::new_local(30, 100); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 5", dir); + app.pump(); + app.resolve_selection(); + app.attach(); + assert!(app.mode == Mode::Attached); + let mut out = io::stdout(); + + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + app.on_key_attached(&mut out, KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT)) + .unwrap(); + assert!(app.view_scroll, "Shift+PageUp must enter the scroll view"); + app.on_key_attached(&mut out, key(KeyCode::Esc)).unwrap(); + assert!(!app.view_scroll, "Esc must return to live"); + + app.on_key_attached(&mut out, KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL)) + .unwrap(); + assert!(app.view_scroll, "Ctrl+PageUp is an entry fallback"); + app.on_key_attached(&mut out, key(KeyCode::Char('x'))) + .unwrap(); + assert!(!app.view_scroll, "typing must snap back to live"); + + // Plain PageUp is forwarded to the child. + app.on_key_attached(&mut out, key(KeyCode::PageUp)).unwrap(); + assert!(!app.view_scroll); } /// A wheel notch on the dashboard moves the selection like an arrow key. diff --git a/src/protocol.rs b/src/protocol.rs index 99fef94..9fb7e1f 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -59,6 +59,8 @@ pub enum Command { col: u16, row: u16, }, + /// Move a task's scrollback viewport. + Scrollback { id: u64, action: ScrollAction }, /// Write the current task set as a named `{dir: [cmds]}` recipe. SaveSession { name: String }, /// Spawn every command in a named recipe, each in its (existing) dir. @@ -67,6 +69,15 @@ pub enum Command { Shutdown, } +/// A `Command::Scrollback` movement in history rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollAction { + Up(u16), + Down(u16), + Top, + Live, +} + /// A mouse button in a `Command::Mouse`. Values match xterm button codes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MouseBtn { @@ -131,6 +142,8 @@ pub struct ScreenView { /// Whether the child is on the alternate screen. Without mouse capture, /// this determines whether alternate scroll is enabled. pub alt_screen: bool, + /// Rows the viewport is scrolled back from live output. + pub scrollback: usize, } // --- wire format ------------------------------------------------------------- @@ -291,6 +304,20 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { let _ = o.insert("col", *col as u64); let _ = o.insert("row", *row as u64); } + Command::Scrollback { id, action } => { + let _ = o.insert("t", "sb"); + let _ = o.insert("id", *id); + let (a, n) = match action { + ScrollAction::Up(n) => ("u", Some(*n)), + ScrollAction::Down(n) => ("d", Some(*n)), + ScrollAction::Top => ("t", None), + ScrollAction::Live => ("l", None), + }; + let _ = o.insert("a", a); + if let Some(n) = n { + let _ = o.insert("n", n as u64); + } + } Command::SaveSession { name } => { let _ = o.insert("t", "save"); let _ = o.insert("name", name.as_str()); @@ -391,6 +418,16 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { row: v["row"].as_u64()? as u16, } } + "sb" => Command::Scrollback { + id: v["id"].as_u64()?, + action: match v["a"].as_str()? { + "u" => ScrollAction::Up(v["n"].as_u64()? as u16), + "d" => ScrollAction::Down(v["n"].as_u64()? as u16), + "t" => ScrollAction::Top, + "l" => ScrollAction::Live, + _ => return None, + }, + }, "save" => Command::SaveSession { name: v["name"].as_str()?.to_string(), }, @@ -447,6 +484,7 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { let _ = header.insert("hide", sv.hide_cursor); let _ = header.insert("mouse", sv.wants_mouse); let _ = header.insert("alt", sv.alt_screen); + let _ = header.insert("sb", sv.scrollback as u64); let mut lines = jzon::JsonValue::new_array(); for l in &sv.lines { let _ = lines.push(l.as_str()); @@ -511,6 +549,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { hide_cursor: h["hide"].as_bool()?, wants_mouse: h["mouse"].as_bool()?, alt_screen: h["alt"].as_bool()?, + scrollback: h["sb"].as_u64()? as usize, })) } _ => None, @@ -593,6 +632,22 @@ mod tests { col: 10, row: 5, }, + Command::Scrollback { + id: 3, + action: ScrollAction::Up(23), + }, + Command::Scrollback { + id: 3, + action: ScrollAction::Down(1), + }, + Command::Scrollback { + id: 3, + action: ScrollAction::Top, + }, + Command::Scrollback { + id: 3, + action: ScrollAction::Live, + }, Command::SaveSession { name: "work".into(), }, @@ -669,6 +724,7 @@ mod tests { hide_cursor: false, wants_mouse: true, alt_screen: false, + scrollback: 42, }); let (k, p) = encode_event(&screen); assert_eq!(k, KIND_SCREEN); diff --git a/src/supervisor.rs b/src/supervisor.rs index 2b6bb61..4d96a37 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -12,7 +12,7 @@ use std::time::{Duration, Instant}; use crate::core::{Wake, Waker}; use crate::path; -use crate::protocol::{Command, Event, ScreenView, TaskView}; +use crate::protocol::{Command, Event, ScreenView, ScrollAction, TaskView}; use crate::session::{self, SessionConfig}; use crate::task::Task; @@ -20,8 +20,8 @@ use crate::task::Task; /// the client, computes lifecycle. It holds the clock and the live parser. const IDLE_AFTER: Duration = Duration::from_millis(600); -/// Send-on-change fingerprint for the watched screen and its input hints. -type LastScreen = (u64, Vec, (u16, u16), bool, (bool, bool)); +/// Send-on-change fingerprint for the watched screen and scrollback offset. +type LastScreen = (u64, Vec, (u16, u16), bool, (bool, bool), usize); /// Ceiling for PTY dimensions accepted from a (possibly crafted) `Resize`. A 0 /// dimension underflows vt100 (`grid.rs` does `size.rows - 1`): panic in debug, @@ -171,8 +171,13 @@ impl Supervisor { } } Command::Watch { id } => { - // A new target must receive a complete screen. + // Reset the previous task's viewport before changing targets. if id != self.watched { + if let Some(old) = self.watched + && let Some(t) = self.by_id_mut(old) + { + t.scroll_view(ScrollAction::Live); + } self.last_screen = None; } self.watched = id; @@ -195,6 +200,11 @@ impl Supervisor { let _ = t.send_mouse(kind, col, row); } } + Command::Scrollback { id, action } => { + if let Some(t) = self.by_id_mut(id) { + t.scroll_view(action); + } + } Command::SaveSession { name } => self.save_session(&name), Command::LoadSession { name } => self.load_session(&name), Command::Shutdown => self.shutdown_all(), @@ -265,23 +275,26 @@ impl Supervisor { { let (formatted, cursor, hide_cursor) = t.formatted(); let hints = t.input_hints(); + let sb = t.scroll_offset(); // Send only when rendering or input-policy state changes. let unchanged = matches!( &self.last_screen, - Some((lid, lf, lc, lh, lhints)) + Some((lid, lf, lc, lh, lhints, lsb)) if *lid == id && *lf == formatted && *lc == cursor - && *lh == hide_cursor && *lhints == hints + && *lh == hide_cursor && *lhints == hints && *lsb == sb ); if !unchanged { - self.last_screen = Some((id, formatted.clone(), cursor, hide_cursor, hints)); + self.last_screen = Some((id, formatted.clone(), cursor, hide_cursor, hints, sb)); self.events.push(Event::Screen(ScreenView { id, lines: t.screen_lines(), formatted, cursor, - hide_cursor, + // Hide the live cursor while displaying scrollback. + hide_cursor: hide_cursor || sb > 0, wants_mouse: hints.0, alt_screen: hints.1, + scrollback: sb, })); } } diff --git a/src/task.rs b/src/task.rs index af47721..7cf252b 100644 --- a/src/task.rs +++ b/src/task.rs @@ -13,7 +13,10 @@ use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system} use rustix::process::{WaitId, WaitIdOptions, waitid}; use crate::core::{Wake, Waker}; -use crate::protocol::MouseKind; +use crate::protocol::{MouseKind, ScrollAction}; + +/// Number of history rows retained by each task's terminal grid. +const SCROLLBACK: usize = 2000; /// Map a dependency error (portable-pty returns `anyhow`) into `io::Error` so /// the whole crate speaks stdlib `io::Result` and never grows an `anyhow` dep. @@ -265,7 +268,7 @@ impl Task { let mut reader = pair.master.try_clone_reader().map_err(io_err)?; let writer = pair.master.take_writer().map_err(io_err)?; - let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0))); + let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK))); let last_activity = Arc::new(Mutex::new(Instant::now())); let handle = { @@ -437,10 +440,35 @@ impl Task { } pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> { + // Input returns the viewport to live before writing to the PTY. + { + let mut p = grid(&self.parser); + if p.screen().scrollback() > 0 { + p.screen_mut().set_scrollback(0); + } + } self.writer.write_all(bytes)?; self.writer.flush() } + /// Move the scrollback viewport, clamped to retained history. + pub fn scroll_view(&mut self, action: ScrollAction) { + let mut p = grid(&self.parser); + let cur = p.screen().scrollback(); + let target = match action { + ScrollAction::Up(n) => cur.saturating_add(n as usize), + ScrollAction::Down(n) => cur.saturating_sub(n as usize), + ScrollAction::Top => usize::MAX, + ScrollAction::Live => 0, + }; + p.screen_mut().set_scrollback(target); + } + + /// Rows the viewport is scrolled back from live output. + pub fn scroll_offset(&self) -> usize { + grid(&self.parser).screen().scrollback() + } + /// Forward a clipboard paste in whichever shape the child negotiated; see /// [`paste_bytes`]. The grid lock is released before the PTY write: the /// write can block on a full PTY buffer, and the reader thread needs the @@ -814,6 +842,37 @@ mod tests { ); } + /// Scrollback clamps at both ends and input returns to live output. + #[test] + fn viewport_scrolls_and_snaps_live_on_input() { + let mut t = spawn(9, "cat"); + // Feed enough rows to create scrollback. + for i in 0..50 { + grid(&t.parser).process(format!("line{i}\r\n").as_bytes()); + } + assert_eq!(t.scroll_offset(), 0); + t.scroll_view(ScrollAction::Up(10)); + assert_eq!(t.scroll_offset(), 10); + t.scroll_view(ScrollAction::Down(4)); + assert_eq!(t.scroll_offset(), 6); + t.scroll_view(ScrollAction::Top); + let top = t.scroll_offset(); + assert!(top > 0); + assert!( + t.screen_lines()[0].starts_with("line0"), + "Top must show the oldest stored row, got {:?}", + t.screen_lines()[0] + ); + // Large upward movement clamps at the oldest row. + t.scroll_view(ScrollAction::Live); + t.scroll_view(ScrollAction::Up(10_000)); + assert_eq!(t.scroll_offset(), top); + // Input returns the viewport to live output. + t.send_input(b"x").unwrap(); + assert_eq!(t.scroll_offset(), 0); + t.terminate(); + } + /// Input hints track child terminal-mode changes. #[test] fn input_hints_track_child_modes() { diff --git a/src/ui.rs b/src/ui.rs index 5811886..11be1b9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -477,7 +477,14 @@ fn render_attached(out: &mut impl Write, app: &App) -> io::Result<()> { } let cols = app.cols as usize; - let bar = format!(" [attached] {} Ctrl-\\ background", v.command); + // Display the scrollback offset when viewing history. + let bar = match screen.map_or(0, |s| s.scrollback) { + 0 => format!(" [attached] {} Ctrl-\\ background", v.command), + n => format!( + " [scroll ↑{n}] {} Esc live · PgUp/PgDn move · Ctrl-\\ background", + v.command + ), + }; queue!( out, MoveTo(0, app.rows.saturating_sub(1)),