diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index c1ef0939..824e52c5 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Resizing restored panes no longer aborts the server when libghostty-vt reflows a terminal whose pre-resize cursor row is past the new height. (#465) +- Full-screen TUIs such as Neovim now receive resize-generated terminal responses after Herdr internal pane resizes, so grown panes redraw without waiting for extra input. (#471) ## [0.6.8] - 2026-06-04 diff --git a/src/pane.rs b/src/pane.rs index 248f5bb1..19556ffe 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -722,11 +722,24 @@ impl PaneRuntimeIo { } } - fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { + fn resize( + &self, + rows: u16, + cols: u16, + cell_width_px: u32, + cell_height_px: u32, + terminal_responses: Vec, + ) { match self { #[cfg(unix)] PaneRuntimeIo::Actor(actor) => { - actor.resize(rows, cols, cell_width_px, cell_height_px); + actor.resize( + rows, + cols, + cell_width_px, + cell_height_px, + terminal_responses, + ); } #[cfg(test)] PaneRuntimeIo::TestChannel { resize_tx, .. } => { @@ -1814,9 +1827,16 @@ impl PaneRuntime { return; } self.current_size.set(size); - self.terminal + let terminal_responses = self + .terminal .resize(rows, cols, cell_width_px, cell_height_px); - self.io.resize(rows, cols, cell_width_px, cell_height_px); + self.io.resize( + rows, + cols, + cell_width_px, + cell_height_px, + terminal_responses, + ); } pub fn nudge_child_redraw_after_handoff(&self) { diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 88924d36..f2fceaa8 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -146,9 +146,15 @@ impl PaneTerminal { .process_pty_bytes(pane_id, shell_pid, bytes, response_writer) } - pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { + pub fn resize( + &self, + rows: u16, + cols: u16, + cell_width_px: u32, + cell_height_px: u32, + ) -> Vec { self.ghostty - .resize(rows, cols, cell_width_px, cell_height_px); + .resize(rows, cols, cell_width_px, cell_height_px) } pub fn scroll_up(&self, lines: usize) { @@ -662,7 +668,13 @@ impl GhosttyPaneTerminal { } } - pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { + pub fn resize( + &self, + rows: u16, + cols: u16, + cell_width_px: u32, + cell_height_px: u32, + ) -> Vec { if let Ok(mut core) = self.core.lock() { let offset_from_bottom = core .terminal @@ -694,6 +706,7 @@ impl GhosttyPaneTerminal { let _ = core .terminal .resize(cols, rows, cell_width_px, cell_height_px); + let terminal_responses = self.drain_pending_pty_responses(); let bottom_is_blank = ghostty_detection_text(&core) .map(|text| text.trim().is_empty()) @@ -716,6 +729,9 @@ impl GhosttyPaneTerminal { remaining -= 1; } } + terminal_responses + } else { + Vec::new() } } @@ -2621,6 +2637,21 @@ mod tests { assert!(pane.recent_text(3).trim().is_empty()); } + #[test] + fn resize_returns_in_band_size_report_response() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.mode_set(2048, true).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let responses = pane.resize(40, 100, 9, 18); + + assert_eq!( + responses, + vec![Bytes::from_static(b"\x1B[48;40;100;720;900t")] + ); + } + #[test] fn synchronized_output_suppresses_intermediate_render_requests_until_batch_ends() { let (tx, _rx) = mpsc::channel(4); diff --git a/src/pty/actor.rs b/src/pty/actor.rs index cce58a75..70634475 100644 --- a/src/pty/actor.rs +++ b/src/pty/actor.rs @@ -50,9 +50,15 @@ struct PtyResize { cell_height_px: u32, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PtyResizeRequest { + resize: PtyResize, + terminal_responses: Vec, +} + #[derive(Default)] struct SharedPtyControls { - resize: Option, + resize: Option, nudge: Option, } @@ -151,17 +157,27 @@ impl PtyIoActorHandle { } } - pub(crate) fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { + pub(crate) fn resize( + &self, + rows: u16, + cols: u16, + cell_width_px: u32, + cell_height_px: u32, + terminal_responses: Vec, + ) { { let mut controls = self .controls .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - controls.resize = Some(PtyResize { - rows, - cols, - cell_width_px, - cell_height_px, + controls.resize = Some(PtyResizeRequest { + resize: PtyResize { + rows, + cols, + cell_width_px, + cell_height_px, + }, + terminal_responses, }); } self.wake_actor(); @@ -592,8 +608,9 @@ impl PtyIoActorRunner { if self.state == ActorState::Released { return; } - if let Some(resize) = resize { - self.resize(resize); + if let Some(request) = resize { + self.resize(request.resize); + self.enqueue_terminal_responses(request.terminal_responses); } if let Some(nudge) = nudge { self.nudge(nudge); @@ -612,16 +629,19 @@ impl PtyIoActorRunner { } Ok(n) => { let result = (self.on_read)(&buf[..n]); - for response in result.terminal_responses { - if self.state != ActorState::Released { - self.pending_writes.push_back(response); - } - } + self.enqueue_terminal_responses(result.terminal_responses); true } } } + fn enqueue_terminal_responses(&mut self, terminal_responses: Vec) { + if self.state == ActorState::Released { + return; + } + self.pending_writes.extend(terminal_responses); + } + fn flush_pending_writes_once(&mut self) { while let Some(bytes) = self.pending_writes.front() { let chunk = &bytes[self.current_write_offset..]; @@ -922,18 +942,21 @@ mod tests { controls: Arc::clone(&controls), }; - handle.resize(20, 80, 8, 16); - handle.resize(40, 120, 9, 18); + handle.resize(20, 80, 8, 16, vec![Bytes::from_static(b"old")]); + handle.resize(40, 120, 9, 18, vec![Bytes::from_static(b"new")]); handle.nudge_child_redraw_after_handoff(41, 121, 10, 20); let controls = controls.lock().expect("controls lock"); assert_eq!( controls.resize, - Some(PtyResize { - rows: 40, - cols: 120, - cell_width_px: 9, - cell_height_px: 18, + Some(PtyResizeRequest { + resize: PtyResize { + rows: 40, + cols: 120, + cell_width_px: 9, + cell_height_px: 18, + }, + terminal_responses: vec![Bytes::from_static(b"new")], }) ); assert_eq!( @@ -947,6 +970,20 @@ mod tests { ); } + #[test] + fn resize_writes_terminal_responses_after_applying_resize() { + let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); + let response = Bytes::from_static(b"\x1B[48;40;100;720;900t"); + + handle.resize(40, 100, 9, 18, vec![response.clone()]); + + let mut buf = vec![0; response.len()]; + peer.read_exact(&mut buf) + .expect("peer receives resize response"); + assert_eq!(Bytes::from(buf), response); + handle.shutdown(); + } + #[tokio::test] async fn async_user_input_waits_for_queue_capacity() { let (data_tx, mut data_rx) = mpsc::channel(1);