From dcb3cd5eee9049281ac7de0b6b65e1c15f23f514 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:56:15 +0800 Subject: [PATCH] fix(daemon): keep the newest mark's reading when suppression collapses a batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `suppress_relayed_prompt_marks` compares everything but `mark_at_prompt`, so that two marks the local editor can no longer tell apart still collapse into one `Prompt` message. But `dedup_by` keeps the *earlier* of the pair, and a far shell's whole turn can arrive in a single read: the previous command's `D`, the prompt's `A`/`B`, then the next command's `C`. With `at_prompt` cleared across the batch those two entries differ only in the mark's own reading, and the `C` was the one being dropped — leaving `mark_at_prompt` standing at `true` while a remote command was starting, which `PaneContext::at_prompt` reports and `pane_freeness` answers `free` from. The reverse order loses the prompt instead: a command that starts and finishes inside one read collapses onto its own `C`, and the pane reads busy for as long as it takes some unrelated output to arrive — which on an idle prompt is never. Both need the same thing, so carry the reading onto the survivor before the later entry goes. `command` only tells the pair apart when the far shell names it; nushell's integration and several third-party ones emit a bare `133;C`. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- crates/tty7-core/src/daemon/pane.rs | 52 +++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 08461a67..fa1d9d12 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -2880,16 +2880,26 @@ fn same_dir(a: &Path, b: &Path) -> bool { /// only thing this machine knows about the far shell (#840). Because of it, /// dedup has to compare what is actually emitted rather than the whole struct /// — two marks that used to collapse into one `Prompt` message must still -/// collapse when only their unsuppressed twin tells them apart. +/// collapse when only their unsuppressed twin tells them apart, and the +/// survivor has to carry the *newest* twin: a whole turn can arrive in one read +/// (`D`, the prompt's `A`/`B`, then the next command's `C`), and dropping the +/// last entry's reading would hand the pane back a prompt it has already left. fn suppress_relayed_prompt_marks(shell: &mut Vec) { for s in shell.iter_mut() { s.at_prompt = false; } shell.dedup_by(|a, b| { - a.active == b.active + let same = a.active == b.active && a.at_prompt == b.at_prompt && a.last_exit_code == b.last_exit_code - && a.command == b.command + && a.command == b.command; + // `dedup_by` keeps `b`, the earlier of the pair, and drops `a`. Move + // the reading over first so the collapse costs a `Prompt` message and + // nothing else. + if same { + b.mark_at_prompt = a.mark_at_prompt; + } + same }); } @@ -4315,6 +4325,42 @@ mod tests { assert!(local.shell.last().unwrap().at_prompt); } + /// The collapse that suppression performs must not hand the pane back a + /// prompt it has already left. A far shell's whole turn can arrive in one + /// read — the previous command's `D`, the prompt's `A`/`B`, then the next + /// command's `C` — and once `at_prompt` is cleared across the batch those + /// two entries differ only in the mark's own reading. The collapse keeps + /// the earlier entry, so that reading has to travel with it or freeness + /// answers `free` while a remote command is running (#840). + #[test] + fn suppression_collapses_a_batch_onto_its_newest_marks_reading() { + let mut s = OscSniffer::new(); + // Bare `133;C`: nushell's integration emits it with no command name, as + // do several third-party ones, so `command` cannot tell the two entries + // apart either. + let mut turn = s.feed(b"\x1b]133;D;0\x07\x1b]133;A\x07\x1b]133;B\x07\x1b]133;C\x07"); + suppress_relayed_prompt_marks(&mut turn.shell); + assert_eq!( + turn.shell.len(), + 1, + "still one `Prompt` message, exactly as before the mark was added" + ); + assert!( + !turn.shell.last().unwrap().mark_at_prompt, + "the newest mark started a command, so the pane is not at a prompt" + ); + + // And the other order: a command that starts and finishes inside one + // read has to end at the prompt, not at the `C` that opened it. + let mut turn = s.feed(b"\x1b]133;C\x07\x1b]133;D;0\x07\x1b]133;A\x07"); + suppress_relayed_prompt_marks(&mut turn.shell); + assert_eq!(turn.shell.len(), 1); + assert!( + turn.shell.last().unwrap().mark_at_prompt, + "the newest mark is the prompt the far shell just drew" + ); + } + /// A prompt mark that arrives while the pane is pointed at a remote host /// can only be the far shell's — the near one cannot be at a prompt while /// the connection owns its pty. That is what proves the far side runs the