use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap};
use crate::state::App;
use crate::state::session::{AgentInfo, ToolState};
use crate::ui::{truncate, truncate_tail, wrap};
const PROMPT_MAX_LINES: usize = 6;
pub(crate) struct EraCache {
agent_id: String,
calls: usize,
prompts: usize,
flags: Vec<bool>,
total: usize,
}
fn era_flags<'a>(
cache: &'a mut Option<EraCache>,
agent_id: &str,
agent: &AgentInfo,
model: &crate::state::session::SessionModel,
) -> &'a EraCache {
let stale = cache.as_ref().is_none_or(|c| {
c.agent_id != agent_id
|| c.calls != agent.tool_calls.len()
|| c.prompts != model.prompts.len()
});
if stale {
let (flags, total) = era_header_flags(agent, model);
*cache = Some(EraCache {
agent_id: agent_id.to_string(),
calls: agent.tool_calls.len(),
prompts: model.prompts.len(),
flags,
total,
});
}
cache.as_ref().unwrap()
}
pub fn render(frame: &mut Frame, area: Rect, app: &mut App, agent_id: &str) {
let palette = app.flow.theme.palette();
let App {
session,
era_cache,
detail_scroll,
detail_follow,
..
} = app;
let bg = Style::default().bg(palette.surface);
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.muted).bg(palette.surface))
.style(bg)
.padding(Padding::horizontal(1))
.title_top(
Line::from(" esc ✕ ")
.right_aligned()
.style(bg.fg(palette.subtle)),
);
if let Some(a) = session.agent(agent_id) {
let n = era_flags(era_cache, agent_id, a, session).total;
if n > 8 {
let label = if *detail_follow {
" j/k ↕ tail ".to_string()
} else {
format!(" j/k ↕ {}/{} ", detail_scroll, n)
};
block = block.title_bottom(
Line::from(label)
.right_aligned()
.style(bg.fg(palette.subtle)),
);
}
}
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let Some(agent) = session.agent(agent_id) else {
let para = Paragraph::new(Line::from(Span::styled(
"no detail for this agent",
Style::default().fg(palette.muted),
)))
.style(Style::default().bg(palette.surface));
frame.render_widget(para, inner);
return;
};
let provenance = session.provenance(agent).and_then(|c| {
let prompt = session.provenance_prompt(c).map(str::to_string);
let reasoning = c.reasoning.clone();
(prompt.is_some() || reasoning.is_some()).then_some((prompt, reasoning))
});
let prov_text_w = (inner.width as usize).saturating_sub(10);
let prov_prompt: Vec<String> = provenance
.as_ref()
.and_then(|(p, _)| p.as_deref())
.map(|p| wrap(p, prov_text_w, PROMPT_MAX_LINES))
.unwrap_or_default();
let prov_thought: Vec<String> = provenance
.as_ref()
.and_then(|(_, r)| r.as_deref())
.map(|r| wrap(r, prov_text_w, PROMPT_MAX_LINES))
.unwrap_or_default();
let prov_rows = if provenance.is_some() {
(1 + prov_prompt.len() + prov_thought.len()) as u16
} else {
0
};
let [header_area, prov_area, tools_area] = Layout::vertical([
Constraint::Length(6),
Constraint::Length(prov_rows),
Constraint::Fill(1),
])
.areas(inner);
render_header(frame, header_area, agent, &palette);
if provenance.is_some() {
render_provenance(frame, prov_area, &prov_prompt, &prov_thought, &palette);
}
render_tools(
frame,
tools_area,
agent_id,
agent,
session,
era_cache,
detail_scroll,
detail_follow,
&palette,
);
}
fn render_header(frame: &mut Frame, area: Rect, agent: &AgentInfo, palette: &rataflow::Palette) {
let status_text = agent.status_word();
let status_color = crate::ui::status_color(agent.status, palette);
let bg = Style::default().bg(palette.surface);
let mut lines: Vec<Line> = Vec::new();
let title = agent.agent_type.as_deref().unwrap_or("claude");
lines.push(Line::from(Span::styled(
title,
bg.fg(palette.text).add_modifier(Modifier::BOLD),
)));
let mut status_spans = vec![Span::styled(status_text, bg.fg(status_color))];
if let Some(model) = agent.model.as_ref() {
status_spans.push(Span::styled(" ", bg));
status_spans.push(Span::styled(model.as_str(), bg.fg(palette.subtle)));
}
lines.push(Line::from(status_spans));
if let Some(timing) = fmt_timing(agent) {
lines.push(Line::from(Span::styled(timing, bg.fg(palette.muted))));
}
lines.push(Line::from(Span::styled(
format!(
"{} tools · {} tok",
agent.tool_calls.len(),
agent.output_tokens
),
bg.fg(palette.muted),
)));
if let Some(desc) = agent.description.as_ref().filter(|d| !d.is_empty()) {
lines.push(Line::from(Span::styled(
desc.as_str(),
bg.fg(palette.subtle),
)));
}
frame.render_widget(
Paragraph::new(lines).style(bg).wrap(Wrap { trim: true }),
area,
);
}
fn render_provenance(
frame: &mut Frame,
area: Rect,
prompt: &[String],
reasoning: &[String],
palette: &rataflow::Palette,
) {
if area.height == 0 {
return;
}
let bg = Style::default().bg(palette.surface);
let label = bg.fg(palette.accent);
let width = area.width as usize;
let block = Style::default().bg(palette.muted);
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
"─ triggered by ".to_string() + &"─".repeat(width.saturating_sub(15)),
bg.fg(palette.muted),
))];
for (i, l) in prompt.iter().enumerate() {
let prefix = if i == 0 { "↳ prompt " } else { " " };
let pad = width.saturating_sub(
unicode_width::UnicodeWidthStr::width(prefix)
+ unicode_width::UnicodeWidthStr::width(l.as_str()),
);
lines.push(Line::from(vec![
Span::styled(prefix, block.fg(palette.accent)),
Span::styled(l.clone(), block.fg(palette.text)),
Span::styled(" ".repeat(pad), block),
]));
}
for (i, l) in reasoning.iter().enumerate() {
let prefix = if i == 0 { "↳ thought " } else { " " };
lines.push(Line::from(vec![
Span::styled(prefix, label),
Span::styled(l.clone(), bg.fg(palette.subtle)),
]));
}
frame.render_widget(Paragraph::new(lines).style(bg), area);
}
#[allow(clippy::too_many_arguments)]
fn render_tools(
frame: &mut Frame,
area: Rect,
agent_id: &str,
agent: &AgentInfo,
model: &crate::state::session::SessionModel,
era_cache: &mut Option<EraCache>,
detail_scroll: &mut u16,
detail_follow: &mut bool,
palette: &rataflow::Palette,
) {
let bg = Style::default().bg(palette.surface);
let block = Block::default()
.borders(Borders::TOP)
.border_style(bg.fg(palette.muted))
.title(Span::styled(" tool calls ", bg.fg(palette.subtle)))
.style(bg);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
if agent.tool_calls.is_empty() {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"no tool calls",
bg.fg(palette.muted),
)))
.style(bg),
inner,
);
return;
}
let width = inner.width as usize;
let header_before = &era_flags(era_cache, agent_id, agent, model).flags;
let mut headers: Vec<Option<Vec<String>>> = Vec::with_capacity(agent.tool_calls.len());
let mut total = 0usize;
for (tc, is_header) in agent.tool_calls.iter().zip(header_before) {
let wrapped = if *is_header
&& let Some(e) = model.prompt_for_ts(tc.ts)
&& let Some(p) = model.prompts.get(e)
{
Some(wrap(&p.excerpt, width.saturating_sub(2), usize::MAX))
} else {
None
};
total += wrapped.as_ref().map_or(0, Vec::len) + 1;
headers.push(wrapped);
}
let (scroll, follow) = resolve_scroll(
total.min(u16::MAX as usize) as u16,
inner.height,
*detail_scroll,
*detail_follow,
);
*detail_scroll = scroll;
*detail_follow = follow;
let view_start = scroll as usize;
let view_end = view_start + inner.height as usize;
let mut lines: Vec<Line> = Vec::with_capacity(inner.height as usize + 4);
let mut idx = 0usize;
let mut first_built: Option<usize> = None;
for (tc, wrapped) in agent.tool_calls.iter().zip(&headers) {
let rows = wrapped.as_ref().map_or(0, Vec::len) + 1;
if idx + rows > view_start && idx < view_end {
if first_built.is_none() {
first_built = Some(idx);
}
if let Some(wrapped) = wrapped {
let block = Style::default().bg(palette.muted);
for l in wrapped {
let pad =
width.saturating_sub(2 + unicode_width::UnicodeWidthStr::width(l.as_str()));
lines.push(Line::from(vec![
Span::styled("▍ ", block.fg(palette.accent)),
Span::styled(l.clone(), block.fg(palette.text)),
Span::styled(" ".repeat(pad), block),
]));
}
}
lines.push(tool_line(tc, width, palette));
}
idx += rows;
if idx >= view_end {
break;
}
}
let local_scroll = scroll.saturating_sub(first_built.unwrap_or(0) as u16);
frame.render_widget(
Paragraph::new(lines).style(bg).scroll((local_scroll, 0)),
inner,
);
}
fn resolve_scroll(total: u16, height: u16, scroll: u16, follow: bool) -> (u16, bool) {
let max = total.saturating_sub(height);
let offset = if follow { max } else { scroll.min(max) };
(offset, offset >= max)
}
fn era_header_flags(
agent: &AgentInfo,
model: &crate::state::session::SessionModel,
) -> (Vec<bool>, usize) {
let mut flags = Vec::with_capacity(agent.tool_calls.len());
let mut distinct = 0usize;
let mut prev: Option<usize> = None;
let mut headers = 0usize;
for tc in &agent.tool_calls {
let era = model.prompt_for_ts(tc.ts);
let is_boundary = matches!(era, Some(e) if prev != Some(e));
if is_boundary {
distinct += 1;
}
flags.push(is_boundary);
if let Some(e) = era {
prev = Some(e);
}
if is_boundary {
headers += 1;
}
}
if distinct <= 1 {
return (vec![false; agent.tool_calls.len()], agent.tool_calls.len());
}
(flags, agent.tool_calls.len() + headers)
}
pub fn tool_list_lines(agent: &AgentInfo, model: &crate::state::session::SessionModel) -> usize {
era_header_flags(agent, model).1
}
fn tool_line(
tc: &crate::state::session::ToolCallInfo,
width: usize,
palette: &rataflow::Palette,
) -> Line<'static> {
let bg = Style::default().bg(palette.surface);
let (glyph, color) = match tc.state {
ToolState::Pending => ('⏳', palette.accent),
ToolState::Ok => ('✓', palette.success),
ToolState::Err => ('✗', palette.error),
};
let w = |s: &str| unicode_width::UnicodeWidthStr::width(s);
let head = format!("{glyph} ");
let mut used = w(&head) + w(tc.name.as_str());
let mut spans = vec![
Span::styled(head, bg.fg(color)),
Span::styled(
tc.name.clone(),
bg.fg(palette.text).add_modifier(Modifier::BOLD),
),
];
let time = tc.ts.map(|t| {
t.with_timezone(&chrono::Local)
.format("%H:%M:%S")
.to_string()
});
let time_w = time.as_ref().map(|t| t.chars().count() + 1).unwrap_or(0);
if let Some(summary) = tc.summary.as_ref().filter(|s| !s.is_empty()) {
let budget = width.saturating_sub(used + 1 + time_w);
let summary = if matches!(tc.name.as_str(), "Read" | "Write" | "Edit") {
truncate_tail(summary, budget)
} else {
truncate(summary, budget)
};
used += 1 + w(&summary);
spans.push(Span::styled(format!(" {summary}"), bg.fg(palette.subtle)));
}
if let Some(time) = time {
let pad = width.saturating_sub(used + time_w);
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), bg));
}
spans.push(Span::styled(format!(" {time}"), bg.fg(palette.muted)));
}
Line::from(spans)
}
fn fmt_timing(agent: &AgentInfo) -> Option<String> {
match (agent.first_ts, agent.last_ts) {
(Some(first), Some(last)) => {
let secs = (last - first).num_seconds().max(0);
if secs >= 60 {
Some(format!("⏱ {}m {}s", secs / 60, secs % 60))
} else {
Some(format!("⏱ {secs}s"))
}
}
(Some(first), None) => Some(format!(
"⏱ started {}",
first.with_timezone(&chrono::Local).format("%H:%M:%S")
)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_scroll_clamps_and_reconciles_tail() {
assert_eq!(resolve_scroll(5, 10, 3, false), (0, true));
assert_eq!(resolve_scroll(20, 8, 0, true), (12, true));
assert_eq!(resolve_scroll(20, 8, 5, false), (5, false));
assert_eq!(resolve_scroll(20, 8, 99, false), (12, true));
}
#[test]
fn tool_list_lines_counts_era_headers() {
use crate::state::session::{SessionModel, ToolCallInfo, ToolState};
use crate::tailer::{Source, Update};
use crate::transcript::parse_line;
let mut m = SessionModel::new("s".into());
for (uid, ts, text) in [
("p1", "2026-06-07T10:00:00.000Z", "first"),
("p2", "2026-06-07T11:00:00.000Z", "second"),
] {
let line = format!(
r#"{{"type":"user","uuid":"{uid}","parentUuid":null,"origin":{{"kind":"human"}},"timestamp":"{ts}","message":{{"role":"user","content":"{text}"}}}}"#
);
m.apply_update(&Update::Entry {
source: Source::Main,
entry: parse_line(&line).unwrap(),
});
}
let agent = m.agents.get_mut(crate::state::session::MAIN_ID).unwrap();
for (i, ts) in [
"2026-06-07T10:30:00.000Z",
"2026-06-07T11:30:00.000Z",
"2026-06-07T11:31:00.000Z",
]
.iter()
.enumerate()
{
agent.tool_calls.push(ToolCallInfo {
id: format!("t{i}"),
name: "Bash".into(),
summary: None,
ts: Some(ts.parse().unwrap()),
end_ts: None,
state: ToolState::Ok,
});
}
let agent = m.agent(crate::state::session::MAIN_ID).unwrap();
assert_eq!(tool_list_lines(agent, &m), 5);
}
use chrono::{TimeZone, Utc};
fn agent_with_ts(first: Option<i64>, last: Option<i64>) -> AgentInfo {
let mut a = AgentInfo::new(crate::state::session::AgentKind::Subagent);
a.first_ts = first.map(|s| Utc.timestamp_opt(s, 0).unwrap());
a.last_ts = last.map(|s| Utc.timestamp_opt(s, 0).unwrap());
a
}
#[test]
fn timing_duration_under_a_minute() {
let a = agent_with_ts(Some(100), Some(142));
assert_eq!(fmt_timing(&a).as_deref(), Some("⏱ 42s"));
}
#[test]
fn timing_duration_over_a_minute() {
let a = agent_with_ts(Some(0), Some(125));
assert_eq!(fmt_timing(&a).as_deref(), Some("⏱ 2m 5s"));
}
#[test]
fn timing_negative_clamped() {
let a = agent_with_ts(Some(100), Some(50));
assert_eq!(fmt_timing(&a).as_deref(), Some("⏱ 0s"));
}
#[test]
fn timing_none_when_no_first() {
let a = agent_with_ts(None, None);
assert!(fmt_timing(&a).is_none());
}
}