From 3607453087fdc870b7c6aff5cf64b202f630c513 Mon Sep 17 00:00:00 2001 From: akbash Date: Tue, 11 Aug 2026 22:55:49 +0300 Subject: [PATCH] fix(render): compact large terminal redraws (#2675) refs #2670 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> --- docs/next/CHANGELOG.md | 1 + src/protocol/render_ansi.rs | 26 ++++++------- src/server/headless.rs | 74 +++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 13 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 52dd9f0a..dfdd04db 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -19,6 +19,7 @@ - Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay. ### Fixed +- Remote clients now continue redrawing at very large terminal sizes instead of freezing when a full ANSI frame exceeds the transport limit. (#2670) - OpenCode panes now track the root conversation selected in their own TUI for native restore without adopting activity from attached clients. (#2450) - Server stop requests now bypass pane and API traffic, preventing busy sessions from blocking shutdown or admitting a client while shutdown is pending. (#2612) - Fish `Ctrl+Alt` keybindings now work in panes after legacy Alt-prefixed control bytes are decoded with both modifiers. (#2514) diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index 1e6b9aa4..97685d4d 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -633,9 +633,11 @@ fn write_ime_anchor_cursor_state(writer: &mut impl Write, cursor: HostCursorStat } fn write_all_cells(writer: &mut impl Write, frame: &FrameData) { + let mut last_sgr = String::new(); let mut active_hyperlink = None; for row in 0..frame.height { let mut to_skip = 0usize; + let mut next_inline_col = None; for col in 0..frame.width { if to_skip > 0 { to_skip -= 1; @@ -646,25 +648,23 @@ fn write_all_cells(writer: &mut impl Write, frame: &FrameData) { let cell = &frame.cells[idx]; if cell.skip { + next_inline_col = None; continue; } - // Move cursor to position (1-based). - let _ = write!(writer, "\x1b[{};{}H", row + 1, col + 1); - - // Set style. - let sgr = build_sgr(cell.fg, cell.bg, cell.modifier); - let _ = writer.write_all(sgr.as_bytes()); - - write_hyperlink_if_changed( + let cursor_position = (next_inline_col != Some(col)).then_some((col, row)); + write_cell( writer, + cursor_position, + cell, + &mut last_sgr, &mut active_hyperlink, - cell_hyperlink_uri(frame, cell), + frame, ); - - // Write the symbol. - let _ = writer.write_all(cell.symbol.as_bytes()); - to_skip = cell_width(cell).saturating_sub(1); + let width = cell_width(cell); + next_inline_col = + (cell.symbol.is_ascii() && width == 1).then_some(col.saturating_add(1)); + to_skip = width.saturating_sub(1); } } diff --git a/src/server/headless.rs b/src/server/headless.rs index ff6f367d..7393a7c5 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -9155,6 +9155,80 @@ next_tab = "" ); } + #[test] + fn render_and_stream_sends_large_terminal_frame_for_terminal_ansi_client() { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (278, 85), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::TerminalAnsi, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + server.render_and_stream(); + match read_server_message( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial terminal frame"), + ) { + ServerMessage::Terminal(frame) => { + assert_eq!(frame.seq, 1); + assert_eq!((frame.width, frame.height), (278, 85)); + assert!(frame.full); + } + other => panic!("expected terminal frame, got {other:?}"), + } + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 1, + cols: 710, + rows: 202, + cell_width_px: 0, + cell_height_px: 0, + })); + server.render_and_stream(); + + match read_server_message( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("large terminal frame"), + ) { + ServerMessage::Terminal(frame) => { + assert_eq!(frame.seq, 2); + assert_eq!((frame.width, frame.height), (710, 202)); + assert!(frame.full); + assert!(!frame.bytes.is_empty()); + } + other => panic!("expected terminal frame, got {other:?}"), + } + + server.app.state.mode = crate::app::Mode::Navigate; + server.render_and_stream(); + match read_server_message( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("follow-up terminal frame"), + ) { + ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 3), + other => panic!("expected terminal frame, got {other:?}"), + } + } + #[test] fn terminal_ansi_input_does_not_reset_blit_baseline() { let mut server = test_headless_server();