Compare commits

...

1 Commits

Author SHA1 Message Date
Rakan El Khalil
29c9a70231 fix(tui): fully repaint inline status after history inserts
- Invalidate the viewport after inline history writes so the next redraw repaints the live bottom pane instead of diffing against stale terminal state.
- Clear fully blank spacer rows from column 0. Previously blank rows emitted ClearToEnd from column 1, which could leave stale characters in column 0.
- Force interior spaces to repaint so the spaces around the working indicator cannot remain clobbered.
- Avoid redundant leading-space writes on rows that are already cleared from column 0.

Co-authored-by: Codex <noreply@openai.com>
2026-04-02 12:44:31 -07:00
2 changed files with 239 additions and 7 deletions

View File

@@ -478,6 +478,16 @@ where
self.visible_history_rows
}
pub(crate) fn invalidate_viewport(&mut self) {
let previous = self.previous_buffer_mut();
previous.reset();
// Mark every previous cell as skipped so the next diff treats the viewport as unknown.
// This forces interior spaces to be repainted too, not just non-space glyphs.
for cell in &mut previous.content {
cell.skip = true;
}
}
pub(crate) fn note_history_rows_inserted(&mut self, inserted_rows: u16) {
self.visible_history_rows = self
.visible_history_rows
@@ -510,7 +520,7 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec<DrawCommand> {
let next_buffer = &b.content;
let mut updates = vec![];
let mut last_nonblank_columns = vec![0; a.area.height as usize];
let mut clear_start_columns = vec![0; a.area.height as usize];
for y in 0..a.area.height {
let row_start = y as usize * a.area.width as usize;
let row_end = row_start + a.area.width as usize;
@@ -522,23 +532,25 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec<DrawCommand> {
// Multi-width glyphs extend that region through their full displayed width.
// After that point the rest of the row can be cleared with a single ClearToEnd, a perf win
// versus emitting multiple space Put commands.
let mut last_nonblank_column = 0usize;
let mut last_nonblank_column = None;
let mut column = 0usize;
while column < row.len() {
let cell = &row[column];
let width = display_width(cell.symbol());
if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() {
last_nonblank_column = column + (width.saturating_sub(1));
last_nonblank_column = Some(column + width.saturating_sub(1));
}
column += width.max(1); // treat zero-width symbols as width 1
}
if last_nonblank_column + 1 < row.len() {
let (x, y) = a.pos_of(row_start + last_nonblank_column + 1);
let clear_start_column = last_nonblank_column.map_or(0, |column| column + 1);
if clear_start_column < row.len() {
let (x, y) = a.pos_of(row_start + clear_start_column);
updates.push(DrawCommand::ClearToEnd { x, y, bg });
}
last_nonblank_columns[y as usize] = last_nonblank_column as u16;
clear_start_columns[y as usize] = u16::try_from(clear_start_column)
.expect("row clear start should fit in terminal width");
}
// Cells invalidated by drawing/replacing preceding multi-width characters:
@@ -550,7 +562,7 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec<DrawCommand> {
if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 {
let (x, y) = a.pos_of(i);
let row = i / a.area.width as usize;
if x <= last_nonblank_columns[row] {
if x < clear_start_columns[row] {
updates.push(DrawCommand::Put {
x,
y,
@@ -702,6 +714,10 @@ mod tests {
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use crate::test_backend::VT100Backend;
#[test]
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
@@ -748,4 +764,55 @@ mod tests {
"expected clear-to-end to start after the remaining wide char; commands: {commands:?}"
);
}
#[test]
fn diff_buffers_clear_to_end_starts_at_column_zero_for_blank_rows() {
let area = Rect::new(0, 0, 4, 1);
let mut previous = Buffer::empty(area);
let next = Buffer::empty(area);
previous
.cell_mut((0, 0))
.expect("cell should exist")
.set_symbol("X");
let commands = diff_buffers(&previous, &next);
assert!(
commands
.iter()
.any(|command| matches!(command, DrawCommand::ClearToEnd { x: 0, y: 0, .. })),
"expected blank row clear-to-end to start at column 0; commands: {commands:?}"
);
assert!(
!commands
.iter()
.any(|command| matches!(command, DrawCommand::Put { x: 0, y: 0, .. })),
"expected blank row not to emit a redundant leading-space put; commands: {commands:?}"
);
}
#[test]
fn invalidate_viewport_repaints_interior_spaces() {
let width: u16 = 12;
let height: u16 = 1;
let mut term =
Terminal::with_options(VT100Backend::new(width, height)).expect("create terminal");
let area = Rect::new(0, 0, width, height);
term.set_viewport_area(area);
term.draw(|frame| {
Paragraph::new("•XWorkingX(").render(frame.area(), frame.buffer_mut());
})
.expect("draw dirty row");
term.invalidate_viewport();
term.draw(|frame| {
Paragraph::new("• Working (").render(frame.area(), frame.buffer_mut());
})
.expect("draw row with spaces");
assert_eq!(term.backend().vt100().screen().contents(), "• Working (");
}
}

View File

@@ -176,6 +176,9 @@ where
}
if wrapped_lines > 0 {
terminal.note_history_rows_inserted(wrapped_lines);
// History insertion mutates the terminal outside the diffed viewport buffer.
// Force the next draw to repaint the live viewport instead of diffing against stale state.
terminal.invalidate_viewport();
}
Ok(())
@@ -334,8 +337,114 @@ mod tests {
use super::*;
use crate::markdown_render::render_markdown_text;
use crate::test_backend::VT100Backend;
use ratatui::backend::Backend;
use ratatui::backend::ClearType as BackendClearType;
use ratatui::backend::WindowSize;
use ratatui::buffer::Cell;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use ratatui::style::Color;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::io;
use std::io::Write;
struct RecordingBackend {
inner: VT100Backend,
writes: Vec<u8>,
}
impl RecordingBackend {
fn new(width: u16, height: u16) -> Self {
Self {
inner: VT100Backend::new(width, height),
writes: Vec::new(),
}
}
fn clear_writes(&mut self) {
self.writes.clear();
}
}
impl Write for RecordingBackend {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writes.extend_from_slice(buf);
self.inner.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
std::io::Write::flush(&mut self.inner)
}
}
impl Backend for RecordingBackend {
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
self.inner.draw(content)
}
fn hide_cursor(&mut self) -> io::Result<()> {
self.inner.hide_cursor()
}
fn show_cursor(&mut self) -> io::Result<()> {
self.inner.show_cursor()
}
fn get_cursor_position(&mut self) -> io::Result<ratatui::layout::Position> {
self.inner.get_cursor_position()
}
fn set_cursor_position<P: Into<ratatui::layout::Position>>(
&mut self,
position: P,
) -> io::Result<()> {
self.inner.set_cursor_position(position)
}
fn clear(&mut self) -> io::Result<()> {
self.inner.clear()
}
fn clear_region(&mut self, clear_type: BackendClearType) -> io::Result<()> {
self.inner.clear_region(clear_type)
}
fn append_lines(&mut self, line_count: u16) -> io::Result<()> {
self.inner.append_lines(line_count)
}
fn size(&self) -> io::Result<Size> {
self.inner.size()
}
fn window_size(&mut self) -> io::Result<WindowSize> {
self.inner.window_size()
}
fn flush(&mut self) -> io::Result<()> {
ratatui::backend::Backend::flush(&mut self.inner)
}
fn scroll_region_up(
&mut self,
region: std::ops::Range<u16>,
scroll_by: u16,
) -> io::Result<()> {
self.inner.scroll_region_up(region, scroll_by)
}
fn scroll_region_down(
&mut self,
region: std::ops::Range<u16>,
scroll_by: u16,
) -> io::Result<()> {
self.inner.scroll_region_down(region, scroll_by)
}
}
#[test]
fn writes_bold_then_regular_spans() {
@@ -733,4 +842,60 @@ mod tests {
"expected URL content to appear immediately after prompt (allowing at most one spacer row), got prompt_row={prompt_row}, url_row={url_row}, rows={rows:?}",
);
}
#[test]
fn vt100_wrapped_history_insert_redraws_clobbered_viewport_rows() {
let width: u16 = 24;
let height: u16 = 8;
let backend = RecordingBackend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let viewport = Rect::new(0, 3, width, 4);
term.set_viewport_area(viewport);
let render_viewport = |term: &mut crate::custom_terminal::Terminal<RecordingBackend>| {
term.draw(|frame| {
let lines = vec![
Line::from("Working 14s"),
Line::from("esc to interrupt"),
Line::from("summarize diffs"),
Line::from("composer >"),
];
Paragraph::new(lines).render(frame.area(), frame.buffer_mut());
})
.expect("draw viewport");
};
render_viewport(&mut term);
term.backend_mut().clear_writes();
let long_url = format!(
"https://example.test/api/v1/projects/alpha/{}",
"very-long-segment-".repeat(6),
);
insert_history_lines(
&mut term,
vec![Line::from(vec!["".into(), long_url.into()])],
)
.expect("insert wrapped history line");
term.backend_mut().clear_writes();
render_viewport(&mut term);
let redraw = String::from_utf8_lossy(&term.backend().writes).into_owned();
let rows: Vec<String> = term
.backend()
.inner
.vt100()
.screen()
.rows(0, width)
.collect();
assert!(
redraw.contains("Working") && redraw.contains("composer"),
"expected redraw to repaint the live viewport after out-of-band history insertion, got {redraw:?}"
);
assert_eq!(rows[4].trim_end(), "Working 14s");
assert_eq!(rows[5].trim_end(), "esc to interrupt");
assert_eq!(rows[6].trim_end(), "summarize diffs");
assert_eq!(rows[7].trim_end(), "composer >");
}
}