mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
fix(terminal): replay a resize at the prompt the way it was drawn live
A pane resized while its shell sat at the prompt gets the shell's SIGWINCH redraw — up the rows the prompt last took, clear below, draw — written against the cursor tty7's prompt reflow left. The ring keeps those bytes but not that cursor, and every replayed Size was applied as a plain resize. A pane narrowed below its prompt's width and widened again came back from a window relaunch with the line above the prompt erased: on a restored pane, the second row of the restored-screen banner. The replay now tracks whether the shell was at its prompt from the marks the ring carries, and reflows a replayed Size at the prompt as the live one was. A restored pane's new segment opens with the restore preamble, which nothing redraws over, so a Size heading it stays a plain resize and the dead shell's last prompt stays on screen.
This commit is contained in:
+137
-6
@@ -1255,6 +1255,14 @@ impl RemoteTerminal {
|
||||
// daemon's replay (an attach or a relink), which it sends
|
||||
// entirely as `Snapshot`s followed by the stored state.
|
||||
let mut replaying_state = true;
|
||||
// Whether the replay, as far as it has got, has the shell at
|
||||
// its prompt — read off the marks the ring carries, because
|
||||
// the shell state the daemon reports comes after the ring.
|
||||
let mut replayed_prompt = false;
|
||||
// A replayed Size that found the shell at its prompt, held
|
||||
// until the bytes it heads show whether that shell is still
|
||||
// the one drawing them (see the Size arm).
|
||||
let mut held_size: Option<WinSize> = None;
|
||||
let mut cursor_scan = ParkedCursorScanner::new();
|
||||
let mut parked_cursor = ParkedCursorRepair::default();
|
||||
// #837: the command marks, cut at exactly where they land so
|
||||
@@ -1478,6 +1486,21 @@ impl RemoteTerminal {
|
||||
{
|
||||
proxy.send_event(super::color_scheme::query_reply());
|
||||
}
|
||||
if let Some(ws) = held_size.take() {
|
||||
let redraws = match &msg {
|
||||
DaemonMsg::Snapshot(bytes) => !opens_a_restored_pane(bytes),
|
||||
_ => true,
|
||||
};
|
||||
let mut term = term.lock();
|
||||
if quit.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
super::prompt_reflow::resize(
|
||||
&mut term,
|
||||
TermSize::new(ws.cols as usize, ws.rows as usize),
|
||||
redraws,
|
||||
);
|
||||
}
|
||||
match msg {
|
||||
// Geometry, applied at this exact stream position.
|
||||
// During replay each ring segment is preceded by
|
||||
@@ -1489,12 +1512,28 @@ impl RemoteTerminal {
|
||||
DaemonMsg::Size(ws) => {
|
||||
flush_batch!();
|
||||
// A live echo lands just before the shell's
|
||||
// SIGWINCH redraw; a replayed Size heads old
|
||||
// bytes that nothing is going to repaint.
|
||||
let shell_redraws = !replaying_state
|
||||
&& !local_conpty.load(Ordering::Relaxed)
|
||||
&& shell.lock().is_ok_and(|s| s.active && s.at_prompt);
|
||||
{
|
||||
// SIGWINCH redraw. So does a replayed one that
|
||||
// found the shell at its prompt: the segment it
|
||||
// heads opens with that same redraw, written
|
||||
// against the cursor the live reflow left —
|
||||
// replayed as a plain resize, its cursor-up
|
||||
// landed a row high and its clear-below ate
|
||||
// the line above the prompt.
|
||||
let at_prompt = match replaying_state {
|
||||
true => replayed_prompt,
|
||||
false => shell.lock().is_ok_and(|s| s.active && s.at_prompt),
|
||||
};
|
||||
let shell_redraws =
|
||||
at_prompt && !local_conpty.load(Ordering::Relaxed);
|
||||
// Unless the shell that was at the prompt is
|
||||
// gone: a restored pane's ring is the dead
|
||||
// shell's segments, then one at the new pane's
|
||||
// size that opens with the restore preamble —
|
||||
// and nothing redraws the old prompt there.
|
||||
// Only the bytes after this frame can say which.
|
||||
if shell_redraws && replaying_state {
|
||||
held_size = Some(ws);
|
||||
} else {
|
||||
let mut term = term.lock();
|
||||
if quit.load(Ordering::SeqCst) {
|
||||
return;
|
||||
@@ -1509,6 +1548,9 @@ impl RemoteTerminal {
|
||||
}
|
||||
DaemonMsg::Snapshot(bytes) => {
|
||||
flush_batch!();
|
||||
if opens_a_restored_pane(&bytes) {
|
||||
replayed_prompt = false;
|
||||
}
|
||||
cursor_scan.reset();
|
||||
parked_cursor.reset();
|
||||
proxy.replaying.store(true, Ordering::Relaxed);
|
||||
@@ -1521,9 +1563,12 @@ impl RemoteTerminal {
|
||||
feed_grid(&term, &mut processor, &bytes, cuts, &quit, |term, cut| {
|
||||
match cut {
|
||||
ReaderCut::Command(mark) => {
|
||||
replayed_prompt =
|
||||
matches!(mark, CommandMark::Finished);
|
||||
command_cursor.apply(term, mark)
|
||||
}
|
||||
ReaderCut::Prompt(mark) => {
|
||||
replayed_prompt = true;
|
||||
prompt_break.apply(term, mark)
|
||||
}
|
||||
ReaderCut::Parked(_) => {}
|
||||
@@ -3641,6 +3686,16 @@ fn command_cuts(tok: &mut OscTokenizer, bytes: &[u8], cuts: &mut Vec<(usize, Rea
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a replayed segment is where a restored pane's new shell begins:
|
||||
/// the daemon opens it with the restore preamble, whose input-mode resets no
|
||||
/// shell writes. They sit at its very start, behind at most a retitle.
|
||||
fn opens_a_restored_pane(bytes: &[u8]) -> bool {
|
||||
let resets = tty7_core::daemon::pane::INPUT_MODE_RESETS;
|
||||
bytes[..bytes.len().min(4096)]
|
||||
.windows(resets.len())
|
||||
.any(|w| w == resets)
|
||||
}
|
||||
|
||||
/// How much output the reader parses per hold of the grid lock. Every UI-thread
|
||||
/// handler that touches the grid — a scroll, a selection drag, a keystroke —
|
||||
/// queues on this lock, and one UI thread serves every window, so a hold is how
|
||||
@@ -3868,6 +3923,82 @@ mod replay_tests {
|
||||
drop(daemon);
|
||||
}
|
||||
|
||||
/// A pane narrowed below its prompt's width and widened again, then
|
||||
/// re-attached. Each resize at the prompt was followed by zsh's SIGWINCH
|
||||
/// redraw — up the rows the prompt last took, clear below, draw — and the
|
||||
/// ring keeps those bytes, but not the cursor the live reflow placed for
|
||||
/// them. Replayed as plain resizes, the redraw after the widening moved up
|
||||
/// from a prompt that was one row again and cleared the line above it:
|
||||
/// the second row of the restored-screen banner went missing on every
|
||||
/// window relaunch.
|
||||
#[test]
|
||||
fn a_replayed_prompt_redraw_keeps_the_line_above_it() {
|
||||
crate::core::config::pin_test_config_dir();
|
||||
let (client_side, mut daemon) = socket_pair();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(50, 12)).unwrap();
|
||||
let prompt = "\x1b]133;A\x07user@host dir % \x1b]133;B\x07";
|
||||
let banner = format!("{}{}", "B".repeat(50), "b".repeat(26));
|
||||
|
||||
DaemonMsg::Size(ws(50, 12)).encode(&mut daemon).unwrap();
|
||||
DaemonMsg::Snapshot(format!("{banner}\r\n{prompt}").into_bytes())
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
// 50 → 12: the prompt took one row, so zsh only returns and clears,
|
||||
// and the 16-cell prompt now wraps onto a second row.
|
||||
DaemonMsg::Size(ws(12, 12)).encode(&mut daemon).unwrap();
|
||||
DaemonMsg::Snapshot(format!("\r\x1b[J{prompt}").into_bytes())
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
// 12 → 50: it took two rows, so zsh goes up one first.
|
||||
DaemonMsg::Size(ws(50, 12)).encode(&mut daemon).unwrap();
|
||||
DaemonMsg::Snapshot(format!("\x1b[A\r\x1b[J{prompt}END").into_bytes())
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
|
||||
let text = settled(&term, &["END"]);
|
||||
assert!(
|
||||
text.contains(&"b".repeat(26)),
|
||||
"the banner's second row is gone:\n{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
screen_text(&term),
|
||||
format!(
|
||||
"{}\n{}\nuser@host dir % END\n",
|
||||
"B".repeat(50),
|
||||
"b".repeat(26)
|
||||
)
|
||||
);
|
||||
drop(daemon);
|
||||
}
|
||||
|
||||
/// The same replay through a restored pane: the dead shell's last prompt,
|
||||
/// then the new pane's segment opening with the restore preamble. Nothing
|
||||
/// redraws that old prompt, so the resize between them must not hand its
|
||||
/// line back to a shell — it stays on screen above the banner.
|
||||
#[test]
|
||||
fn a_restored_panes_old_prompt_survives_the_replay() {
|
||||
crate::core::config::pin_test_config_dir();
|
||||
let (client_side, mut daemon) = socket_pair();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(50, 12)).unwrap();
|
||||
let prompt = "\x1b]133;A\x07user@host dir % \x1b]133;B\x07";
|
||||
|
||||
DaemonMsg::Size(ws(50, 12)).encode(&mut daemon).unwrap();
|
||||
DaemonMsg::Snapshot(format!("OLD-OUTPUT\r\n{prompt}").into_bytes())
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
DaemonMsg::Size(ws(40, 12)).encode(&mut daemon).unwrap();
|
||||
let mut restored = tty7_core::daemon::pane::restore_preamble(Some("new shell"));
|
||||
restored.extend_from_slice(format!("{prompt}END").as_bytes());
|
||||
DaemonMsg::Snapshot(restored).encode(&mut daemon).unwrap();
|
||||
|
||||
let text = settled(&term, &["END"]);
|
||||
assert!(
|
||||
text.contains("OLD-OUTPUT\nuser@host dir %\n"),
|
||||
"the dead shell's last prompt was erased:\n{text}"
|
||||
);
|
||||
drop(daemon);
|
||||
}
|
||||
|
||||
/// A real pane's ring is megabytes; one `Snapshot` frame is far larger than
|
||||
/// the reader's 256 KiB read buffer, so the frame is assembled across many
|
||||
/// reads — several of which time out at `QUIT_POLL` while the sender is
|
||||
|
||||
Reference in New Issue
Block a user