fix(tui): force repaint on terminal resize

Handle terminal resize as a distinct TUI event so resize-driven renders can clear and repaint the viewport instead of relying on the normal diff path.

This avoids stale xterm.js rendering when blurred split panes resize quickly, where focus later repaired the duplicated composer state.
This commit is contained in:
Felipe Coury
2026-04-15 09:56:22 -03:00
parent 9716aa5091
commit b368aa722f
10 changed files with 49 additions and 13 deletions

View File

@@ -4219,7 +4219,11 @@ impl App {
app_server: &mut AppServerSession,
event: TuiEvent,
) -> Result<AppRunControl> {
if matches!(event, TuiEvent::Draw) {
let is_resize = matches!(event, TuiEvent::Resize);
if is_resize {
tui.force_full_repaint();
}
if matches!(event, TuiEvent::Draw | TuiEvent::Resize) {
self.handle_draw_pre_render(tui)?;
}
@@ -4238,7 +4242,7 @@ impl App {
let pasted = pasted.replace("\r", "\n");
self.chat_widget.handle_paste(pasted);
}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
if self.backtrack_render_pending {
self.backtrack_render_pending = false;
self.render_transcript_once(tui);

View File

@@ -363,7 +363,7 @@ impl App {
/// source of truth for the active cell and its cache invalidation key, and because `App` owns
/// overlay lifecycle and frame scheduling for animations.
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
if let TuiEvent::Draw = &event
if matches!(&event, TuiEvent::Draw | TuiEvent::Resize)
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
{
let active_key = self.chat_widget.active_cell_transcript_key();

View File

@@ -97,7 +97,7 @@ pub(crate) async fn run_cwd_selection_prompt(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
})?;

View File

@@ -153,7 +153,7 @@ pub(crate) async fn run_model_migration_prompt(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});

View File

@@ -474,7 +474,7 @@ pub(crate) async fn run_onboarding_app(
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
if !did_full_clear_after_success
&& onboarding_screen.steps.iter().any(|step| {
if let Step::Auth(w) = step {

View File

@@ -743,7 +743,7 @@ impl TranscriptOverlay {
}
other => self.view.handle_key_event(tui, other),
},
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
self.render(frame.area(), frame.buffer);
})?;
@@ -807,7 +807,7 @@ impl StaticOverlay {
}
other => self.view.handle_key_event(tui, other),
},
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
self.render(frame.area(), frame.buffer);
})?;

View File

@@ -288,7 +288,7 @@ async fn run_session_picker_with_loader(
return Ok(sel);
}
}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
if let Ok(size) = alt.tui.terminal.size() {
let list_height = size.height.saturating_sub(4) as usize;
state.update_view_rows(list_height);

View File

@@ -274,6 +274,7 @@ fn set_panic_hook() {
pub enum TuiEvent {
Key(KeyEvent),
Paste(String),
Resize,
Draw,
}
@@ -294,6 +295,7 @@ pub struct Tui {
notification_backend: Option<DesktopNotificationBackend>,
notification_condition: NotificationCondition,
is_zellij: bool,
force_full_repaint: bool,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
}
@@ -329,6 +331,7 @@ impl Tui {
notification_backend: Some(detect_backend(NotificationMethod::default())),
notification_condition: NotificationCondition::default(),
is_zellij,
force_full_repaint: false,
alt_screen_enabled: true,
}
}
@@ -351,6 +354,10 @@ impl Tui {
self.frame_requester.clone()
}
pub(crate) fn force_full_repaint(&mut self) {
self.force_full_repaint = true;
}
pub fn enhanced_keys_supported(&self) -> bool {
self.enhanced_keys_supported
}
@@ -591,9 +598,18 @@ impl Tui {
.suspend_context
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
let force_full_repaint = self.force_full_repaint;
self.force_full_repaint = false;
// Precompute any viewport updates that need a cursor-position query before entering
// the synchronized update, to avoid racing with the event reader.
let mut pending_viewport_area = self.pending_viewport_area()?;
// the synchronized update, to avoid racing with the event reader. Explicit resize
// events skip this heuristic because xterm.js can report stale cursor positions while
// a blurred split pane is being resized rapidly.
let mut pending_viewport_area = if force_full_repaint {
None
} else {
self.pending_viewport_area()?
};
stdout().sync_update(|_| {
#[cfg(unix)]
@@ -615,6 +631,11 @@ impl Tui {
self.is_zellij,
)?;
if force_full_repaint {
terminal.clear()?;
needs_full_repaint = true;
}
if needs_full_repaint {
terminal.invalidate_viewport();
}

View File

@@ -244,7 +244,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
Some(TuiEvent::Key(key_event))
}
Event::Resize(_, _) => Some(TuiEvent::Draw),
Event::Resize(_, _) => Some(TuiEvent::Resize),
Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)),
Event::FocusGained => {
self.terminal_focused.store(true, Ordering::Relaxed);
@@ -451,6 +451,17 @@ mod tests {
assert!(matches!(first, Some(TuiEvent::Draw)));
}
#[tokio::test(flavor = "current_thread")]
async fn resize_event_maps_to_resize() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream(broker, draw_rx, terminal_focused);
handle.send(Ok(Event::Resize(80, 24)));
let next = stream.next().await;
assert!(matches!(next, Some(TuiEvent::Resize)));
}
#[tokio::test(flavor = "current_thread")]
async fn error_or_eof_ends_stream() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();

View File

@@ -57,7 +57,7 @@ pub(crate) async fn run_update_prompt_if_needed(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
})?;