mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
fix(tui): stabilize history ordering and scrollback
This commit is contained in:
Generated
+1
@@ -1559,6 +1559,7 @@ dependencies = [
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shlex",
|
||||
|
||||
@@ -18,6 +18,7 @@ debug-logs = []
|
||||
dev-faults = []
|
||||
# Fork-specific extensions layered on top of upstream history rendering.
|
||||
code-fork = []
|
||||
test-helpers = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -112,6 +113,7 @@ insta = "1.43.1"
|
||||
pretty_assertions = "1"
|
||||
rand = "0.9"
|
||||
strip-ansi-escapes = "0.1"
|
||||
serde_bytes = "0.11"
|
||||
[build-dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -74,7 +74,7 @@ mod terminal_handlers;
|
||||
mod terminal;
|
||||
mod tools;
|
||||
mod running_tools;
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-helpers"))]
|
||||
pub mod smoke_helpers;
|
||||
use self::agent_install::{
|
||||
start_agent_install_session,
|
||||
@@ -5366,6 +5366,18 @@ impl ChatWidget<'_> {
|
||||
if !is_reasoning_cell {
|
||||
self.clear_reasoning_in_progress();
|
||||
}
|
||||
let is_background_cell = matches!(cell.kind(), HistoryCellType::BackgroundEvent);
|
||||
let mut key = key;
|
||||
let mut key_bumped = false;
|
||||
if !is_background_cell {
|
||||
if let Some(last) = self.last_assigned_order {
|
||||
if key <= last {
|
||||
key = Self::order_key_successor(last);
|
||||
key_bumped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine insertion position across the entire history
|
||||
let mut pos = self.history_cells.len();
|
||||
for i in 0..self.history_cells.len() {
|
||||
@@ -5521,6 +5533,15 @@ impl ChatWidget<'_> {
|
||||
);
|
||||
}
|
||||
self.cell_order_seq.insert(pos, key);
|
||||
if key_bumped {
|
||||
if let Some(stream) = self.history_cells[pos]
|
||||
.as_any()
|
||||
.downcast_ref::<crate::history_cell::StreamingContentCell>()
|
||||
{
|
||||
self.stream_order_seq
|
||||
.insert((StreamKind::Answer, stream.state().stream_id.clone()), key);
|
||||
}
|
||||
}
|
||||
self.last_assigned_order = Some(match self.last_assigned_order {
|
||||
Some(prev) => prev.max(key),
|
||||
None => key,
|
||||
@@ -5571,7 +5592,7 @@ impl ChatWidget<'_> {
|
||||
fn history_insert_existing_record(
|
||||
&mut self,
|
||||
mut cell: Box<dyn HistoryCell>,
|
||||
key: OrderKey,
|
||||
mut key: OrderKey,
|
||||
tag: &'static str,
|
||||
id: HistoryId,
|
||||
) -> usize {
|
||||
@@ -5592,7 +5613,18 @@ impl ChatWidget<'_> {
|
||||
.downcast_ref::<crate::history_cell::CollapsibleReasoningCell>()
|
||||
.is_some();
|
||||
if !is_reasoning_cell {
|
||||
self.clear_reasoning_in_progress();
|
||||
self.clear_reasoning_in_progress();
|
||||
}
|
||||
|
||||
let is_background_cell = matches!(cell.kind(), HistoryCellType::BackgroundEvent);
|
||||
let mut key_bumped = false;
|
||||
if !is_background_cell {
|
||||
if let Some(last) = self.last_assigned_order {
|
||||
if key <= last {
|
||||
key = Self::order_key_successor(last);
|
||||
key_bumped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut pos = self.history_cells.len();
|
||||
@@ -5683,6 +5715,15 @@ impl ChatWidget<'_> {
|
||||
);
|
||||
}
|
||||
self.cell_order_seq.insert(pos, key);
|
||||
if key_bumped {
|
||||
if let Some(stream) = self.history_cells[pos]
|
||||
.as_any()
|
||||
.downcast_ref::<crate::history_cell::StreamingContentCell>()
|
||||
{
|
||||
self.stream_order_seq
|
||||
.insert((StreamKind::Answer, stream.state().stream_id.clone()), key);
|
||||
}
|
||||
}
|
||||
self.last_assigned_order = Some(match self.last_assigned_order {
|
||||
Some(prev) => prev.max(key),
|
||||
None => key,
|
||||
@@ -15704,7 +15745,7 @@ fi\n\
|
||||
"final-answer: ordered insert new AssistantMarkdownCell id={:?}",
|
||||
id
|
||||
);
|
||||
let key = match id.as_deref() {
|
||||
let mut key = match id.as_deref() {
|
||||
Some(rid) => self
|
||||
.try_stream_order_key(StreamKind::Answer, rid)
|
||||
.unwrap_or_else(|| {
|
||||
@@ -15719,6 +15760,18 @@ fi\n\
|
||||
self.next_internal_key()
|
||||
}
|
||||
};
|
||||
if let Some(last) = self.last_assigned_order {
|
||||
if key <= last {
|
||||
// Background notices anchor themselves at out = i32::MAX. If a final answer arrives
|
||||
// after those notices we still want it to appear at the bottom, so bump the key
|
||||
// just past the most-recently assigned slot.
|
||||
key = Self::order_key_successor(last);
|
||||
if let Some(ref want) = id {
|
||||
self.stream_order_seq
|
||||
.insert((StreamKind::Answer, want.clone()), key);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"[order] final Answer ordered insert id={:?} {}",
|
||||
id,
|
||||
@@ -18854,6 +18907,445 @@ fi\n\
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chatwidget::smoke_helpers::ChatWidgetHarness;
|
||||
use crate::history_cell::HistoryCellType;
|
||||
use code_core::history::state::{
|
||||
AssistantStreamDelta,
|
||||
AssistantStreamState,
|
||||
InlineSpan,
|
||||
MessageLine,
|
||||
MessageLineKind,
|
||||
PlainMessageKind,
|
||||
PlainMessageRole,
|
||||
PlainMessageState,
|
||||
TextEmphasis,
|
||||
TextTone,
|
||||
};
|
||||
use code_core::protocol::OrderMeta;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::Terminal;
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn reset_history(chat: &mut ChatWidget<'_>) {
|
||||
chat.history_cells.clear();
|
||||
chat.history_cell_ids.clear();
|
||||
chat.history_state = HistoryState::new();
|
||||
chat.history_render.invalidate_all();
|
||||
chat.cell_order_seq.clear();
|
||||
chat.cell_order_dbg.clear();
|
||||
chat.ui_background_seq_counters.clear();
|
||||
chat.last_assigned_order = None;
|
||||
chat.last_seen_request_index = 0;
|
||||
chat.current_request_index = 0;
|
||||
chat.internal_seq = 0;
|
||||
chat.synthetic_system_req = None;
|
||||
chat.layout.scroll_offset = 0;
|
||||
chat.layout.last_max_scroll.set(0);
|
||||
chat.layout.last_history_viewport_height.set(0);
|
||||
}
|
||||
|
||||
fn insert_plain_cell(chat: &mut ChatWidget<'_>, lines: &[&str]) {
|
||||
use code_core::history::state::{
|
||||
InlineSpan,
|
||||
MessageLine,
|
||||
MessageLineKind,
|
||||
PlainMessageKind,
|
||||
PlainMessageRole,
|
||||
PlainMessageState,
|
||||
TextEmphasis,
|
||||
TextTone,
|
||||
};
|
||||
|
||||
let state = PlainMessageState {
|
||||
id: HistoryId::ZERO,
|
||||
role: PlainMessageRole::System,
|
||||
kind: PlainMessageKind::Plain,
|
||||
header: None,
|
||||
lines: lines
|
||||
.iter()
|
||||
.map(|text| MessageLine {
|
||||
kind: MessageLineKind::Paragraph,
|
||||
spans: vec![InlineSpan {
|
||||
text: (*text).to_string(),
|
||||
tone: TextTone::Default,
|
||||
emphasis: TextEmphasis::default(),
|
||||
entity: None,
|
||||
}],
|
||||
})
|
||||
.collect(),
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let key = chat.next_internal_key();
|
||||
let _ = chat.history_insert_plain_state_with_key(state, key, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_keeps_new_answers_after_prior_backgrounds() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let chat = harness.chat();
|
||||
reset_history(chat);
|
||||
|
||||
chat.last_seen_request_index = 1;
|
||||
chat.current_request_index = 1;
|
||||
chat.internal_seq = 0;
|
||||
|
||||
chat.push_background_tail("background-one".to_string());
|
||||
chat.push_background_tail("background-two".to_string());
|
||||
|
||||
assert_eq!(chat.history_cells.len(), 2, "expected two background cells");
|
||||
|
||||
let answer_id = "answer-turn-1";
|
||||
let seeded_key = OrderKey {
|
||||
req: 1,
|
||||
out: 1,
|
||||
seq: 0,
|
||||
};
|
||||
chat.seed_stream_order_key(StreamKind::Answer, answer_id, seeded_key);
|
||||
|
||||
let response_text = "assistant-response";
|
||||
chat.insert_final_answer_with_id(
|
||||
Some(answer_id.to_string()),
|
||||
vec![Line::from(response_text)],
|
||||
response_text.to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(chat.history_cells.len(), 3, "expected assistant cell to be added");
|
||||
|
||||
let tail_kinds: Vec<HistoryCellType> = chat
|
||||
.history_cells
|
||||
.iter()
|
||||
.map(|cell| cell.kind())
|
||||
.collect();
|
||||
|
||||
let len = tail_kinds.len();
|
||||
assert_eq!(
|
||||
&tail_kinds[len - 3..],
|
||||
&[
|
||||
HistoryCellType::BackgroundEvent,
|
||||
HistoryCellType::BackgroundEvent,
|
||||
HistoryCellType::Assistant,
|
||||
],
|
||||
"assistant output should appear after existing background cells",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollback_spacer_preserves_top_cell_bottom_line() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let chat = harness.chat();
|
||||
reset_history(chat);
|
||||
|
||||
insert_plain_cell(chat, &["old-1", "old-2"]);
|
||||
insert_plain_cell(chat, &["mid-1", "mid-2"]);
|
||||
insert_plain_cell(chat, &["new-1", "new-2"]);
|
||||
|
||||
let viewport_height = 6;
|
||||
chat.layout.scroll_offset = 2;
|
||||
|
||||
let mut terminal = Terminal::new(TestBackend::new(40, viewport_height)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&*chat, frame.area()))
|
||||
.expect("draw history");
|
||||
|
||||
let adjusted = chat.history_render.adjust_scroll_to_content(2);
|
||||
assert_eq!(adjusted, 1, "scroll origin should step back from spacer row");
|
||||
|
||||
let prefix = chat.history_render.prefix_sums.borrow();
|
||||
assert!(!prefix.is_empty(), "prefix sums populated after draw");
|
||||
let start_idx = match prefix.binary_search(&adjusted) {
|
||||
Ok(i) => i,
|
||||
Err(i) => i.saturating_sub(1),
|
||||
};
|
||||
assert_eq!(start_idx, 0, "expected first cell to be visible after adjustment");
|
||||
|
||||
let content_y = prefix[start_idx];
|
||||
drop(prefix);
|
||||
let skip_top = adjusted.saturating_sub(content_y);
|
||||
assert_eq!(skip_top, 1, "should display the second line of the oldest cell");
|
||||
|
||||
let cell = &chat.history_cells[start_idx];
|
||||
let lines = cell.display_lines_trimmed();
|
||||
let line = lines
|
||||
.get(skip_top as usize)
|
||||
.expect("line available after scroll adjustment");
|
||||
let text: String = line.spans.iter().map(|span| span.content.as_ref()).collect();
|
||||
assert_eq!(text.trim(), "old-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollback_spacer_exact_offset_adjusts_to_content() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let chat = harness.chat();
|
||||
reset_history(chat);
|
||||
|
||||
insert_plain_cell(chat, &["old-1", "old-2"]);
|
||||
insert_plain_cell(chat, &["mid-1", "mid-2"]);
|
||||
insert_plain_cell(chat, &["new-1", "new-2"]);
|
||||
|
||||
let viewport_height = 6;
|
||||
chat.layout.scroll_offset = 2;
|
||||
|
||||
{
|
||||
let mut terminal =
|
||||
Terminal::new(TestBackend::new(40, viewport_height)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&*chat, frame.area()))
|
||||
.expect("draw history");
|
||||
}
|
||||
|
||||
let ranges = chat.history_render.spacing_ranges_for_test();
|
||||
let (pos, _) = ranges
|
||||
.first()
|
||||
.copied()
|
||||
.expect("expected a spacer-induced adjustment");
|
||||
let adjusted = chat.history_render.adjust_scroll_to_content(pos);
|
||||
assert!(
|
||||
adjusted < pos,
|
||||
"scroll adjustment should reduce the origin when landing on a spacer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollback_top_boundary_retains_oldest_content() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let chat = harness.chat();
|
||||
reset_history(chat);
|
||||
|
||||
insert_plain_cell(chat, &["old-1", "old-2"]);
|
||||
insert_plain_cell(chat, &["mid-1", "mid-2"]);
|
||||
insert_plain_cell(chat, &["new-1", "new-2"]);
|
||||
|
||||
{
|
||||
let mut terminal = Terminal::new(TestBackend::new(40, 6)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&*chat, frame.area()))
|
||||
.expect("draw history");
|
||||
}
|
||||
|
||||
let max_scroll = chat.layout.last_max_scroll.get();
|
||||
assert!(max_scroll > 0, "expected overflow to produce a positive max scroll");
|
||||
chat.layout.scroll_offset = max_scroll;
|
||||
|
||||
let mut terminal = Terminal::new(TestBackend::new(40, 6)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&*chat, frame.area()))
|
||||
.expect("draw history at top boundary");
|
||||
|
||||
let max_scroll = chat.layout.last_max_scroll.get();
|
||||
let scroll_from_top = max_scroll.saturating_sub(chat.layout.scroll_offset);
|
||||
let effective = chat.history_render.adjust_scroll_to_content(scroll_from_top);
|
||||
let prefix = chat.history_render.prefix_sums.borrow();
|
||||
let mut start_idx = match prefix.binary_search(&effective) {
|
||||
Ok(i) => i,
|
||||
Err(i) => i.saturating_sub(1),
|
||||
};
|
||||
start_idx = start_idx.min(prefix.len().saturating_sub(1));
|
||||
start_idx = start_idx.min(chat.history_cells.len().saturating_sub(1));
|
||||
let content_y = prefix[start_idx];
|
||||
drop(prefix);
|
||||
|
||||
let skip = effective.saturating_sub(content_y) as usize;
|
||||
let cell = &chat.history_cells[start_idx];
|
||||
let lines = cell.display_lines_trimmed();
|
||||
let target_index = skip.min(lines.len().saturating_sub(1));
|
||||
let visible = lines
|
||||
.get(target_index)
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(
|
||||
visible.contains("old-1"),
|
||||
"scrolling to the top should keep the oldest content visible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_stream_delta_should_follow_existing_background_tail() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let chat = harness.chat();
|
||||
reset_history(chat);
|
||||
|
||||
chat.last_seen_request_index = 1;
|
||||
chat.push_background_tail("background".to_string());
|
||||
|
||||
let stream_state = AssistantStreamState {
|
||||
id: HistoryId::ZERO,
|
||||
stream_id: "stream-1".into(),
|
||||
preview_markdown: "partial".into(),
|
||||
deltas: vec![AssistantStreamDelta {
|
||||
delta: "partial".into(),
|
||||
sequence: Some(0),
|
||||
received_at: SystemTime::now(),
|
||||
}],
|
||||
citations: vec![],
|
||||
metadata: None,
|
||||
in_progress: true,
|
||||
last_updated_at: SystemTime::now(),
|
||||
};
|
||||
let stream_cell = history_cell::new_streaming_content(stream_state, &chat.config);
|
||||
|
||||
chat.history_insert_with_key_global_tagged(
|
||||
Box::new(stream_cell),
|
||||
OrderKey {
|
||||
req: 1,
|
||||
out: 0,
|
||||
seq: 0,
|
||||
},
|
||||
"stream",
|
||||
None,
|
||||
);
|
||||
|
||||
let kinds: Vec<HistoryCellType> = chat
|
||||
.history_cells
|
||||
.iter()
|
||||
.map(|cell| cell.kind())
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![HistoryCellType::BackgroundEvent, HistoryCellType::Assistant],
|
||||
"streaming assistant output should append after the existing background tail cell",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_tool_reasoning_explore_should_preserve_arrival_sequence() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let chat = harness.chat();
|
||||
reset_history(chat);
|
||||
|
||||
chat.last_seen_request_index = 1;
|
||||
|
||||
let make_plain = |text: &str| PlainMessageState {
|
||||
id: HistoryId::ZERO,
|
||||
role: PlainMessageRole::System,
|
||||
kind: PlainMessageKind::Plain,
|
||||
header: None,
|
||||
lines: vec![MessageLine {
|
||||
kind: MessageLineKind::Paragraph,
|
||||
spans: vec![InlineSpan {
|
||||
text: text.to_string(),
|
||||
tone: TextTone::Default,
|
||||
emphasis: TextEmphasis::default(),
|
||||
entity: None,
|
||||
}],
|
||||
}],
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
// Reasoning arrives first with later output index.
|
||||
let reasoning_key = ChatWidget::order_key_from_order_meta(&OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(2),
|
||||
sequence_number: Some(0),
|
||||
});
|
||||
chat.history_insert_plain_state_with_key(make_plain("reasoning"), reasoning_key, "reasoning");
|
||||
|
||||
// Explore summary follows immediately afterwards.
|
||||
let explore_key = ChatWidget::order_key_from_order_meta(&OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(3),
|
||||
sequence_number: Some(0),
|
||||
});
|
||||
chat.history_insert_plain_state_with_key(make_plain("explore"), explore_key, "explore");
|
||||
|
||||
// Tool run summary arrives last but references an earlier output index.
|
||||
let tool_key = ChatWidget::order_key_from_order_meta(&OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(1),
|
||||
sequence_number: Some(0),
|
||||
});
|
||||
chat.history_insert_plain_state_with_key(make_plain("tool"), tool_key, "tool");
|
||||
|
||||
let labels: Vec<String> = chat
|
||||
.history_cells
|
||||
.iter()
|
||||
.map(|cell| {
|
||||
cell.display_lines_trimmed()
|
||||
.first()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec!["reasoning".to_string(), "explore".to_string(), "tool".to_string()],
|
||||
"later inserts with smaller output_index should not leapfrog visible reasoning/explore summaries",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_cross_request_pre_prompt_should_not_prepend_previous_turn() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let chat = harness.chat();
|
||||
reset_history(chat);
|
||||
|
||||
let make_plain = |text: &str| PlainMessageState {
|
||||
id: HistoryId::ZERO,
|
||||
role: PlainMessageRole::System,
|
||||
kind: PlainMessageKind::Plain,
|
||||
header: None,
|
||||
lines: vec![MessageLine {
|
||||
kind: MessageLineKind::Paragraph,
|
||||
spans: vec![InlineSpan {
|
||||
text: text.to_string(),
|
||||
tone: TextTone::Default,
|
||||
emphasis: TextEmphasis::default(),
|
||||
entity: None,
|
||||
}],
|
||||
}],
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
chat.history_insert_plain_state_with_key(
|
||||
make_plain("req1"),
|
||||
OrderKey {
|
||||
req: 1,
|
||||
out: 0,
|
||||
seq: 0,
|
||||
},
|
||||
"req1",
|
||||
);
|
||||
|
||||
chat.last_seen_request_index = 1;
|
||||
chat.pending_user_prompts_for_next_turn = 0;
|
||||
|
||||
let key = chat.system_order_key(SystemPlacement::PrePromptInCurrent, None);
|
||||
chat.history_insert_plain_state_with_key(make_plain("system"), key, "system");
|
||||
|
||||
let labels: Vec<String> = chat
|
||||
.history_cells
|
||||
.iter()
|
||||
.map(|cell| {
|
||||
cell.display_lines_trimmed()
|
||||
.first()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec!["req1".to_string(), "system".to_string()],
|
||||
"pre-prompt system notices for a new request should append after the prior turn rather than prepending it",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
fn append_thought_ellipsis(text: &str) -> String {
|
||||
let trimmed = text.trim_end();
|
||||
if trimmed.ends_with('…') {
|
||||
@@ -23260,6 +23752,7 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
prefix.push(0);
|
||||
let mut acc = 0u16;
|
||||
let content_width = content_area.width.saturating_sub(GUTTER_WIDTH);
|
||||
let mut spacing_ranges: Vec<(u16, u16)> = Vec::new();
|
||||
|
||||
for (idx, vis) in cells.iter().enumerate() {
|
||||
let cell = vis.cell.expect("rendered cell missing source");
|
||||
@@ -23303,7 +23796,10 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
if should_add_spacing {
|
||||
let spacing_start = acc;
|
||||
acc = acc.saturating_add(spacing);
|
||||
// Track the spacer interval so scroll adjustments can skip over it later.
|
||||
spacing_ranges.push((spacing_start, acc));
|
||||
}
|
||||
prefix.push(acc);
|
||||
}
|
||||
@@ -23316,6 +23812,7 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
}
|
||||
self.history_render
|
||||
.update_prefix_cache(content_area.width, prefix, total_height, cells.len());
|
||||
self.history_render.update_spacing_ranges(spacing_ranges);
|
||||
rendered_cells_full = Some(cells);
|
||||
}
|
||||
|
||||
@@ -23362,7 +23859,13 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
(content_area.y, scroll_from_top)
|
||||
// If our scroll origin landed on a spacer row between cells, nudge it up so
|
||||
// the viewport starts with real content instead of an empty separator.
|
||||
let adjusted_scroll_from_top = self
|
||||
.history_render
|
||||
.adjust_scroll_to_content(scroll_from_top);
|
||||
|
||||
(content_area.y, adjusted_scroll_from_top)
|
||||
};
|
||||
|
||||
// Record current viewport height for the next frame
|
||||
|
||||
@@ -37,6 +37,9 @@ pub(crate) struct HistoryRenderState {
|
||||
pub(crate) last_prefix_count: Cell<usize>,
|
||||
pub(crate) last_total_height: Cell<u16>,
|
||||
pub(crate) prefix_valid: Cell<bool>,
|
||||
// Row intervals that correspond to inter-cell spacing so we can avoid
|
||||
// landing the viewport on empty gaps when scrolling.
|
||||
spacing_ranges: RefCell<Vec<(u16, u16)>>,
|
||||
}
|
||||
|
||||
impl HistoryRenderState {
|
||||
@@ -50,6 +53,7 @@ impl HistoryRenderState {
|
||||
last_prefix_count: Cell::new(0),
|
||||
last_total_height: Cell::new(0),
|
||||
prefix_valid: Cell::new(false),
|
||||
spacing_ranges: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +63,7 @@ impl HistoryRenderState {
|
||||
self.prefix_sums.borrow_mut().clear();
|
||||
self.last_total_height.set(0);
|
||||
self.prefix_valid.set(false);
|
||||
self.spacing_ranges.borrow_mut().clear();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_width_change(&self, width: u16) {
|
||||
@@ -73,6 +78,7 @@ impl HistoryRenderState {
|
||||
self.last_total_height.set(0);
|
||||
self.prefix_valid.set(false);
|
||||
self.height_cache_last_width.set(width);
|
||||
self.spacing_ranges.borrow_mut().clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +95,7 @@ impl HistoryRenderState {
|
||||
self.prefix_sums.borrow_mut().clear();
|
||||
self.last_total_height.set(0);
|
||||
self.prefix_valid.set(false);
|
||||
self.spacing_ranges.borrow_mut().clear();
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_all(&self) {
|
||||
@@ -97,6 +104,7 @@ impl HistoryRenderState {
|
||||
self.prefix_sums.borrow_mut().clear();
|
||||
self.last_total_height.set(0);
|
||||
self.prefix_valid.set(false);
|
||||
self.spacing_ranges.borrow_mut().clear();
|
||||
}
|
||||
|
||||
pub(crate) fn should_rebuild_prefix(&self, width: u16, count: usize) -> bool {
|
||||
@@ -129,6 +137,43 @@ impl HistoryRenderState {
|
||||
self.prefix_valid.set(true);
|
||||
}
|
||||
|
||||
pub(crate) fn update_spacing_ranges(&self, ranges: Vec<(u16, u16)>) {
|
||||
*self.spacing_ranges.borrow_mut() = ranges;
|
||||
}
|
||||
|
||||
pub(crate) fn adjust_scroll_to_content(&self, mut scroll_pos: u16) -> u16 {
|
||||
if scroll_pos == 0 {
|
||||
return scroll_pos;
|
||||
}
|
||||
let ranges = self.spacing_ranges.borrow();
|
||||
if ranges.is_empty() {
|
||||
return scroll_pos;
|
||||
}
|
||||
// Walk backwards until we hit a true cell row or run out of history.
|
||||
loop {
|
||||
let mut adjusted = false;
|
||||
for &(start, end) in ranges.iter() {
|
||||
if start == 0 {
|
||||
continue;
|
||||
}
|
||||
if scroll_pos >= start && scroll_pos < end {
|
||||
scroll_pos = start.saturating_sub(1);
|
||||
adjusted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !adjusted || scroll_pos == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
scroll_pos
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn spacing_ranges_for_test(&self) -> Vec<(u16, u16)> {
|
||||
self.spacing_ranges.borrow().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn last_total_height(&self) -> u16 {
|
||||
self.last_total_height.get()
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::tui::TerminalInfo;
|
||||
use code_core::history::state::HistoryRecord;
|
||||
use code_core::config::{Config, ConfigOverrides, ConfigToml};
|
||||
use code_core::protocol::Event;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
static TEST_RUNTIME: Lazy<Runtime> = Lazy::new(|| {
|
||||
@@ -60,7 +62,7 @@ impl ChatWidgetHarness {
|
||||
self.chat.handle_code_event(event);
|
||||
}
|
||||
|
||||
pub fn drain_events(&self) -> Vec<AppEvent> {
|
||||
pub(crate) fn drain_events(&self) -> Vec<AppEvent> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
out.push(ev);
|
||||
@@ -68,9 +70,41 @@ impl ChatWidgetHarness {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn chat(&mut self) -> &mut ChatWidget<'static> {
|
||||
pub(crate) fn chat(&mut self) -> &mut ChatWidget<'static> {
|
||||
&mut self.chat
|
||||
}
|
||||
|
||||
pub(crate) fn poll_until<F>(&mut self, mut predicate: F, timeout: Duration) -> Vec<AppEvent>
|
||||
where
|
||||
F: FnMut(&[AppEvent]) -> bool,
|
||||
{
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut collected = Vec::new();
|
||||
|
||||
loop {
|
||||
while let Ok(event) = self.events.try_recv() {
|
||||
if !matches!(event, AppEvent::RequestRedraw) {
|
||||
collected.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
if predicate(&collected) {
|
||||
break;
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
|
||||
collected
|
||||
}
|
||||
|
||||
pub(crate) fn history_records(&self) -> Vec<HistoryRecord> {
|
||||
self.chat.history_state.records.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_has_insert_history(events: &[AppEvent]) {
|
||||
|
||||
@@ -101,6 +101,94 @@ pub use cli::Cli;
|
||||
pub use self::markdown_render::render_markdown_text;
|
||||
pub use public_widgets::composer_input::{ComposerAction, ComposerInput};
|
||||
|
||||
#[cfg(feature = "test-helpers")]
|
||||
pub mod test_helpers {
|
||||
pub use crate::chatwidget::smoke_helpers::ChatWidgetHarness;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use code_core::history::state::HistoryRecord;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn assert_has_terminal_chunk_containing(
|
||||
harness: &mut ChatWidgetHarness,
|
||||
needle: &str,
|
||||
) {
|
||||
let events = harness.poll_until(
|
||||
|events| {
|
||||
events.iter().any(|event| {
|
||||
match event {
|
||||
AppEvent::TerminalChunk { chunk, .. } => {
|
||||
String::from_utf8_lossy(chunk).contains(needle)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
},
|
||||
Duration::from_millis(200),
|
||||
);
|
||||
crate::chatwidget::smoke_helpers::assert_has_terminal_chunk_containing(&events, needle);
|
||||
}
|
||||
|
||||
pub fn assert_has_background_event_containing(
|
||||
harness: &mut ChatWidgetHarness,
|
||||
needle: &str,
|
||||
) {
|
||||
let events = harness.poll_until(
|
||||
|events| {
|
||||
events.iter().any(|event| {
|
||||
match event {
|
||||
AppEvent::InsertBackgroundEvent { message, .. } => {
|
||||
message.contains(needle)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
},
|
||||
Duration::from_millis(200),
|
||||
);
|
||||
crate::chatwidget::smoke_helpers::assert_has_background_event_containing(&events, needle);
|
||||
}
|
||||
|
||||
pub fn assert_has_codex_event(harness: &mut ChatWidgetHarness) {
|
||||
let events = harness.poll_until(
|
||||
|events| events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::CodexEvent(_))),
|
||||
Duration::from_millis(200),
|
||||
);
|
||||
crate::chatwidget::smoke_helpers::assert_has_codex_event(&events);
|
||||
}
|
||||
|
||||
pub fn assert_has_insert_history(harness: &mut ChatWidgetHarness) {
|
||||
let events = harness.poll_until(
|
||||
|events| {
|
||||
events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
AppEvent::InsertHistory(_)
|
||||
| AppEvent::InsertHistoryWithKind { .. }
|
||||
| AppEvent::InsertFinalAnswer { .. }
|
||||
)
|
||||
})
|
||||
},
|
||||
Duration::from_millis(200),
|
||||
);
|
||||
crate::chatwidget::smoke_helpers::assert_has_insert_history(&events);
|
||||
}
|
||||
|
||||
pub fn assert_no_events(harness: &mut ChatWidgetHarness) {
|
||||
let events = harness.poll_until(
|
||||
|events| !events.is_empty(),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
crate::chatwidget::smoke_helpers::assert_no_events(&events);
|
||||
}
|
||||
|
||||
pub fn history_records(harness: &ChatWidgetHarness) -> Vec<HistoryRecord> {
|
||||
harness.history_records()
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_configured_in_config_file(code_home: &std::path::Path) -> bool {
|
||||
let config_path = code_home.join("config.toml");
|
||||
let Ok(contents) = std::fs::read_to_string(&config_path) else {
|
||||
|
||||
+100
-30
@@ -13,6 +13,7 @@ use code_core::history::state::{
|
||||
HistoryRecord,
|
||||
HistoryState,
|
||||
MessageMetadata,
|
||||
PatchEventType,
|
||||
};
|
||||
use code_core::protocol::{
|
||||
AgentMessageDeltaEvent,
|
||||
@@ -34,14 +35,7 @@ use code_core::protocol::{
|
||||
OrderMeta,
|
||||
TokenUsage,
|
||||
};
|
||||
use code_tui::app_event::AppEvent;
|
||||
use code_tui::chatwidget::smoke_helpers::{
|
||||
assert_has_background_event_containing,
|
||||
assert_has_codex_event,
|
||||
assert_has_insert_history,
|
||||
assert_has_terminal_chunk_containing,
|
||||
ChatWidgetHarness,
|
||||
};
|
||||
use code_tui::test_helpers::ChatWidgetHarness;
|
||||
use code_tui::{Cli, ComposerAction, ComposerInput};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use mcp_types::CallToolResult;
|
||||
@@ -470,7 +464,11 @@ fn smoke_exec_command_stream() {
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
parsed_cmd: Vec::new(),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(0),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
@@ -481,7 +479,11 @@ fn smoke_exec_command_stream() {
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: ByteBuf::from(vec![b'h', b'i', b'\n']),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(1),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
@@ -494,11 +496,29 @@ fn smoke_exec_command_stream() {
|
||||
exit_code: 0,
|
||||
duration: Duration::from_millis(20),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(2),
|
||||
}),
|
||||
});
|
||||
|
||||
let events = harness.drain_events();
|
||||
assert_has_terminal_chunk_containing(&events, "hi");
|
||||
let records = code_tui::test_helpers::history_records(&harness);
|
||||
let exec_record = records
|
||||
.into_iter()
|
||||
.find_map(|record| match record {
|
||||
HistoryRecord::Exec(record) if record.call_id.as_deref() == Some(&call_id) => Some(record),
|
||||
_ => None,
|
||||
})
|
||||
.expect("exec record present");
|
||||
|
||||
assert_eq!(exec_record.exit_code, Some(0), "exec exit code should be success");
|
||||
let stdout = exec_record
|
||||
.stdout_chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.content.as_str())
|
||||
.collect::<String>();
|
||||
assert_eq!(stdout, "hi\n", "stdout should include streamed content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -521,11 +541,36 @@ fn smoke_approval_flow() {
|
||||
reason: Some("Apply change".into()),
|
||||
grant_root: None,
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(1),
|
||||
sequence_number: Some(0),
|
||||
}),
|
||||
});
|
||||
|
||||
let events = harness.drain_events();
|
||||
assert_has_background_event_containing(&events, "sample.txt");
|
||||
let records = code_tui::test_helpers::history_records(&harness);
|
||||
let patch_record = records
|
||||
.into_iter()
|
||||
.find_map(|record| match record {
|
||||
HistoryRecord::Patch(record) => Some(record),
|
||||
_ => None,
|
||||
})
|
||||
.expect("patch approval record present");
|
||||
|
||||
assert!(
|
||||
matches!(patch_record.patch_type, PatchEventType::ApprovalRequest),
|
||||
"expected approval request patch cell"
|
||||
);
|
||||
let change = patch_record
|
||||
.changes
|
||||
.get(&PathBuf::from("sample.txt"))
|
||||
.expect("change for sample.txt present");
|
||||
match change {
|
||||
FileChange::Add { content } => {
|
||||
assert_eq!(content, "Hello world", "patch change should include file contents");
|
||||
}
|
||||
other => panic!("expected FileChange::Add, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -539,7 +584,11 @@ fn smoke_custom_tool_call() {
|
||||
tool_name: "browser_navigate".into(),
|
||||
parameters: Some(json!({ "url": "https://example.com" })),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(1),
|
||||
sequence_number: Some(0),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
@@ -552,11 +601,14 @@ fn smoke_custom_tool_call() {
|
||||
duration: Duration::from_millis(40),
|
||||
result: Ok("ok".into()),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(1),
|
||||
sequence_number: Some(1),
|
||||
}),
|
||||
});
|
||||
|
||||
let events = harness.drain_events();
|
||||
assert_has_codex_event(&events);
|
||||
code_tui::test_helpers::assert_has_codex_event(&mut harness);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -575,7 +627,11 @@ fn smoke_mcp_tool_invocation() {
|
||||
call_id: "mcp-1".into(),
|
||||
invocation: invocation.clone(),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(1),
|
||||
sequence_number: Some(0),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
@@ -588,14 +644,17 @@ fn smoke_mcp_tool_invocation() {
|
||||
result: Ok(CallToolResult {
|
||||
content: Vec::new(),
|
||||
is_error: Some(false),
|
||||
_meta: None,
|
||||
structured_content: None,
|
||||
}),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(1),
|
||||
sequence_number: Some(1),
|
||||
}),
|
||||
});
|
||||
|
||||
let events = harness.drain_events();
|
||||
assert_has_codex_event(&events);
|
||||
code_tui::test_helpers::assert_has_codex_event(&mut harness);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -607,7 +666,11 @@ fn smoke_streaming_assistant_message() {
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "Hello".into(),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(0),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
@@ -616,7 +679,11 @@ fn smoke_streaming_assistant_message() {
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: " world".into(),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(1),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
@@ -625,9 +692,12 @@ fn smoke_streaming_assistant_message() {
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Hello world".into(),
|
||||
}),
|
||||
order: None,
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(2),
|
||||
}),
|
||||
});
|
||||
|
||||
let events = harness.drain_events();
|
||||
assert_has_insert_history(&events);
|
||||
code_tui::test_helpers::assert_has_insert_history(&mut harness);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user