diff --git a/src/tui/src/ui/harness_pane/mod.rs b/src/tui/src/ui/harness_pane/mod.rs index dc6d009e2..185a88f5e 100644 --- a/src/tui/src/ui/harness_pane/mod.rs +++ b/src/tui/src/ui/harness_pane/mod.rs @@ -91,14 +91,22 @@ impl LocalSessions { /// Scroll `session_id` by one wheel notch at pane-relative `(col, row)`. /// - /// Two paths, chosen by what the child asked for rather than by what we - /// would prefer: + /// The path is chosen by what the child is *doing right now* rather than by + /// what we would prefer: /// + /// - the harness is gone, so none of the forwarding paths below can reach it + /// — the wheel moves the emulator's own retained lines, which is how the + /// operator read how the session ended; /// - the harness enabled mouse reporting (Claude Code and Codex both do), so /// the notch is forwarded and *its* scrollback moves — which is the one /// the operator means, because it holds the whole conversation rather than /// the last screenful the emulator happened to retain; - /// - the harness enables alternate scrolling without mouse reporting (Codex), + /// - Codex does not negotiate mouse reports in current releases. Once its + /// input layer is up (bracketed paste — the readiness signal the pane + /// reads from the emulator) its TUI + /// consumes cursor keys to move through the transcript, so a notch becomes + /// cursor-key input even when it does not advertise alternate scrolling; + /// - another harness enables alternate scrolling without mouse reporting, /// so the notch becomes cursor-key input as xterm's alternate-scroll mode /// specifies; /// - otherwise our emulator's own retained lines move instead. Not as good, @@ -110,15 +118,34 @@ impl LocalSessions { /// own history. A mouse-reporting harness receives one notch and decides /// what that notch means itself. pub fn scroll(&self, session_id: &str, col: u16, row: u16, up: bool, rows: usize) { + // A dead child cannot receive a wheel event, however it configured its + // terminal while it lived: the row is retained so the operator can read + // how it ended, and a notch must move that — not write cursor keys (or a + // report) into a pty nobody is reading, which would also strand the + // wheel forever. Every forwarding path below is therefore gated on the + // session still running. + if !self.is_running(session_id) { + self.sessions.scroll_history(session_id, rows, up); + return; + } if let Some((mode, encoding)) = self.sessions.mouse_protocol(session_id) { if let Some(bytes) = mouse::wheel(mode, encoding, col, row, up) { - // A failed write means the child died; the pane notices on its - // next frame, and a lost wheel notch is not worth a message. + // A failed write means the child died between the last frame and + // this notch; the pane notices on its next draw, and a lost + // wheel notch is not worth a message. let _ = self.sessions.write(session_id, &bytes); return; } } - if self.sessions.alternate_scroll(session_id) == Some(true) { + // Codex gets cursor keys only once its input layer is up: a codex that + // is still painting (or a shell standing in for one) has nothing to + // consume them, and sending them would only garble its first paint. + let codex = self + .sessions + .row(session_id) + .is_some_and(|row| row.provider == medulla::protocol::HarnessProvider::Codex) + && self.sessions.bracketed_paste(session_id) == Some(true); + if codex || self.sessions.alternate_scroll(session_id) == Some(true) { let arrow = if up { b"\x1b[A" } else { b"\x1b[B" }; let mut bytes = Vec::with_capacity(arrow.len() * rows); for _ in 0..rows { diff --git a/src/tui/src/ui/harness_pane/tests/session.rs b/src/tui/src/ui/harness_pane/tests/session.rs index 4de2db24b..feba3d23c 100644 --- a/src/tui/src/ui/harness_pane/tests/session.rs +++ b/src/tui/src/ui/harness_pane/tests/session.rs @@ -29,13 +29,18 @@ use super::super::LocalSessions; /// `--session-id`, which `/bin/sh` would reject as an unknown option. Codex /// takes no preset id, so its argv is empty and the script is the whole command. pub(super) fn sh(script: &str) -> LaunchSpec { + sh_for(HarnessProvider::Codex, script) +} + +/// A shell PTY carrying `provider` metadata for provider-specific input tests. +fn sh_for(provider: HarnessProvider, script: &str) -> LaunchSpec { let mut env = HashMap::new(); if let Ok(path) = std::env::var("PATH") { env.insert("PATH".to_string(), path); } env.insert("TERM".to_string(), "xterm-256color".to_string()); LaunchSpec { - provider: HarnessProvider::Codex, + provider, preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), @@ -291,6 +296,36 @@ fn alternate_scroll_without_mouse_reporting_gets_arrow_scroll_events() { sessions.close(&id); } +#[test] +fn codex_without_mouse_or_alternate_scroll_gets_arrow_scroll_events() { + let sessions = PtyManager::new(); + let harnesses = harnesses(sessions.clone()); + // Current Codex releases enable bracketed paste and enhanced keyboard input, + // but no longer advertise DECSET 1007. The provider still expects wheel + // scrolling to reach its transcript as cursor keys. + let id = sessions + .open(sh( + "printf '\\033[?2004hready'; sleep 0.3; cat -v; sleep 30", + )) + .unwrap(); + + wait_for("the Codex stand-in to become ready", || { + text(&harnesses, &id).contains("ready") + }); + assert_eq!(sessions.alternate_scroll(&id), Some(false)); + assert!(matches!( + sessions.mouse_protocol(&id), + Some((vt100::MouseProtocolMode::None, _)) + )); + + harnesses.scroll(&id, 3, 4, true, 3); + + wait_for("Codex to receive translated wheel input", || { + text(&harnesses, &id).contains("^[[A^[[A^[[A") + }); + sessions.close(&id); +} + #[test] fn alternate_screen_without_alternate_scroll_does_not_receive_arrows() { let sessions = PtyManager::new(); @@ -320,6 +355,9 @@ fn a_child_that_never_asked_for_the_mouse_gets_our_scrollback_instead() { let sessions = PtyManager::new(); let harnesses = harnesses(sessions.clone()); // Enough lines to push history off a 30-row screen, and no mouse reporting. + // Codex stands in for "some harness": the stand-in never turns on bracketed + // paste or alternate scrolling, so the wheel must fall through to our own + // emulator rather than be synthesized into cursor keys. let id = sessions .open(sh( "i=1; while [ $i -le 200 ]; do echo line-$i; i=$((i+1)); done; sleep 30", @@ -353,6 +391,37 @@ fn a_child_that_never_asked_for_the_mouse_gets_our_scrollback_instead() { sessions.close(&id); } +#[test] +fn an_exited_codex_session_scrolls_our_retained_history() { + let sessions = PtyManager::new(); + let harnesses = harnesses(sessions.clone()); + // A codex that raised its input layer (bracketed paste) and then exited: + // there is no child to synthesize cursor keys for any more, so the wheel + // must move the emulator's own retained lines — the ones the operator is + // reading how the session ended. + let id = sessions + .open(sh( + "printf '\\033[?2004h'; i=1; while [ $i -le 200 ]; do echo line-$i; i=$((i+1)); done", + )) + .unwrap(); + + wait_for("the codex stand-in to exit", || !harnesses.is_running(&id)); + wait_for("the retained screen to hold the tail", || { + text(&harnesses, &id).contains("line-200") + }); + // The input layer was up, but the child is gone: cursor keys have no + // listener, which is exactly the case that must fall through. + assert_eq!(harnesses.sessions.bracketed_paste(&id), Some(true)); + assert!(harnesses.write(&id, b"x").is_err()); + + harnesses.scroll(&id, 0, 0, true, 40); + let scrolled = text(&harnesses, &id); + assert!( + !scrolled.contains("line-200"), + "an exited codex must scroll its retained history:\n{scrolled}" + ); +} + #[test] fn scrolling_down_at_the_live_edge_stays_put_rather_than_underflowing() { let sessions = PtyManager::new();