test(tui/vt100): reproduce bottom clipping regression

This commit is contained in:
James Peter
2025-10-07 06:07:10 +10:00
parent 41532c4e08
commit dd99724659
8 changed files with 294 additions and 72 deletions
+87 -24
View File
@@ -3,12 +3,15 @@
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::history_cell::{self, HistoryCellType};
use crate::markdown_render::render_markdown_text;
use crate::tui::TerminalInfo;
use code_core::history::state::HistoryRecord;
use code_core::config::{Config, ConfigOverrides, ConfigToml};
use code_core::protocol::Event;
use std::collections::VecDeque;
use code_core::history::state::HistoryRecord;
use code_core::protocol::{BackgroundEventEvent, Event, EventMsg, OrderMeta};
use once_cell::sync::Lazy;
use ratatui::text::Line;
use std::collections::VecDeque;
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use tokio::runtime::Runtime;
@@ -24,6 +27,7 @@ static TEST_RUNTIME: Lazy<Runtime> = Lazy::new(|| {
pub struct ChatWidgetHarness {
chat: ChatWidget<'static>,
events: Receiver<AppEvent>,
helper_seq: u64,
}
impl ChatWidgetHarness {
@@ -56,7 +60,11 @@ impl ChatWidgetHarness {
None,
);
Self { chat, events: rx }
Self {
chat,
events: rx,
helper_seq: 0,
}
}
pub fn handle_event(&mut self, event: Event) {
@@ -64,51 +72,61 @@ impl ChatWidgetHarness {
}
pub(crate) fn flush_into_widget(&mut self) {
let mut queue: VecDeque<AppEvent> = self.drain_events().into();
if queue.is_empty() {
return;
}
let mut queue: VecDeque<AppEvent> = self
.drain_events()
.into_iter()
.filter(|event| !matches!(event, AppEvent::RequestRedraw))
.collect();
let mut debug = Vec::new();
while let Some(event) = queue.pop_front() {
match event {
AppEvent::InsertHistory(lines) => {
debug.push("InsertHistory");
self.chat.insert_history_lines(lines);
}
AppEvent::InsertHistoryWithKind { id, kind, lines } => {
debug.push("InsertHistoryWithKind");
self.chat.insert_history_lines_with_kind(kind, id, lines);
}
AppEvent::InsertFinalAnswer { id, lines, source } => {
debug.push("InsertFinalAnswer");
self.chat.insert_final_answer_with_id(id, lines, source);
}
AppEvent::InsertBackgroundEvent { message, placement, order } => {
debug.push("InsertBackgroundEvent");
self.chat
.insert_background_event_with_placement(message, placement, order);
}
AppEvent::CommitTick => {
debug.push("CommitTick");
self.chat.on_commit_tick();
let newly_emitted = self.drain_events();
if !newly_emitted.is_empty() {
queue.extend(newly_emitted);
queue.extend(
newly_emitted
.into_iter()
.filter(|event| !matches!(event, AppEvent::RequestRedraw)),
);
}
}
AppEvent::StartCommitAnimation => {
debug.push("StartCommitAnimation");
}
AppEvent::StopCommitAnimation => {
debug.push("StopCommitAnimation");
}
AppEvent::StartCommitAnimation
| AppEvent::StopCommitAnimation
| AppEvent::ScheduleFrameIn(_)
| AppEvent::SetTerminalTitle { .. }
| AppEvent::EmitTuiNotification { .. }
| AppEvent::RequestRedraw
| AppEvent::Redraw
| AppEvent::PreviewTheme(_)
| AppEvent::PreviewSpinner(_) => {}
// Other events are either no-ops for VT100 rendering or handled elsewhere.
_ => {}
}
}
if !debug.is_empty() {
println!("flush events: {debug:?}");
if !queue.is_empty() {
continue;
}
let newly_emitted = self.drain_events();
if !newly_emitted.is_empty() {
queue.extend(
newly_emitted
.into_iter()
.filter(|event| !matches!(event, AppEvent::RequestRedraw)),
);
}
}
}
@@ -124,6 +142,45 @@ impl ChatWidgetHarness {
&mut self.chat
}
pub fn push_user_prompt(&mut self, message: impl Into<String>) {
let state = history_cell::new_user_prompt(message.into());
self.chat.history_push_plain_state(state);
}
pub fn push_background_event(&mut self, message: impl Into<String>) {
let seq = self.next_helper_seq();
self.handle_event(Event {
id: format!("bg-helper-{seq}"),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: message.into(),
}),
order: Some(OrderMeta {
request_ordinal: 0,
output_index: Some(u32::MAX),
sequence_number: Some(seq),
}),
});
}
pub fn push_assistant_markdown(&mut self, markdown: impl Into<String>) {
let markdown = markdown.into();
let mut rendered = render_markdown_text(&markdown);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(rendered.lines.len() + 1);
lines.push(Line::from("assistant"));
lines.extend(rendered.lines.drain(..));
let state = history_cell::plain_message_state_from_lines(lines, HistoryCellType::Assistant);
self.chat.history_push_plain_state(state);
}
pub(crate) fn set_standard_terminal_mode(&mut self, enabled: bool) {
self.chat.set_standard_terminal_mode(enabled);
}
pub(crate) fn force_scroll_offset(&mut self, offset: u16) {
self.chat.layout.scroll_offset = offset;
}
pub(crate) fn poll_until<F>(&mut self, mut predicate: F, timeout: Duration) -> Vec<AppEvent>
where
F: FnMut(&[AppEvent]) -> bool,
@@ -156,6 +213,12 @@ impl ChatWidgetHarness {
self.flush_into_widget();
self.chat.history_state.records.clone()
}
fn next_helper_seq(&mut self) -> u64 {
let next = self.helper_seq;
self.helper_seq = self.helper_seq.saturating_add(1);
next
}
}
pub fn assert_has_insert_history(events: &[AppEvent]) {
+8
View File
@@ -245,6 +245,14 @@ pub mod test_helpers {
pub fn history_records(harness: &mut ChatWidgetHarness) -> Vec<HistoryRecord> {
harness.history_records()
}
pub fn set_standard_terminal_mode(harness: &mut ChatWidgetHarness, enabled: bool) {
harness.set_standard_terminal_mode(enabled);
}
pub fn force_scroll_offset(harness: &mut ChatWidgetHarness, offset: u16) {
harness.force_scroll_offset(offset);
}
}
fn theme_configured_in_config_file(code_home: &std::path::Path) -> bool {
+10 -2
View File
@@ -120,12 +120,20 @@ impl Backend for VT100Backend {
}
fn size(&self) -> io::Result<Size> {
Ok(self.vt100().screen().size().into())
let (rows, cols) = self.vt100().screen().size();
Ok(Size {
width: cols,
height: rows,
})
}
fn window_size(&mut self) -> io::Result<WindowSize> {
let (rows, cols) = self.vt100().screen().size();
Ok(WindowSize {
columns_rows: self.vt100().screen().size().into(),
columns_rows: Size {
width: cols,
height: rows,
},
// Arbitrary size, we don't rely on this in testing.
pixels: Size {
width: 640,
@@ -3,36 +3,66 @@ source: tui/tests/vt100_chatwidget_snapshot.rs
expression: combined
---
--- frame 0 ---
┌──┐
│ │
└──┘
cr
a
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Code • Directory: ~/.code/working/code/branches/code-branch-20251006-101336/code-rs/tui │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
› Can you review the last run and summarize the findings?
Earlier summary: Reviewed files and prepared shell commands.
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you this morning? │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+H help
--- frame 1 ---
┌──┐
│ │
└──┘
cr
a
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Code • Directory: ~/.code/working/code/branches/code-branch-20251006-101336/code-rs/tui │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Earlier summary: Reviewed files and prepared shell commands.
Considering recent edits and repo state... Evaluating plan viability.…
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you this morning? │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+R show reasoning • Ctrl+H help
--- frame 2 ---
┌──┐
│ │
└──┘
cr
a
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Code • Directory: ~/.code/working/code/branches/code-branch-20251006-101336/code-rs/tui │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Earlier summary: Reviewed files and prepared shell commands.
Considering recent edits and repo state... Evaluating plan viability.…
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you this morning? │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+R show reasoning • Ctrl+H help
--- frame 3 ---
┌──┐
│ │
└──┘
cr
a
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Code • Directory: ~/.code/working/code/branches/code-branch-20251006-101336/code-rs/tui │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Earlier summary: Reviewed files and prepared shell commands.
Considering recent edits and repo state... Evaluating plan viability.…
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you this morning? │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+R show reasoning • Ctrl+H help
--- frame 4 ---
┌──┐
│ │
└──┘
ta
wi
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Code • Directory: ~/.code/working/code/branches/code-branch-20251006-101336/code-rs/tui │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
- Lines that wrap must remain visible even as the buffer grows.
Streaming content continues to arrive, pushing earlier lines upward but keeping the tail visible
to the user.
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you this morning? │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+R show reasoning • Ctrl+H help
@@ -2,6 +2,26 @@
source: tui/tests/vt100_chatwidget_snapshot.rs
expression: output
---
┌────────────────────┐
│ Code │
└────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ Code │
└────────────────────────────────────────────────────────────────────────────┘
★ Popular commands:
/agents - create and configure agents
/model - choose model & reasoning effort NEW with GPT-5-Codex!
/chrome - connect to Chrome
/plan - create a comprehensive plan (multiple agents)
/code - perform a coding task (multiple agents)
/branch - work in an isolated /branch then /merge when done (great for
parallel work)
/limits - visualize weekly and hourly rate limits
/review - review your changes for potential issues
/auto - work autonomously on long tasks with Auto Drive Experimental
/cloud - browse, apply, and create cloud tasks NEW
┌────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you this morning? │
└────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+H help
@@ -2,6 +2,26 @@
source: tui/tests/vt100_chatwidget_snapshot.rs
expression: output
---
┌────────────────────┐
│ Code │
└────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ Code │
└────────────────────────────────────────────────────────────────────────────┘
parallel work)
/limits - visualize weekly and hourly rate limits
/review - review your changes for potential issues
/auto - work autonomously on long tasks with Auto Drive Experimental
/cloud - browse, apply, and create cloud tasks NEW
• Here's a simple function:
┌ rust ────────────────────────────┐
│ fn hello() { │
│ println!("Hello, world!"); │
│ } │
└──────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you this morning? │
└────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+H help
@@ -2,6 +2,26 @@
source: tui/tests/vt100_chatwidget_snapshot.rs
expression: output
---
┌────────────────────┐
│ Code │
└────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ Code │
└────────────────────────────────────────────────────────────────────────────┘
/cloud - browse, apply, and create cloud tasks NEW
› Can you help me understand the available commands?
• Hello! How can I help you today?
• I can help with various tasks including:
- Writing code
- Reading files
- Running commands
┌────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you this morning? │
└────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+H help
+65 -12
View File
@@ -42,7 +42,8 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use code_core::protocol::{
AgentMessageDeltaEvent, AgentMessageEvent, Event, EventMsg, OrderMeta,
AgentMessageDeltaEvent, AgentMessageEvent, AgentReasoningDeltaEvent, AgentReasoningEvent, Event,
EventMsg, OrderMeta,
};
use code_tui::test_helpers::{
render_chat_widget_frames_to_vt100, render_chat_widget_to_vt100, ChatWidgetHarness,
@@ -51,6 +52,7 @@ use code_tui::test_helpers::{
#[test]
fn baseline_empty_chat() {
let mut harness = ChatWidgetHarness::new();
code_tui::test_helpers::set_standard_terminal_mode(&mut harness, false);
let output = render_chat_widget_to_vt100(&mut harness, 80, 24);
insta::assert_snapshot!("empty_chat", output);
@@ -60,6 +62,8 @@ fn baseline_empty_chat() {
fn baseline_simple_conversation() {
let mut harness = ChatWidgetHarness::new();
harness.push_user_prompt("Can you help me understand the available commands?");
harness.handle_event(Event {
id: "msg-1".into(),
event_seq: 0,
@@ -178,7 +182,6 @@ fn baseline_simple_conversation() {
});
let records = code_tui::test_helpers::history_records(&mut harness);
dbg!(&records);
assert!(!records.is_empty(), "history should contain records after events");
assert!(
records.iter().any(|record| match record {
@@ -246,10 +249,55 @@ fn baseline_multiline_formatting() {
fn clip_repro_autofollow_wrap() {
let mut harness = ChatWidgetHarness::new();
// Seed history with a mix of user input, background notices, reasoning, and prior answers to
// better match the conditions where the clipping bug manifests in the app.
harness.push_user_prompt("Can you review the last run and summarize the findings?");
harness.push_background_event("✅ Connected to Chrome via CDP");
let reasoning_id = "reason-1".to_string();
let reasoning_order = |seq: u64| OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(seq),
};
harness.handle_event(Event {
id: reasoning_id.clone(),
event_seq: 0,
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "Considering recent edits and repo state... ".into(),
}),
order: Some(reasoning_order(0)),
});
harness.handle_event(Event {
id: reasoning_id.clone(),
event_seq: 1,
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "Evaluating plan viability.".into(),
}),
order: Some(reasoning_order(1)),
});
harness.handle_event(Event {
id: reasoning_id.clone(),
event_seq: 2,
msg: EventMsg::AgentReasoning(AgentReasoningEvent {
text: "Plan looks feasible; preparing response.".into(),
}),
order: Some(reasoning_order(2)),
});
// Older assistant response to push the scrollback.
harness.push_assistant_markdown("Earlier summary: Reviewed files and prepared shell commands.");
harness.push_background_event("ℹ️ Auto Drive queued additional tasks");
let mut frames: Vec<String> = Vec::new();
let viewport = (100, 12);
// Ensure seeded events are reflected in the widget before capturing frames.
let _ = code_tui::test_helpers::history_records(&mut harness);
// Initial frame before streaming begins
frames.extend(render_chat_widget_frames_to_vt100(&mut harness, &[(80, 6)]));
frames.extend(render_chat_widget_frames_to_vt100(&mut harness, &[viewport]));
let base_order = |seq: u64| OrderMeta {
request_ordinal: 1,
@@ -260,9 +308,9 @@ fn clip_repro_autofollow_wrap() {
let stream_id = "answer-1".to_string();
let deltas = [
"Here is a summary of the wrap behavior: the viewport should stay locked near the bottom even when lines wrap across the width of the terminal.",
" Additional streaming content continues to arrive, pushing earlier lines upward but keeping the tail visible to the user.",
" Finally, ensure the lines remain visible after auto-follow adjustments." ,
"Here is a summary of the wrap behavior:\n\n- The viewport should stay locked near the bottom.\n- Lines that wrap must remain visible even as the buffer grows.",
"\nStreaming content continues to arrive, pushing earlier lines upward but keeping the tail visible to the user.\n1. Capture each delta.\n2. Commit the render.\n3. Maintain auto-follow.",
"\nFinally, ensure the lines remain visible after auto-follow adjustments by re-evaluating the scroll state and redrawing the composer footer." ,
];
for (idx, delta) in deltas.iter().enumerate() {
@@ -274,11 +322,17 @@ fn clip_repro_autofollow_wrap() {
}),
order: Some(base_order(idx as u64)),
});
frames.extend(render_chat_widget_frames_to_vt100(&mut harness, &[(80, 6)]));
frames.extend(render_chat_widget_frames_to_vt100(&mut harness, &[viewport]));
}
// Final assistant message closes the stream
let final_message = deltas.join("");
// Simulate the user scrolling slightly above the bottom just before the final answer
// arrives. The auto-follow logic should pull us back down when the assistant
// completes, but the bug we are chasing causes the final wrapped lines to disappear.
code_tui::test_helpers::force_scroll_offset(&mut harness, 6);
let tail_marker = "TAIL: stay visible";
let final_message = format!("{}\n\n{}", deltas.join(""), tail_marker);
harness.handle_event(Event {
id: stream_id.clone(),
event_seq: deltas.len() as u64,
@@ -289,7 +343,7 @@ fn clip_repro_autofollow_wrap() {
});
let records = code_tui::test_helpers::history_records(&mut harness);
assert!(!records.is_empty(), "history should contain records for clip repro");
frames.extend(render_chat_widget_frames_to_vt100(&mut harness, &[(80, 6)]));
frames.extend(render_chat_widget_frames_to_vt100(&mut harness, &[viewport]));
let combined = frames
.iter()
@@ -302,8 +356,7 @@ fn clip_repro_autofollow_wrap() {
insta::assert_snapshot!("clip_repro_autofollow_wrap", combined);
assert!(
last_frame.contains("tail visible to the user")
|| last_frame.contains("lines remain visible"),
"Final wrapped line should remain visible in the latest frame"
!last_frame.contains(tail_marker),
"Tail marker should be clipped from the viewport to reproduce the bug"
);
}