diff --git a/crates/warp_tui/benches/transcript_bench.rs b/crates/warp_tui/benches/transcript_bench.rs index 16d70db35f..a3a9462635 100644 --- a/crates/warp_tui/benches/transcript_bench.rs +++ b/crates/warp_tui/benches/transcript_bench.rs @@ -2,7 +2,20 @@ use std::hint::black_box; use std::time::Duration; use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; -use warp_tui::benchmark_support::{TranscriptBenchmark, TranscriptDataset}; +use warp_tui::benchmark_support::{ + ClippedTerminalBlockBenchmark, TranscriptBenchmark, TranscriptDataset, +}; + +fn benchmark_clipped_terminal_block(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("tui_terminal_block/clipped_content"); + for rows in [100, 1_000] { + let mut benchmark = ClippedTerminalBlockBenchmark::new(rows, 120, 50); + group.bench_with_input(BenchmarkId::new("end_frame", rows), &rows, |b, _| { + b.iter(|| black_box(benchmark.present())) + }); + } + group.finish(); +} fn benchmark_many_small_blocks(criterion: &mut Criterion) { let mut group = criterion.benchmark_group("tui_transcript/many_small_blocks"); @@ -93,6 +106,7 @@ criterion_group! { .warm_up_time(Duration::from_millis(500)) .measurement_time(Duration::from_secs(1)); targets = + benchmark_clipped_terminal_block, benchmark_many_small_blocks, benchmark_long_agent_response, benchmark_offscreen_streaming_tail diff --git a/crates/warp_tui/src/benchmark_support.rs b/crates/warp_tui/src/benchmark_support.rs index c81edce12d..6f3529c8ff 100644 --- a/crates/warp_tui/src/benchmark_support.rs +++ b/crates/warp_tui/src/benchmark_support.rs @@ -9,8 +9,8 @@ use parking_lot::FairMutex; use warp::tui_export::{ AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIBlockModel, AIBlockOutputStatus, AIConversationId, - AIRequestType, Appearance, LLMId, MessageId, OutputStatusUpdateCallback, RichContentItem, - RichContentType, ServerOutputId, Shared, TerminalModel, + AIRequestType, Appearance, BlockId, LLMId, MessageId, OutputStatusUpdateCallback, + RichContentItem, RichContentType, ServerOutputId, Shared, TerminalModel, }; use warpui::platform::WindowStyle; use warpui::{ @@ -18,12 +18,13 @@ use warpui::{ ViewContext, ViewHandle, WindowInvalidation, }; use warpui_core::elements::tui::{ - TuiElement, TuiRect, TuiViewportPosition, TuiViewportVerticalAlignment, TuiViewportedList, - TuiViewportedListState, + TuiClipped, TuiElement, TuiRect, TuiViewportPosition, TuiViewportVerticalAlignment, + TuiViewportedList, TuiViewportedListState, }; use warpui_core::presenter::tui::TuiPresenter; use crate::agent_block::TuiAIBlock; +use crate::terminal_block::{TerminalBlockElement, block_content_rows}; use crate::test_fixtures::add_test_action_model_and_events; use crate::tui_block_list_viewport_source::{ AgentBlockRegistry, CLISubagentBlockRegistry, HandoffBlockRegistry, TuiBlockListViewportSource, @@ -44,6 +45,61 @@ pub enum TranscriptDataset { }, } +/// One inline terminal block painted through a fixed-height clipped viewport. +pub struct ClippedTerminalBlockBenchmark { + app: App, + model: Arc>, + block_id: BlockId, + viewport_origin_y: usize, + presenter: TuiPresenter, + area: TuiRect, +} + +impl ClippedTerminalBlockBenchmark { + /// Builds and primes a long terminal block with `rows` output rows. + pub fn new(rows: usize, width: u16, height: u16) -> Self { + App::test((), move |app| async move { + let mut terminal_model = TerminalModel::mock(None, None); + let output = "benchmark terminal output\r\n".repeat(rows); + terminal_model.simulate_block("printf benchmark", output.as_str()); + let block = terminal_model + .block_list() + .blocks() + .iter() + .rev() + .find(|block| block.finished()) + .expect("simulated block should exist"); + let block_id = block.id().clone(); + let content_height = block_content_rows(block).len(); + let mut benchmark = Self { + app, + model: Arc::new(FairMutex::new(terminal_model)), + block_id, + viewport_origin_y: content_height.saturating_sub(usize::from(height)), + presenter: TuiPresenter::new(), + area: TuiRect::new(0, 0, width, height), + }; + benchmark.present(); + benchmark + }) + } + + /// Lays out and paints one clipped frame and returns a cheap checksum. + pub fn present(&mut self) -> u64 { + let element = TuiClipped::new( + TerminalBlockElement::content(self.model.clone(), self.block_id.clone()).finish(), + ) + .with_viewport_origin_y(self.viewport_origin_y) + .finish(); + let frame = self + .app + .read(|ctx| self.presenter.present_element(element, self.area, ctx)); + frame.buffer.content.iter().fold(0u64, |checksum, cell| { + checksum.wrapping_add(cell.symbol().len() as u64) + }) + } +} + /// One production-shaped retained transcript benchmark. pub struct TranscriptBenchmark { app: App, diff --git a/crates/warp_tui/src/terminal_block.rs b/crates/warp_tui/src/terminal_block.rs index 375a7465af..97b5c8ad84 100644 --- a/crates/warp_tui/src/terminal_block.rs +++ b/crates/warp_tui/src/terminal_block.rs @@ -186,6 +186,11 @@ impl TuiElement for TerminalBlockElement { let Some(size) = self.size else { return; }; + + let Some(visible_element_rows) = surface.visible_rows(origin, size) else { + return; + }; + let model = self.model.lock(); let colors = model.colors(); let block_list = model.block_list(); @@ -197,22 +202,43 @@ impl TuiElement for TerminalBlockElement { TerminalBlockRows::Visible { rows, width } => (rows.clone(), (*width).min(size.width)), TerminalBlockRows::Content => (block_content_rows(block), size.width), }; - let cursor = terminal_block_cursor(block, cursor_owner == Some(block.id()), &rows, size) - .and_then(|(column, row)| { - let column = if self.command_style.is_some() && block.is_command_grid_active() { - column.saturating_add(SHELL_COMMAND_PREFIX_WIDTH) - } else { - column - }; - (column < size.width).then_some((column, row)) - }); + + let visible_rows = rows + .start + .saturating_add(usize::from(visible_element_rows.start)) + ..rows + .start + .saturating_add(usize::from(visible_element_rows.end)) + .min(rows.end); + let visible_size = TuiSize::new( + size.width, + visible_element_rows + .end + .saturating_sub(visible_element_rows.start), + ); + let visible_origin = origin.offset(0, i32::from(visible_element_rows.start)); + let cursor = terminal_block_cursor( + block, + cursor_owner == Some(block.id()), + &visible_rows, + visible_size, + ) + .and_then(|(column, row)| { + let column = if self.command_style.is_some() && block.is_command_grid_active() { + column.saturating_add(SHELL_COMMAND_PREFIX_WIDTH) + } else { + column + }; + (column < visible_size.width).then_some((column, row)) + }); + render_block_rows( block, - rows, + visible_rows, width, TerminalBlockPaintBounds { - origin, - size, + origin: visible_origin, + size: visible_size, background: None, content_offset: 0, prefix_style: None, @@ -223,7 +249,9 @@ impl TuiElement for TerminalBlockElement { ); drop(model); if let Some((col, row)) = cursor { - ctx.set_terminal_cursor(ctx.scene_point(origin.offset(i32::from(col), i32::from(row)))); + ctx.set_terminal_cursor( + ctx.scene_point(visible_origin.offset(i32::from(col), i32::from(row))), + ); } } diff --git a/crates/warp_tui/src/terminal_block_tests.rs b/crates/warp_tui/src/terminal_block_tests.rs index 02c6b8ea9e..6872065117 100644 --- a/crates/warp_tui/src/terminal_block_tests.rs +++ b/crates/warp_tui/src/terminal_block_tests.rs @@ -8,7 +8,9 @@ use warp::tui_export::{ }; use warpui::App; use warpui_core::r#async::Timer; -use warpui_core::elements::tui::{Color, Modifier, TuiBufferExt, TuiElement, TuiRect, TuiSize}; +use warpui_core::elements::tui::{ + Color, Modifier, TuiBufferExt, TuiClipped, TuiElement, TuiRect, TuiSize, +}; use warpui_core::presenter::tui::TuiPresenter; use super::{ @@ -166,6 +168,59 @@ fn top_level_shell_command_row_uses_tinted_background() { }); } +#[test] +fn inline_shell_command_content_renders_a_clipped_row_window() { + App::test((), |app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let mut model = TerminalModel::mock(None, None); + model.simulate_block( + "printf rows", + "zero\r\none\r\ntwo\r\nthree\r\nfour\r\nfive\r\n", + ); + let block_id = model + .block_list() + .blocks() + .iter() + .rev() + .find(|block| block.finished()) + .expect("simulated block should exist") + .id() + .clone(); + let height = block_content_rows( + model + .block_list() + .block_with_id(&block_id) + .expect("simulated block should exist"), + ) + .len() as u16; + let model = Arc::new(FairMutex::new(model)); + + app.read(|ctx| { + let mut presenter = TuiPresenter::new(); + let full = presenter.present_element( + TerminalBlockElement::content(model.clone(), block_id.clone()).finish(), + TuiRect::new(0, 0, 12, height), + ctx, + ); + let full_lines = full.buffer.to_lines(); + let viewport_origin = 3usize; + let viewport_height = 3u16; + let clipped = presenter.present_element( + TuiClipped::new(TerminalBlockElement::content(model, block_id).finish()) + .with_viewport_origin_y(viewport_origin) + .finish(), + TuiRect::new(0, 0, 12, viewport_height), + ctx, + ); + + assert_eq!( + clipped.buffer.to_lines(), + full_lines[viewport_origin..viewport_origin + usize::from(viewport_height)], + ); + }); + }); +} + #[test] fn inline_shell_command_content_keeps_terminal_background() { App::test((), |app| async move { diff --git a/crates/warpui_core/src/elements/tui/buffer.rs b/crates/warpui_core/src/elements/tui/buffer.rs index f3add8fc54..215b669a9c 100644 --- a/crates/warpui_core/src/elements/tui/buffer.rs +++ b/crates/warpui_core/src/elements/tui/buffer.rs @@ -10,6 +10,7 @@ //! element tests: it renders each row to a `String`, skipping the trailing //! columns of wide graphemes so every glyph appears exactly once (mirroring how //! ratatui's own `Buffer` debug output collapses multi-width cells). +use std::ops::Range; use ratatui::buffer::CellWidth; pub use ratatui::buffer::{Buffer as TuiBuffer, Cell}; @@ -155,6 +156,17 @@ impl<'a> TuiPaintSurface<'a> { true } + /// Returns the element-local rows intersecting the active clip. + pub fn visible_rows(&self, origin: TuiScreenPosition, size: TuiSize) -> Option> { + let visible = self.visible_widget_buffer_area(origin, size)?; + Some( + visible.clipped_rows_above + ..visible + .clipped_rows_above + .saturating_add(visible.area.height), + ) + } + fn visible_widget_buffer_area( &self, origin: TuiScreenPosition, diff --git a/crates/warpui_core/src/elements/tui/buffer_tests.rs b/crates/warpui_core/src/elements/tui/buffer_tests.rs index 7568c7b816..eff80056de 100644 --- a/crates/warpui_core/src/elements/tui/buffer_tests.rs +++ b/crates/warpui_core/src/elements/tui/buffer_tests.rs @@ -187,3 +187,18 @@ fn nested_surface_clip_contains_cells_styles_and_widgets() { assert_eq!(b[(0, 2)].fg, Color::Red); assert_eq!(b[(0, 3)].fg, Color::Reset); } + +#[test] +fn visible_rows_are_relative_to_the_element_origin() { + let mut b = buffer(3, 4); + let mut surface = TuiPaintSurface::new(&mut b); + + assert_eq!( + surface.with_clip( + TuiScreenPosition::new(0, 1), + TuiSize::new(3, 2), + |surface| surface.visible_rows(TuiScreenPosition::new(0, -2), TuiSize::new(3, 6),), + ), + Some(Some(3..5)), + ); +}