use super::{CacheState, ChatMessage, MsgSlot, RenderCtx, TranscriptStore, THINKING_PLACEHOLDER};
impl TranscriptStore {
pub(crate) fn tool_running(&self, tool_id: &str) -> bool {
if tool_id.is_empty() {
self.tool_start_time.is_some()
} else {
self.tool_start_times.contains_key(tool_id)
}
}
pub(crate) fn tool_started_at(&self, tool_id: &str) -> Option<std::time::Instant> {
self.tool_start_times
.get(tool_id)
.copied()
.or(self.tool_start_time)
}
pub(crate) fn tool_header_runs(&self, idx: usize, tool_id: &str) -> bool {
if !self.tool_running(tool_id) {
return false;
}
if tool_id.is_empty() {
return idx + 1 == self.messages.len();
}
!matches!(
self.messages.get(idx + 1).map(|m| &m.msg),
Some(ChatMessage::ToolResult { tool_id: t, .. }) if t == tool_id
)
}
pub(crate) fn streaming_text_idx(&self) -> Option<usize> {
if self.text_sealed {
return None;
}
let i = self
.messages
.iter()
.rposition(|m| !matches!(m.msg, ChatMessage::System(_)))?;
let own = i + 1 == self.messages.len() || self.response_start.is_some_and(|s| i >= s);
(own && matches!(self.messages[i].msg, ChatMessage::Text(_))).then_some(i)
}
fn is_live(&self, idx: usize) -> bool {
match self.messages.get(idx).map(|m| &m.msg) {
Some(ChatMessage::Thinking(t)) => t == THINKING_PLACEHOLDER,
Some(
ChatMessage::ToolUseStart { tool_id, .. } | ChatMessage::ToolUse { tool_id, .. },
) => self.tool_running(tool_id),
Some(ChatMessage::ToolResult { .. }) => self.is_active_tool_result(idx),
_ => false,
}
}
fn scan_live(&self) -> Vec<usize> {
(0..self.messages.len())
.filter(|&i| self.is_live(i))
.collect()
}
pub(super) fn relive(&mut self) {
self.live = self.scan_live();
}
pub(super) fn live_note(&mut self, idx: usize) {
let live = self.is_live(idx);
match (self.live.binary_search(&idx), live) {
(Err(at), true) => self.live.insert(at, idx),
(Ok(at), false) => {
self.live.remove(at);
}
_ => {}
}
}
pub(super) fn live_inserted(&mut self, at: usize) {
for i in self.live.iter_mut().filter(|i| **i >= at) {
*i += 1;
}
self.live_note(at);
}
pub(super) fn live_removed(&mut self, at: usize) {
self.live.retain(|&i| i != at);
for i in self.live.iter_mut().filter(|i| **i > at) {
*i -= 1;
}
}
pub(super) fn live_drained_front(&mut self, n: usize) {
self.live.retain(|&i| i >= n);
for i in self.live.iter_mut() {
*i -= n;
}
}
pub(super) fn set_tool_clock(&mut self, at: Option<std::time::Instant>) {
let flipped = self.tool_start_time.is_some() != at.is_some();
self.tool_start_time = at;
if flipped {
self.relive();
}
}
pub(crate) fn has_live(&self) -> bool {
!self.live.is_empty()
}
pub(crate) fn tick_live(&mut self) -> bool {
if self.live.is_empty() {
return false;
}
self.live_refresh = true;
true
}
#[cfg(test)]
pub(crate) fn live_slots(&self) -> &[usize] {
&self.live
}
pub(super) fn refresh_live_slots(&mut self, width: usize, ctx: &RenderCtx<'_>) {
if !std::mem::take(&mut self.live_refresh) {
return;
}
let limit = match &self.cache {
CacheState::Clean(c) if c.per_msg.len() == self.messages.len() => c.per_msg.len(),
CacheState::Dirty(c, k) => (*k).min(c.per_msg.len()),
_ => return,
};
let Some(cache) = self.cache.line_cache() else {
return;
};
if cache.width != width {
return;
}
let todo: Vec<usize> = self
.live
.iter()
.copied()
.take_while(|&i| i < limit)
.filter(|&i| cache.per_msg[i].lines.is_some())
.collect();
if todo.is_empty() {
return;
}
let fresh: Vec<(usize, MsgSlot)> = todo
.into_iter()
.map(|i| (i, self.render_message_lines(i, width, ctx)))
.collect();
let Some(cache) = self.cache.line_cache_mut() else {
return;
};
let mut moved: Option<usize> = None;
for (i, slot) in fresh {
if moved.is_none() && slot.height() != cache.per_msg[i].height() {
moved = Some(i);
}
cache.per_msg[i] = slot;
}
if let Some(k) = moved {
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);
}
}
pub(super) fn debug_assert_live(&self) {
#[cfg(debug_assertions)]
debug_assert_eq!(
self.live,
self.scan_live(),
"live slots drifted from the transcript"
);
}
}
#[cfg(test)]
mod tests {
use super::super::{ChatMessage, RenderCtx, TranscriptStore, THINKING_PLACEHOLDER};
use crate::tui::clock::TuiClock;
fn store() -> TranscriptStore {
TranscriptStore::new(TuiClock::test())
}
fn ctx() -> RenderCtx<'static> {
RenderCtx {
spinner_frame: 0,
streaming: true,
agent_name: "agent",
}
}
fn running(s: &mut TranscriptStore, id: &str) {
s.on_tool_use_start(id.into(), "bash".into());
s.on_tool_use_finalized(id.into(), "bash".into(), r#"{"command":"sleep 9"}"#.into());
}
#[test]
fn running_cards_and_the_placeholder_are_live_wherever_they_are() {
let mut s = store();
s.push_msg(ChatMessage::User("go".into()));
running(&mut s, "a");
running(&mut s, "b");
s.push_msg(ChatMessage::System("→ steering: x".into()));
assert_eq!(s.live_slots(), &[1, 2]);
s.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.into()));
assert_eq!(s.live_slots(), &[1, 2, 4]);
s.debug_assert_live();
}
#[test]
fn a_finished_card_stops_being_live_and_inserts_shift_the_rest() {
let mut s = store();
running(&mut s, "a");
running(&mut s, "b");
s.on_tool_result_delta("b".into(), "out\n".into());
assert_eq!(s.live_slots(), &[0, 1, 2]);
s.on_tool_result("a".into(), "done".into());
assert_eq!(s.live_slots(), &[2, 3]);
s.on_tool_result("b".into(), "done".into());
assert!(!s.has_live(), "{:?}", s.live_slots());
assert!(!s.tick_live(), "nothing live: the tick does nothing");
s.debug_assert_live();
}
#[test]
fn removals_and_the_scrollback_cap_keep_indices_in_step() {
let mut s = store();
s.set_scrollback(4, 0);
s.push_msg(ChatMessage::User("go".into()));
s.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.into()));
s.push_msg(ChatMessage::System("notice".into()));
s.drop_empty_thinking();
assert!(!s.has_live());
running(&mut s, "a");
for n in 0..200 {
s.push_msg(ChatMessage::System(format!("notice {n}")));
s.debug_assert_live();
}
assert!(s.scrollback_dropped() > 0, "the cap drained the front");
running(&mut s, "b");
for n in 0..2 {
s.push_msg(ChatMessage::System(format!("later {n}")));
s.debug_assert_live();
}
let b = s
.messages()
.iter()
.position(|m| matches!(&m.msg, ChatMessage::ToolUse { tool_id, .. } if tool_id == "b"))
.expect("b is on screen");
assert!(s.live_slots().contains(&b), "{:?} vs {b}", s.live_slots());
}
#[test]
fn a_tick_re_renders_only_the_live_slots_above_the_tail() {
let mut s = store();
s.push_msg(ChatMessage::User("go".into()));
running(&mut s, "a");
s.push_msg(ChatMessage::Text("long reply".into()));
s.push_msg(ChatMessage::System("→ steering: x".into()));
s.sync_cache(80, &ctx());
s.sync_cache(80, &ctx());
for i in 0..s.messages().len() {
let slot = s.render_message_lines(i, 80, &ctx());
s.cache.line_cache_mut().unwrap().per_msg[i] = slot;
}
s.cache.line_cache_mut().unwrap().rebuild_cum_from(0);
s.probe_reset();
assert!(s.tick_live());
s.sync_cache(80, &ctx());
assert_eq!(s.probe_render_count(), 1, "only the running card re-renders");
s.sync_cache(80, &ctx());
assert_eq!(s.probe_render_count(), 1, "the refresh is one-shot per tick");
}
#[test]
fn the_streaming_text_block_is_found_under_notices() {
let mut s = store();
s.begin_response();
s.append_or_update_text("hello");
assert_eq!(s.streaming_text_idx(), Some(0));
s.push_msg(ChatMessage::System("→ steering: x".into()));
assert_eq!(s.streaming_text_idx(), Some(0), "still the reply's block");
running(&mut s, "a");
assert_eq!(s.streaming_text_idx(), None, "a tool card ended the block");
s.seal_text_block();
assert_eq!(s.streaming_text_idx(), None);
}
}