fix(tui/autodrive): avoid full render rebuilds (fixes #485)

This commit is contained in:
James Peter
2026-01-02 22:20:27 +10:00
parent 20204a2a9a
commit 6db1e0b0a9
4 changed files with 266 additions and 85 deletions
+187 -82
View File
@@ -37790,67 +37790,12 @@ impl WidgetRef for &ChatWidget<'_> {
self.ensure_render_request_cache();
let render_requests: Vec<RenderRequest> = {
let render_request_cache = self.render_request_cache.borrow();
let mut render_requests =
Vec::with_capacity(self.history_cells.len().saturating_add(3));
for (cell, seed) in self
.history_cells
.iter()
.zip(render_request_cache.iter())
{
let assistant = cell
.as_any()
.downcast_ref::<crate::history_cell::AssistantMarkdownCell>();
render_requests.push(RenderRequest {
history_id: seed.history_id,
cell: Some(cell.as_ref()),
assistant,
use_cache: seed.use_cache,
fallback_lines: seed.fallback_lines.clone(),
kind: seed.kind.clone(),
config: &self.config,
});
}
let extra_count = (self.active_exec_cell.is_some() as usize)
.saturating_add(streaming_cell.is_some() as usize)
.saturating_add(queued_preview_cells.len());
let request_count = self.history_cells.len().saturating_add(extra_count);
if let Some(ref cell) = self.active_exec_cell {
render_requests.push(RenderRequest {
history_id: HistoryId::ZERO,
cell: Some(cell as &dyn HistoryCell),
assistant: None,
use_cache: false,
fallback_lines: None,
kind: RenderRequestKind::Legacy,
config: &self.config,
});
}
if let Some(ref cell) = streaming_cell {
render_requests.push(RenderRequest {
history_id: HistoryId::ZERO,
cell: Some(cell as &dyn HistoryCell),
assistant: None,
use_cache: false,
fallback_lines: None,
kind: RenderRequestKind::Legacy,
config: &self.config,
});
}
for c in &queued_preview_cells {
render_requests.push(RenderRequest {
history_id: HistoryId::ZERO,
cell: Some(c as &dyn HistoryCell),
assistant: None,
use_cache: false,
fallback_lines: None,
kind: RenderRequestKind::Legacy,
config: &self.config,
});
}
render_requests
};
let mut render_requests_full: Option<Vec<RenderRequest>> = None;
// Calculate total content height using prefix sums; build if needed
let spacing = 1u16; // Standard spacing between cells
@@ -37876,14 +37821,82 @@ impl WidgetRef for &ChatWidget<'_> {
self.history_virtualization_sync_pending.set(true);
self.app_event_tx.send(AppEvent::SyncHistoryVirtualization);
}
let request_count = render_requests.len();
let perf_enabled = self.perf_state.enabled;
let needs_prefix_rebuild =
self.history_render
.should_rebuild_prefix(content_area.width, request_count);
let perf_enabled = self.perf_state.enabled;
let mut rendered_cells_full: Option<Vec<VisibleCell>> = None;
if needs_prefix_rebuild {
if render_requests_full.is_none() {
let render_request_cache = self.render_request_cache.borrow();
let mut render_requests = Vec::with_capacity(request_count);
for (cell, seed) in self
.history_cells
.iter()
.zip(render_request_cache.iter())
{
let assistant = cell
.as_any()
.downcast_ref::<crate::history_cell::AssistantMarkdownCell>();
render_requests.push(RenderRequest {
history_id: seed.history_id,
cell: Some(cell.as_ref()),
assistant,
use_cache: seed.use_cache,
fallback_lines: seed.fallback_lines.clone(),
kind: seed.kind.clone(),
config: &self.config,
});
}
if let Some(ref cell) = self.active_exec_cell {
render_requests.push(RenderRequest {
history_id: HistoryId::ZERO,
cell: Some(cell as &dyn HistoryCell),
assistant: None,
use_cache: false,
fallback_lines: None,
kind: RenderRequestKind::Legacy,
config: &self.config,
});
}
if let Some(ref cell) = streaming_cell {
render_requests.push(RenderRequest {
history_id: HistoryId::ZERO,
cell: Some(cell as &dyn HistoryCell),
assistant: None,
use_cache: false,
fallback_lines: None,
kind: RenderRequestKind::Legacy,
config: &self.config,
});
}
for c in &queued_preview_cells {
render_requests.push(RenderRequest {
history_id: HistoryId::ZERO,
cell: Some(c as &dyn HistoryCell),
assistant: None,
use_cache: false,
fallback_lines: None,
kind: RenderRequestKind::Legacy,
config: &self.config,
});
}
if perf_enabled {
let mut p = self.perf_state.stats.borrow_mut();
p.render_requests_full =
p.render_requests_full.saturating_add(render_requests.len() as u64);
}
render_requests_full = Some(render_requests);
}
let render_requests = render_requests_full
.as_ref()
.expect("render requests missing after rebuild");
let mut used_fast_append = false;
if self.try_append_prefix_fast(&render_requests, render_settings, content_area.width) {
used_fast_append = true;
@@ -38169,13 +38182,105 @@ impl WidgetRef for &ChatWidget<'_> {
};
end_idx = end_idx.saturating_add(1).min(request_count);
enum VisibleRequests<'a> {
Full(&'a [RenderRequest<'a>]),
Owned(Vec<RenderRequest<'a>>),
}
let history_len = self.history_cells.len();
let visible_requests = if let Some(ref full_requests) = render_requests_full {
VisibleRequests::Full(&full_requests[start_idx..end_idx])
} else {
let render_request_cache = self.render_request_cache.borrow();
let mut requests = Vec::with_capacity(end_idx.saturating_sub(start_idx));
for idx in start_idx..end_idx {
if idx < history_len {
let cell = &self.history_cells[idx];
let seed = &render_request_cache[idx];
let assistant = cell
.as_any()
.downcast_ref::<crate::history_cell::AssistantMarkdownCell>();
requests.push(RenderRequest {
history_id: seed.history_id,
cell: Some(cell.as_ref()),
assistant,
use_cache: seed.use_cache,
fallback_lines: seed.fallback_lines.clone(),
kind: seed.kind.clone(),
config: &self.config,
});
continue;
}
let extra_idx = idx.saturating_sub(history_len);
let mut extra_cursor = 0usize;
if let Some(ref cell) = self.active_exec_cell {
if extra_idx == extra_cursor {
requests.push(RenderRequest {
history_id: HistoryId::ZERO,
cell: Some(cell as &dyn HistoryCell),
assistant: None,
use_cache: false,
fallback_lines: None,
kind: RenderRequestKind::Legacy,
config: &self.config,
});
continue;
}
extra_cursor = extra_cursor.saturating_add(1);
}
if let Some(ref cell) = streaming_cell {
if extra_idx == extra_cursor {
requests.push(RenderRequest {
history_id: HistoryId::ZERO,
cell: Some(cell as &dyn HistoryCell),
assistant: None,
use_cache: false,
fallback_lines: None,
kind: RenderRequestKind::Legacy,
config: &self.config,
});
continue;
}
extra_cursor = extra_cursor.saturating_add(1);
}
let queued_idx = extra_idx.saturating_sub(extra_cursor);
if let Some(cell) = queued_preview_cells.get(queued_idx) {
requests.push(RenderRequest {
history_id: HistoryId::ZERO,
cell: Some(cell as &dyn HistoryCell),
assistant: None,
use_cache: false,
fallback_lines: None,
kind: RenderRequestKind::Legacy,
config: &self.config,
});
}
}
VisibleRequests::Owned(requests)
};
let visible_requests_slice = match &visible_requests {
VisibleRequests::Full(slice) => *slice,
VisibleRequests::Owned(vec) => vec.as_slice(),
};
if perf_enabled {
let mut p = self.perf_state.stats.borrow_mut();
p.render_requests_visible = p
.render_requests_visible
.saturating_add(visible_requests_slice.len() as u64);
}
let mut _subset_rendered: Option<Vec<VisibleCell>> = None;
let visible_slice: &[VisibleCell] = if let Some(ref full) = rendered_cells_full {
&full[start_idx..end_idx]
} else {
_subset_rendered = Some(self.history_render.visible_cells(
&self.history_state,
&render_requests[start_idx..end_idx],
visible_requests_slice,
render_settings,
));
_subset_rendered.as_ref().map(|v| v.as_slice()).unwrap_or(&[])
@@ -38209,6 +38314,19 @@ impl WidgetRef for &ChatWidget<'_> {
}
let mut height_mismatches: Vec<HeightMismatch> = Vec::new();
let is_collapsed_reasoning_at = |idx: usize| {
if idx >= request_count {
return false;
}
if idx < history_len {
return self.history_cells[idx]
.as_any()
.downcast_ref::<crate::history_cell::CollapsibleReasoningCell>()
.map(|rc| rc.is_collapsed())
.unwrap_or(false);
}
false
};
for (offset, visible) in visible_slice.iter().enumerate() {
let idx = start_idx + offset;
@@ -38233,7 +38351,7 @@ impl WidgetRef for &ChatWidget<'_> {
let item_height = visible.height;
if content_area.width > 0 {
if let Some(req) = render_requests.get(idx) {
if let Some(req) = visible_requests_slice.get(offset) {
if req.history_id != HistoryId::ZERO
&& matches!(item.kind(), history_cell::HistoryCellType::Reasoning)
{
@@ -38635,24 +38753,11 @@ impl WidgetRef for &ChatWidget<'_> {
.map(|rc| rc.is_collapsed())
.unwrap_or(false);
if this_is_collapsed_reasoning {
let prev_is_collapsed_reasoning = render_requests
.get(idx.saturating_sub(1))
.and_then(|req| req.cell)
.and_then(|c| {
c.as_any()
.downcast_ref::<crate::history_cell::CollapsibleReasoningCell>()
.map(|rc| rc.is_collapsed())
})
.unwrap_or(false);
let next_is_collapsed_reasoning = render_requests
.get(idx + 1)
.and_then(|req| req.cell)
.and_then(|c| {
c.as_any()
.downcast_ref::<crate::history_cell::CollapsibleReasoningCell>()
.map(|rc| rc.is_collapsed())
})
let prev_is_collapsed_reasoning = idx
.checked_sub(1)
.map(is_collapsed_reasoning_at)
.unwrap_or(false);
let next_is_collapsed_reasoning = is_collapsed_reasoning_at(idx + 1);
if prev_is_collapsed_reasoning && next_is_collapsed_reasoning {
should_add_spacing = false;
}
+5 -2
View File
@@ -25,6 +25,8 @@ pub struct PerfStats {
pub cells_overlay_scrim: u64,
pub ns_overlay_body_bg: u128,
pub cells_overlay_body_bg: u64,
pub render_requests_full: u64,
pub render_requests_visible: u64,
// Hotspots: time spent computing heights on cache misses
pub hot_total: std::collections::HashMap<(usize, u16), ItemStat>,
pub hot_render: std::collections::HashMap<(usize, u16), ItemStat>,
@@ -61,9 +63,11 @@ impl PerfStats {
let ms_overlay_body = (self.ns_overlay_body_bg as f64) / 1_000_000.0;
let mut out = String::new();
out.push_str(&format!(
"perf: frames={}\n prefix_rebuilds={}\n height_cache: total hits={} misses={}\n height_cache (render): hits={} misses={}\n time: total_height={:.2}ms render_visible={:.2}ms\n time: widget_render_total={:.2}ms\n paint: history_clear={:.2}ms (cells={}) gutter_bg={:.2}ms (cells={})\n paint: overlay_scrim={:.2}ms (cells={}) overlay_body_bg={:.2}ms (cells={})",
"perf: frames={}\n prefix_rebuilds={}\n render_requests: full={} visible={}\n height_cache: total hits={} misses={}\n height_cache (render): hits={} misses={}\n time: total_height={:.2}ms render_visible={:.2}ms\n time: widget_render_total={:.2}ms\n paint: history_clear={:.2}ms (cells={}) gutter_bg={:.2}ms (cells={})\n paint: overlay_scrim={:.2}ms (cells={}) overlay_body_bg={:.2}ms (cells={})",
self.frames,
self.prefix_rebuilds,
self.render_requests_full,
self.render_requests_visible,
self.height_hits_total,
self.height_misses_total,
self.height_hits_render,
@@ -213,4 +217,3 @@ impl PerfStats {
self.ns_undo_restore = self.ns_undo_restore.saturating_add(ns);
}
}
+13 -1
View File
@@ -1,6 +1,6 @@
#![allow(dead_code)]
use super::{ChatWidget, ExecCallId, RunningCommand};
use super::{perf::PerfStats, ChatWidget, ExecCallId, RunningCommand};
use crate::app_event::{AppEvent, AutoContinueMode};
use crate::app_event_sender::AppEventSender;
use crate::history_cell::{self, HistoryCellType};
@@ -286,6 +286,18 @@ impl ChatWidgetHarness {
.count()
}
pub fn enable_perf(&mut self, enabled: bool) {
self.chat.enable_perf(enabled);
}
pub fn perf_stats_snapshot(&self) -> PerfStats {
self.chat.perf_state.stats.borrow().clone()
}
pub fn history_len(&self) -> usize {
self.chat.history_cells.len()
}
pub fn bottom_spacer_lines(&self) -> u16 {
self.chat.history_render.bottom_spacer_lines_for_test()
}
@@ -0,0 +1,61 @@
#![cfg(test)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use code_tui::test_helpers::{
render_chat_widget_to_vt100, AutoContinueModeFixture, ChatWidgetHarness,
};
#[test]
fn auto_drive_countdown_keeps_render_requests_local() {
let mut harness = ChatWidgetHarness::new();
harness.enable_perf(true);
for idx in 0..300 {
harness.push_user_prompt(format!(
"User {idx}: keep extending the long AutoDrive session transcript."
));
harness.push_assistant_markdown(format!(
"Assistant {idx}: adding more content so the history grows large enough to exercise render performance."
));
}
harness.auto_drive_activate(
"Diagnose long-running session performance",
false,
false,
AutoContinueModeFixture::TenSeconds,
);
harness.auto_drive_set_awaiting_submission(
"Continue",
"Waiting for coordinator",
None,
);
let _ = render_chat_widget_to_vt100(&mut harness, 120, 40);
let before = harness.perf_stats_snapshot();
harness.auto_drive_advance_countdown(9);
let _ = render_chat_widget_to_vt100(&mut harness, 120, 40);
let after = harness.perf_stats_snapshot();
let full_delta = after
.render_requests_full
.saturating_sub(before.render_requests_full);
let visible_delta = after
.render_requests_visible
.saturating_sub(before.render_requests_visible);
let history_len = harness.history_len();
assert!(
history_len > 100,
"history should be large enough to stress render requests"
);
assert_eq!(
full_delta, 0,
"stable history should not rebuild full render requests on countdown ticks"
);
assert!(
visible_delta > 0 && (visible_delta as usize) < history_len,
"visible render requests should stay below the full history size"
);
}