use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Widget},
};
use crate::config::constants::ui;
use crate::ui::tui::session::terminal_capabilities;
use crate::ui::tui::session::{Session, TranscriptLine, spinner_frame_for_phase};
pub struct TranscriptWidget<'a> {
session: &'a mut Session,
show_scrollbar: bool,
custom_style: Option<Style>,
}
impl<'a> TranscriptWidget<'a> {
pub fn new(session: &'a mut Session) -> Self {
Self {
session,
show_scrollbar: false,
custom_style: None,
}
}
#[must_use]
pub fn show_scrollbar(mut self, show: bool) -> Self {
self.show_scrollbar = show;
self
}
#[must_use]
pub fn custom_style(mut self, style: Style) -> Self {
self.custom_style = Some(style);
self
}
}
impl<'a> Widget for TranscriptWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
self.session.set_transcript_area(None);
self.session.clear_transcript_file_link_targets();
return;
}
let block = Block::new()
.border_type(terminal_capabilities::get_border_type())
.style(self.session.styles.default_style())
.border_style(self.session.styles.border_style());
let inner = block.inner(area);
block.render(area, buf);
if inner.height == 0 || inner.width == 0 {
self.session.set_transcript_area(None);
self.session.clear_transcript_file_link_targets();
return;
}
self.session.set_transcript_area(Some(inner));
let effective_height = inner.height.min(ui::TUI_MAX_VIEWPORT_HEIGHT);
let effective_width = inner.width.min(ui::TUI_MAX_VIEWPORT_WIDTH);
self.session.apply_transcript_rows(effective_height);
let content_width = effective_width;
if content_width == 0 {
self.session.clear_transcript_file_link_targets();
return;
}
self.session.apply_transcript_width(content_width);
let viewport_rows = effective_height as usize;
let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
let effective_padding = padding.min(viewport_rows.saturating_sub(1));
let total_rows = self.session.total_transcript_rows(content_width) + effective_padding;
let (top_offset, _clamped_total_rows) = self
.session
.prepare_transcript_scroll(total_rows, viewport_rows);
let vertical_offset = top_offset.min(self.session.scroll_manager.max_offset());
self.session.transcript_view_top = vertical_offset;
let visible_start = vertical_offset;
let scroll_area = inner;
let cached_lines = self.session.collect_transcript_window_cached(
content_width,
visible_start,
viewport_rows,
);
let fill_count = viewport_rows.saturating_sub(cached_lines.len());
let needs_mutation = fill_count > 0 || !self.session.queued_inputs.is_empty();
let mut visible_lines = if needs_mutation {
let mut lines = cached_lines.to_vec();
if fill_count > 0 {
let target_len = lines.len() + fill_count;
lines.resize_with(target_len, TranscriptLine::default);
}
self.session.overlay_queue_lines(&mut lines, content_width);
self.session
.decorate_visible_cached_transcript_links(lines, scroll_area)
} else {
self.session
.decorate_borrowed_cached_transcript_links(cached_lines.as_slice(), scroll_area)
};
apply_active_file_operation_spinner(self.session, &mut visible_lines);
if self.session.transcript_clear_required {
Clear.render(scroll_area, buf);
self.session.transcript_clear_required = false;
}
apply_full_width_line_backgrounds(buf, scroll_area, &visible_lines);
let paragraph = Paragraph::new(visible_lines).style(self.session.styles.default_style());
paragraph.render(scroll_area, buf);
}
}
const FILE_OPERATION_STATUS_TOOLS: &[&str] = &[
"write_file",
"create_file",
"edit_file",
"apply_patch",
"search_replace",
"delete_file",
"unified_file",
];
const FILE_OPERATION_INDICATORS: &[&str] = &[
"❋ Writing ",
"❋ Editing ",
"❋ Applying patch to ",
"❋ Search/replace in ",
"❋ Deleting ",
];
fn apply_active_file_operation_spinner(session: &Session, lines: &mut [Line<'static>]) {
let Some(frame) = active_file_operation_spinner_frame(session) else {
return;
};
for line in lines.iter_mut().rev() {
if is_file_operation_indicator_line(line) && replace_indicator_icon(line, frame) {
break;
}
}
}
fn active_file_operation_spinner_frame(session: &Session) -> Option<&'static str> {
if !session.appearance.should_animate_progress_status() {
return None;
}
let left = session.input_status_left.as_deref()?.to_ascii_lowercase();
let is_active_file_tool = FILE_OPERATION_STATUS_TOOLS
.iter()
.any(|tool_name| left.contains(&format!("running tool: {tool_name}")));
is_active_file_tool.then(|| spinner_frame_for_phase(session.shimmer_state.phase()))
}
fn is_file_operation_indicator_line(line: &Line<'_>) -> bool {
let text = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
FILE_OPERATION_INDICATORS
.iter()
.any(|pattern| text.contains(pattern))
}
fn replace_indicator_icon(line: &mut Line<'static>, frame: &str) -> bool {
let mut replaced = false;
let mut new_spans = Vec::with_capacity(line.spans.len() + 2);
for span in std::mem::take(&mut line.spans) {
if replaced {
new_spans.push(span);
continue;
}
let style = span.style;
let text = span.content.into_owned();
let Some(icon_index) = text.find('❋') else {
new_spans.push(Span::styled(text, style));
continue;
};
let icon_end = icon_index + '❋'.len_utf8();
if icon_index > 0 {
new_spans.push(Span::styled(text[..icon_index].to_string(), style));
}
new_spans.push(Span::styled(frame.to_string(), style));
if icon_end < text.len() {
new_spans.push(Span::styled(text[icon_end..].to_string(), style));
}
replaced = true;
}
line.spans = new_spans;
replaced
}
fn line_background(line: &Line<'_>) -> Option<Color> {
line.spans.iter().find_map(|span| span.style.bg)
}
fn apply_full_width_line_backgrounds(buf: &mut Buffer, area: Rect, lines: &[Line<'_>]) {
if area.width == 0 || area.height == 0 {
return;
}
let max_rows = usize::from(area.height).min(lines.len());
for (row, line) in lines.iter().take(max_rows).enumerate() {
if let Some(bg) = line_background(line) {
let row_rect = Rect::new(area.x, area.y + row as u16, area.width, 1);
buf.set_style(row_rect, Style::default().bg(bg));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core_tui::types::{InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme};
use std::sync::Arc;
fn segment(text: &str) -> InlineSegment {
InlineSegment {
text: text.to_string(),
style: Arc::new(InlineTextStyle::default()),
}
}
fn row_text(buf: &Buffer, area: Rect, row: u16) -> String {
(area.left()..area.right())
.map(|x| buf[(x, row)].symbol())
.collect::<String>()
}
#[test]
fn scroll_metric_invalidation_does_not_request_transcript_clear() {
let mut session = Session::new(InlineTheme::default(), None, 12);
session.transcript_clear_required = false;
session.invalidate_scroll_metrics();
assert!(!session.transcript_clear_required);
}
#[test]
fn render_clears_stale_wrapped_rows_when_requested() {
let area = Rect::new(0, 0, 14, 6);
let inner = Rect::new(1, 1, 12, 4);
let mut buf = Buffer::empty(area);
let mut session = Session::new(InlineTheme::default(), None, 12);
session.push_line(
InlineMessageKind::Agent,
vec![segment("this line wraps across several rows")],
);
TranscriptWidget::new(&mut session).render(area, &mut buf);
let revision = session.next_revision();
session.lines[0].segments = vec![segment("short")];
session.lines[0].revision = revision;
session.mark_line_dirty(0);
session.invalidate_transcript_cache();
for row in inner.y + 1..inner.bottom() {
for x in inner.left()..inner.right() {
buf[(x, row)].set_symbol("X");
}
}
TranscriptWidget::new(&mut session).render(area, &mut buf);
assert!(
(inner.y + 1..inner.bottom()).all(|row| row_text(&buf, inner, row).trim().is_empty())
);
}
#[test]
fn render_preserves_queue_overlay_lines() {
let area = Rect::new(0, 0, 20, 6);
let inner = Rect::new(1, 1, 18, 4);
let mut buf = Buffer::empty(area);
let mut session = Session::new(InlineTheme::default(), None, 12);
session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
session.push_queued_input("queued follow-up".to_string());
TranscriptWidget::new(&mut session).render(area, &mut buf);
let bottom_row = row_text(&buf, inner, inner.bottom() - 1);
assert!(bottom_row.contains("queued"));
}
#[test]
fn render_clears_stale_queue_overlay_rows_when_queue_is_removed() {
let area = Rect::new(0, 0, 20, 6);
let inner = Rect::new(1, 1, 18, 4);
let mut buf = Buffer::empty(area);
let mut session = Session::new(InlineTheme::default(), None, 12);
session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
session.push_queued_input("queued follow-up".to_string());
TranscriptWidget::new(&mut session).render(area, &mut buf);
assert!(row_text(&buf, inner, inner.bottom() - 1).contains("queued"));
let _ = session.pop_latest_queued_input();
TranscriptWidget::new(&mut session).render(area, &mut buf);
assert!(row_text(&buf, inner, inner.bottom() - 1).trim().is_empty());
}
#[test]
fn resize_larger_keeps_existing_transcript_lines_visible() {
let small_area = Rect::new(0, 0, 20, 4);
let large_area = Rect::new(0, 0, 20, 10);
let small_inner = Rect::new(1, 1, 18, 2);
let large_inner = Rect::new(1, 1, 18, 8);
let mut small_buf = Buffer::empty(small_area);
let mut large_buf = Buffer::empty(large_area);
let mut session = Session::new(InlineTheme::default(), None, 12);
for index in 0..6 {
session.push_line(
InlineMessageKind::Agent,
vec![segment(&format!("line {index}"))],
);
}
TranscriptWidget::new(&mut session).render(small_area, &mut small_buf);
let small_rendered: Vec<String> = (small_inner.y..small_inner.bottom())
.map(|row| row_text(&small_buf, small_inner, row).trim().to_string())
.filter(|row| !row.is_empty())
.collect();
TranscriptWidget::new(&mut session).render(large_area, &mut large_buf);
let rendered: Vec<String> = (large_inner.y..large_inner.bottom())
.map(|row| row_text(&large_buf, large_inner, row).trim().to_string())
.filter(|row| !row.is_empty())
.collect();
assert!(rendered.len() > small_rendered.len());
assert!(rendered.iter().any(|row| row == "line 1"));
assert!(rendered.iter().any(|row| row == "line 5"));
}
#[test]
fn width_resize_keeps_transcript_visible() {
let wide_area = Rect::new(0, 0, 28, 8);
let narrow_area = Rect::new(0, 0, 16, 8);
let narrow_inner = Rect::new(1, 1, 14, 6);
let mut wide_buf = Buffer::empty(wide_area);
let mut narrow_buf = Buffer::empty(narrow_area);
let mut session = Session::new(InlineTheme::default(), None, 12);
for index in 0..4 {
session.push_line(
InlineMessageKind::Agent,
vec![segment(&format!("line {index}"))],
);
}
TranscriptWidget::new(&mut session).render(wide_area, &mut wide_buf);
TranscriptWidget::new(&mut session).render(narrow_area, &mut narrow_buf);
let rendered: Vec<String> = (narrow_inner.y..narrow_inner.bottom())
.map(|row| row_text(&narrow_buf, narrow_inner, row).trim().to_string())
.filter(|row| !row.is_empty())
.collect();
assert!(!rendered.is_empty());
assert!(rendered.iter().any(|row| row == "line 1"));
assert!(rendered.iter().any(|row| row == "line 3"));
}
}