use color_eyre::Result;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
};
use super::Component;
use crate::action::Action;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LineKind {
User,
Assistant,
Thought,
ToolResult {
call_id: String,
},
System,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MessageLine {
pub kind: LineKind,
pub text: String,
}
pub(crate) struct Transcript {
pub conversation_id: Option<String>,
pub lines: Vec<MessageLine>,
pub streaming: bool,
pub thoughts_collapsed: bool,
scroll: Option<u16>,
total_rows: u16,
viewport_rows: u16,
}
impl Default for Transcript {
fn default() -> Self {
Self {
conversation_id: None,
lines: Vec::new(),
streaming: false,
thoughts_collapsed: true,
scroll: None,
total_rows: 0,
viewport_rows: 0,
}
}
}
impl Transcript {
fn select(&mut self, id: &str) {
if self.conversation_id.as_deref() == Some(id) {
return;
}
self.conversation_id = Some(id.to_owned());
self.lines.clear();
self.streaming = false;
self.scroll = None;
}
fn targets_current(&self, conversation_id: &str) -> bool {
self.conversation_id.as_deref() == Some(conversation_id)
}
const fn toggle_thoughts(&mut self) {
self.thoughts_collapsed = !self.thoughts_collapsed;
}
fn scroll_up(&mut self, n: u16) {
let cur = self.scroll.unwrap_or_else(|| self.max_scroll());
self.scroll = Some(cur.saturating_sub(n));
}
fn scroll_down(&mut self, n: u16) {
let cur = self.scroll.unwrap_or_else(|| self.max_scroll());
let next = cur.saturating_add(n);
if next >= self.max_scroll() {
self.scroll = None; } else {
self.scroll = Some(next);
}
}
const fn max_scroll(&self) -> u16 {
self.total_rows.saturating_sub(self.viewport_rows)
}
fn render_lines(&self) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::with_capacity(self.lines.len());
for ml in &self.lines {
match &ml.kind {
LineKind::User => out.push(prefixed(
"you",
&ml.text,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
Style::default().fg(Color::White),
)),
LineKind::Assistant => out.push(prefixed(
"agent",
&ml.text,
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
Style::default(),
)),
LineKind::Thought => {
if self.thoughts_collapsed {
let preview = first_line(&ml.text);
out.push(Line::from(vec![
Span::styled(
"thought ▸ ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
),
Span::styled(
preview,
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
),
]));
} else {
out.push(prefixed(
"thought ▾",
&ml.text,
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::ITALIC),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::ITALIC),
));
}
}
LineKind::ToolResult { call_id } => {
let head = if call_id.is_empty() {
"← result".to_owned()
} else {
format!("← result ({call_id})")
};
out.push(prefixed(
&head,
&ml.text,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::DIM),
Style::default().fg(Color::Gray),
));
}
LineKind::System => out.push(Line::from(Span::styled(
ml.text.clone(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
))),
}
}
out
}
}
fn prefixed(prefix: &str, text: &str, prefix_style: Style, text_style: Style) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{prefix}: "), prefix_style),
Span::styled(text.to_owned(), text_style),
])
}
fn first_line(text: &str) -> String {
text.lines().next().unwrap_or("").to_owned()
}
impl Component for Transcript {
fn controls(&self) -> Vec<(&'static str, &'static str)> {
vec![
("↑↓/jk", "scroll"),
("PgUp/PgDn", "page"),
("t", "thoughts"),
]
}
fn handle(&mut self, action: &Action) -> Option<Action> {
match action {
Action::Select(id) => {
self.select(id);
}
Action::TranscriptHistory {
conversation_id,
lines,
} => {
self.select(conversation_id);
if self.targets_current(conversation_id) {
self.lines.clone_from(lines);
self.scroll = None;
}
}
Action::Key(key) => {
use ratatui::crossterm::event::KeyCode;
match key.code {
KeyCode::Char('t') => self.toggle_thoughts(),
KeyCode::Up | KeyCode::Char('k') => self.scroll_up(1),
KeyCode::Down | KeyCode::Char('j') => self.scroll_down(1),
KeyCode::PageUp => self.scroll_up(self.viewport_rows.max(1)),
KeyCode::PageDown => self.scroll_down(self.viewport_rows.max(1)),
KeyCode::Home => self.scroll = Some(0),
KeyCode::End => self.scroll = None,
_ => {}
}
}
_ => {}
}
None
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let id_label = self.conversation_id.as_deref().unwrap_or("(none)");
let status = if self.streaming { " ● streaming" } else { "" };
let title = format!(" transcript: {id_label}{status} ");
let block = Block::default().title(title).borders(Borders::ALL);
let inner = block.inner(area);
frame.render_widget(block, area);
if self.lines.is_empty() {
let hint = if self.conversation_id.is_some() {
"no messages yet"
} else {
"select a conversation"
};
let widget = Paragraph::new(Span::styled(
hint,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
));
frame.render_widget(widget, inner);
self.total_rows = 0;
self.viewport_rows = inner.height;
return Ok(());
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
let text_area = cols[0];
let bar_area = cols[1];
let lines = self.render_lines();
let line_count = u16::try_from(lines.len()).unwrap_or(u16::MAX);
self.total_rows = line_count;
self.viewport_rows = text_area.height;
let max_scroll = self.max_scroll();
let offset = self.scroll.map_or(max_scroll, |s| s.min(max_scroll));
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((offset, 0));
frame.render_widget(para, text_area);
let mut sb_state = ScrollbarState::new(self.total_rows as usize)
.viewport_content_length(self.viewport_rows as usize)
.position(offset as usize);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
frame.render_stateful_widget(scrollbar, bar_area, &mut sb_state);
Ok(())
}
}