pub(crate) mod estimate;
pub(crate) struct RenderCtx<'a> {
pub(crate) spinner_frame: usize,
pub(crate) streaming: bool,
pub(crate) agent_name: &'a str,
}
pub(crate) const THINKING_PLACEHOLDER: &str = "\u{2026}\u{200B}";
#[derive(Clone)]
pub(crate) enum ChatMessage {
User(String),
Thinking(String),
Text(String),
ToolUseStart {
tool_id: String,
tool_name: String,
partial_input: String,
},
ToolUse {
tool_id: String,
tool_name: String,
input: String,
},
ToolResult {
tool_id: String,
content: String,
elapsed_ms: Option<u64>,
},
Error(String),
System(String),
Event {
source: String,
severity: String,
text: String,
},
}
impl ChatMessage {
pub(crate) fn approx_len(&self) -> usize {
match self {
ChatMessage::User(t)
| ChatMessage::Text(t)
| ChatMessage::Thinking(t)
| ChatMessage::Error(t)
| ChatMessage::System(t) => t.len(),
ChatMessage::ToolUseStart { tool_id, tool_name, partial_input } => {
tool_id.len() + tool_name.len() + partial_input.len()
}
ChatMessage::ToolUse { tool_id, tool_name, input } => tool_id.len() + tool_name.len() + input.len(),
ChatMessage::ToolResult { tool_id, content, .. } => tool_id.len() + content.len(),
ChatMessage::Event { source, severity, text } => source.len() + severity.len() + text.len(),
}
}
pub(crate) fn source_text(&self) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
match self {
ChatMessage::User(t) => Cow::Borrowed(t.as_str()),
ChatMessage::Text(t) => Cow::Borrowed(t.as_str()),
ChatMessage::Thinking(t) if t == THINKING_PLACEHOLDER => Cow::Borrowed(""),
ChatMessage::Thinking(t) => Cow::Borrowed(t.as_str()),
ChatMessage::ToolUseStart { partial_input, .. } => {
Cow::Borrowed(partial_input.as_str())
}
ChatMessage::ToolUse { input, .. } => {
match serde_json::from_str::<serde_json::Value>(input) {
Ok(v) => Cow::Owned(
serde_json::to_string_pretty(&v).unwrap_or_else(|_| input.clone()),
),
Err(_) => Cow::Borrowed(input.as_str()),
}
}
ChatMessage::ToolResult { content, .. } => Cow::Borrowed(content.as_str()),
ChatMessage::Error(t) => Cow::Borrowed(t.as_str()),
ChatMessage::System(t) => Cow::Borrowed(t.as_str()),
ChatMessage::Event { text, .. } => Cow::Borrowed(text.as_str()),
}
}
}
pub(crate) struct TimestampedMsg {
pub(crate) msg: ChatMessage,
pub(crate) time: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum LineMeta {
Chrome,
Content {
range: std::ops::Range<usize>,
content_col: u16,
},
ContentLine { msg_idx: usize, src_line: usize },
}
fn source_line_range(source: &str, idx: usize) -> std::ops::Range<usize> {
let base = source.as_ptr() as usize;
for (i, l) in source.lines().enumerate() {
if i == idx {
let start = l.as_ptr() as usize - base;
return start..start + l.len();
}
}
source.len()..source.len()
}
#[allow(dead_code)] #[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum HeightState {
Exact(usize),
Estimated(usize),
}
impl HeightState {
#[inline]
pub(crate) fn value(&self) -> usize {
match self {
HeightState::Exact(n) | HeightState::Estimated(n) => *n,
}
}
#[allow(dead_code)] #[inline]
pub(crate) fn is_exact(&self) -> bool {
matches!(self, HeightState::Exact(_))
}
}
pub(crate) struct MsgSlot {
pub(crate) lines: Option<Vec<ratatui::text::Line<'static>>>,
pub(crate) meta: Option<Vec<LineMeta>>,
pub(crate) height: HeightState,
}
impl MsgSlot {
#[inline]
pub(crate) fn height(&self) -> usize {
self.height.value()
}
#[track_caller]
pub(crate) fn lines(&self) -> &[ratatui::text::Line<'static>] {
self.lines
.as_deref()
.expect("MsgSlot demoted -- promote (re-render) before reading lines")
}
#[inline]
pub(crate) fn meta_slice(&self) -> &[LineMeta] {
self.meta.as_deref().unwrap_or(&[])
}
}
pub(crate) struct LineCache {
pub(crate) width: usize,
pub(crate) per_msg: Vec<MsgSlot>,
pub(crate) cum_heights: Vec<usize>,
}
impl LineCache {
pub(crate) fn new(width: usize, per_msg: Vec<MsgSlot>) -> Self {
let mut cache = LineCache {
width,
per_msg,
cum_heights: Vec::new(),
};
cache.rebuild_cum_from(0);
cache
}
pub(crate) fn rebuild_cum_from(&mut self, k: usize) -> usize {
let k = k
.min(self.per_msg.len())
.min(self.cum_heights.len().saturating_sub(1));
self.cum_heights.truncate(k + 1);
if self.cum_heights.is_empty() {
debug_assert_eq!(k, 0);
self.cum_heights.push(0);
}
let mut acc = self.cum_heights[k];
let mut written = 0usize;
for slot in &self.per_msg[k..] {
acc += slot.height();
self.cum_heights.push(acc);
written += 1;
}
written
}
pub(crate) fn total_height(&self) -> usize {
*self.cum_heights.last().unwrap_or(&0)
}
}
#[cfg(any(test, feature = "testing"))]
#[derive(Default)]
pub(crate) struct PerfProbe {
pub(crate) renders: std::sync::atomic::AtomicUsize,
pub(crate) cum_writes: std::sync::atomic::AtomicUsize,
}
pub(crate) enum CacheState {
Missing,
Clean(LineCache),
Dirty(LineCache, usize),
}
impl CacheState {
pub(crate) fn line_cache(&self) -> Option<&LineCache> {
match self {
CacheState::Missing => None,
CacheState::Clean(c) | CacheState::Dirty(c, _) => Some(c),
}
}
pub(crate) fn line_cache_mut(&mut self) -> Option<&mut LineCache> {
match self {
CacheState::Missing => None,
CacheState::Clean(c) | CacheState::Dirty(c, _) => Some(c),
}
}
#[cfg(test)]
pub(crate) fn dirty_from(&self) -> Option<usize> {
match self {
CacheState::Dirty(_, k) => Some(*k),
_ => None,
}
}
#[cfg(test)]
#[allow(dead_code)] pub(crate) fn mark_clean(&mut self) {
if matches!(self, CacheState::Dirty(..)) {
let CacheState::Dirty(c, _) = std::mem::replace(self, CacheState::Missing) else {
unreachable!()
};
*self = CacheState::Clean(c);
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ScrollAnchor {
pub(crate) msg_idx: usize,
pub(crate) row_in_msg: usize,
}
pub(crate) const SCROLLBACK_HYSTERESIS_MSGS: usize = 64;
pub(crate) const SCROLLBACK_HYSTERESIS_BYTES: usize = 256 * 1024;
pub(crate) const SCROLLBACK_SENTINEL_PREFIX: &str = "… ";
pub(crate) const SCROLLBACK_SENTINEL_MARK: &str = "(scrollback cap ";
pub(crate) struct TranscriptStore {
messages: Vec<TimestampedMsg>,
text_sealed: bool,
response_start: Option<usize>,
scroll_back: u16,
scroll_pinned: bool,
last_line_count: usize,
scroll_anchor: Option<ScrollAnchor>,
cache: CacheState,
show_full_output: bool,
max_msgs: usize,
max_bytes: usize,
pushes_since_audit: usize,
bytes_since_audit: usize,
scrollback_dropped: usize,
tool_start_time: Option<std::time::Instant>,
tool_start_times: std::collections::HashMap<String, std::time::Instant>,
viewport: Option<ratatui::layout::Rect>,
visible_range: Option<(usize, usize)>,
selection_anchor: Option<SelPos>,
selection_end: Option<SelPos>,
clock: super::clock::TuiClock,
#[cfg(any(test, feature = "testing"))]
probe: PerfProbe,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct SelPos {
msg_idx: usize,
line_in_msg: usize,
col: u16,
src_byte: Option<usize>,
}
pub(crate) struct VisibleWindow {
pub(crate) lines: std::sync::Arc<[ratatui::text::Line<'static>]>,
pub(crate) lines_width: usize,
pub(crate) scroll_back: u16,
pub(crate) selection: Option<(u16, u16, u16, u16)>,
pub(crate) is_empty: bool,
}
impl TranscriptStore {
pub(crate) fn new(clock: super::clock::TuiClock) -> Self {
Self {
clock,
messages: Vec::new(),
text_sealed: false,
response_start: None,
scroll_back: 0,
scroll_pinned: true,
last_line_count: 0,
scroll_anchor: None,
cache: CacheState::Missing,
show_full_output: false,
max_msgs: 0,
max_bytes: 0,
pushes_since_audit: 0,
bytes_since_audit: 0,
scrollback_dropped: 0,
tool_start_time: None,
tool_start_times: std::collections::HashMap::new(),
viewport: None,
visible_range: None,
selection_anchor: None,
selection_end: None,
#[cfg(any(test, feature = "testing"))]
probe: PerfProbe::default(),
}
}
pub(crate) fn set_scrollback(&mut self, msgs: usize, bytes: usize) {
self.max_msgs = msgs;
self.max_bytes = bytes;
}
#[allow(dead_code)]
pub(crate) fn scrollback_dropped(&self) -> usize {
self.scrollback_dropped
}
fn source_bytes(&self) -> usize {
self.messages.iter().map(|m| m.msg.approx_len()).sum()
}
fn enforce_scrollback(&mut self, pushed: usize) {
if self.max_msgs == 0 && self.max_bytes == 0 {
return;
}
self.pushes_since_audit += 1;
self.bytes_since_audit += pushed;
let over_msgs = self.max_msgs > 0 && self.messages.len() > self.max_msgs + SCROLLBACK_HYSTERESIS_MSGS;
let audit_due = self.max_bytes > 0
&& (self.pushes_since_audit >= SCROLLBACK_HYSTERESIS_MSGS
|| self.bytes_since_audit >= SCROLLBACK_HYSTERESIS_BYTES);
if !over_msgs && !audit_due {
return;
}
self.pushes_since_audit = 0;
self.bytes_since_audit = 0;
let mut drop = 0usize;
if self.max_msgs > 0 && self.messages.len() > self.max_msgs {
drop = self.messages.len() - self.max_msgs;
}
if self.max_bytes > 0 {
let total = self.source_bytes();
if total > self.max_bytes + SCROLLBACK_HYSTERESIS_BYTES {
let mut running = total;
let mut i = 0;
while running > self.max_bytes && i < self.messages.len() {
running -= self.messages[i].msg.approx_len();
i += 1;
}
drop = drop.max(i);
}
}
let drop = drop.min(self.messages.len().saturating_sub(1));
if drop == 0 {
return;
}
self.drain_front(drop);
}
fn drain_front(&mut self, n: usize) {
let had_sentinel = matches!(
self.messages.first().map(|m| &m.msg),
Some(ChatMessage::System(t)) if t.starts_with(SCROLLBACK_SENTINEL_PREFIX) && t.contains(SCROLLBACK_SENTINEL_MARK)
);
let n = if had_sentinel { n.max(1) } else { n };
let dropped_real = if had_sentinel { n - 1 } else { n };
self.messages.drain(0..n);
self.scrollback_dropped += dropped_real;
let sentinel = TimestampedMsg {
msg: ChatMessage::System(format!(
"{SCROLLBACK_SENTINEL_PREFIX}{} earlier message(s) hidden {SCROLLBACK_SENTINEL_MARK}{}; /resync reloads the last {} user/assistant items from the daemon, without tool output)",
self.scrollback_dropped, self.max_msgs, agent_engine::session::types::tail_items_from_env()
)),
time: chrono::Local::now().format("%H:%M").to_string(),
};
self.messages.insert(0, sentinel);
self.response_start = self.response_start.and_then(|s| (s >= n).then(|| s - n + 1));
self.scroll_anchor = match self.scroll_anchor.take() {
Some(a) if a.msg_idx >= n => Some(ScrollAnchor { msg_idx: a.msg_idx - n + 1, row_in_msg: a.row_in_msg }),
_ => None,
};
self.clear_selection();
self.cache = CacheState::Missing;
}
#[allow(dead_code)]
pub(crate) fn scrollback(&self) -> (usize, usize) {
(self.max_msgs, self.max_bytes)
}
#[cfg(any(test, feature = "testing"))]
pub(crate) fn probe_render_count(&self) -> usize {
self.probe
.renders
.load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(any(test, feature = "testing"))]
pub(crate) fn probe_cum_write_count(&self) -> usize {
self.probe
.cum_writes
.load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(any(test, feature = "testing"))]
pub(crate) fn probe_reset(&self) {
self.probe
.renders
.store(0, std::sync::atomic::Ordering::Relaxed);
self.probe
.cum_writes
.store(0, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(any(test, feature = "testing"))]
pub(crate) fn probe_note_render(&self) {
self.probe
.renders
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
#[inline]
pub(crate) fn scroll_back_to_stop(
scroll_back: usize,
total_height: usize,
content_height: usize,
) -> usize {
total_height
.saturating_sub(scroll_back)
.saturating_sub(content_height)
}
#[allow(dead_code)] #[inline]
pub(crate) fn stop_to_scroll_back(
s_top: usize,
total_height: usize,
content_height: usize,
) -> usize {
let max_back = total_height.saturating_sub(content_height);
total_height
.saturating_sub(content_height)
.saturating_sub(s_top)
.min(max_back)
}
pub(crate) fn capture_anchor(
cache: &LineCache,
scroll_back: usize,
content_height: usize,
) -> Option<ScrollAnchor> {
if cache.per_msg.is_empty() || scroll_back == 0 {
return None;
}
let total = cache.total_height();
let s_top = Self::scroll_back_to_stop(scroll_back, total, content_height);
let k = cache
.cum_heights
.partition_point(|&c| c <= s_top)
.saturating_sub(1)
.min(cache.per_msg.len() - 1);
let row_in_msg = s_top.saturating_sub(cache.cum_heights[k]);
let h = cache.per_msg[k].height().max(1);
let row_in_msg = row_in_msg.min(h - 1);
Some(ScrollAnchor {
msg_idx: k,
row_in_msg,
})
}
#[allow(dead_code)] pub(crate) fn anchor_to_scroll_back(
anchor: &ScrollAnchor,
cache: &LineCache,
content_height: usize,
) -> usize {
if cache.per_msg.is_empty() || content_height == 0 {
return 0;
}
let total = cache.total_height();
if total == 0 {
return 0;
}
let msg_idx = anchor.msg_idx.min(cache.per_msg.len() - 1);
let h = cache.per_msg[msg_idx].height().max(1);
let row_in_msg = anchor.row_in_msg.min(h - 1);
let s_top = cache.cum_heights[msg_idx].saturating_add(row_in_msg);
Self::stop_to_scroll_back(s_top, total, content_height)
}
#[allow(dead_code)] #[inline]
pub(crate) fn clamp_scroll_back(
scroll_back: usize,
total_height: usize,
content_height: usize,
) -> usize {
total_height.saturating_sub(content_height).min(scroll_back)
}
fn sync_anchor_from_scroll_back(&mut self) {
if self.scroll_back == 0 {
self.scroll_anchor = None;
return;
}
let Some(cache) = self.cache.line_cache() else {
self.scroll_anchor = None;
return;
};
let content_height = self.viewport.map(|r| r.height as usize).unwrap_or(40);
self.scroll_anchor = Self::capture_anchor(cache, self.scroll_back as usize, content_height);
}
pub(crate) fn scroll_up(&mut self, lines: u16) {
self.scroll_back = self.scroll_back.saturating_add(lines);
self.scroll_pinned = false;
self.sync_anchor_from_scroll_back();
}
pub(crate) fn scroll_down(&mut self, lines: u16) {
self.scroll_back = self.scroll_back.saturating_sub(lines);
if self.scroll_back == 0 {
self.scroll_pinned = true;
}
self.sync_anchor_from_scroll_back();
}
pub(crate) fn scroll_to_bottom(&mut self) {
self.scroll_back = 0;
self.scroll_pinned = true;
self.scroll_anchor = None;
}
pub(crate) fn push_msg(&mut self, msg: ChatMessage) {
self.messages.push(TimestampedMsg {
msg,
time: chrono::Local::now().format("%H:%M").to_string(),
});
if self.scroll_pinned {
self.scroll_back = 0;
}
self.invalidate_last();
let pushed = self.messages.last().map(|m| m.msg.approx_len()).unwrap_or(0);
self.enforce_scrollback(pushed);
}
pub(crate) fn push_tool_result(
&mut self,
tool_id: String,
content: String,
elapsed_ms: Option<u64>,
) {
let use_idx = if tool_id.is_empty() {
None
} else {
debug_assert!(
self.messages
.iter()
.filter(|m| matches!(
&m.msg,
ChatMessage::ToolUse { tool_id: tid, .. }
| ChatMessage::ToolUseStart { tool_id: tid, .. }
if tid == &tool_id
))
.count()
<= 1,
"push_tool_result: duplicate ToolUse/ToolUseStart for tool_id={tool_id:?}"
);
self.messages.iter().position(|m| {
matches!(
&m.msg,
ChatMessage::ToolUse { tool_id: tid, .. }
| ChatMessage::ToolUseStart { tool_id: tid, .. }
if tid == &tool_id
)
})
};
let msg = ChatMessage::ToolResult {
tool_id,
content,
elapsed_ms,
};
let pushed = msg.approx_len();
match use_idx {
Some(i) => {
let at = (i + 1).min(self.messages.len());
self.messages.insert(
at,
TimestampedMsg {
msg,
time: chrono::Local::now().format("%H:%M").to_string(),
},
);
if self.scroll_pinned {
self.scroll_back = 0;
}
self.selection_remap_insert(at);
self.invalidate_watermark(i);
self.enforce_scrollback(pushed);
}
None => self.push_msg(msg),
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn cap_resumed_display(&mut self, cap: usize) {
if self.messages.len() <= cap {
return;
}
let omitted = self.messages.len() - cap;
self.messages.drain(0..omitted);
self.messages.insert(
0,
TimestampedMsg {
msg: ChatMessage::System(format!(
"… {omitted} earlier message(s) hidden to speed resume — full history is still in the model's context"
)),
time: chrono::Local::now().format("%H:%M").to_string(),
},
);
}
pub(crate) fn invalidate(&mut self) {
self.selection_on_invalidate(0);
self.cache = CacheState::Missing;
}
pub(crate) fn invalidate_from(&mut self, idx: usize) {
self.selection_on_invalidate(idx);
self.invalidate_watermark(idx);
}
fn invalidate_watermark(&mut self, idx: usize) {
self.cache = match std::mem::replace(&mut self.cache, CacheState::Missing) {
CacheState::Missing => CacheState::Missing,
CacheState::Clean(c) => CacheState::Dirty(c, idx),
CacheState::Dirty(c, k) => CacheState::Dirty(c, k.min(idx)),
};
}
fn selection_on_invalidate(&mut self, k: usize) {
let max_idx = match (&self.selection_anchor, &self.selection_end) {
(Some(a), Some(e)) => a.msg_idx.max(e.msg_idx),
(Some(a), None) => a.msg_idx,
_ => return,
};
if k <= max_idx {
self.clear_selection();
}
}
fn selection_remap_insert(&mut self, at: usize) {
let (min_idx, max_idx) = match (&self.selection_anchor, &self.selection_end) {
(Some(a), Some(e)) => (a.msg_idx.min(e.msg_idx), a.msg_idx.max(e.msg_idx)),
(Some(a), None) => (a.msg_idx, a.msg_idx),
_ => return,
};
if at > max_idx {
} else if at <= min_idx {
for p in [&mut self.selection_anchor, &mut self.selection_end] {
if let Some(p) = p.as_mut() {
p.msg_idx += 1;
}
}
} else {
self.clear_selection();
}
}
pub(crate) fn invalidate_last(&mut self) {
self.invalidate_from(self.messages.len().saturating_sub(1));
}
pub(crate) fn line_cache(&self) -> Option<&LineCache> {
self.cache.line_cache()
}
pub(crate) fn sync_cache(&mut self, content_width: usize, ctx: &RenderCtx<'_>) {
let needs_full_rebuild = self.line_cache().map_or(true, |c| c.width != content_width);
if self.line_cache().is_some_and(|c| c.width != content_width) {
self.clear_selection();
}
if needs_full_rebuild {
let per_msg: Vec<MsgSlot> = self
.messages
.iter()
.map(|m| MsgSlot {
lines: None,
meta: None,
height: HeightState::Estimated(estimate::estimate_message_height(
&m.msg,
content_width,
)),
})
.collect();
let cache = LineCache::new(content_width, per_msg);
#[cfg(any(test, feature = "testing"))]
self.probe.cum_writes.fetch_add(
cache.cum_heights.len(),
std::sync::atomic::Ordering::Relaxed,
);
self.cache = CacheState::Clean(cache);
} else if let CacheState::Dirty(cache, k) = &self.cache {
let k = *k;
let n = self.messages.len();
let needs_resize = cache.per_msg.len() != n;
let fresh: Vec<MsgSlot> = (k..n)
.map(|i| self.render_message_lines(i, content_width, ctx))
.collect();
let CacheState::Dirty(mut cache, _) =
std::mem::replace(&mut self.cache, CacheState::Missing)
else {
unreachable!("cache must be Dirty here")
};
if needs_resize {
cache.per_msg.truncate(k);
cache.per_msg.extend(fresh);
} else {
for (offset, rendered) in fresh.into_iter().enumerate() {
cache.per_msg[k + offset] = rendered;
}
}
let _cum_written = cache.rebuild_cum_from(k);
#[cfg(any(test, feature = "testing"))]
self.probe
.cum_writes
.fetch_add(_cum_written, std::sync::atomic::Ordering::Relaxed);
self.cache = CacheState::Clean(cache);
}
if matches!(self.cache, CacheState::Missing) {
let per_msg: Vec<MsgSlot> = (0..self.messages.len())
.map(|i| self.render_message_lines(i, content_width, ctx))
.collect();
self.cache = CacheState::Clean(LineCache::new(content_width, per_msg));
}
self.debug_assert_cum_identity();
}
fn debug_assert_cum_identity(&self) {
#[cfg(debug_assertions)]
if let Some(c) = self.line_cache() {
debug_assert_eq!(
c.cum_heights.len(),
c.per_msg.len() + 1,
"cum_heights must have per_msg.len()+1 entries"
);
debug_assert_eq!(c.cum_heights.first(), Some(&0));
let mut acc = 0usize;
for (i, slot) in c.per_msg.iter().enumerate() {
if let Some(lines) = &slot.lines {
debug_assert_eq!(
lines.len(),
slot.height(),
"slot {i}: meta must stay parallel to rendered rows"
);
}
acc += slot.height();
debug_assert_eq!(
c.cum_heights[i + 1],
acc,
"cum_heights[{}] inconsistent with slot heights",
i + 1
);
}
}
}
pub(crate) fn visible_window(
&mut self,
msg_area: ratatui::layout::Rect,
ctx: &RenderCtx<'_>,
) -> VisibleWindow {
let content_height = msg_area.height.saturating_sub(2) as usize;
let content_width = msg_area.width.saturating_sub(2) as usize;
self.sync_cache(content_width, ctx);
let total = self.line_cache().map_or(0, |c| c.total_height());
if self.scroll_pinned {
self.scroll_back = 0;
} else {
let prev = self.last_line_count;
if total > prev && prev > 0 {
let growth = (total - prev) as u16;
self.scroll_back = self.scroll_back.saturating_add(growth);
}
let max_back = total.saturating_sub(content_height).min(u16::MAX as usize) as u16;
if self.scroll_back > max_back {
self.scroll_back = max_back;
}
}
self.last_line_count = total;
if self.scroll_back == 0 {
self.scroll_anchor = None;
} else if let Some(cache) = self.cache.line_cache() {
self.scroll_anchor =
Self::capture_anchor(cache, self.scroll_back as usize, content_height);
}
self.promote_window(content_height, ctx);
let total = self.line_cache().map_or(0, |c| c.total_height());
let scroll_back = self.scroll_back;
let end = total.saturating_sub(scroll_back as usize);
let start = end.saturating_sub(content_height);
let msg_inner = ratatui::layout::Rect {
x: msg_area.x + 1,
y: msg_area.y + 1,
width: msg_area.width.saturating_sub(2),
height: msg_area.height.saturating_sub(2),
};
self.viewport = Some(msg_inner);
self.visible_range = Some((start, end));
let to_promote: Vec<usize> = self
.line_cache()
.and_then(|c| {
Self::window_msg_range(c, start, end).map(|(f, l)| {
(f..=l)
.filter(|&mi| c.per_msg[mi].lines.is_none())
.collect()
})
})
.unwrap_or_default();
for mi in to_promote {
self.promote_slot(mi, content_width, ctx);
}
let assembled = self.assemble_window(start, end);
let lines: std::sync::Arc<[ratatui::text::Line<'static>]> = assembled.into();
if let Some(cache) = self.cache.line_cache_mut() {
let n = cache.per_msg.len();
if n > 0 && total > 0 {
let halo = content_height.max(1);
let keep_lo = start.saturating_sub(halo);
let keep_hi = (end + halo).min(total).max(keep_lo + 1);
let (kf, kl) =
Self::window_msg_range(cache, keep_lo, keep_hi).unwrap_or((0, n - 1));
let keep_first = kf.saturating_sub(1);
let keep_last = (kl + 1).min(n - 1);
for (mi, slot) in cache.per_msg.iter_mut().enumerate() {
if mi < keep_first || mi > keep_last {
slot.lines = None;
}
}
}
}
VisibleWindow {
lines,
lines_width: content_width,
scroll_back,
selection: self.selection_range(), is_empty: self.messages.is_empty(),
}
}
const PROMOTE_HALO_MSGS: usize = 16;
fn promote_window(&mut self, content_height: usize, ctx: &RenderCtx<'_>) {
loop {
let Some(cache) = self.cache.line_cache() else {
return;
};
let n = cache.per_msg.len();
let total = cache.total_height();
if n == 0 || total == 0 || content_height == 0 {
return;
}
let width = cache.width;
let max_top = total.saturating_sub(content_height);
let s_top = if self.scroll_back == 0 {
max_top
} else if let Some(anchor) = &self.scroll_anchor {
let mi = anchor.msg_idx.min(n - 1);
let h = cache.per_msg[mi].height().max(1);
(cache.cum_heights[mi] + anchor.row_in_msg.min(h - 1)).min(max_top)
} else {
Self::scroll_back_to_stop(self.scroll_back as usize, total, content_height)
};
let Some((first, last)) = Self::window_msg_range(cache, s_top, s_top + content_height)
else {
return;
};
let lo = first.saturating_sub(Self::PROMOTE_HALO_MSGS);
let hi = (last + Self::PROMOTE_HALO_MSGS).min(n - 1);
let to_promote: Vec<usize> = (lo..=hi)
.filter(|&mi| !cache.per_msg[mi].height.is_exact())
.collect();
if to_promote.is_empty() {
return; }
let fresh: Vec<(usize, MsgSlot)> = to_promote
.iter()
.map(|&mi| (mi, self.render_message_lines(mi, width, ctx)))
.collect();
let mut min_corrected: Option<usize> = None;
{
let cache = self
.cache
.line_cache_mut()
.expect("cache existed at loop head");
for (mi, slot) in fresh {
if slot.height() != cache.per_msg[mi].height() {
min_corrected = Some(min_corrected.map_or(mi, |m: usize| m.min(mi)));
}
cache.per_msg[mi] = slot;
}
}
if let Some(k) = min_corrected {
let _cum_written = self
.cache
.line_cache_mut()
.expect("cache existed at loop head")
.rebuild_cum_from(k);
#[cfg(any(test, feature = "testing"))]
self.probe
.cum_writes
.fetch_add(_cum_written, std::sync::atomic::Ordering::Relaxed);
let cache = self.cache.line_cache().expect("cache existed at loop head");
let new_total = cache.total_height();
if self.scroll_back != 0 {
let sb = match &self.scroll_anchor {
Some(anchor) => Self::anchor_to_scroll_back(anchor, cache, content_height),
None => Self::clamp_scroll_back(
self.scroll_back as usize,
new_total,
content_height,
),
};
self.scroll_back = sb.min(u16::MAX as usize) as u16;
if self.scroll_back == 0 {
self.scroll_anchor = None;
}
}
self.last_line_count = new_total;
}
}
}
fn window_msg_range(cache: &LineCache, start: usize, end: usize) -> Option<(usize, usize)> {
if start >= end || cache.per_msg.is_empty() {
return None;
}
let first = cache
.cum_heights
.partition_point(|&c| c <= start)
.saturating_sub(1);
let last = cache
.cum_heights
.partition_point(|&c| c < end)
.saturating_sub(1);
Some((first, last))
}
fn assemble_window(&self, start: usize, end: usize) -> Vec<ratatui::text::Line<'static>> {
let Some(cache) = self.line_cache() else {
return Vec::new();
};
let Some((first, last)) = Self::window_msg_range(cache, start, end) else {
return Vec::new();
};
let mut out = Vec::with_capacity(end - start);
for mi in first..=last {
let slot = &cache.per_msg[mi];
let base = cache.cum_heights[mi];
let lo = start.saturating_sub(base);
let hi = (end - base).min(slot.height());
out.extend(slot.lines()[lo..hi].iter().cloned());
}
out
}
fn promote_slot(&mut self, msg_idx: usize, width: usize, ctx: &RenderCtx<'_>) {
let needs = self
.line_cache()
.and_then(|c| c.per_msg.get(msg_idx))
.is_some_and(|s| s.lines.is_none());
if !needs {
return;
}
let fresh = self.render_message_lines(msg_idx, width, ctx);
if let Some(cache) = self.cache.line_cache_mut() {
if let Some(slot) = cache.per_msg.get_mut(msg_idx) {
let was_exact = slot.height.is_exact();
if was_exact {
debug_assert_eq!(
fresh.height(),
slot.height(),
"promoted slot {msg_idx} must re-render to its measured height"
);
}
let corrected = fresh.height() != slot.height();
*slot = fresh;
if !was_exact && corrected {
let _cum_written = cache.rebuild_cum_from(msg_idx);
#[cfg(any(test, feature = "testing"))]
self.probe
.cum_writes
.fetch_add(_cum_written, std::sync::atomic::Ordering::Relaxed);
}
}
}
}
fn flat_index_to_content(&self, flat_idx: usize) -> Option<(usize, usize)> {
let cache = self.line_cache()?;
let mut off = flat_idx;
for (mi, slot) in cache.per_msg.iter().enumerate() {
if off < slot.height() {
return Some((mi, off));
}
off -= slot.height();
}
None
}
fn content_to_flat_index(&self, msg_idx: usize, line_in_msg: usize) -> Option<usize> {
let cache = self.line_cache()?;
let slot = cache.per_msg.get(msg_idx)?;
if line_in_msg >= slot.height() {
return None;
}
let prefix: usize = cache.per_msg[..msg_idx].iter().map(|e| e.height()).sum();
Some(prefix + line_in_msg)
}
fn map_event_to_selpos(&self, col: u16, row: u16) -> Option<SelPos> {
let rect = self.viewport?;
if rect.width == 0 {
return None;
}
let (msg_idx, line_in_msg) = self.event_row_to_content(row)?;
let col = col.saturating_sub(rect.x).min(rect.width - 1);
let src_byte = self.resolve_src_byte(msg_idx, line_in_msg, col);
Some(SelPos {
msg_idx,
line_in_msg,
col,
src_byte,
})
}
fn event_row_to_content(&self, row: u16) -> Option<(usize, usize)> {
let rect = self.viewport?;
let total = self.line_cache()?.total_height();
if total == 0 || rect.width == 0 {
return None;
}
let content_height = rect.height as usize;
let end = total - (self.scroll_back as usize).min(total.saturating_sub(1));
let start = end.saturating_sub(content_height);
let row_off = row.saturating_sub(rect.y) as usize;
let flat_idx = (start + row_off).min(end - 1);
self.flat_index_to_content(flat_idx)
}
fn promote_for_event(&mut self, row: u16, ctx: &RenderCtx<'_>) {
let Some((msg_idx, _)) = self.event_row_to_content(row) else {
return;
};
let Some(width) = self.line_cache().map(|c| c.width) else {
return;
};
self.promote_slot(msg_idx, width, ctx);
}
fn resolve_src_byte(&self, msg_idx: usize, line_in_msg: usize, col: u16) -> Option<usize> {
use super::text_metrics::char_width;
let entry = self.line_cache()?.per_msg.get(msg_idx)?;
let LineMeta::Content { range, content_col } = entry.meta_slice().get(line_in_msg)? else {
return None;
};
let lines = entry.lines.as_ref()?;
let text: String = lines[line_in_msg]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let mut byte = 0usize;
let mut w: u16 = 0;
for ch in text.chars() {
if w >= *content_col {
break;
}
w += char_width(ch) as u16;
byte += ch.len_utf8();
}
if w != *content_col {
return None; }
let slice = text.get(byte..byte + range.len())?;
let mut off = 0usize;
let mut cur = *content_col;
for ch in slice.chars() {
let cw = char_width(ch) as u16;
if cur + cw > col {
break;
}
cur += cw;
off += ch.len_utf8();
}
Some(range.start + off)
}
pub(crate) fn selection_begin(&mut self, col: u16, row: u16, ctx: &RenderCtx<'_>) {
self.promote_for_event(row, ctx);
self.selection_anchor = self.map_event_to_selpos(col, row);
self.selection_end = None;
}
pub(crate) fn selection_drag(&mut self, col: u16, row: u16, ctx: &RenderCtx<'_>) {
if self.selection_anchor.is_some() {
self.promote_for_event(row, ctx);
if let Some(pos) = self.map_event_to_selpos(col, row) {
self.selection_end = Some(pos);
}
}
}
pub(crate) fn selection_release(&mut self, col: u16, row: u16, ctx: &RenderCtx<'_>) {
if let Some(anchor) = self.selection_anchor.clone() {
self.promote_for_event(row, ctx);
let Some(end) = self.map_event_to_selpos(col, row) else {
return;
};
if anchor == end {
self.clear_selection();
} else {
self.selection_end = Some(end);
}
}
}
pub(crate) fn has_selection(&self) -> bool {
self.selection_anchor.is_some() && self.selection_end.is_some()
}
pub(crate) fn clear_selection(&mut self) {
self.selection_anchor = None;
self.selection_end = None;
}
pub(crate) fn selection_range(&self) -> Option<(u16, u16, u16, u16)> {
let a = self.selection_anchor.as_ref()?;
let b = self.selection_end.as_ref()?;
let (s, e) = if (a.msg_idx, a.line_in_msg, a.col) <= (b.msg_idx, b.line_in_msg, b.col) {
(a, b)
} else {
(b, a)
};
let rect = self.viewport?;
let (vis_start, vis_end) = self.visible_range?;
if vis_end <= vis_start || rect.width == 0 {
return None;
}
let sf = self.content_to_flat_index(s.msg_idx, s.line_in_msg)?;
let ef = self.content_to_flat_index(e.msg_idx, e.line_in_msg)?;
if ef < vis_start || sf >= vis_end {
return None;
}
let max_col = rect.width - 1;
let (sf, sc) = if sf < vis_start {
(vis_start, 0)
} else {
(sf, s.col.min(max_col))
};
let (ef, ec) = if ef >= vis_end {
(vis_end - 1, max_col)
} else {
(ef, e.col.min(max_col))
};
Some((
rect.x + sc,
rect.y + (sf - vis_start) as u16,
rect.x + ec,
rect.y + (ef - vis_start) as u16,
))
}
pub(crate) fn hit_test(&self, col: u16, row: u16) -> bool {
if let Some(rect) = self.viewport {
col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
} else {
false
}
}
fn promote_range_for_copy(&mut self, lo: usize, hi: usize) {
let width = match self.cache.line_cache() {
Some(c) => c.width,
None => return,
};
let to_promote: Vec<usize> = match self.cache.line_cache() {
Some(c) => (lo..=hi.min(c.per_msg.len().saturating_sub(1)))
.filter(|&mi| !c.per_msg[mi].height.is_exact())
.collect(),
None => return,
};
let ctx = RenderCtx {
spinner_frame: 0,
streaming: false,
agent_name: "",
};
for mi in to_promote {
self.promote_slot(mi, width, &ctx);
}
}
pub(crate) fn selected_text(&mut self) -> Option<String> {
let (s_msg, s_line, s_col, s_src, e_msg, e_line, e_col, e_src) = {
let a = self.selection_anchor.as_ref()?;
let b = self.selection_end.as_ref()?;
if (a.msg_idx, a.line_in_msg, a.col) <= (b.msg_idx, b.line_in_msg, b.col) {
(
a.msg_idx,
a.line_in_msg,
a.col,
a.src_byte,
b.msg_idx,
b.line_in_msg,
b.col,
b.src_byte,
)
} else {
(
b.msg_idx,
b.line_in_msg,
b.col,
b.src_byte,
a.msg_idx,
a.line_in_msg,
a.col,
a.src_byte,
)
}
};
self.promote_range_for_copy(s_msg, e_msg);
let _ = self.line_cache()?; let s = SelPos {
msg_idx: s_msg,
line_in_msg: s_line,
col: s_col,
src_byte: s_src,
};
let e = SelPos {
msg_idx: e_msg,
line_in_msg: e_line,
col: e_col,
src_byte: e_src,
};
let cache = self.line_cache()?;
let mut parts: Vec<String> = Vec::new();
for mi in s.msg_idx..=e.msg_idx {
let Some(entry) = cache.per_msg.get(mi) else {
continue;
};
let source = self.source_text(mi);
let src: &str = &source;
if src.is_empty() || entry.meta_slice().is_empty() {
continue;
}
if mi != s.msg_idx && mi != e.msg_idx {
parts.push(src.to_string());
continue;
}
let last_row = entry.meta_slice().len().saturating_sub(1);
let row_lo = if mi == s.msg_idx {
s.line_in_msg.min(last_row)
} else {
0
};
let row_hi = if mi == e.msg_idx {
e.line_in_msg.min(last_row)
} else {
last_row
};
if row_lo > row_hi {
continue;
}
let is_content = |r: usize| !matches!(entry.meta_slice()[r], LineMeta::Chrome);
let Some(first) = (row_lo..=row_hi).find(|&r| is_content(r)) else {
continue; };
let last = (row_lo..=row_hi)
.rev()
.find(|&r| is_content(r))
.unwrap_or(first);
let last_content_in_msg = entry
.meta_slice()
.iter()
.rposition(|m| !matches!(m, LineMeta::Chrome));
let lo = if mi == s.msg_idx {
match &entry.meta_slice()[first] {
LineMeta::Content { range, .. } if first == s.line_in_msg => {
s.src_byte.unwrap_or(range.start)
}
LineMeta::Content { range, .. } => range.start,
LineMeta::ContentLine { src_line, .. } => {
source_line_range(src, *src_line).start
}
LineMeta::Chrome => unreachable!("first is a content row"),
}
} else {
0
};
let hi = if mi == e.msg_idx {
match &entry.meta_slice()[last] {
LineMeta::Content { range, .. } if last == e.line_in_msg => {
e.src_byte.unwrap_or(range.end)
}
LineMeta::Content { range, .. } => range.end,
LineMeta::ContentLine { .. } if Some(last) == last_content_in_msg => src.len(),
LineMeta::ContentLine { src_line, .. } => source_line_range(src, *src_line).end,
LineMeta::Chrome => unreachable!("last is a content row"),
}
} else {
src.len()
};
let (lo, hi) = (lo.min(src.len()), hi.min(src.len()));
if lo < hi {
parts.push(src[lo..hi].to_string());
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
}
pub(crate) fn show_full_output(&self) -> bool {
self.show_full_output
}
pub(crate) fn set_show_full_output(&mut self, v: bool) {
self.show_full_output = v;
self.invalidate();
}
pub(crate) fn uses_spinner(&self) -> bool {
self.messages
.iter()
.enumerate()
.any(|(idx, msg)| match &msg.msg {
ChatMessage::Thinking(text) => text == THINKING_PLACEHOLDER,
ChatMessage::ToolUseStart { .. } => true,
ChatMessage::ToolUse { .. } => {
idx == self.messages.len().saturating_sub(1) && self.tool_start_time.is_some()
}
ChatMessage::ToolResult { .. } => self.is_active_tool_result(idx),
_ => false,
})
}
pub(crate) fn is_active_tool_result(&self, idx: usize) -> bool {
if self.tool_start_time.is_none() {
return false;
}
match self.messages.get(idx).map(|m| &m.msg) {
Some(ChatMessage::ToolResult {
tool_id,
elapsed_ms: None,
..
}) => self.tool_start_times.contains_key(tool_id),
_ => false,
}
}
pub(crate) fn find_preceding_read_extension(&self, idx: usize) -> String {
let target_id: Option<String> = match self.messages.get(idx).map(|m| &m.msg) {
Some(ChatMessage::ToolResult { tool_id, .. }) if !tool_id.is_empty() => {
Some(tool_id.clone())
}
_ => None,
};
if let Some(id) = target_id {
for m in self.messages.iter() {
if let ChatMessage::ToolUse {
tool_id,
tool_name,
input,
} = &m.msg
{
if tool_id == &id && tool_name == "read" {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(input) {
if let Some(path) = parsed["path"].as_str() {
if let Some(ext) = std::path::Path::new(path).extension() {
return ext.to_string_lossy().to_string();
}
}
}
return String::new();
}
}
}
}
if idx == 0 {
return String::new();
}
for i in (0..idx).rev() {
if let ChatMessage::ToolUse {
ref tool_name,
ref input,
..
} = self.messages[i].msg
{
if tool_name == "read" {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(input) {
if let Some(path) = parsed["path"].as_str() {
if let Some(ext) = std::path::Path::new(path).extension() {
return ext.to_string_lossy().to_string();
}
}
}
}
break; }
}
String::new()
}
pub(crate) fn find_preceding_tool_name(&self, idx: usize) -> Option<String> {
if let Some(ChatMessage::ToolResult { tool_id, .. }) =
self.messages.get(idx).map(|m| &m.msg)
{
if !tool_id.is_empty() {
for m in self.messages.iter() {
match &m.msg {
ChatMessage::ToolUse {
tool_id: tid,
tool_name,
..
}
| ChatMessage::ToolUseStart {
tool_id: tid,
tool_name,
..
} if tid == tool_id => {
return Some(tool_name.clone());
}
_ => {}
}
}
}
}
if idx == 0 {
return None;
}
for i in (0..idx).rev() {
if let ChatMessage::ToolUse { ref tool_name, .. } = self.messages[i].msg {
return Some(tool_name.clone());
}
if let ChatMessage::ToolUseStart { ref tool_name, .. } = self.messages[i].msg {
return Some(tool_name.clone());
}
}
None
}
pub(crate) fn find_tool_use_start_idx(&self, tool_id: &str) -> Option<usize> {
self.messages.iter().rposition(|m| {
matches!(
&m.msg,
ChatMessage::ToolUseStart { tool_id: tid, .. } if tid == tool_id
)
})
}
pub(crate) fn find_tool_result_idx(&self, tool_id: &str) -> Option<usize> {
self.messages.iter().rposition(|m| {
matches!(
&m.msg,
ChatMessage::ToolResult { tool_id: tid, .. } if tid == tool_id
)
})
}
pub(crate) fn on_tool_use_start(&mut self, tool_id: String, tool_name: String) {
self.drop_empty_thinking();
let now = self.clock.now();
self.tool_start_time = Some(now);
if !tool_id.is_empty() {
self.tool_start_times.insert(tool_id.clone(), now);
}
self.push_msg(ChatMessage::ToolUseStart {
tool_id,
tool_name,
partial_input: String::new(),
});
}
pub(crate) fn on_tool_use_delta(&mut self, tool_id: &str, delta: &str) {
let target_idx = if !tool_id.is_empty() {
self.find_tool_use_start_idx(tool_id)
} else {
self.messages
.iter()
.rposition(|m| matches!(&m.msg, ChatMessage::ToolUseStart { .. }))
};
if let Some(idx) = target_idx {
if let ChatMessage::ToolUseStart {
ref mut partial_input,
..
} = self.messages[idx].msg
{
partial_input.push_str(delta);
self.invalidate_from(idx);
}
}
}
pub(crate) fn on_tool_use_finalized(
&mut self,
tool_id: String,
tool_name: String,
input_str: String,
) {
self.drop_empty_thinking();
let now = self.clock.now();
if !tool_id.is_empty() {
self.tool_start_times.entry(tool_id.clone()).or_insert(now);
}
self.tool_start_time = Some(now);
if let Some(idx) = self.find_tool_use_start_idx(&tool_id) {
self.messages[idx].msg = ChatMessage::ToolUse {
tool_id,
tool_name,
input: input_str,
};
self.invalidate_from(idx);
return;
}
self.push_msg(ChatMessage::ToolUse {
tool_id,
tool_name,
input: input_str,
});
}
pub(crate) fn on_tool_result_delta(&mut self, tool_id: String, delta: String) {
if let Some(idx) = self.find_tool_result_idx(&tool_id) {
if let ChatMessage::ToolResult {
ref mut content,
elapsed_ms,
..
} = self.messages[idx].msg
{
if elapsed_ms.is_none() {
content.push_str(&delta);
self.invalidate_from(idx);
} else {
tracing::debug!(tool_id, bytes = delta.len(), "late tool_result delta after finalize; dropped");
}
return;
}
}
self.push_tool_result(tool_id, delta, None);
}
pub(crate) fn on_tool_result(&mut self, tool_id: String, result: String) {
let elapsed = self
.tool_start_times
.remove(&tool_id)
.map(|t| t.elapsed().as_millis() as u64);
if self.tool_start_times.is_empty() {
self.tool_start_time = None;
}
let use_idx = if tool_id.is_empty() {
None
} else {
self.messages.iter().position(|m| {
matches!(
&m.msg,
ChatMessage::ToolUse { tool_id: tid, .. }
| ChatMessage::ToolUseStart { tool_id: tid, .. }
if tid == &tool_id
)
})
};
if let Some(idx) = self.find_tool_result_idx(&tool_id) {
if let ChatMessage::ToolResult {
ref mut content,
elapsed_ms,
..
} = self.messages[idx].msg
{
if elapsed_ms.is_none() {
*content = result;
self.messages[idx].msg = ChatMessage::ToolResult {
tool_id,
content: std::mem::take(content),
elapsed_ms: elapsed,
};
self.invalidate_from(use_idx.map_or(idx, |u| u.min(idx)));
return;
}
}
}
self.push_tool_result(tool_id, result, elapsed);
}
pub(crate) fn append_or_update_text(&mut self, text: &str) {
let response_start = self.response_start;
self.drop_empty_thinking();
if !std::mem::take(&mut self.text_sealed) {
if let Some(TimestampedMsg {
msg: ChatMessage::Text(ref mut existing),
..
}) = self.messages.last_mut()
{
existing.push_str(text);
self.invalidate_last();
return;
}
let own_block = self
.messages
.iter()
.rposition(|m| !matches!(m.msg, ChatMessage::System(_)))
.filter(|&i| response_start.is_some_and(|start| i >= start));
if let Some(i) = own_block {
if let ChatMessage::Text(ref mut existing) = self.messages[i].msg {
existing.push_str(text);
self.invalidate_from(i);
return;
}
}
}
self.push_msg(ChatMessage::Text(text.to_string()));
self.invalidate_last();
}
pub(crate) fn seal_text_block(&mut self) {
self.text_sealed = true;
self.response_start = None;
}
pub(crate) fn begin_response(&mut self) {
self.response_start = Some(self.messages.len());
}
pub(crate) fn end_response(&mut self) {
self.response_start = None;
}
pub(crate) fn response_start(&self) -> Option<usize> {
self.response_start
}
pub(crate) fn append_or_update_thinking(&mut self, text: &str) {
if let Some(TimestampedMsg {
msg: ChatMessage::Thinking(ref mut existing),
..
}) = self.messages.last_mut()
{
if existing == THINKING_PLACEHOLDER {
*existing = text.to_string();
} else {
existing.push_str(text);
}
} else {
self.push_msg(ChatMessage::Thinking(text.to_string()));
}
self.invalidate_last();
}
pub(crate) fn drop_empty_thinking(&mut self) {
let candidate_idx = self
.messages
.iter()
.rposition(|m| !matches!(&m.msg, ChatMessage::System(_)));
if let Some(idx) = candidate_idx {
if let ChatMessage::Thinking(t) = &self.messages[idx].msg {
if t == THINKING_PLACEHOLDER || t.is_empty() {
self.messages.remove(idx);
self.invalidate();
}
}
}
}
pub(crate) fn reset_response_preview(&mut self, start: usize, last: Option<ChatMessage>) {
let mut index = 0;
self.messages.retain(|m| {
let keep = index < start
|| !matches!(
m.msg,
ChatMessage::Text(_)
| ChatMessage::Thinking(_)
| ChatMessage::ToolUseStart { .. }
| ChatMessage::ToolUse { .. }
);
index += 1;
keep
});
if let Some(last) = last {
if let Some(message) = start.checked_sub(1).and_then(|i| self.messages.get_mut(i)) {
message.msg = last;
}
}
self.tool_start_times.retain(|id, _| {
self.messages.iter().any(|m| {
matches!(&m.msg, ChatMessage::ToolUseStart { tool_id, .. }
| ChatMessage::ToolUse { tool_id, .. } if tool_id == id)
})
});
if self.tool_start_times.is_empty() {
self.tool_start_time = None;
}
self.clear_selection();
self.invalidate();
}
pub(crate) fn messages(&self) -> &[TimestampedMsg] {
&self.messages
}
pub(crate) fn source_text(&self, idx: usize) -> std::borrow::Cow<'_, str> {
self.messages[idx].msg.source_text()
}
pub(crate) fn is_empty(&self) -> bool {
self.messages.is_empty()
}
#[allow(dead_code)]
pub(crate) fn message_count(&self) -> usize {
self.messages.len()
}
pub(crate) fn clear(&mut self) {
self.messages.clear();
self.response_start = None;
self.clear_selection();
self.cache = CacheState::Missing;
self.scrollback_dropped = 0;
self.pushes_since_audit = 0;
self.bytes_since_audit = 0;
}
#[allow(dead_code)]
pub(crate) fn scroll_back_pos(&self) -> u16 {
self.scroll_back
}
#[allow(dead_code)]
pub(crate) fn is_pinned(&self) -> bool {
self.scroll_pinned
}
pub(crate) fn tool_start_time(&self) -> Option<std::time::Instant> {
self.tool_start_time
}
#[allow(dead_code)]
pub(crate) fn tool_start_time_for(&self, tool_id: &str) -> Option<std::time::Instant> {
self.tool_start_times.get(tool_id).copied()
}
#[cfg(test)]
pub(crate) fn cache_dirty_from(&self) -> Option<usize> {
self.cache.dirty_from()
}
#[cfg(test)]
pub(crate) fn test_set_cache_clean(&mut self, mut cache: LineCache) {
cache.rebuild_cum_from(0);
self.cache = CacheState::Clean(cache);
}
#[cfg(test)]
#[allow(dead_code)] pub(crate) fn test_set_cache_dirty(&mut self, mut cache: LineCache, from: usize) {
cache.rebuild_cum_from(0);
self.cache = CacheState::Dirty(cache, from);
}
#[cfg(test)]
pub(crate) fn test_take_cache(&mut self) -> CacheState {
std::mem::replace(&mut self.cache, CacheState::Missing)
}
#[cfg(test)]
#[allow(dead_code)] pub(crate) fn test_put_cache(&mut self, cs: CacheState) {
self.cache = cs;
}
#[cfg(test)]
pub(crate) fn test_last_msg_mut(&mut self) -> Option<&mut TimestampedMsg> {
self.messages.last_mut()
}
#[cfg(test)]
pub(crate) fn test_insert_at(&mut self, idx: usize, msg: TimestampedMsg) {
self.messages.insert(idx, msg);
}
#[cfg(test)]
pub(crate) fn test_set_tool_start_time(&mut self, t: Option<std::time::Instant>) {
self.tool_start_time = t;
}
#[cfg(test)]
pub(crate) fn test_insert_tool_start_time(&mut self, id: String, t: std::time::Instant) {
self.tool_start_times.insert(id, t);
}
#[cfg(test)]
pub(crate) fn test_set_scroll_back(&mut self, v: u16) {
self.scroll_back = v;
self.scroll_pinned = v == 0;
}
#[allow(dead_code)] pub(crate) fn scroll_anchor(&self) -> Option<&ScrollAnchor> {
self.scroll_anchor.as_ref()
}
#[cfg(test)]
#[allow(dead_code)] pub(crate) fn test_set_scroll_anchor(&mut self, anchor: Option<ScrollAnchor>) {
self.scroll_anchor = anchor;
}
}
impl Default for TranscriptStore {
fn default() -> Self {
Self::new(super::clock::TuiClock::real())
}
}
#[cfg(test)]
mod visible_window_tests {
use super::*;
fn test_ctx() -> RenderCtx<'static> {
RenderCtx {
spinner_frame: 0,
streaming: false,
agent_name: "synaps",
}
}
#[test]
fn visible_window_publish_clones_only_viewport_not_full_buffer() {
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
for i in 0..20 {
store.push_msg(ChatMessage::Text(format!("line {i}")));
}
let content_width: usize = 80;
let content_height: usize = 5; let msg_area = ratatui::layout::Rect {
x: 0,
y: 1,
width: content_width as u16 + 2,
height: content_height as u16 + 2,
};
let vw = store.visible_window(msg_area, &test_ctx());
let total = store
.line_cache()
.expect("line cache populated after layout")
.total_height();
assert!(total >= 20, "sanity: need >= 20 flat lines, got {total}");
let to_str = |sl: &[ratatui::text::Line<'static>]| -> Vec<String> {
sl.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
};
assert_eq!(
vw.lines.len(),
content_height,
"vw.lines.len() must equal content_height ({content_height}), \
got {} (full buffer len = {total}) — full-buffer publish regression",
vw.lines.len()
);
assert_eq!(
vw.lines_width, content_width,
"lines_width must be the content width"
);
assert_eq!(
vw.scroll_back, 0,
"pinned → post-clamp scroll_back must be 0"
);
assert!(!vw.is_empty, "20 messages → is_empty must be false");
let oracle = store.render_lines(content_width, &test_ctx());
let o_len = oracle.len();
assert_eq!(
to_str(&vw.lines),
to_str(&oracle[o_len - content_height..]),
"published window content must equal the reference render's bottom window"
);
let visible_render: Vec<ratatui::text::Line> = vw.lines.to_vec();
assert_eq!(
to_str(&visible_render),
to_str(&oracle[o_len - content_height..]),
"render-thread .to_vec() on vw.lines must equal the visible window"
);
store.scroll_up(10); let vw2 = store.visible_window(msg_area, &test_ctx());
assert_eq!(
vw2.lines.len(),
content_height,
"mid-scroll window must also have viewport length"
);
assert_eq!(vw2.scroll_back, 10, "scroll_back 10 is within clamp range");
assert_ne!(
to_str(&vw2.lines),
to_str(&vw.lines),
"different scroll positions must yield different window content"
);
assert_eq!(
to_str(&vw2.lines),
to_str(&oracle[o_len - 15..o_len - 10]),
"mid-scroll window must equal the oracle's bottom-relative slice"
);
}
}
#[cfg(test)]
mod source_text_tests {
use super::*;
use std::borrow::Cow;
#[test]
fn user_is_raw_input_verbatim() {
let m = ChatMessage::User("hello world\nwith a second line".into());
assert_eq!(m.source_text(), "hello world\nwith a second line");
}
#[test]
fn user_paste_placeholder_copies_as_stored_display_form() {
let m = ChatMessage::User("[Pasted 42 lines]".into());
assert_eq!(m.source_text(), "[Pasted 42 lines]");
}
#[test]
fn text_is_raw_markdown_with_markers_fences_and_tables() {
let src = "some **bold** text\n\n```rust\nlet x = 1;\n```\n| a | b |\n|---|---|";
let m = ChatMessage::Text(src.into());
assert_eq!(m.source_text(), src);
}
#[test]
fn thinking_is_raw_markdown() {
let m = ChatMessage::Thinking("- consider\n- decide".into());
assert_eq!(m.source_text(), "- consider\n- decide");
}
#[test]
fn thinking_placeholder_is_empty_source() {
let m = ChatMessage::Thinking(THINKING_PLACEHOLDER.into());
assert_eq!(m.source_text(), "");
}
#[test]
fn tool_use_start_is_the_raw_partial_fragment() {
let m = ChatMessage::ToolUseStart {
tool_id: "t".into(),
tool_name: "write".into(),
partial_input: r#"{"path": "a.rs", "content": "fn ma"#.into(),
};
assert_eq!(m.source_text(), r#"{"path": "a.rs", "content": "fn ma"#);
}
#[test]
fn tool_use_is_pretty_printed_input_json_lock_l5() {
let m = ChatMessage::ToolUse {
tool_id: "t".into(),
tool_name: "bash".into(),
input: r#"{"command":"ls -la","timeout":30}"#.into(),
};
let src = m.source_text();
assert_eq!(
src, "{\n \"command\": \"ls -la\",\n \"timeout\": 30\n}",
"L5: ToolUse source must be serde_json pretty form"
);
assert!(matches!(src, Cow::Owned(_)));
}
#[test]
fn tool_use_unparseable_input_falls_back_to_raw_bytes() {
let m = ChatMessage::ToolUse {
tool_id: "t".into(),
tool_name: "bash".into(),
input: "not json {".into(),
};
assert_eq!(m.source_text(), "not json {");
assert!(matches!(m.source_text(), Cow::Borrowed(_)));
}
#[test]
fn tool_result_is_raw_stored_output_untruncated() {
let long: String = (0..200).map(|i| format!("line {i}\n")).collect();
let m = ChatMessage::ToolResult {
tool_id: "t".into(),
content: long.clone(),
elapsed_ms: Some(3),
};
assert_eq!(m.source_text(), long.as_str());
}
#[test]
fn error_system_are_verbatim() {
assert_eq!(
ChatMessage::Error("boom\ntail".into()).source_text(),
"boom\ntail"
);
assert_eq!(ChatMessage::System("notice".into()).source_text(), "notice");
}
#[test]
fn event_text_only_source_tag_is_chrome() {
let m = ChatMessage::Event {
source: "mail".into(),
severity: "high".into(),
text: "inbox message arrived".into(),
};
assert_eq!(m.source_text(), "inbox message arrived");
}
#[test]
fn store_source_text_delegates_per_index() {
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
store.push_msg(ChatMessage::User("u".into()));
store.push_msg(ChatMessage::Text("t".into()));
assert_eq!(store.source_text(0), "u");
assert_eq!(store.source_text(1), "t");
}
}
#[cfg(test)]
mod slice3_lazy_ratchets {
use super::super::highlight;
use super::*;
use ratatui::layout::Rect;
const VIEWPORT_ROWS: usize = 40;
const VIEWPORT_COLS: usize = 120;
const RENDER_BOUND: usize = VIEWPORT_ROWS + 2 * TranscriptStore::PROMOTE_HALO_MSGS; fn msg_area(rows: usize, cols: usize) -> Rect {
Rect {
x: 0,
y: 0,
width: (cols + 2) as u16,
height: (rows + 2) as u16,
}
}
fn test_ctx() -> RenderCtx<'static> {
RenderCtx {
spinner_frame: 0,
streaming: false,
agent_name: "synaps",
}
}
fn make_synthetic_store(n_plain: usize, n_code: usize) -> TranscriptStore {
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
for i in 0..n_plain {
store.push_msg(ChatMessage::Text(format!(
"Synthetic assistant message {i}.\n\
This is a second line of text to give the message some height.\n\
Third line: the quick brown fox jumps over the lazy dog."
)));
}
for i in 0..n_code {
store.push_msg(ChatMessage::Text(format!(
"Message with off-screen fenced code block {i}.\n\
```rust\n\
fn synthetic_{i}() -> usize {{\n\
let x = {i};\n\
x * x\n\
}}\n\
```\n\
After the code block."
)));
}
for i in 0..5 {
store.push_msg(ChatMessage::User(format!("User tail message {i}")));
}
store
}
#[test]
#[ignore = "slow: halo reaches fenced tail msgs → loads syntect; run with --ignored"]
#[serial_test::serial(highlight)]
fn baseline_slice0_cold_missing_renders_all_messages() {
const N_PLAIN: usize = 800;
const N_CODE: usize = 200;
const TOTAL: usize = N_PLAIN + N_CODE + 5;
let mut store = make_synthetic_store(N_PLAIN, N_CODE);
store.probe_reset();
highlight::highlight_reset_counters();
let area = msg_area(VIEWPORT_ROWS, VIEWPORT_COLS);
let _vw = store.visible_window(area, &test_ctx());
let renders = store.probe_render_count();
let hl_calls = highlight::highlight_call_count();
let ss_touched = highlight::syntax_set_was_touched();
eprintln!(
"[baseline_slice0_cold_missing_renders_all_messages]\n \
renders={renders} (I-RENDER ratchet: ≤ {RENDER_BOUND}; eager baseline was == {TOTAL})\n \
highlight_calls={hl_calls}\n \
syntax_set_touched={ss_touched}"
);
assert!(
renders <= RENDER_BOUND,
"I-RENDER: cold Missing frame must render ≤ {RENDER_BOUND} messages \
(viewport + 2·halo), rendered {renders} of {TOTAL}"
);
assert!(
renders > 0,
"sanity: the cold frame must render SOMETHING (the visible tail)"
);
}
#[test]
fn slice3_t1_cold_first_frame_renders_at_most_viewport_plus_halo() {
const TOTAL: usize = 1_000;
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
for i in 0..TOTAL {
store.push_msg(ChatMessage::Text(format!(
"Plain ratchet message {i}.\n\
Second line of message {i}.\n\
Third line: the quick brown fox jumps over the lazy dog."
)));
}
store.probe_reset();
let area = msg_area(VIEWPORT_ROWS, VIEWPORT_COLS);
let _vw = store.visible_window(area, &test_ctx());
let renders = store.probe_render_count();
eprintln!(
"[slice3_t1] cold first frame renders={renders} (bound {RENDER_BOUND}, n={TOTAL})"
);
assert!(
renders <= RENDER_BOUND,
"I-RENDER (T1): first frame from Missing at n={TOTAL} must perform \
≤ {RENDER_BOUND} exact renders, performed {renders}"
);
assert!(
renders > 0,
"sanity: cold frame must render the visible tail"
);
}
#[test]
fn slice3_t2_second_frame_renders_delta_zero() {
const TOTAL: usize = 1_000;
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
for i in 0..TOTAL {
store.push_msg(ChatMessage::Text(format!(
"Plain ratchet message {i}.\n\
Second line of message {i}.\n\
Third line: the quick brown fox jumps over the lazy dog."
)));
}
let area = msg_area(VIEWPORT_ROWS, VIEWPORT_COLS);
let ctx = test_ctx();
let _vw = store.visible_window(area, &ctx);
store.probe_reset();
let _vw2 = store.visible_window(area, &ctx);
let renders = store.probe_render_count();
let cum_writes = store.probe_cum_write_count();
eprintln!("[slice3_t2] second frame renders={renders} cum_writes={cum_writes}");
assert_eq!(
renders, 0,
"T2: second frame with no input must render 0 messages, rendered {renders}"
);
assert_eq!(
cum_writes, 0,
"T2: second frame must write 0 cumulative-offset entries (lock L4)"
);
}
#[test]
#[serial_test::serial(highlight)]
fn slice3_t3_offscreen_code_fence_zero_highlight_calls() {
const N_CODE: usize = 200; const N_PLAIN: usize = 800;
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
for i in 0..N_CODE {
store.push_msg(ChatMessage::Text(format!(
"Message with off-screen fenced code block {i}.\n\
```rust\n\
fn synthetic_{i}() -> usize {{\n\
let x = {i};\n\
x * x\n\
}}\n\
```\n\
After the code block."
)));
}
for i in 0..N_PLAIN {
store.push_msg(ChatMessage::Text(format!(
"Synthetic assistant message {i}.\n\
This is a second line of text to give the message some height.\n\
Third line: the quick brown fox jumps over the lazy dog."
)));
}
for i in 0..5 {
store.push_msg(ChatMessage::User(format!("User tail message {i}")));
}
store.probe_reset();
highlight::highlight_reset_counters(); let hl_before = highlight::highlight_call_count();
let area = msg_area(VIEWPORT_ROWS, VIEWPORT_COLS);
let _vw = store.visible_window(area, &test_ctx());
let renders = store.probe_render_count();
let hl_calls = highlight::highlight_call_count() - hl_before;
let ss_touched = highlight::syntax_set_was_touched();
eprintln!(
"[slice3_t3] renders={renders} highlight_calls={hl_calls} \
syntax_set_touched={ss_touched} (I-HILITE targets: 0 / false)"
);
assert_eq!(
hl_calls, 0,
"I-HILITE: off-screen (outside halo) code fences must trigger \
ZERO syntect highlight calls on the first frame, got {hl_calls}"
);
assert!(
renders <= RENDER_BOUND,
"I-RENDER holds here too: rendered {renders} > {RENDER_BOUND}"
);
assert!(
!ss_touched,
"I-HILITE: SYNTAX_SET must not initialize when no fence is in \
the viewport + halo"
);
}
#[test]
#[ignore = "slow: loads syntect defaults; run explicitly with --ignored"]
#[serial_test::serial(highlight)]
fn baseline_slice0_warm_cache_second_frame_zero_renders() {
let mut store = make_synthetic_store(100, 20);
let area = msg_area(VIEWPORT_ROWS, VIEWPORT_COLS);
let ctx = test_ctx();
let _vw = store.visible_window(area, &ctx);
store.probe_reset();
let hl_before = highlight::highlight_call_count();
let _vw2 = store.visible_window(area, &ctx);
let renders = store.probe_render_count();
let hl_calls = highlight::highlight_call_count() - hl_before;
eprintln!(
"[baseline_slice0_warm_cache_second_frame_zero_renders]\n \
renders={renders} (expected: 0)\n \
highlight_calls={hl_calls} (expected: 0)"
);
assert_eq!(
renders, 0,
"second frame on a clean cache must trigger zero renders"
);
assert_eq!(
hl_calls, 0,
"second frame on a clean cache must trigger zero highlight calls"
);
}
}
#[cfg(test)]
mod scroll_anchor_tests {
use super::*;
fn uniform_cache(n: usize, h: usize) -> LineCache {
let per_msg: Vec<MsgSlot> = (0..n)
.map(|_| MsgSlot {
lines: None,
meta: None,
height: HeightState::Exact(h),
})
.collect();
LineCache::new(80, per_msg)
}
fn cache_from_heights(heights: &[usize]) -> LineCache {
let per_msg: Vec<MsgSlot> = heights
.iter()
.map(|&h| MsgSlot {
lines: None,
meta: None,
height: HeightState::Exact(h),
})
.collect();
LineCache::new(80, per_msg)
}
fn test_ctx() -> RenderCtx<'static> {
RenderCtx {
spinner_frame: 0,
streaming: false,
agent_name: "synaps",
}
}
#[test]
fn ta1_correction_below_anchor_s_top_unchanged() {
let cache_before = uniform_cache(10, 5); let content_height = 10usize;
let scroll_back_before = 23usize; let s_top_before = TranscriptStore::scroll_back_to_stop(
scroll_back_before,
cache_before.total_height(),
content_height,
);
assert_eq!(s_top_before, 17);
let anchor =
TranscriptStore::capture_anchor(&cache_before, scroll_back_before, content_height)
.expect("capture must succeed");
assert_eq!(anchor.msg_idx, 3); assert_eq!(anchor.row_in_msg, 2);
let mut heights: Vec<usize> = vec![5; 10];
heights[7] = 15; let cache_after = cache_from_heights(&heights);
let sb_after =
TranscriptStore::anchor_to_scroll_back(&anchor, &cache_after, content_height);
let s_top_after = TranscriptStore::scroll_back_to_stop(
sb_after,
cache_after.total_height(),
content_height,
);
assert_eq!(
s_top_after, s_top_before,
"correction below anchor must not change S_top (was {s_top_before}, got {s_top_after})"
);
assert_eq!(sb_after, 33);
}
#[test]
fn ta2_correction_above_anchor_shifts_coordinate_anchor_screen_row_fixed() {
let cache_before = uniform_cache(10, 5);
let content_height = 10usize;
let scroll_back_before = 23usize;
let anchor =
TranscriptStore::capture_anchor(&cache_before, scroll_back_before, content_height)
.expect("capture must succeed");
let mut heights: Vec<usize> = vec![5; 10];
heights[1] = 15; let cache_after = cache_from_heights(&heights);
let sb_after =
TranscriptStore::anchor_to_scroll_back(&anchor, &cache_after, content_height);
let s_top_after = TranscriptStore::scroll_back_to_stop(
sb_after,
cache_after.total_height(),
content_height,
);
assert_eq!(
s_top_after, 27,
"correction above anchor: S_top must shift by delta (expected 27 = cum[3]+row_in_msg, got {s_top_after})"
);
let cum_anchor = cache_after.cum_heights[anchor.msg_idx];
assert_eq!(
s_top_after.saturating_sub(cum_anchor),
anchor.row_in_msg,
"anchor msg row_in_msg must still point to the top-of-viewport row"
);
}
#[test]
fn ta3_anchor_message_shrink_clamps_row_in_msg() {
let cache_before = uniform_cache(10, 5);
let content_height = 10usize;
let scroll_back_before = 23usize;
let anchor =
TranscriptStore::capture_anchor(&cache_before, scroll_back_before, content_height)
.expect("capture must succeed");
assert_eq!(anchor.msg_idx, 3);
assert_eq!(anchor.row_in_msg, 2);
let mut heights: Vec<usize> = vec![5; 10];
heights[3] = 2; let cache_after = cache_from_heights(&heights);
let sb_after =
TranscriptStore::anchor_to_scroll_back(&anchor, &cache_after, content_height);
let s_top_after = TranscriptStore::scroll_back_to_stop(
sb_after,
cache_after.total_height(),
content_height,
);
assert_eq!(
s_top_after, 16,
"shrunk anchor msg: S_top must clamp to 16 (got {s_top_after})"
);
assert!(
(17i64 - s_top_after as i64).abs() <= 5,
"shrink motion must be bounded to one anchor-message height"
);
}
#[test]
fn ta4_empty_and_stale_no_panic() {
let empty = cache_from_heights(&[]);
let anchor_result = TranscriptStore::capture_anchor(&empty, 0, 40);
assert!(
anchor_result.is_none(),
"empty cache capture must return None"
);
let small = cache_from_heights(&[5, 5]); let stale = ScrollAnchor {
msg_idx: 99,
row_in_msg: 3,
};
let _ = TranscriptStore::anchor_to_scroll_back(&stale, &small, 10);
let cache = uniform_cache(5, 5);
let anchor = ScrollAnchor {
msg_idx: 2,
row_in_msg: 0,
};
let sb = TranscriptStore::anchor_to_scroll_back(&anchor, &cache, 0);
assert_eq!(sb, 0, "zero viewport → scroll_back 0");
let cache_zero = cache_from_heights(&[0, 5, 5]);
let _ = TranscriptStore::capture_anchor(&cache_zero, 5, 5); }
#[test]
fn ta5_pinned_bottom_anchor_none_recomputes() {
let cache = uniform_cache(10, 5); let content_height = 10usize;
let anchor = TranscriptStore::capture_anchor(&cache, 0, content_height);
assert!(
anchor.is_none(),
"scroll_back==0 must return None (pinned mode, not a coordinate)"
);
let anchor_sb40 = TranscriptStore::capture_anchor(&cache, 40, content_height);
assert!(
anchor_sb40.is_some(),
"scroll_back>0 with s_top==0 must still return Some (anchored, not pinned)"
);
let anchor_over = TranscriptStore::capture_anchor(&cache, 99, content_height);
assert!(
anchor_over.is_some(),
"over-scrolled (sb>max_back) must still return Some"
);
}
#[test]
fn ta6_round_trip_capture_restore_idempotent() {
let cache = uniform_cache(20, 5); let content_height = 10usize;
for sb in [10usize, 25, 50, 70, 85] {
let anchor = TranscriptStore::capture_anchor(&cache, sb, content_height);
match anchor {
None => {
assert_eq!(sb, 0, "capture returns None only for sb==0");
}
Some(ref a) => {
let sb_restored =
TranscriptStore::anchor_to_scroll_back(a, &cache, content_height);
assert_eq!(
sb_restored, sb,
"round-trip must be idempotent: sb={sb} → anchor={a:?} → {sb_restored}"
);
}
}
}
}
#[test]
fn ta7_clamp_scroll_back_edge_cases() {
assert_eq!(TranscriptStore::clamp_scroll_back(99, 0, 10), 0);
assert_eq!(TranscriptStore::clamp_scroll_back(50, 5, 10), 0);
assert_eq!(TranscriptStore::clamp_scroll_back(10, 50, 10), 10);
assert_eq!(TranscriptStore::clamp_scroll_back(100, 50, 10), 40);
assert_eq!(TranscriptStore::clamp_scroll_back(30, 50, 0), 30);
}
#[test]
fn ta8_scroll_mutations_keep_anchor_in_sync() {
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
for i in 0..30 {
store.push_msg(ChatMessage::Text(format!(
"Message {i}: some content here."
)));
}
let msg_area = ratatui::layout::Rect {
x: 0,
y: 0,
width: 82,
height: 12, };
let _ = store.visible_window(msg_area, &test_ctx());
assert!(
store.scroll_anchor().is_none(),
"initially pinned: scroll_anchor must be None"
);
store.scroll_up(5);
assert!(
store.scroll_anchor().is_some(),
"after scroll_up: anchor must be Some"
);
let n_msgs = store.message_count();
if let Some(a) = store.scroll_anchor() {
assert!(a.msg_idx < n_msgs, "anchor.msg_idx must be in range");
}
store.scroll_down(9999); assert!(
store.scroll_anchor().is_none(),
"after scroll_down to 0: anchor must be None"
);
assert!(store.is_pinned(), "scroll_back 0 → must be pinned");
store.scroll_up(3);
assert!(
store.scroll_anchor().is_some(),
"after scroll_up: anchor Some"
);
store.scroll_to_bottom();
assert!(
store.scroll_anchor().is_none(),
"scroll_to_bottom must clear anchor"
);
}
}
#[cfg(test)]
mod slice4_stream_resize {
use super::*;
use ratatui::layout::Rect;
const HALO: usize = TranscriptStore::PROMOTE_HALO_MSGS;
fn ctx() -> RenderCtx<'static> {
RenderCtx {
spinner_frame: 0,
streaming: true,
agent_name: "synaps",
}
}
fn ctx_idle() -> RenderCtx<'static> {
RenderCtx {
spinner_frame: 0,
streaming: false,
agent_name: "synaps",
}
}
fn msg_area(rows: usize, cols: usize) -> Rect {
Rect {
x: 0,
y: 0,
width: (cols + 2) as u16,
height: (rows + 2) as u16,
}
}
fn to_strs(lines: &[ratatui::text::Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
#[test]
fn slice4_t5_streaming_bottom_rows_exact_while_estimated_history_above() {
const N_HISTORY: usize = 950;
const N_STREAM: usize = 50;
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
for i in 0..N_HISTORY {
store.push_msg(ChatMessage::Text(format!(
"History message {i}: the quick brown fox jumps over the lazy dog."
)));
}
let area = msg_area(40, 120);
let _ = store.visible_window(area, &ctx_idle());
store.probe_reset();
let mut prev_total: usize = 0;
for i in 0..N_STREAM {
store.push_msg(ChatMessage::Text(format!(
"Streaming message {i}: line one.\nLine two of streaming message {i}."
)));
let vw = store.visible_window(area, &ctx());
let cache = store
.line_cache()
.expect("cache must exist during streaming");
let n = cache.per_msg.len();
let last_slot = &cache.per_msg[n - 1];
assert!(
last_slot.height.is_exact(),
"T5: last streamed slot must be Exact after frame {i}, got Estimated"
);
assert!(
last_slot.meta.is_some(),
"T5: last streamed slot must have meta (Some) after frame {i}"
);
assert!(
!vw.lines.is_empty(),
"T5: visible window must not be empty during streaming (frame {i})"
);
assert_eq!(
vw.scroll_back, 0,
"T5: pinned during streaming → scroll_back must be 0"
);
let total = cache.total_height();
assert!(
total > prev_total,
"T5: total_height must grow with each message (was {prev_total}, now {total})"
);
prev_total = total;
let tail_lo = n.saturating_sub(HALO + 1);
let all_exact_in_tail = cache.per_msg[tail_lo..].iter().all(|s| s.height.is_exact());
assert!(
all_exact_in_tail,
"T5: all slots in halo around tail must be Exact during streaming (frame {i})"
);
}
let renders_after_streaming = store.probe_render_count();
let cache = store.line_cache().unwrap();
let n = cache.per_msg.len();
let halo_lo = n.saturating_sub(HALO + N_STREAM + 10); let estimated_count = cache.per_msg[..halo_lo.min(N_HISTORY.saturating_sub(100))]
.iter()
.filter(|s| !s.height.is_exact())
.count();
assert!(
estimated_count > N_HISTORY / 2,
"T5: most history must remain Estimated (got only {estimated_count} Estimated \
of {halo_lo} checked above the halo)"
);
eprintln!(
"[T5] streaming {N_STREAM} msgs over {N_HISTORY} estimated history: \
renders_delta={renders_after_streaming} estimated_above_halo={estimated_count}"
);
}
#[test]
fn slice4_t6_resize_cum_identity_and_visible_window_matches_oracle() {
const N: usize = 200;
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
for i in 0..N {
store.push_msg(ChatMessage::Text(format!(
"Resize test message {i}: the quick brown fox jumps over the lazy dog. \
A longer second sentence to provide wrapping material at narrow widths."
)));
}
let area120 = msg_area(40, 120);
let vw1 = store.visible_window(area120, &ctx_idle());
assert_cum_identity(store.line_cache().unwrap(), "initial w=120");
let oracle120 = store.render_lines(120, &ctx_idle());
let o_len = oracle120.len();
let vw1_len = vw1.lines.len();
let oracle_tail_120 = &oracle120[o_len.saturating_sub(vw1_len)..];
assert_eq!(
to_strs(&vw1.lines),
to_strs(oracle_tail_120),
"T6 w=120: visible window must match oracle's tail (I-RESIZE)"
);
store.probe_reset();
let area80 = msg_area(40, 80);
let vw2 = store.visible_window(area80, &ctx_idle());
assert_cum_identity(store.line_cache().unwrap(), "after resize to w=80");
let oracle80 = store.render_lines(80, &ctx_idle());
let o80_len = oracle80.len();
let vw2_len = vw2.lines.len();
let oracle_tail_80 = &oracle80[o80_len.saturating_sub(vw2_len)..];
assert_eq!(
to_strs(&vw2.lines),
to_strs(oracle_tail_80),
"T6 w=80: visible window must match oracle's tail after resize (I-RESIZE)"
);
store.probe_reset();
let vw3 = store.visible_window(area120, &ctx_idle());
assert_cum_identity(store.line_cache().unwrap(), "after resize back to w=120");
let oracle120b = store.render_lines(120, &ctx_idle());
let ob_len = oracle120b.len();
let vw3_len = vw3.lines.len();
let oracle_tail_120b = &oracle120b[ob_len.saturating_sub(vw3_len)..];
assert_eq!(
to_strs(&vw3.lines),
to_strs(oracle_tail_120b),
"T6 w=120 (second visit): visible window must match oracle's tail (I-RESIZE)"
);
eprintln!(
"[T6] resize 120→80→120 on N={N} messages: cum identity holds at all three widths; \
visible window matches oracle tail each time"
);
}
fn assert_cum_identity(cache: &LineCache, label: &str) {
assert_eq!(
cache.cum_heights.len(),
cache.per_msg.len() + 1,
"cum identity ({label}): cum_heights.len() must be per_msg.len()+1"
);
assert_eq!(
cache.cum_heights.first(),
Some(&0),
"cum identity ({label}): cum_heights[0] must be 0"
);
let mut acc = 0usize;
for (i, slot) in cache.per_msg.iter().enumerate() {
acc += slot.height();
assert_eq!(
cache.cum_heights[i + 1],
acc,
"cum identity ({label}): cum_heights[{}] inconsistent (expected {acc})",
i + 1
);
}
}
}
#[cfg(test)]
mod slice5_copy_promote_on_touch {
use super::*;
use ratatui::layout::Rect;
fn ctx() -> RenderCtx<'static> {
RenderCtx {
spinner_frame: 0,
streaming: false,
agent_name: "synaps",
}
}
fn msg_area_40x120() -> Rect {
Rect {
x: 0,
y: 0,
width: 122,
height: 42,
}
}
fn make_store(n: usize) -> TranscriptStore {
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
for i in 0..n {
store.push_msg(ChatMessage::Text(format!(
"T7/T8 message {i}: content for selection test.\n\
Second line of message {i}."
)));
}
store
}
fn oracle_copy(store: &TranscriptStore, lo: usize, hi: usize) -> String {
let parts: Vec<String> = (lo..=hi)
.map(|i| store.source_text(i).to_string())
.collect();
parts.join("\n\n")
}
#[test]
fn slice5_t7_copy_off_screen_range_matches_oracle_renders_approx_201() {
const N: usize = 1_000;
const SEL_LO: usize = 100;
const SEL_HI: usize = 300;
let mut store = make_store(N);
let area = msg_area_40x120();
let _ = store.visible_window(area, &ctx());
{
let cache = store.line_cache().unwrap();
let n_estimated = cache.per_msg[SEL_LO..=SEL_HI]
.iter()
.filter(|s| !s.height.is_exact())
.count();
assert!(
n_estimated > 150,
"T7 precondition: most of msgs {SEL_LO}..={SEL_HI} must be Estimated \
before copy (got only {n_estimated} Estimated)"
);
}
let sel_lo_src_len = store.source_text(SEL_LO).len();
let sel_hi_src_len = store.source_text(SEL_HI).len();
store.selection_anchor = Some(SelPos {
msg_idx: SEL_LO,
line_in_msg: 0,
col: 0,
src_byte: Some(0),
});
store.selection_end = Some(SelPos {
msg_idx: SEL_HI,
line_in_msg: 99, col: 0,
src_byte: Some(sel_hi_src_len),
});
store.probe_reset();
let copied = store
.selected_text()
.expect("T7: selected_text must return Some for msgs 100..=300");
let renders_after_copy = store.probe_render_count();
let oracle = oracle_copy(&store, SEL_LO, SEL_HI);
let expected_hi_portion = store.source_text(SEL_HI).to_string();
assert!(
copied.ends_with(&expected_hi_portion),
"T7: copied text must end with full source of msg {SEL_HI}\n\
expected suffix: {expected_hi_portion:?}\n\
got: {copied:?}"
);
let sel_lo_src = store.source_text(SEL_LO).to_string();
assert!(
copied.starts_with(&sel_lo_src[..sel_lo_src_len]),
"T7: copied text must start with full source of msg {SEL_LO}\n\
expected prefix: {sel_lo_src:?}\n\
got start: {:?}",
&copied[..copied.len().min(100)]
);
let expected_range = 201;
assert!(
renders_after_copy <= expected_range * 2,
"T7: promote-on-touch must render at most ~{} messages (got {renders_after_copy})",
expected_range * 2
);
assert!(
renders_after_copy > 0,
"T7: at least some Estimated slots must have been promoted (got 0)"
);
eprintln!(
"[T7] select msgs {SEL_LO}..{SEL_HI} while tail viewport: \
renders={renders_after_copy} (expected ≤ {}); \
copied {} chars; oracle {} chars",
expected_range * 2,
copied.len(),
oracle.len()
);
}
#[test]
fn slice5_t8_second_copy_renders_delta_zero() {
const N: usize = 500;
const SEL_LO: usize = 50;
const SEL_HI: usize = 150;
let mut store = make_store(N);
let area = msg_area_40x120();
let _ = store.visible_window(area, &ctx());
let sel_hi_src_len = store.source_text(SEL_HI).len();
store.selection_anchor = Some(SelPos {
msg_idx: SEL_LO,
line_in_msg: 0,
col: 0,
src_byte: Some(0),
});
store.selection_end = Some(SelPos {
msg_idx: SEL_HI,
line_in_msg: 99,
col: 0,
src_byte: Some(sel_hi_src_len),
});
store.probe_reset();
let first = store
.selected_text()
.expect("T8: first selected_text must return Some");
let renders_first = store.probe_render_count();
assert!(renders_first > 0, "T8: first copy must promote some slots");
eprintln!("[T8] first copy: renders={renders_first}");
store.selection_anchor = Some(SelPos {
msg_idx: SEL_LO,
line_in_msg: 0,
col: 0,
src_byte: Some(0),
});
store.selection_end = Some(SelPos {
msg_idx: SEL_HI,
line_in_msg: 99,
col: 0,
src_byte: Some(sel_hi_src_len),
});
store.probe_reset();
let second = store
.selected_text()
.expect("T8: second selected_text must return Some");
let renders_second = store.probe_render_count();
assert_eq!(
renders_second, 0,
"T8: second copy of same range must render 0 messages (meta retained), \
rendered {renders_second}"
);
assert_eq!(first, second, "T8: both copies must produce identical text");
eprintln!(
"[T8] second copy: renders={renders_second} (expected 0); \
output byte-identical: {}",
first == second
);
}
}
#[cfg(test)]
mod scrollback_cap_tests {
use super::*;
fn ctx() -> RenderCtx<'static> {
RenderCtx {
spinner_frame: 0,
streaming: false,
agent_name: "synaps",
}
}
fn store(msgs: usize, bytes: usize) -> TranscriptStore {
let mut s = TranscriptStore::new(super::super::clock::TuiClock::real());
s.set_scrollback(msgs, bytes);
s
}
fn is_sentinel(m: &TimestampedMsg) -> bool {
matches!(&m.msg, ChatMessage::System(t) if t.contains(SCROLLBACK_SENTINEL_MARK))
}
#[test]
fn unbounded_never_drains() {
let mut s = store(0, 0);
for i in 0..1000 {
s.push_msg(ChatMessage::Text(format!("m{i}")));
}
assert_eq!(s.message_count(), 1000);
assert_eq!(s.scrollback_dropped(), 0);
}
#[test]
fn msg_cap_drains_with_hysteresis_and_single_sentinel() {
let mut s = store(100, 0);
for i in 0..164 {
s.push_msg(ChatMessage::Text(format!("m{i}")));
}
assert_eq!(s.message_count(), 164, "at the threshold: untouched");
s.push_msg(ChatMessage::Text("m164".into()));
assert_eq!(s.message_count(), 101, "drained to cap + sentinel");
assert!(is_sentinel(&s.messages()[0]));
assert_eq!(s.scrollback_dropped(), 65);
match &s.messages()[0].msg {
ChatMessage::System(t) => {
assert!(t.contains("65 earlier message(s)"), "{t}");
assert!(t.contains("scrollback cap 100"), "{t}");
assert!(t.contains("/resync"), "{t}");
}
_ => unreachable!(),
}
assert!(matches!(&s.messages()[1].msg, ChatMessage::Text(t) if t == "m65"));
for i in 165..300 {
s.push_msg(ChatMessage::Text(format!("m{i}")));
}
let sentinels = s.messages().iter().filter(|m| is_sentinel(m)).count();
assert_eq!(sentinels, 1);
assert!(s.message_count() <= 101 + SCROLLBACK_HYSTERESIS_MSGS);
let last = match &s.messages().last().unwrap().msg {
ChatMessage::Text(t) => t.clone(),
_ => unreachable!(),
};
assert_eq!(last, "m299");
let texts: Vec<usize> = s
.messages()
.iter()
.filter_map(|m| match &m.msg {
ChatMessage::Text(t) => t[1..].parse().ok(),
_ => None,
})
.collect();
assert!(texts.windows(2).all(|w| w[1] == w[0] + 1), "{texts:?}");
assert_eq!(s.scrollback_dropped() + texts.len(), 300);
}
#[test]
fn byte_cap_drains_on_audit() {
let mut s = store(0, 10 * 1024);
let big = "x".repeat(8 * 1024);
for _ in 0..70 {
s.push_msg(ChatMessage::Text(big.clone()));
}
assert!(s.message_count() < 70, "{}", s.message_count());
assert!(is_sentinel(&s.messages()[0]));
let bytes: usize = s.messages().iter().map(|m| m.msg.approx_len()).sum();
assert!(bytes <= 10 * 1024 + SCROLLBACK_HYSTERESIS_BYTES + 8 * 1024, "{bytes}");
assert!(s.message_count() >= 1);
}
#[test]
fn cache_stays_parallel_after_drain() {
let mut s = store(20, 0);
for i in 0..50 {
s.push_msg(ChatMessage::Text(format!("m{i}")));
}
s.sync_cache(80, &ctx());
assert_eq!(s.line_cache().unwrap().per_msg.len(), 50);
for i in 50..90 {
s.push_msg(ChatMessage::Text(format!("m{i}")));
}
assert!(s.message_count() < 90);
assert!(s.line_cache().is_none(), "drain = full invalidate");
s.sync_cache(80, &ctx());
assert_eq!(s.line_cache().unwrap().per_msg.len(), s.message_count());
s.push_msg(ChatMessage::Text("tail".into()));
s.sync_cache(80, &ctx());
assert_eq!(s.line_cache().unwrap().per_msg.len(), s.message_count());
}
#[test]
fn selection_cleared_and_anchor_fixed_on_drain() {
let mut s = store(10, 0);
for i in 0..74 {
s.push_msg(ChatMessage::Text(format!("m{i}")));
}
s.selection_anchor = Some(SelPos { msg_idx: 70, line_in_msg: 0, col: 0, src_byte: Some(0) });
s.selection_end = Some(SelPos { msg_idx: 72, line_in_msg: 0, col: 1, src_byte: Some(1) });
s.test_set_scroll_anchor(Some(ScrollAnchor { msg_idx: 70, row_in_msg: 0 }));
s.push_msg(ChatMessage::Text("m74".into()));
assert_eq!(s.message_count(), 11);
assert!(s.selection_anchor.is_none() && s.selection_end.is_none());
assert_eq!(s.scroll_anchor().map(|a| a.msg_idx), Some(6));
assert!(matches!(&s.messages()[6].msg, ChatMessage::Text(t) if t == "m70"));
s.test_set_scroll_anchor(Some(ScrollAnchor { msg_idx: 2, row_in_msg: 0 }));
for i in 75..150 {
s.push_msg(ChatMessage::Text(format!("m{i}")));
}
assert!(s.scroll_anchor().is_none());
}
#[test]
fn tool_result_after_drained_tool_use_appends() {
let mut s = store(10, 0);
s.push_msg(ChatMessage::ToolUse { tool_id: "t0".into(), tool_name: "bash".into(), input: "{}".into() });
for i in 0..80 {
s.push_msg(ChatMessage::Text(format!("m{i}")));
}
assert!(!s.messages().iter().any(|m| matches!(&m.msg, ChatMessage::ToolUse { tool_id, .. } if tool_id == "t0")));
s.push_tool_result("t0".into(), "late".into(), None);
assert!(matches!(&s.messages().last().unwrap().msg, ChatMessage::ToolResult { content, .. } if content == "late"));
s.push_msg(ChatMessage::ToolUse { tool_id: "t1".into(), tool_name: "bash".into(), input: "{}".into() });
s.push_msg(ChatMessage::Text("after".into()));
s.push_tool_result("t1".into(), "out".into(), None);
let n = s.message_count();
assert!(matches!(&s.messages()[n - 3].msg, ChatMessage::ToolUse { .. }));
assert!(matches!(&s.messages()[n - 2].msg, ChatMessage::ToolResult { .. }));
}
#[test]
fn scrollback_defaults_by_transport() {
use super::super::app::scrollback_from_env;
use super::super::run_setup::TransportMode;
for k in ["SYNAPS_TUI_SCROLLBACK", "SYNAPS_TUI_SCROLLBACK_BYTES", "SYNAPS_CLIENT_SCROLLBACK_MSGS", "SYNAPS_CLIENT_SCROLLBACK_BYTES"] {
std::env::remove_var(k);
}
assert_eq!(scrollback_from_env(&TransportMode::Socket), (400, 2 * 1024 * 1024));
}
}
#[cfg(test)]
mod late_delta_tests {
use super::*;
#[test]
fn late_delta_after_finalize_does_not_add_a_block() {
let mut s = TranscriptStore::default();
s.on_tool_use_start("t1".into(), "bash".into());
s.on_tool_result_delta("t1".into(), "Password: ".into());
s.on_tool_result("t1".into(), "Password: \nlen=3".into());
let before = s.message_count();
s.on_tool_result_delta("t1".into(), "len=3\n".into());
assert_eq!(s.message_count(), before, "late delta created a second ToolResult");
let results: Vec<_> = s
.messages()
.iter()
.filter(|m| matches!(&m.msg, ChatMessage::ToolResult { tool_id, .. } if tool_id == "t1"))
.collect();
assert_eq!(results.len(), 1);
match &results[0].msg {
ChatMessage::ToolResult { content, elapsed_ms, .. } => {
assert_eq!(content, "Password: \nlen=3");
assert!(elapsed_ms.is_some());
}
_ => unreachable!(),
}
}
}
#[cfg(test)]
mod response_reset_tests {
use super::*;
#[test]
fn reset_response_preview_basic_truncation() {
let mut store = TranscriptStore::default();
store.push_msg(ChatMessage::User("question".into()));
store.push_msg(ChatMessage::Text("answer round 1".into()));
let start = store.messages().len();
let last = store.messages().last().map(|m| m.msg.clone());
store.begin_response();
store.append_or_update_text(" failed continuation");
store.on_tool_use_start("failed_call".into(), "write".into());
assert!(store.messages().len() > start);
store.reset_response_preview(start, last);
assert_eq!(store.messages().len(), 2);
assert_eq!(store.messages()[1].msg.source_text(), "answer round 1");
}
#[test]
fn reset_discards_only_current_response_and_keeps_steering() {
let mut store = TranscriptStore::new(super::super::clock::TuiClock::real());
store.push_msg(ChatMessage::User("question".into()));
store.push_msg(ChatMessage::Text("previous round".into()));
let start = store.messages().len();
let last = store.messages().last().map(|m| m.msg.clone());
store.begin_response();
store.append_or_update_text(" failed continuation");
store.on_tool_use_start("failed_call".into(), "write".into());
store.push_msg(ChatMessage::User("human steering".into()));
store.push_msg(ChatMessage::System("retry notice".into()));
store.reset_response_preview(start, last);
assert_eq!(store.messages().len(), 4);
assert_eq!(store.messages()[1].msg.source_text(), "previous round");
assert_eq!(store.messages()[2].msg.source_text(), "human steering");
assert!(store.tool_start_time.is_none());
store.begin_response();
store.append_or_update_text("new response");
assert_eq!(
store.messages().last().unwrap().msg.source_text(),
"new response"
);
}
#[test]
fn a_notice_mid_reply_does_not_split_the_reply() {
let mut store = TranscriptStore::default();
store.push_msg(ChatMessage::User("question".into()));
store.push_msg(ChatMessage::Text("earlier reply".into()));
store.push_msg(ChatMessage::System("a notice between turns".into()));
let start = store.messages().len();
store.begin_response();
store.append_or_update_text("The quick ");
assert_eq!(store.messages().len(), start + 1, "new block: the old one predates the response");
store.push_msg(ChatMessage::System("input is owned by client #1".into()));
store.append_or_update_text("brown fox");
let texts: Vec<String> = store
.messages()
.iter()
.filter(|m| matches!(m.msg, ChatMessage::Text(_)))
.map(|m| m.msg.source_text().to_string())
.collect();
assert_eq!(texts, ["earlier reply", "The quick brown fox"], "one block for the reply");
store.reset_response_preview(start, None);
let texts: Vec<String> = store
.messages()
.iter()
.filter(|m| matches!(m.msg, ChatMessage::Text(_)))
.map(|m| m.msg.source_text().to_string())
.collect();
assert_eq!(texts, ["earlier reply"], "the reset removes exactly the response");
}
#[test]
fn the_response_start_survives_a_scrollback_drain() {
let mut store = TranscriptStore::default();
for i in 0..10 {
store.push_msg(ChatMessage::User(format!("old {i}")));
}
store.begin_response();
store.append_or_update_text("reply part one");
store.push_msg(ChatMessage::System("a notice".into()));
store.drain_front(6);
let start = store.response_start().expect("start kept");
assert!(matches!(store.messages()[start].msg, ChatMessage::Text(_)));
store.append_or_update_text(", part two");
assert_eq!(
store.messages()[start].msg.source_text(),
"reply part one, part two",
"one block after the drain"
);
store.reset_response_preview(start, None);
assert!(
!store
.messages()
.iter()
.any(|m| matches!(m.msg, ChatMessage::Text(_))),
"the reset removed exactly the response"
);
store.begin_response();
store.append_or_update_text("doomed");
store.drain_front(store.messages().len());
assert_eq!(store.response_start(), None);
}
#[test]
fn a_new_turn_opens_a_new_text_block() {
let mut store = TranscriptStore::default();
store.push_msg(ChatMessage::Text("first turn's reply".into()));
store.seal_text_block();
store.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
store.begin_response();
store.append_or_update_text("second turn");
store.append_or_update_text("'s reply");
let texts: Vec<String> = store
.messages()
.iter()
.map(|m| m.msg.source_text().to_string())
.collect();
assert_eq!(texts, ["first turn's reply", "second turn's reply"]);
}
}