perf(tui): batch clear buffer regions with helper

This commit is contained in:
James Peter
2025-09-21 08:38:43 +10:00
parent b88908c649
commit 5c19353d6c
7 changed files with 218 additions and 275 deletions
+15 -26
View File
@@ -4,6 +4,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::user_approval_widget::ApprovalRequest;
use bottom_pane_view::BottomPaneView;
use crate::util::buffer::fill_rect;
use codex_core::protocol::TokenUsage;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
@@ -716,11 +717,7 @@ impl WidgetRef for &BottomPane<'_> {
let base_style = ratatui::style::Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(base_style);
}
}
fill_rect(buf, area, Some(' '), base_style);
let mut y_offset = 0u16;
if let Some(ring) = &self.live_ring {
@@ -767,11 +764,7 @@ impl WidgetRef for &BottomPane<'_> {
};
// Ensure view background is painted under its content
let view_bg = ratatui::style::Style::default().bg(crate::colors::background());
for y in view_rect.y..view_rect.y.saturating_add(view_rect.height) {
for x in view_rect.x..view_rect.x.saturating_add(view_rect.width) {
buf[(x, y)].set_style(view_bg);
}
}
fill_rect(buf, view_rect, None, view_bg);
view.render(view_rect, buf);
}
return;
@@ -784,22 +777,18 @@ impl WidgetRef for &BottomPane<'_> {
// Add horizontal padding (2 chars on each side) for Message input
let horizontal_padding = 1u16;
let composer_rect = Rect {
x: area.x + horizontal_padding,
y: area.y + y_offset,
width: area.width.saturating_sub(horizontal_padding * 2),
// Reserve bottom padding
height: (area.height - y_offset)
- BottomPane::BOTTOM_PAD_LINES.min((area.height - y_offset).saturating_sub(1)),
};
// Paint the composer area background before rendering widgets
let comp_bg = ratatui::style::Style::default().bg(crate::colors::background());
for y in composer_rect.y..composer_rect.y.saturating_add(composer_rect.height) {
for x in composer_rect.x..composer_rect.x.saturating_add(composer_rect.width) {
buf[(x, y)].set_style(comp_bg);
}
}
(&self.composer).render_ref(composer_rect, buf);
let composer_rect = Rect {
x: area.x + horizontal_padding,
y: area.y + y_offset,
width: area.width.saturating_sub(horizontal_padding * 2),
// Reserve bottom padding
height: (area.height - y_offset)
- BottomPane::BOTTOM_PAD_LINES.min((area.height - y_offset).saturating_sub(1)),
};
// Paint the composer area background before rendering widgets
let comp_bg = ratatui::style::Style::default().bg(crate::colors::background());
fill_rect(buf, composer_rect, None, comp_bg);
(&self.composer).render_ref(composer_rect, buf);
}
}
}
+9 -7
View File
@@ -1,3 +1,4 @@
use crate::util::buffer::fill_rect;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -923,17 +924,18 @@ impl TextArea {
) {
let bg = crate::colors::background();
let fg = crate::colors::text();
let line_style = Style::default().bg(bg).fg(fg);
for (row, idx) in range.enumerate() {
let r = &lines[idx];
let y = area.y + row as u16;
// Fill the entire row area with theme background to avoid terminal default leaking through.
let line_bg = Style::default().bg(bg).fg(fg);
for x in area.x..area.x + area.width {
buf[(x, y)].set_style(line_bg);
}
// Paint the row background in one pass to avoid per-cell index math.
fill_rect(buf, Rect::new(area.x, y, area.width, 1), None, line_style);
// Draw the text on top using theme foreground + background to preserve consistent look.
let line_range = r.start..r.end - 1;
buf.set_string(area.x, y, &self.text[line_range.clone()], line_bg);
if r.end > r.start {
let line_range = r.start..r.end - 1;
buf.set_string(area.x, y, &self.text[line_range], line_style);
}
}
}
}
+63 -102
View File
@@ -134,6 +134,7 @@ use crate::sanitize::Options as SanitizeOptions;
use crate::sanitize::sanitize_for_tui;
use crate::streaming::StreamKind;
use crate::streaming::controller::AppEventHistorySink;
use crate::util::buffer::fill_rect;
use crate::user_approval_widget::ApprovalRequest;
use codex_ansi_escape::ansi_escape_line;
use codex_browser::BrowserManager;
@@ -1242,10 +1243,7 @@ impl ChatWidget<'_> {
crate::markdown::append_markdown(text, &mut lines, &self.config);
let key = self.next_internal_key();
let _ = self.history_insert_with_key_global(
Box::new(PlainHistoryCell {
lines,
kind: HistoryCellType::Assistant,
}),
Box::new(PlainHistoryCell::new(lines, HistoryCellType::Assistant)),
key,
);
}
@@ -1614,6 +1612,7 @@ impl ChatWidget<'_> {
plain.kind = history_cell::HistoryCellType::Patch {
kind: history_cell::PatchKind::ApplySuccess,
};
plain.invalidate_layout_cache();
self.request_redraw();
return;
}
@@ -3012,10 +3011,10 @@ impl ChatWidget<'_> {
combined.extend(body);
self.history_replace_at(
idx - 1,
Box::new(crate::history_cell::PlainHistoryCell {
lines: combined,
kind: crate::history_cell::HistoryCellType::Plain,
}),
Box::new(crate::history_cell::PlainHistoryCell::new(
combined,
crate::history_cell::HistoryCellType::Plain,
)),
);
self.history_remove_at(idx);
}
@@ -3193,10 +3192,10 @@ impl ChatWidget<'_> {
"command: {}",
original_text.trim()
)));
self.history_push(crate::history_cell::PlainHistoryCell {
lines: ack,
kind: crate::history_cell::HistoryCellType::Notice,
});
self.history_push(crate::history_cell::PlainHistoryCell::new(
ack,
crate::history_cell::HistoryCellType::Notice,
));
message
.ordered_items
@@ -3248,10 +3247,10 @@ impl ChatWidget<'_> {
}
)));
lines.push(Line::from(format!("command: {}", original_text.trim())));
self.history_push(crate::history_cell::PlainHistoryCell {
self.history_push(crate::history_cell::PlainHistoryCell::new(
lines,
kind: crate::history_cell::HistoryCellType::Notice,
});
crate::history_cell::HistoryCellType::Notice,
));
// Replace the message with the resolved prompt
message
@@ -4526,15 +4525,15 @@ impl ChatWidget<'_> {
return;
}
if tool_name == "wait" && !success && content.trim() == "Cancelled by user." {
let wait_cancelled_cell = PlainHistoryCell {
lines: vec![Line::styled(
let wait_cancelled_cell = PlainHistoryCell::new(
vec![Line::styled(
"Wait cancelled",
Style::default()
.fg(crate::colors::error())
.add_modifier(Modifier::BOLD),
)],
kind: HistoryCellType::Error,
};
HistoryCellType::Error,
);
if let Some(idx) = entry_idx {
if idx < self.history_cells.len() {
@@ -4818,10 +4817,10 @@ impl ChatWidget<'_> {
for l in text.lines() {
lines.push(ratatui::text::Line::from(l.to_string()))
}
self.history_push(crate::history_cell::PlainHistoryCell {
self.history_push(crate::history_cell::PlainHistoryCell::new(
lines,
kind: crate::history_cell::HistoryCellType::Notice,
});
crate::history_cell::HistoryCellType::Notice,
));
}
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
@@ -5015,10 +5014,10 @@ impl ChatWidget<'_> {
}
}
self.history_push(crate::history_cell::PlainHistoryCell {
self.history_push(crate::history_cell::PlainHistoryCell::new(
lines,
kind: crate::history_cell::HistoryCellType::Notice,
});
crate::history_cell::HistoryCellType::Notice,
));
self.request_redraw();
}
@@ -7091,6 +7090,7 @@ impl ChatWidget<'_> {
.downcast_mut::<history_cell::PlainHistoryCell>()
{
history_cell::retint_lines_in_place(&mut plain.lines, &old, &new);
plain.invalidate_layout_cache();
} else if let Some(tool) = cell
.as_any_mut()
.downcast_mut::<history_cell::ToolCallCell>()
@@ -8184,10 +8184,10 @@ impl ChatWidget<'_> {
}
// Add status message
self.history_push(history_cell::PlainHistoryCell {
lines: vec![Line::from("✅ Chrome launched with user profile")],
kind: history_cell::HistoryCellType::BackgroundEvent,
});
self.history_push(history_cell::PlainHistoryCell::new(
vec![Line::from("✅ Chrome launched with user profile")],
history_cell::HistoryCellType::BackgroundEvent,
));
// Show browsing state in input border after launch
self.bottom_pane
.update_status_text("using browser".to_string());
@@ -8809,13 +8809,13 @@ impl ChatWidget<'_> {
}
// Add status message
self.history_push(history_cell::PlainHistoryCell {
lines: vec![Line::from(format!(
self.history_push(history_cell::PlainHistoryCell::new(
vec![Line::from(format!(
"✅ Chrome launched with temporary profile at {}",
profile_dir.display()
))],
kind: history_cell::HistoryCellType::BackgroundEvent,
});
history_cell::HistoryCellType::BackgroundEvent,
));
}
pub(crate) fn handle_browser_command(&mut self, command_text: String) {
@@ -8918,10 +8918,10 @@ impl ChatWidget<'_> {
// Add status message
let status_msg = format!("🌐 Opening internal browser: {}", full_url);
self.history_push(history_cell::PlainHistoryCell {
lines: vec![Line::from(status_msg)],
kind: history_cell::HistoryCellType::BackgroundEvent,
});
self.history_push(history_cell::PlainHistoryCell::new(
vec![Line::from(status_msg)],
history_cell::HistoryCellType::BackgroundEvent,
));
// Also reflect browsing activity in the input border
self.bottom_pane
.update_status_text("using browser".to_string());
@@ -9316,10 +9316,10 @@ impl ChatWidget<'_> {
.lines()
.map(|line| Line::from(line.to_string()))
.collect();
self.history_push(history_cell::PlainHistoryCell {
self.history_push(history_cell::PlainHistoryCell::new(
lines,
kind: history_cell::HistoryCellType::BackgroundEvent,
});
history_cell::HistoryCellType::BackgroundEvent,
));
}
/// Handle `/mcp` command: manage MCP servers (status/on/off/add).
@@ -9816,10 +9816,10 @@ impl ChatWidget<'_> {
.lines()
.map(|line| Line::from(line.to_string()))
.collect();
self.history_push(history_cell::PlainHistoryCell {
self.history_push(history_cell::PlainHistoryCell::new(
lines,
kind: history_cell::HistoryCellType::BackgroundEvent,
});
history_cell::HistoryCellType::BackgroundEvent,
));
return;
}
@@ -12764,31 +12764,22 @@ impl WidgetRef for &ChatWidget<'_> {
.saturating_add(history_area.width)
.saturating_sub(right_pad_start);
if left_pad_w > 0 {
for y in history_area.y..history_area.y.saturating_add(history_area.height) {
for x in history_area.x..history_area.x.saturating_add(left_pad_w) {
buf[(x, y)].set_char(' ').set_style(clear_style);
}
}
let left_rect = Rect::new(history_area.x, history_area.y, left_pad_w, history_area.height);
fill_rect(buf, left_rect, Some(' '), clear_style);
cleared_cells =
cleared_cells.saturating_add((left_pad_w as u64) * (history_area.height as u64));
}
if right_pad_w > 0 {
for y in history_area.y..history_area.y.saturating_add(history_area.height) {
for x in right_pad_start..right_pad_start.saturating_add(right_pad_w) {
buf[(x, y)].set_char(' ').set_style(clear_style);
}
}
let right_rect = Rect::new(right_pad_start, history_area.y, right_pad_w, history_area.height);
fill_rect(buf, right_rect, Some(' '), clear_style);
cleared_cells =
cleared_cells.saturating_add((right_pad_w as u64) * (history_area.height as u64));
}
// Top gap inside content area when content is bottom-aligned
if start_y > content_area.y {
let gap_h = start_y.saturating_sub(content_area.y);
for y in content_area.y..content_area.y.saturating_add(gap_h) {
for x in content_area.x..content_area.x.saturating_add(content_area.width) {
buf[(x, y)].set_char(' ').set_style(clear_style);
}
}
let gap_rect = Rect::new(content_area.x, content_area.y, content_area.width, gap_h);
fill_rect(buf, gap_rect, Some(' '), clear_style);
cleared_cells =
cleared_cells.saturating_add((gap_h as u64) * (content_area.width as u64));
}
@@ -12964,13 +12955,7 @@ impl WidgetRef for &ChatWidget<'_> {
None
};
let style = Style::default().bg(gutter_bg);
for y in gutter_area.y..gutter_area.y.saturating_add(gutter_area.height) {
// Only the first column (symbol column) needs tint; the second is spacing to content
// but tint both for visual continuity with the assistant block.
for x in gutter_area.x..gutter_area.x.saturating_add(gutter_area.width) {
buf[(x, y)].set_char(' ').set_style(style);
}
}
fill_rect(buf, gutter_area, Some(' '), style);
// Also tint the single left padding column so the assistant
// gutter visually reaches the outer edge. The content area
// is inset by a uniform padding; when present, paint that
@@ -12978,9 +12963,8 @@ impl WidgetRef for &ChatWidget<'_> {
// vertical span of this item.
if content_area.x > history_area.x {
let left_col_x = content_area.x.saturating_sub(1);
for y in gutter_area.y..gutter_area.y.saturating_add(gutter_area.height) {
buf[(left_col_x, y)].set_char(' ').set_style(style);
}
let left_rect = Rect::new(left_col_x, gutter_area.y, 1, gutter_area.height);
fill_rect(buf, left_rect, Some(' '), style);
}
// Also tint one column immediately to the right of the content area
// so the assistant block is visually bookended. This column lives in the
@@ -12989,9 +12973,8 @@ impl WidgetRef for &ChatWidget<'_> {
let right_col_x = content_area.x.saturating_add(content_area.width);
let history_right = history_area.x.saturating_add(history_area.width);
if right_col_x < history_right {
for y in item_area.y..item_area.y.saturating_add(item_area.height) {
buf[(right_col_x, y)].set_char(' ').set_style(style);
}
let right_rect = Rect::new(right_col_x, item_area.y, 1, item_area.height);
fill_rect(buf, right_rect, Some(' '), style);
}
if let Some(t0) = _perf_gutter_start {
let dt = t0.elapsed().as_nanos();
@@ -13218,10 +13201,10 @@ impl WidgetRef for &ChatWidget<'_> {
} else {
None
};
for y in screen_y..content_area.y + content_area.height {
for x in content_area.x..content_area.x + content_area.width {
buf[(x, y)].set_char(' ').set_style(clear_style);
}
let gap_height = (content_area.y + content_area.height).saturating_sub(screen_y);
if gap_height > 0 {
let gap_rect = Rect::new(content_area.x, screen_y, content_area.width, gap_height);
fill_rect(buf, gap_rect, Some(' '), clear_style);
}
if let Some(t0) = _perf_hist_clear2 {
let dt = t0.elapsed().as_nanos();
@@ -13284,11 +13267,7 @@ impl WidgetRef for &ChatWidget<'_> {
if self.terminal.overlay().is_some() {
let bg_style = Style::default().bg(crate::colors::background());
for y in bottom_pane_area.y..bottom_pane_area.y + bottom_pane_area.height {
for x in bottom_pane_area.x..bottom_pane_area.x + bottom_pane_area.width {
buf[(x, y)].set_style(bg_style).set_char(' ');
}
}
fill_rect(buf, bottom_pane_area, Some(' '), bg_style);
} else {
// Render the bottom pane directly without a border for now
// The composer has its own layout with hints at the bottom
@@ -13299,11 +13278,7 @@ impl WidgetRef for &ChatWidget<'_> {
let scrim_style = Style::default()
.bg(crate::colors::overlay_scrim())
.fg(crate::colors::text_dim());
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
buf[(x, y)].set_style(scrim_style);
}
}
fill_rect(buf, area, None, scrim_style);
let padding = 1u16;
let footer_reserved = 1.min(bottom_pane_area.height);
@@ -13488,11 +13463,7 @@ impl WidgetRef for &ChatWidget<'_> {
width: footer_area.width,
height: footer_area.height.saturating_sub(1),
};
for y in spacer_area.y..spacer_area.y + spacer_area.height {
for x in spacer_area.x..spacer_area.x + spacer_area.width {
buf[(x, y)].set_char(' ').set_style(inner_bg);
}
}
fill_rect(buf, spacer_area, Some(' '), inner_bg);
}
let instructions_area = Rect {
@@ -13529,13 +13500,7 @@ impl WidgetRef for &ChatWidget<'_> {
} else {
None
};
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
// Overwrite with a dimmed style; we don't Clear so existing glyphs remain,
// but foreground is muted to reduce visual competition.
buf[(x, y)].set_style(scrim_bg);
}
}
fill_rect(buf, area, None, scrim_bg);
if let Some(t0) = _perf_scrim_start {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
@@ -13560,11 +13525,7 @@ impl WidgetRef for &ChatWidget<'_> {
} else {
None
};
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
buf[(x, y)].set_style(bg_style);
}
}
fill_rect(buf, area, None, bg_style);
if let Some(t0) = _perf_overlay_area_bg_start {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
+97 -135
View File
@@ -4,6 +4,7 @@ use crate::sanitize::Mode as SanitizeMode;
use crate::sanitize::Options as SanitizeOptions;
use crate::sanitize::sanitize_for_tui;
use crate::slash_command::SlashCommand;
use crate::util::buffer::fill_rect;
use crate::text_formatting::format_json_compact;
use base64::Engine;
use codex_ansi_escape::ansi_escape_line;
@@ -190,11 +191,7 @@ pub(crate) trait HistoryCell {
_ => crate::colors::background(),
};
let bg_style = Style::default().bg(cell_bg).fg(crate::colors::text());
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, area, Some(' '), bg_style);
// Ensure the entire allocated area is painted with the theme background
// by attaching a background-styled Block to the Paragraph as well.
@@ -1079,6 +1076,51 @@ impl HistoryCell for ExploreAggregationCell {
pub(crate) struct PlainHistoryCell {
pub(crate) lines: Vec<Line<'static>>,
pub(crate) kind: HistoryCellType,
cached_wrap: std::cell::RefCell<Option<PlainWrapCache>>,
}
#[derive(Clone, Copy)]
struct PlainWrapCache {
requested_width: u16,
effective_width: u16,
height: u16,
}
impl PlainHistoryCell {
pub(crate) fn new(lines: Vec<Line<'static>>, kind: HistoryCellType) -> Self {
Self {
lines,
kind,
cached_wrap: std::cell::RefCell::new(None),
}
}
pub(crate) fn invalidate_layout_cache(&self) {
self.cached_wrap.borrow_mut().take();
}
fn cache_desired_height(&self, requested_width: u16, effective_width: u16, height: u16) {
*self.cached_wrap.borrow_mut() = Some(PlainWrapCache {
requested_width,
effective_width,
height,
});
}
fn try_cached_height(&self, requested_width: u16, effective_width: u16) -> Option<u16> {
self.cached_wrap
.borrow()
.as_ref()
.and_then(|cache| {
if cache.requested_width == requested_width
&& cache.effective_width == effective_width
{
Some(cache.height)
} else {
None
}
})
}
}
impl HistoryCell for PlainHistoryCell {
@@ -1114,23 +1156,31 @@ impl HistoryCell for PlainHistoryCell {
}
fn desired_height(&self, width: u16) -> u16 {
if matches!(self.kind, HistoryCellType::User) {
let effective_width = if matches!(self.kind, HistoryCellType::User) {
// Match input composer wrapping by reserving shared right padding.
// Keep this in sync with the composer constants.
let inner_w = width.saturating_sub(crate::layout_consts::USER_HISTORY_RIGHT_PAD.into());
let text = Text::from(self.display_lines_trimmed());
Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(inner_w)
.try_into()
.unwrap_or(0)
width.saturating_sub(crate::layout_consts::USER_HISTORY_RIGHT_PAD.into())
} else {
Paragraph::new(Text::from(self.display_lines_trimmed()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
width
};
if let Some(height) = self.try_cached_height(width, effective_width) {
return height;
}
if effective_width == 0 {
self.cache_desired_height(width, effective_width, 0);
return 0;
}
let text = Text::from(self.display_lines_trimmed());
let height: u16 = Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(effective_width)
.try_into()
.unwrap_or(0);
self.cache_desired_height(width, effective_width, height);
height
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
@@ -1144,11 +1194,7 @@ impl HistoryCell for PlainHistoryCell {
let bg_style = Style::default().bg(cell_bg).fg(crate::colors::text());
// Clear area
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, area, Some(' '), bg_style);
let lines = self.display_lines_trimmed();
let text = Text::from(lines);
@@ -1434,11 +1480,7 @@ impl HistoryCell for AssistantMarkdownCell {
let bg_style = Style::default().bg(cell_bg);
// Clear full area with assistant background
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, area, Some(' '), bg_style);
// Build or reuse cached segments for this width
let plan = self.ensure_layout(area.width);
@@ -1786,11 +1828,7 @@ impl HistoryCell for ExecCell {
let bg_style = Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
for y in pre_area.y..pre_area.y.saturating_add(pre_area.height) {
for x in pre_area.x..pre_area.x.saturating_add(pre_area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, pre_area, Some(' '), bg_style);
let pre_block =
Block::default().style(Style::default().bg(crate::colors::background()));
Paragraph::new(pre_text)
@@ -1813,11 +1851,7 @@ impl HistoryCell for ExecCell {
let bg_style = Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text_dim());
for y in out_area.y..out_area.y.saturating_add(out_area.height) {
for x in out_area.x..out_area.x.saturating_add(out_area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, out_area, Some(' '), bg_style);
let block = Block::default()
.borders(Borders::LEFT)
.border_style(
@@ -1858,9 +1892,7 @@ impl HistoryCell for ExecCell {
height: status_height,
};
let bg_style = Style::default().bg(crate::colors::background());
for x in status_area.x..status_area.x.saturating_add(status_area.width) {
buf[(x, status_area.y)].set_char(' ').set_style(bg_style);
}
fill_rect(buf, status_area, Some(' '), bg_style);
Paragraph::new(Text::from(vec![line]))
.wrap(Wrap { trim: false })
.style(bg_style)
@@ -2041,11 +2073,7 @@ impl HistoryCell for DiffCell {
// Hard clear the entire area: write spaces + background so any
// previously longer content does not bleed into shorter frames.
let bg = Style::default().bg(crate::colors::background());
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg);
}
}
fill_rect(buf, area, Some(' '), bg);
// Center the sign in the two-column gutter by leaving one leading
// space and drawing the sign in the second column.
@@ -2698,11 +2726,7 @@ impl HistoryCell for MergedExecCell {
.bg(crate::colors::background())
.fg(crate::colors::text());
// Hard clear area first
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg);
}
}
fill_rect(buf, area, Some(' '), bg);
// Build one header line based on exec kind
let header_line = match self.kind {
@@ -4448,11 +4472,7 @@ impl HistoryCell for CollapsibleReasoningCell {
let bg_style = Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, area, Some(' '), bg_style);
let lines = self.display_lines_trimmed();
Paragraph::new(Text::from(lines))
.block(Block::default().style(Style::default().bg(crate::colors::background())))
@@ -4481,11 +4501,7 @@ impl HistoryCell for CollapsibleReasoningCell {
// Clear area
let bg = crate::colors::background();
let bg_style = Style::default().bg(bg).fg(dim);
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, area, Some(' '), bg_style);
// Left border like exec output: 1px border + 1px left padding
let block = Block::default()
@@ -4556,11 +4572,7 @@ impl HistoryCell for StreamingContentCell {
let bg_style = Style::default().bg(cell_bg);
// Hard clear area with assistant background
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, area, Some(' '), bg_style);
// Build or reuse cached segments for this width
let plan = self.ensure_stream_layout(area.width);
@@ -5673,10 +5685,7 @@ pub(crate) fn new_background_event(message: String) -> PlainHistoryCell {
let msg_norm = normalize_overwrite_sequences(&message);
lines.extend(msg_norm.lines().map(|line| ansi_escape_line(line).dim()));
// No empty line at end - trimming and spacing handled by renderer
PlainHistoryCell {
lines,
kind: HistoryCellType::BackgroundEvent,
}
PlainHistoryCell::new(lines, HistoryCellType::BackgroundEvent)
}
pub(crate) fn new_session_info(
@@ -5696,15 +5705,9 @@ pub(crate) fn new_session_info(
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("notice".dim()));
lines.extend(popular_commands_lines());
PlainHistoryCell {
lines,
kind: HistoryCellType::Notice,
}
PlainHistoryCell::new(lines, HistoryCellType::Notice)
} else if config.model == model {
PlainHistoryCell {
lines: Vec::new(),
kind: HistoryCellType::Notice,
}
PlainHistoryCell::new(Vec::new(), HistoryCellType::Notice)
} else {
let lines = vec![
Line::from("model changed:")
@@ -5714,10 +5717,7 @@ pub(crate) fn new_session_info(
Line::from(format!("used: {model}")),
// No empty line at end - trimming and spacing handled by renderer
];
PlainHistoryCell {
lines,
kind: HistoryCellType::Notice,
}
PlainHistoryCell::new(lines, HistoryCellType::Notice)
}
}
@@ -5795,10 +5795,7 @@ pub(crate) fn new_popular_commands_notice(_connecting_mcp: bool) -> PlainHistory
lines.extend(popular_commands_lines());
// Connecting status is now rendered as a separate BackgroundEvent cell
// with its own gutter icon and spacing. Keep this notice focused.
PlainHistoryCell {
lines,
kind: HistoryCellType::Notice,
}
PlainHistoryCell::new(lines, HistoryCellType::Notice)
}
/// Background status cell shown during startup while external MCP servers
@@ -5814,10 +5811,7 @@ pub(crate) fn new_connecting_mcp_status() -> PlainHistoryCell {
"Connecting MCP servers…",
Style::default().fg(crate::colors::text_dim()),
)));
PlainHistoryCell {
lines,
kind: HistoryCellType::BackgroundEvent,
}
PlainHistoryCell::new(lines, HistoryCellType::BackgroundEvent)
}
pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell {
@@ -5842,10 +5836,7 @@ pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell {
let content = trim_empty_lines(content);
lines.extend(content);
// No empty line at end - trimming and spacing handled by renderer
PlainHistoryCell {
lines,
kind: HistoryCellType::User,
}
PlainHistoryCell::new(lines, HistoryCellType::User)
}
/// Render a queued user message that will be sent in the next turn.
@@ -5874,10 +5865,7 @@ pub(crate) fn new_queued_user_prompt(message: String) -> PlainHistoryCell {
let content: Vec<Line<'static>> = sanitized.lines().map(|l| ansi_escape_line(l)).collect();
let content = trim_empty_lines(content);
lines.extend(content);
PlainHistoryCell {
lines,
kind: HistoryCellType::User,
}
PlainHistoryCell::new(lines, HistoryCellType::User)
}
/// Expand horizontal tabs to spaces using a fixed tab stop.
@@ -5888,10 +5876,7 @@ pub(crate) fn new_queued_user_prompt(message: String) -> PlainHistoryCell {
#[allow(dead_code)]
pub(crate) fn new_text_line(line: Line<'static>) -> PlainHistoryCell {
PlainHistoryCell {
lines: vec![line],
kind: HistoryCellType::Notice,
}
PlainHistoryCell::new(vec![line], HistoryCellType::Notice)
}
pub(crate) fn new_streaming_content(lines: Vec<Line<'static>>) -> StreamingContentCell {
@@ -8276,11 +8261,7 @@ impl HistoryCell for WebFetchToolCell {
let bg_style = Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
for y in pre_area.y..pre_area.y.saturating_add(pre_area.height) {
for x in pre_area.x..pre_area.x.saturating_add(pre_area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, pre_area, Some(' '), bg_style);
let pre_block =
Block::default().style(Style::default().bg(crate::colors::background()));
Paragraph::new(pre_text)
@@ -8302,11 +8283,7 @@ impl HistoryCell for WebFetchToolCell {
let bg_style = Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text_dim());
for y in body_area.y..body_area.y.saturating_add(body_area.height) {
for x in body_area.x..body_area.x.saturating_add(body_area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
fill_rect(buf, body_area, Some(' '), bg_style);
let block = Block::default()
.borders(Borders::LEFT)
.border_style(
@@ -8948,10 +8925,7 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
.map(|line| ansi_escape_line(line).style(Style::default().fg(crate::colors::error()))),
);
// No empty line at end - trimming and spacing handled by renderer
PlainHistoryCell {
lines,
kind: HistoryCellType::Error,
}
PlainHistoryCell::new(lines, HistoryCellType::Error)
}
pub(crate) fn new_diff_output(diff_output: String) -> DiffCell {
@@ -8980,10 +8954,7 @@ pub(crate) fn new_reasoning_output(reasoning_effort: &ReasoningEffort) -> PlainH
.bold(),
Line::from(format!("Value: {}", reasoning_effort)),
];
PlainHistoryCell {
lines,
kind: HistoryCellType::Notice,
}
PlainHistoryCell::new(lines, HistoryCellType::Notice)
}
pub(crate) fn new_model_output(model: &str, effort: ReasoningEffort) -> PlainHistoryCell {
@@ -8995,10 +8966,7 @@ pub(crate) fn new_model_output(model: &str, effort: ReasoningEffort) -> PlainHis
Line::from(format!("Model: {}", model)),
Line::from(format!("Reasoning Effort: {}", effort)),
];
PlainHistoryCell {
lines,
kind: HistoryCellType::Notice,
}
PlainHistoryCell::new(lines, HistoryCellType::Notice)
}
// Continue with more factory functions...
@@ -9196,10 +9164,7 @@ pub(crate) fn new_status_output(
}
}
PlainHistoryCell {
lines,
kind: HistoryCellType::Notice,
}
PlainHistoryCell::new(lines, HistoryCellType::Notice)
}
pub(crate) fn new_prompts_output() -> PlainHistoryCell {
@@ -9214,10 +9179,7 @@ pub(crate) fn new_prompts_output() -> PlainHistoryCell {
Line::from(" 6. Improve documentation in @filename"),
Line::from(""),
];
PlainHistoryCell {
lines,
kind: HistoryCellType::Notice,
}
PlainHistoryCell::new(lines, HistoryCellType::Notice)
}
fn plan_progress_icon(total: usize, completed: usize) -> &'static str {
@@ -9378,12 +9340,12 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
}
lines.push(Line::from(""));
PlainHistoryCell {
PlainHistoryCell::new(
lines,
kind: HistoryCellType::Patch {
HistoryCellType::Patch {
kind: PatchKind::ApplyFailure,
},
}
)
}
// ==================== PatchSummaryCell ====================
+1
View File
@@ -66,6 +66,7 @@ mod text_formatting;
mod text_processing;
mod theme;
mod util {
pub mod buffer;
pub mod list_window;
}
mod spinner;
+2 -5
View File
@@ -2,6 +2,7 @@
use std::io::Result;
use crate::insert_history;
use crate::util::buffer::fill_rect;
use crate::tui;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -154,11 +155,7 @@ impl TranscriptApp {
let clear_style = Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
for y in content_area.y..content_area.y.saturating_add(content_area.height) {
for x in content_area.x..content_area.x.saturating_add(content_area.width) {
buf[(x, y)].set_char(' ').set_style(clear_style);
}
}
fill_rect(buf, content_area, Some(' '), clear_style);
// Clamp scroll offset to valid range
self.scroll_offset = self
+31
View File
@@ -0,0 +1,31 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
/// Fill the given rectangular area with the provided style and optional character.
///
/// The rectangle is clipped to the buffer's bounds before being applied. When `fill_char`
/// is `Some(_)`, each cell's symbol is replaced; otherwise only the style is updated.
pub fn fill_rect(buf: &mut Buffer, area: Rect, fill_char: Option<char>, style: Style) {
let rect = buf.area.intersection(area);
if rect.width == 0 || rect.height == 0 {
return;
}
let buf_width = buf.area.width as usize;
let offset_x = rect.x.saturating_sub(buf.area.x) as usize;
let offset_y = rect.y.saturating_sub(buf.area.y) as usize;
let row_span = rect.width as usize;
for row in 0..rect.height as usize {
let start = (offset_y + row) * buf_width + offset_x;
let end = start + row_span;
let row = &mut buf.content[start..end];
for cell in row {
cell.set_style(style);
if let Some(ch) = fill_char {
cell.set_char(ch);
}
}
}
}