Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions src/tui/src/ui/harness_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Comment thread
senamakel marked this conversation as resolved.
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 {
Expand Down
71 changes: 70 additions & 1 deletion src/tui/src/ui/harness_pane/tests/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand Down
Loading