use ratatui::{
buffer::Buffer,
prelude::*,
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Clear, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthChar;
use super::markdown::render_markdown_with_width;
use super::{
LiveActivityEvent, SessionActivityEvent,
apps::{BrowserActivityData, LiveBrowserActivityData, WebSearchActivityData},
common::{
AssistantActivityData, CodingEditActivityData, CodingOpenProjectActivityData,
CodingReviewActivityData, ErrorActivityData, ExploredActivityData,
ExploredCallActivityData, GenericAppActivityData, MessageImageAttachment, ReducedMotion,
RuntimeStatusActivityData, TerminalWaitActivityData, ThinkingActivityData,
UserActivityData,
},
exec::{ExecResultActivityData, LiveExecActivityData, TerminalExecutionMeta},
highlight::{
DiffScopeBackgrounds, diff_scope_backgrounds, highlight_patch_lines,
highlight_shell_command,
},
messages::{PatchActivityData, ReplyActivityData, TelegramActivityData},
plan::{PlanActivityData, PlanStepDisplayStatus},
};
use crate::activity_event::{
ExploredCallActivityAction, PatchDiffLineActivityDescriptor, PatchDiffLineKind,
PatchFileActivityDescriptor, PlanActivityKind, TerminalActivityAction, TerminalActivityOrigin,
WebSearchActivityAction,
};
use crate::dashboard::renderable::{FlexRenderable, Renderable, ViewportCulledColumn};
use crate::dashboard::selection::{
SelectableId, SelectableRegion, SelectionRegistry, line_plain_text,
};
use std::collections::HashSet;
const ACTIVITY_TITLE_GAP: &str = " ";
const ACTIVITY_BODY_INDENT: &str = " ";
const ACTIVITY_BULLET: &str = "•";
const USER_PROMPT_PREFIX: &str = "›";
const DETAIL_INITIAL_PREFIX: &str = " └ ";
const DETAIL_SUBSEQUENT_PREFIX: &str = " ";
const COMMAND_CONTINUATION_PREFIX: &str = " │ ";
const PATCH_DIFF_ADD_BACKGROUND: Color = Color::Rgb(20, 74, 42);
const PATCH_DIFF_DELETE_BACKGROUND: Color = Color::Rgb(88, 34, 38);
const SHIMMER_PADDING: usize = 10;
const SHIMMER_SWEEP_MS: i64 = 2_000;
const SHIMMER_BAND_HALF_WIDTH: f32 = 5.0;
pub struct CachedActivityLines {
entries: Vec<Option<CacheEntry>>,
#[cfg(feature = "tui-perf-cmd")]
hits: u64,
#[cfg(feature = "tui-perf-cmd")]
misses: u64,
}
struct CacheEntry {
width: u16,
options: ActivityDataRenderOptions,
cell: SessionActivityEvent,
lines: Vec<Line<'static>>,
height: u16,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct ActivityDataRenderOptions {
thinking_expanded: bool,
}
#[cfg(feature = "tui-perf-cmd")]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct CachedActivityLinesStats {
pub entries: usize,
pub occupied_entries: usize,
pub hits: u64,
pub misses: u64,
}
impl CachedActivityLines {
pub const fn new() -> Self {
Self {
entries: vec![],
#[cfg(feature = "tui-perf-cmd")]
hits: 0,
#[cfg(feature = "tui-perf-cmd")]
misses: 0,
}
}
#[cfg(feature = "tui-perf-cmd")]
pub fn stats(&self) -> CachedActivityLinesStats {
CachedActivityLinesStats {
entries: self.entries.len(),
occupied_entries: self.entries.iter().filter(|entry| entry.is_some()).count(),
hits: self.hits,
misses: self.misses,
}
}
#[cfg(feature = "tui-perf-cmd")]
pub const fn reset_stats(&mut self) {
self.hits = 0;
self.misses = 0;
}
fn ensure_capacity(&mut self, count: usize) {
if self.entries.len() < count {
self.entries.resize_with(count, || None);
} else if self.entries.len() > count {
self.entries.truncate(count);
}
}
fn get(
&mut self,
index: usize,
width: u16,
cell: &SessionActivityEvent,
options: ActivityDataRenderOptions,
) -> Option<CachedCellLines> {
let cached = self.entries.get(index).and_then(|e| {
e.as_ref().and_then(|entry| {
if entry.width == width && entry.options == options && entry.cell == *cell {
Some(CachedCellLines {
lines: entry.lines.clone(),
height: entry.height,
})
} else {
None
}
})
});
#[cfg(feature = "tui-perf-cmd")]
{
if cached.is_some() {
self.hits = self.hits.saturating_add(1);
} else {
self.misses = self.misses.saturating_add(1);
}
}
cached
}
fn set(
&mut self,
index: usize,
width: u16,
options: ActivityDataRenderOptions,
cell: SessionActivityEvent,
lines: Vec<Line<'static>>,
) -> CachedCellLines {
if index >= self.entries.len() {
self.entries.resize_with(index + 1, || None);
}
let height = cached_lines_height(&lines, width);
self.entries[index] = Some(CacheEntry {
width,
options,
cell,
lines: lines.clone(),
height,
});
CachedCellLines { lines, height }
}
}
#[derive(Clone)]
struct CachedCellLines {
lines: Vec<Line<'static>>,
height: u16,
}
impl Renderable for CachedCellLines {
fn render(&self, area: Rect, buf: &mut Buffer) {
if self.lines.is_empty() {
return;
}
Clear.render(area, buf);
Paragraph::new(Text::from(self.lines.clone()))
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
self.height
}
fn render_skip(&self, area: Rect, skip: u16, buf: &mut Buffer) {
if self.lines.is_empty() {
return;
}
if skip == 0 {
self.render(area, buf);
return;
}
Clear.render(area, buf);
Paragraph::new(Text::from(self.lines.clone()))
.wrap(Wrap { trim: false })
.scroll((skip, 0))
.render(area, buf);
}
}
fn cached_lines_height(lines: &[Line<'static>], width: u16) -> u16 {
if lines.is_empty() || width == 0 {
return 0;
}
Paragraph::new(Text::from(lines.to_vec()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(u16::MAX)
}
pub struct ActivityFeedRenderArgs<'a> {
pub cells: &'a [SessionActivityEvent],
pub live_cells: &'a [LiveActivityEvent],
pub expanded_thinking: &'a HashSet<usize>,
pub scroll_offset: u16,
pub cache: &'a mut CachedActivityLines,
pub user_hyperlink_areas: &'a mut Vec<Rect>,
pub selection: &'a SelectionRegistry,
pub selectable_regions: &'a mut Vec<SelectableRegion>,
}
pub fn render_activity_feed_cached(
buf: &mut Buffer,
area: Rect,
args: ActivityFeedRenderArgs<'_>,
) -> u16 {
let ActivityFeedRenderArgs {
cells,
live_cells,
expanded_thinking,
scroll_offset,
cache,
user_hyperlink_areas,
selection,
selectable_regions,
} = args;
user_hyperlink_areas.clear();
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y,
width: area.width.saturating_sub(2),
height: area.height,
};
let total_cells = cells.len() + live_cells.len();
if total_cells == 0 {
let placeholder =
Paragraph::new("No activity yet").style(Style::default().fg(Color::DarkGray));
placeholder.render(inner, buf);
return 0;
}
cache.ensure_capacity(total_cells);
let mut column = ViewportCulledColumn::new();
let mut column_rows = 0u16;
let mut user_cell_rows = Vec::new();
let mut selectable_cell_rows = Vec::new();
let spacer_line = CachedCellLines {
lines: vec![Line::from("")],
height: 1,
};
for (i, cell) in cells.iter().enumerate() {
let options = ActivityDataRenderOptions {
thinking_expanded: matches!(cell, SessionActivityEvent::Thinking(_))
&& expanded_thinking.contains(&i),
};
let cached = cache.get(i, inner.width, cell, options).unwrap_or_else(|| {
let lines = render_activity_cell_lines_with_options(cell, inner.width, options);
cache.set(i, inner.width, options, cell.clone(), lines)
});
let cached_height = cached.height;
if matches!(cell, SessionActivityEvent::User(_)) {
user_cell_rows.push((column_rows, cached_height));
}
selectable_cell_rows.push(SelectableActivityDataRows {
id: committed_activity_selectable_id(cell),
start: column_rows,
height: cached_height,
lines: cached.lines.iter().map(line_plain_text).collect(),
});
column.push(cached);
column_rows = column_rows.saturating_add(cached_height);
if !live_cells.is_empty() || i + 1 < cells.len() {
column.push(spacer_line.clone());
column_rows = column_rows.saturating_add(spacer_line.height);
}
}
for (i, lc) in live_cells.iter().enumerate() {
let idx = cells.len() + i;
let options = ActivityDataRenderOptions::default();
let lines = render_activity_cell_lines_with_options(&lc.event, inner.width, options);
let cached = cache.set(idx, inner.width, options, lc.event.clone(), lines);
let cached_height = cached.height;
if matches!(&lc.event, SessionActivityEvent::User(_)) {
user_cell_rows.push((column_rows, cached_height));
}
selectable_cell_rows.push(SelectableActivityDataRows {
id: SelectableId::new(format!("activity-live:{}", lc.key)),
start: column_rows,
height: cached_height,
lines: cached.lines.iter().map(line_plain_text).collect(),
});
column.push(cached);
column_rows = column_rows.saturating_add(cached_height);
if i + 1 < live_cells.len() {
column.push(spacer_line.clone());
column_rows = column_rows.saturating_add(spacer_line.height);
}
}
let total_height = column.desired_height(inner.width);
let effective_scroll = if scroll_offset == u16::MAX {
total_height.saturating_sub(inner.height)
} else {
scroll_offset
};
column.set_scroll(effective_scroll);
let max_scroll = total_height.saturating_sub(inner.height.saturating_sub(1));
push_visible_user_hyperlink_areas(
user_hyperlink_areas,
inner,
effective_scroll,
total_height.min(inner.height),
&user_cell_rows,
);
let visible_selectable_regions = visible_activity_selectable_regions(
inner,
effective_scroll,
total_height.min(inner.height),
&selectable_cell_rows,
);
let mut flex = FlexRenderable::new();
flex.push(1, column);
flex.render(inner, buf);
for region in &visible_selectable_regions {
if let Some(range) = selection.region_selection(®ion.id) {
region.highlight(&range, buf);
}
}
selectable_regions.extend(visible_selectable_regions);
max_scroll
}
struct SelectableActivityDataRows {
id: SelectableId,
start: u16,
height: u16,
lines: Vec<String>,
}
fn visible_activity_selectable_regions(
area: Rect,
scroll: u16,
viewport_height: u16,
cells: &[SelectableActivityDataRows],
) -> Vec<SelectableRegion> {
if area.width == 0 || viewport_height == 0 {
return Vec::new();
}
let viewport_top = scroll;
let viewport_bottom = scroll.saturating_add(viewport_height);
let mut regions = Vec::new();
for cell in cells {
let end = cell.start.saturating_add(cell.height);
if end <= viewport_top || cell.start >= viewport_bottom {
continue;
}
let skip = viewport_top.saturating_sub(cell.start);
let screen_y = area
.y
.saturating_add(cell.start.saturating_sub(viewport_top));
let screen_offset = screen_y.saturating_sub(area.y);
let visible_height = cell
.height
.saturating_sub(skip)
.min(viewport_height.saturating_sub(screen_offset));
if visible_height > 0 {
regions.push(SelectableRegion::new_with_group(
SelectableId::new(format!("{}:{}", cell.id.as_str(), cell.start)),
SelectableId::new("activity-feed"),
Rect::new(area.x, screen_y, area.width, visible_height),
cell.lines.clone(),
skip,
));
}
}
regions
}
fn committed_activity_selectable_id(cell: &SessionActivityEvent) -> SelectableId {
use sha2::{Digest as _, Sha256};
use std::fmt::Write as _;
let bytes = serde_json::to_vec(cell).unwrap_or_else(|_| format!("{cell:?}").into_bytes());
let digest = Sha256::digest(bytes);
let mut id = String::from("activity:");
for byte in digest.iter().take(12) {
let _ = write!(id, "{byte:02x}");
}
SelectableId::new(id)
}
fn push_visible_user_hyperlink_areas(
out: &mut Vec<Rect>,
area: Rect,
scroll: u16,
viewport_height: u16,
user_rows: &[(u16, u16)],
) {
if area.width == 0 || viewport_height == 0 {
return;
}
let viewport_top = scroll;
let viewport_bottom = scroll.saturating_add(viewport_height);
for &(start, height) in user_rows {
let end = start.saturating_add(height);
if end <= viewport_top || start >= viewport_bottom {
continue;
}
let skip = viewport_top.saturating_sub(start);
let screen_y = area.y.saturating_add(start.saturating_sub(viewport_top));
let screen_offset = screen_y.saturating_sub(area.y);
let visible_height = height
.saturating_sub(skip)
.min(viewport_height.saturating_sub(screen_offset));
if visible_height > 0 {
out.push(Rect::new(area.x, screen_y, area.width, visible_height));
}
}
}
impl Renderable for SessionActivityEvent {
fn render(&self, area: Rect, buf: &mut Buffer) {
let lines = render_activity_cell_lines(self, area.width);
if lines.is_empty() {
return;
}
Clear.render(area, buf);
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let lines = render_activity_cell_lines(self, width);
cached_lines_height(&lines, width)
}
}
fn render_activity_cell_lines(cell: &SessionActivityEvent, max_width: u16) -> Vec<Line<'static>> {
render_activity_cell_lines_with_options(cell, max_width, ActivityDataRenderOptions::default())
}
fn render_activity_cell_lines_with_options(
cell: &SessionActivityEvent,
max_width: u16,
options: ActivityDataRenderOptions,
) -> Vec<Line<'static>> {
match cell {
SessionActivityEvent::Assistant(cell) => render_assistant_cell_lines(cell, max_width),
SessionActivityEvent::User(cell) => render_user_cell_lines(cell, max_width),
SessionActivityEvent::Browser(cell) => render_browser_cell_lines(cell, max_width),
SessionActivityEvent::LiveBrowser(cell) => render_live_browser_cell_lines(cell, max_width),
SessionActivityEvent::WebSearch(cell) => render_web_search_cell_lines(cell, max_width),
SessionActivityEvent::CodingOpenProject(cell) => {
render_coding_open_project_cell_lines(cell, max_width)
}
SessionActivityEvent::Explored(cell) => render_explored_cell_lines(cell, max_width),
SessionActivityEvent::CodingEdit(cell) => render_coding_edit_cell_lines(cell, max_width),
SessionActivityEvent::CodingReview(cell) => render_coding_review_cell_lines(cell),
SessionActivityEvent::GenericApp(cell) => render_generic_app_cell_lines(cell),
SessionActivityEvent::PlanResult(cell) => render_plan_cell_lines(cell, max_width),
SessionActivityEvent::ExecResult(cell) => render_exec_cell_lines(cell, max_width),
SessionActivityEvent::LiveExec(cell) => render_live_exec_cell_lines(cell, max_width),
SessionActivityEvent::Patch(cell) => render_patch_cell_lines(cell, max_width),
SessionActivityEvent::Telegram(cell) => render_telegram_cell_lines(cell, max_width),
SessionActivityEvent::Reply(cell) => render_reply_cell_lines(cell, max_width),
SessionActivityEvent::TerminalWait(cell) => {
render_terminal_wait_cell_lines(cell, max_width)
}
SessionActivityEvent::Warning(cell) => render_warning_cell_lines(cell, max_width),
SessionActivityEvent::Error(cell) => render_error_cell_lines(cell, max_width),
SessionActivityEvent::Thinking(cell) => {
render_thinking_cell_lines(cell, max_width, options.thinking_expanded)
}
SessionActivityEvent::RuntimeStatus(cell) => {
render_runtime_status_cell_lines(cell, max_width)
}
SessionActivityEvent::Workflow(cell) => render_workflow_cell_lines(cell, max_width),
}
}
#[cfg(test)]
pub(in crate::dashboard) fn activity_transcript_text(
cells: &[SessionActivityEvent],
live_cells: &[LiveActivityEvent],
) -> String {
activity_transcript_lines(cells, live_cells, u16::MAX)
.into_iter()
.map(|line| rendered_line_text(&line))
.collect::<Vec<_>>()
.join("\n")
}
pub(in crate::dashboard) fn activity_transcript_lines(
cells: &[SessionActivityEvent],
live_cells: &[LiveActivityEvent],
width: u16,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for cell in cells {
append_transcript_cell_lines(&mut lines, activity_cell_transcript_lines(cell, width));
}
for live_cell in live_cells {
append_transcript_cell_lines(
&mut lines,
activity_cell_transcript_lines(&live_cell.event, width),
);
}
if lines.is_empty() {
vec![Line::from(Span::styled("No activity yet.", dim_style()))]
} else {
lines
}
}
fn append_transcript_cell_lines(target: &mut Vec<Line<'static>>, mut lines: Vec<Line<'static>>) {
if lines.is_empty() {
return;
}
if !target.is_empty() {
target.push(Line::from(""));
}
target.append(&mut lines);
}
fn activity_cell_transcript_lines(cell: &SessionActivityEvent, width: u16) -> Vec<Line<'static>> {
match cell {
SessionActivityEvent::Assistant(cell) => {
transcript_markdown_section("ASSISTANT", &cell.content, Color::White, width)
}
SessionActivityEvent::User(cell) => transcript_user_lines(cell, width),
SessionActivityEvent::Thinking(cell) => {
transcript_markdown_section("THINKING", &cell.content, Color::Gray, width)
}
SessionActivityEvent::PlanResult(cell) => transcript_plan_lines(cell, width),
SessionActivityEvent::ExecResult(cell) => exec_transcript_lines(cell, width),
SessionActivityEvent::LiveExec(cell) => live_exec_transcript_lines(cell, width),
SessionActivityEvent::Patch(cell) => {
patch_transcript_styled_lines("PATCH", &cell.summary_line, &cell.files, width)
}
SessionActivityEvent::WebSearch(cell) => render_web_search_cell_lines(cell, width),
SessionActivityEvent::Warning(cell) => {
transcript_text_section("WARNING", &cell.title, &cell.body_lines, width)
}
SessionActivityEvent::Error(cell) => {
transcript_text_section("ERROR", &cell.title, &cell.body_lines, width)
}
SessionActivityEvent::Workflow(cell) => workflow_transcript_lines(cell, width),
_ => transcript_plain_block(&activity_cell_transcript_block(cell)),
}
}
#[cfg(test)]
fn rendered_line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn transcript_header(title: impl Into<String>) -> Line<'static> {
Line::from(vec![Span::styled(
title.into(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)])
}
fn transcript_plain_block(block: &str) -> Vec<Line<'static>> {
block
.lines()
.enumerate()
.map(|(index, line)| {
if index == 0 {
transcript_header(line.to_string())
} else {
Line::from(line.to_string())
}
})
.collect()
}
fn transcript_markdown_section(
title: &str,
body: &str,
base_color: Color,
width: u16,
) -> Vec<Line<'static>> {
let mut lines = vec![transcript_header(title)];
if !body.trim().is_empty() {
lines.extend(prefixed_detail_lines(
render_markdown_with_width(body, base_color, Some(detail_markdown_width(width))),
width,
));
}
lines
}
fn transcript_user_lines(cell: &UserActivityData, width: u16) -> Vec<Line<'static>> {
let mut lines = vec![transcript_header("USER")];
let mut source_lines = user_source_lines(cell);
source_lines.extend(
cell.image_attachments
.iter()
.enumerate()
.map(|(index, attachment)| transcript_image_attachment_line(index, attachment)),
);
lines.push(blank_user_message_line());
lines.extend(user_prompt_lines(source_lines, width));
lines.push(blank_user_message_line());
lines
}
fn transcript_text_section(
title: &str,
primary: &str,
body_lines: &[String],
width: u16,
) -> Vec<Line<'static>> {
let mut lines = vec![transcript_header(title)];
let mut body = Vec::new();
if !primary.trim().is_empty() {
body.push(Line::from(primary.to_string()));
}
body.extend(body_lines.iter().cloned().map(Line::from));
lines.extend(prefixed_detail_lines(body, width));
lines
}
fn transcript_plan_lines(cell: &PlanActivityData, width: u16) -> Vec<Line<'static>> {
let title = match cell.kind {
PlanActivityKind::Proposed => "PROPOSED PLAN",
PlanActivityKind::Updated => "PLAN",
};
let mut lines = vec![transcript_header(title)];
if let Some(explanation) = cell.explanation.as_deref()
&& !explanation.trim().is_empty()
{
lines.extend(prefixed_detail_lines(
render_markdown_with_width(
&format!("note: {}", explanation.trim()),
Color::Gray,
Some(detail_markdown_width(width)),
)
.into_iter()
.map(|mut line| {
line.spans = line
.spans
.into_iter()
.map(|span| {
Span::styled(
span.content.to_string(),
span.style.add_modifier(Modifier::DIM | Modifier::ITALIC),
)
})
.collect();
line
})
.collect(),
width,
));
}
let steps = if cell.steps.is_empty() {
vec![Line::from(Span::styled(
"(empty plan)",
dim_style().add_modifier(Modifier::ITALIC),
))]
} else {
cell.steps
.iter()
.map(|step| {
let (marker, style) = match step.status {
PlanStepDisplayStatus::Completed => {
("✔ ", Style::default().fg(Color::DarkGray))
}
PlanStepDisplayStatus::InProgress => (
"□ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
PlanStepDisplayStatus::Pending => ("□ ", Style::default().fg(Color::Gray)),
};
Line::from(vec![
Span::styled(marker, style),
Span::styled(step.text.clone(), style),
])
})
.collect::<Vec<_>>()
};
lines.extend(prefixed_detail_lines(steps, width));
lines
}
fn workflow_transcript_lines(cell: &super::WorkflowActivityData, width: u16) -> Vec<Line<'static>> {
let mut lines = vec![transcript_header("WORKFLOW")];
let mut body = vec![Line::from(format!(
"{}: {:?}",
cell.workflow_id, cell.status
))];
if let Some(snapshot) = cell.snapshot.as_ref() {
body.extend(workflow_snapshot_outline_lines(snapshot));
}
if !cell.message.trim().is_empty() {
body.push(Line::from(cell.message.clone()));
}
if let Some(output) = cell.output.as_ref() {
body.push(Line::from(output.to_string()));
}
lines.extend(prefixed_detail_lines(body, width));
lines
}
fn workflow_snapshot_outline_lines(
snapshot: &crate::workflow::WorkflowRunSnapshot,
) -> Vec<Line<'static>> {
let total_agent_run_time_ms = snapshot
.workers
.iter()
.map(|worker| worker.agent_run_time_ms)
.sum::<u64>();
let actors = snapshot
.workers
.iter()
.map(workflow_snapshot_actor_id)
.collect::<std::collections::HashSet<_>>();
let mut lines = vec![Line::from(format!(
"run {} · {:?} · {} actor{} · Worked for {} total · input {}",
snapshot.run_id,
snapshot.status,
actors.len(),
if actors.len() == 1 { "" } else { "s" },
format_workflow_agent_run_time(total_agent_run_time_ms),
snapshot.input
))];
let mut actors = Vec::<WorkflowSnapshotActor>::new();
for worker in &snapshot.workers {
let actor_id = workflow_snapshot_actor_id(worker);
if let Some(actor) = actors.iter_mut().find(|actor| actor.actor_id == actor_id) {
actor.attempt_count += 1;
actor.agent_run_time_ms = actor
.agent_run_time_ms
.saturating_add(worker.agent_run_time_ms);
if worker.started_at_ms >= actor.latest_started_at_ms {
actor.latest_started_at_ms = worker.started_at_ms;
actor.model = worker.model.clone();
actor.status = worker.status;
}
} else {
actors.push(WorkflowSnapshotActor {
actor_id,
role: workflow_snapshot_worker_role(worker),
label: String::new(),
model: worker.model.clone(),
status: worker.status,
agent_run_time_ms: worker.agent_run_time_ms,
attempt_count: 1,
latest_started_at_ms: worker.started_at_ms,
});
}
}
let mut role_counts = std::collections::BTreeMap::<String, usize>::new();
for actor in &actors {
*role_counts.entry(actor.role.clone()).or_default() += 1;
}
let mut role_ordinals = std::collections::BTreeMap::<String, usize>::new();
for actor in &mut actors {
if role_counts.get(&actor.role).copied().unwrap_or_default() > 1 {
let ordinal = role_ordinals.entry(actor.role.clone()).or_default();
*ordinal += 1;
actor.label = format!("{} #{}", actor.role, ordinal);
} else {
actor.label = actor.role.clone();
}
}
lines.extend(actors.into_iter().map(|actor| {
Line::from(format!(
"{} {} · {} · {:?} · Worked for {}",
if actor.status == crate::workflow::WorkflowNodeStatus::Completed {
"✓"
} else {
"○"
},
actor.label,
if actor.attempt_count == 1 {
"1 attempt".to_string()
} else {
format!("{} attempts", actor.attempt_count)
},
actor.status,
format_workflow_agent_run_time(actor.agent_run_time_ms)
))
}));
lines
}
struct WorkflowSnapshotActor {
actor_id: String,
role: String,
label: String,
model: String,
status: crate::workflow::WorkflowNodeStatus,
agent_run_time_ms: u64,
attempt_count: usize,
latest_started_at_ms: i64,
}
fn workflow_snapshot_actor_id(worker: &crate::workflow::WorkflowWorkerSnapshot) -> String {
let actor_id = worker.actor_id.trim();
if actor_id.is_empty() {
worker.worker_id.clone()
} else {
actor_id.to_string()
}
}
fn workflow_snapshot_worker_role(worker: &crate::workflow::WorkflowWorkerSnapshot) -> String {
if worker.role.trim().is_empty() {
"agent".to_string()
} else {
worker.role.clone()
}
}
fn format_workflow_agent_run_time(agent_run_time_ms: u64) -> String {
if agent_run_time_ms < 1_000 {
format!("{agent_run_time_ms}ms")
} else if agent_run_time_ms < 60_000 {
format!("{}s", agent_run_time_ms / 1_000)
} else {
format!(
"{}m {:02}s",
agent_run_time_ms / 60_000,
(agent_run_time_ms % 60_000) / 1_000
)
}
}
fn exec_transcript_lines(cell: &ExecResultActivityData, width: u16) -> Vec<Line<'static>> {
let output = cell.command_output();
let mut lines = vec![transcript_header("COMMAND")];
let mut command = highlighted_shell_command_line(&output.command);
command.spans.insert(
0,
Span::styled(
"$ ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
);
lines.extend(prefixed_detail_lines(vec![command], width));
let output_lines = if output.output_lines.is_empty() {
vec![Line::from(Span::styled("(no output)", dim_style()))]
} else {
output
.output_lines
.iter()
.map(|line| Line::from(Span::styled(line.clone(), Style::default().fg(Color::Gray))))
.collect()
};
lines.extend(prefixed_detail_lines(output_lines, width));
if let Some(exit_code) = output.meta.exit_code {
lines.extend(prefixed_detail_lines(
vec![exit_marker_line(exit_code)],
width,
));
}
if let Some(meta) = cell.meta.as_deref()
&& !meta.trim().is_empty()
{
lines.extend(prefixed_detail_lines(
vec![Line::from(Span::styled(meta.to_string(), dim_style()))],
width,
));
}
lines
}
fn live_exec_transcript_lines(cell: &LiveExecActivityData, width: u16) -> Vec<Line<'static>> {
let output = cell.command_output();
let mut lines = vec![transcript_header("COMMAND")];
let mut command = highlighted_shell_command_line(&output.command);
command.spans.insert(
0,
Span::styled(
"$ ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
);
lines.extend(prefixed_detail_lines(vec![command], width));
let mut output_lines = output
.output_lines
.iter()
.map(|line| Line::from(Span::styled(line.clone(), Style::default().fg(Color::Gray))))
.collect::<Vec<_>>();
output_lines.push(Line::from(Span::styled("running...", dim_style())));
lines.extend(prefixed_detail_lines(output_lines, width));
lines
}
fn patch_transcript_styled_lines(
title: &str,
summary: &str,
files: &[PatchFileActivityDescriptor],
width: u16,
) -> Vec<Line<'static>> {
let mut lines = vec![transcript_header(title)];
if !summary.trim().is_empty() {
lines.extend(prefixed_detail_lines(
vec![Line::from(summary.to_string())],
width,
));
}
let diff_backgrounds = diff_scope_backgrounds();
let old_lineno_width = files
.iter()
.flat_map(|file| file.diff_lines.iter().filter_map(|line| line.old_lineno))
.max()
.unwrap_or(0)
.to_string()
.len()
.max(1);
let new_lineno_width = files
.iter()
.flat_map(|file| file.diff_lines.iter().filter_map(|line| line.new_lineno))
.max()
.unwrap_or(0)
.to_string()
.len()
.max(1);
for file in files {
let mut file_lines = vec![render_patch_file_header(file)];
file_lines.extend(render_patch_file_diff_lines(
file,
diff_backgrounds,
old_lineno_width,
new_lineno_width,
));
lines.extend(prefixed_detail_lines(file_lines, width));
}
lines
}
fn activity_cell_transcript_block(cell: &SessionActivityEvent) -> String {
match cell {
SessionActivityEvent::Assistant(cell) => {
transcript_section("ASSISTANT", &cell.content.clone())
}
SessionActivityEvent::User(cell) => transcript_section("USER", &user_transcript_text(cell)),
SessionActivityEvent::Thinking(cell) => {
transcript_section("THINKING", &cell.content.clone())
}
SessionActivityEvent::Browser(cell) => {
let mut lines = vec![format!(
"Captured URL: {}",
cell.url.as_deref().unwrap_or("unknown")
)];
if let Some(line_count) = cell.line_count {
lines.push(format!("lines: {line_count}"));
}
if let Some(ref_count) = cell.ref_count {
lines.push(format!("refs: {ref_count}"));
}
transcript_section("BROWSER", &lines.join("\n"))
}
SessionActivityEvent::LiveBrowser(cell) => {
let mut lines = vec![format!(
"Opening URL: {}",
cell.url.as_deref().unwrap_or(cell.title.as_str())
)];
lines.extend(cell.body_lines.clone());
transcript_section("BROWSER", &lines.join("\n"))
}
SessionActivityEvent::WebSearch(cell) => {
let mut lines = vec![format!("query: {}", cell.query)];
if let Some(url) = cell.url.as_deref() {
lines.push(format!("url: {url}"));
}
lines.extend(cell.body_lines.clone());
let title = match cell.action {
WebSearchActivityAction::Searching => "SEARCHING THE WEB",
WebSearchActivityAction::Searched => "SEARCHED THE WEB",
};
transcript_section(title, &lines.join("\n"))
}
SessionActivityEvent::CodingOpenProject(cell) => transcript_section(
"OPENED PROJECT",
&primary_transcript_text(&cell.project_root, &cell.detail_lines),
),
SessionActivityEvent::Explored(cell) => {
let lines = cell
.calls
.iter()
.flat_map(|call| {
let mut lines = vec![format!("{} {}", call.tool_name, call.summary)];
lines.extend(call.detail_lines.iter().map(|line| format!(" {line}")));
lines
})
.collect::<Vec<_>>();
transcript_section(&cell.title, &lines.join("\n"))
}
SessionActivityEvent::CodingEdit(cell) => {
let mut lines = vec![
cell.selector.clone(),
format!("+{} -{}", cell.added_lines, cell.removed_lines),
];
if let Some(file) = cell.file.as_deref() {
lines.push(format!("file: {file}"));
}
lines.extend(
cell.impact_lines
.iter()
.map(|line| format!("impact: {line}")),
);
lines.extend(patch_files_transcript_lines(&cell.diff_files));
transcript_section(&cell.title, &lines.join("\n"))
}
SessionActivityEvent::CodingReview(cell) => {
let mut lines = Vec::new();
if !cell.title.trim().is_empty() {
lines.push(cell.title.clone());
}
if !cell.summary.trim().is_empty() {
lines.push(cell.summary.clone());
}
if cell.review_pending {
lines.push("review pending".to_string());
}
transcript_section("REVIEW", &lines.join("\n"))
}
SessionActivityEvent::GenericApp(cell) => {
transcript_section(&cell.title, &cell.body_lines.join("\n"))
}
SessionActivityEvent::PlanResult(cell) => {
let mut lines = Vec::new();
if let Some(explanation) = cell.explanation.as_deref()
&& !explanation.trim().is_empty()
{
lines.push(format!("note: {}", explanation.trim()));
}
if cell.steps.is_empty() {
lines.push("(empty plan)".to_string());
} else {
lines.extend(cell.steps.iter().map(|step| {
let marker = match step.status {
PlanStepDisplayStatus::Pending => "[ ]",
PlanStepDisplayStatus::InProgress => "[~]",
PlanStepDisplayStatus::Completed => "[x]",
};
format!("{marker} {}", step.text)
}));
}
let title = match cell.kind {
PlanActivityKind::Proposed => "PROPOSED PLAN",
PlanActivityKind::Updated => "PLAN",
};
transcript_section(title, &lines.join("\n"))
}
SessionActivityEvent::ExecResult(cell) => exec_transcript_block(
"COMMAND",
&cell.title,
cell.meta.as_deref(),
&cell.output_lines,
),
SessionActivityEvent::LiveExec(cell) => live_exec_transcript_block(cell),
SessionActivityEvent::Patch(cell) => {
let mut lines = vec![cell.summary_line.clone()];
lines.extend(patch_files_transcript_lines(&cell.files));
transcript_section("PATCH", &lines.join("\n"))
}
SessionActivityEvent::Telegram(cell) => {
let mut lines = cell.detail_lines.clone();
lines.extend(cell.message_lines.clone());
transcript_section(&cell.title, &lines.join("\n"))
}
SessionActivityEvent::Reply(cell) => {
let title = match cell.disposition {
crate::activity_event::ReplyDisposition::Resolved => "REPLY RESOLVED",
crate::activity_event::ReplyDisposition::Dismissed => "REPLY DISMISSED",
crate::activity_event::ReplyDisposition::Failed => "REPLY FAILED",
};
transcript_section(title, &cell.message_lines.join("\n"))
}
SessionActivityEvent::TerminalWait(cell) => {
transcript_section(&cell.title, &terminal_wait_transcript_text(cell))
}
SessionActivityEvent::RuntimeStatus(cell) => {
transcript_section(&cell.label, &cell.detail.clone().unwrap_or_default())
}
SessionActivityEvent::Warning(cell) => transcript_section(
"WARNING",
&primary_transcript_text(&cell.title, &cell.body_lines),
),
SessionActivityEvent::Error(cell) => transcript_section(
"ERROR",
&primary_transcript_text(&cell.title, &cell.body_lines),
),
SessionActivityEvent::Workflow(cell) => {
transcript_section("WORKFLOW", &workflow_activity_text(cell))
}
}
}
fn workflow_activity_text(cell: &super::WorkflowActivityData) -> String {
let mut lines = vec![format!("{}: {:?}", cell.workflow_id, cell.status)];
if !cell.message.trim().is_empty() {
lines.push(cell.message.clone());
}
if let Some(output) = cell.output.as_ref() {
lines.push(output.to_string());
}
lines.join("\n")
}
fn transcript_section(title: &str, body: &str) -> String {
let body = body.trim_end();
if body.is_empty() {
title.to_string()
} else {
format!("{title}\n{body}")
}
}
fn primary_transcript_text(title: &str, body_lines: &[String]) -> String {
let mut lines = vec![title.to_string()];
lines.extend(body_lines.iter().cloned());
lines.join("\n")
}
fn terminal_wait_transcript_text(cell: &TerminalWaitActivityData) -> String {
let mut lines = Vec::new();
if let Some(meta) = cell.meta.as_deref().filter(|meta| !meta.trim().is_empty()) {
lines.push(format!("meta: {meta}"));
}
lines.extend(cell.body_lines.iter().cloned());
lines.join("\n")
}
fn user_transcript_text(cell: &UserActivityData) -> String {
let mut lines = cell
.content
.trim_end_matches(['\r', '\n'])
.lines()
.map(ToString::to_string)
.collect::<Vec<_>>();
lines.extend(
cell.image_attachments
.iter()
.enumerate()
.map(|(index, attachment)| {
format!(
"[{}: {}] {} {}",
index + 1,
image_attachment_kind(&attachment.uri),
attachment.label,
attachment.uri
)
}),
);
lines.join("\n")
}
fn exec_transcript_block(
title: &str,
command: &str,
meta: Option<&str>,
output_lines: &[String],
) -> String {
let mut lines = vec![format!("$ {command}")];
if let Some(meta) = meta
&& !meta.trim().is_empty()
{
lines.push(meta.to_string());
}
lines.extend(output_lines.iter().cloned());
let parsed_meta = TerminalExecutionMeta::parse(meta, output_lines);
if let Some(exit_code) = parsed_meta.exit_code {
let marker = if exit_code == 0 { "✓" } else { "✗" };
lines.push(format!("{marker} exit={exit_code}"));
}
transcript_section(title, &lines.join("\n"))
}
fn live_exec_transcript_block(cell: &LiveExecActivityData) -> String {
let mut lines = vec![format!("$ {}", cell.title)];
if let Some(started_at_ms) = cell.started_at_ms
&& let Some(elapsed_ms) = elapsed_since_ms(started_at_ms)
{
lines.push(format!("running for {}", format_elapsed_ms(elapsed_ms)));
}
lines.extend(cell.call_lines.iter().map(|line| format!("call: {line}")));
lines.extend(cell.output_lines.iter().cloned());
lines.push("… running".to_string());
transcript_section("COMMAND", &lines.join("\n"))
}
fn elapsed_since_ms(started_at_ms: i64) -> Option<u64> {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_millis();
let now_ms = i64::try_from(now_ms).ok()?;
let elapsed = now_ms.saturating_sub(started_at_ms);
u64::try_from(elapsed).ok()
}
fn format_elapsed_ms(ms: u64) -> String {
if ms < 1_000 {
format!("{ms}ms")
} else {
let seconds = ms / 1_000;
let tenths = (ms % 1_000) / 100;
format!("{seconds}.{tenths}s")
}
}
fn patch_files_transcript_lines(files: &[PatchFileActivityDescriptor]) -> Vec<String> {
files
.iter()
.flat_map(|file| {
let mut lines = vec![format!(
"{} (+{} -{})",
file.path, file.added_lines, file.removed_lines
)];
lines.extend(file.diff_lines.iter().map(patch_diff_line_transcript_text));
lines
})
.collect()
}
fn patch_diff_line_transcript_text(line: &PatchDiffLineActivityDescriptor) -> String {
let gutter = match line.kind {
PatchDiffLineKind::Context => " ",
PatchDiffLineKind::Delete => "-",
PatchDiffLineKind::Add => "+",
PatchDiffLineKind::HunkBreak => "⋮",
};
format!(
"{} {} {} {}",
line.old_lineno
.map(|lineno| lineno.to_string())
.unwrap_or_default(),
line.new_lineno
.map(|lineno| lineno.to_string())
.unwrap_or_default(),
gutter,
line.text
)
}
fn dim_style() -> Style {
Style::default().fg(Color::DarkGray)
}
fn bold_style() -> Style {
Style::default().add_modifier(Modifier::BOLD)
}
fn user_message_style() -> Style {
Style::default()
}
fn activity_header(title: impl Into<String>) -> Line<'static> {
activity_header_with_styles(title, dim_style(), bold_style())
}
fn activity_header_with_styles(
title: impl Into<String>,
bullet_style: Style,
title_style: Style,
) -> Line<'static> {
activity_header_with_marker(
Span::styled(ACTIVITY_BULLET, bullet_style),
title,
title_style,
)
}
fn activity_header_with_marker(
marker: Span<'static>,
title: impl Into<String>,
title_style: Style,
) -> Line<'static> {
Line::from(vec![
marker,
Span::raw(ACTIVITY_TITLE_GAP),
Span::styled(title.into(), title_style),
])
}
fn command_header_lines_from_line(
title: &str,
command: Line<'static>,
bullet_style: Style,
max_width: u16,
) -> Vec<Line<'static>> {
command_header_lines_with_marker(
Span::styled(ACTIVITY_BULLET, bullet_style),
title,
command,
max_width,
)
}
fn command_header_lines_with_marker(
marker: Span<'static>,
title: &str,
command: Line<'static>,
max_width: u16,
) -> Vec<Line<'static>> {
prefixed_wrapped_line(
command,
vec![
marker,
Span::raw(ACTIVITY_TITLE_GAP),
Span::styled(title.to_string(), bold_style()),
Span::raw(ACTIVITY_TITLE_GAP),
],
&[Span::styled(COMMAND_CONTINUATION_PREFIX, dim_style())],
max_width,
)
}
fn activity_marker(started_at_ms: Option<i64>) -> Span<'static> {
let glyphs = ["•", "◦", "▪", "◦"];
let now_ms = unix_time_millis();
let seed = started_at_ms.unwrap_or(0);
let glyph_count = i64::try_from(glyphs.len()).expect("activity marker glyph count fits in i64");
let frame = usize::try_from((now_ms.saturating_sub(seed) / 180).rem_euclid(glyph_count))
.expect("activity marker frame is non-negative");
Span::styled(
glyphs[frame],
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
}
fn shimmer_text(text: &str, started_at_ms: Option<i64>) -> Vec<Span<'static>> {
let now_ms = unix_time_millis();
let elapsed_ms =
started_at_ms.map_or(now_ms, |started_at_ms| now_ms.saturating_sub(started_at_ms));
shimmer_spans_at(text, elapsed_ms, terminal_supports_true_color())
}
fn shimmer_spans_at(text: &str, elapsed_ms: i64, has_true_color: bool) -> Vec<Span<'static>> {
let chars = text.chars().collect::<Vec<_>>();
if chars.is_empty() {
return Vec::new();
}
let char_count = chars.len();
chars
.into_iter()
.enumerate()
.map(|(index, ch)| {
let intensity = shimmer_intensity(index, char_count, elapsed_ms);
let style = if has_true_color {
let (r, g, b) = blend_rgb((255, 255, 255), (128, 128, 128), intensity * 0.9);
Style::default()
.fg(Color::Rgb(r, g, b))
.add_modifier(Modifier::BOLD)
} else {
shimmer_style_for_level(intensity)
};
Span::styled(ch.to_string(), style)
})
.collect()
}
fn shimmer_intensity(index: usize, char_count: usize, elapsed_ms: i64) -> f32 {
if char_count == 0 {
return 0.0;
}
let period = char_count.saturating_add(SHIMMER_PADDING * 2);
let elapsed_in_sweep = elapsed_ms.rem_euclid(SHIMMER_SWEEP_MS);
let elapsed_in_sweep = u16::try_from(elapsed_in_sweep)
.expect("shimmer elapsed time is bounded by its sweep duration");
let sweep_ms = u16::try_from(SHIMMER_SWEEP_MS).expect("shimmer sweep fits in u16");
let period = u16::try_from(period).unwrap_or(u16::MAX);
let index = u16::try_from(index).unwrap_or(u16::MAX);
let padding = u16::try_from(SHIMMER_PADDING).expect("shimmer padding fits in u16");
let phase = f32::from(elapsed_in_sweep) / f32::from(sweep_ms);
let pos = phase * f32::from(period);
let char_pos = f32::from(index) + f32::from(padding);
let dist = (char_pos - pos).abs();
if dist <= SHIMMER_BAND_HALF_WIDTH {
let x = std::f32::consts::PI * (dist / SHIMMER_BAND_HALF_WIDTH);
0.5 * (1.0 + x.cos())
} else {
0.0
}
}
fn shimmer_style_for_level(intensity: f32) -> Style {
if intensity < 0.2 {
Style::default().add_modifier(Modifier::DIM)
} else if intensity < 0.6 {
Style::default()
} else {
Style::default().add_modifier(Modifier::BOLD)
}
}
fn terminal_supports_true_color() -> bool {
supports_color::on_cached(supports_color::Stream::Stdout).is_some_and(|level| level.has_16m)
}
fn blend_rgb(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) {
let alpha = alpha.clamp(0.0, 1.0);
let weight = if alpha < 0.1 {
0
} else if alpha < 0.2 {
1
} else if alpha < 0.3 {
2
} else if alpha < 0.4 {
3
} else if alpha < 0.5 {
4
} else if alpha < 0.6 {
5
} else if alpha < 0.7 {
6
} else if alpha < 0.8 {
7
} else if alpha < 0.9 {
8
} else if alpha < 1.0 {
9
} else {
10
};
(
blend_channel(fg.0, bg.0, weight),
blend_channel(fg.1, bg.1, weight),
blend_channel(fg.2, bg.2, weight),
)
}
fn blend_channel(foreground: u8, background: u8, foreground_weight: u16) -> u8 {
let background_weight = 10u16.saturating_sub(foreground_weight);
let blended = (u16::from(background) * background_weight
+ u16::from(foreground) * foreground_weight)
/ 10;
u8::try_from(blended).expect("weighted average of two u8 channels fits in u8")
}
fn unix_time_millis() -> i64 {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
i64::try_from(millis).unwrap_or(i64::MAX)
}
fn user_prompt_lines(lines: Vec<Line<'static>>, max_width: u16) -> Vec<Line<'static>> {
let mut lines = prefix_wrapped_lines(
lines,
&[
Span::styled(USER_PROMPT_PREFIX, dim_style().add_modifier(Modifier::BOLD)),
Span::raw(ACTIVITY_TITLE_GAP),
],
&[Span::raw(ACTIVITY_BODY_INDENT)],
max_width,
);
for line in &mut lines {
line.style = line.style.patch(user_message_style());
}
lines
}
fn blank_user_message_line() -> Line<'static> {
let mut line = Line::from("");
line.style = user_message_style();
line
}
fn prefixed_body_lines(lines: Vec<Line<'static>>, max_width: u16) -> Vec<Line<'static>> {
prefix_wrapped_lines(
lines,
&[Span::raw(ACTIVITY_BODY_INDENT)],
&[Span::raw(ACTIVITY_BODY_INDENT)],
max_width,
)
}
fn body_markdown_width(max_width: u16) -> u16 {
max_width
.saturating_sub(
u16::try_from(ACTIVITY_BODY_INDENT.len()).expect("body indent width fits in u16"),
)
.max(1)
}
fn detail_markdown_width(max_width: u16) -> u16 {
max_width
.saturating_sub(
u16::try_from(DETAIL_SUBSEQUENT_PREFIX.len()).expect("detail prefix width fits in u16"),
)
.max(1)
}
fn prefixed_detail_lines(lines: Vec<Line<'static>>, max_width: u16) -> Vec<Line<'static>> {
prefix_wrapped_lines(
lines,
&[Span::styled(DETAIL_INITIAL_PREFIX, dim_style())],
&[Span::raw(DETAIL_SUBSEQUENT_PREFIX)],
max_width,
)
}
fn prefix_wrapped_lines(
lines: Vec<Line<'static>>,
initial_prefix: &[Span<'static>],
subsequent_prefix: &[Span<'static>],
max_width: u16,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
for (index, line) in lines.into_iter().enumerate() {
let prefix = if index == 0 {
initial_prefix.to_vec()
} else {
subsequent_prefix.to_vec()
};
out.extend(prefixed_wrapped_line(
line,
prefix,
subsequent_prefix,
max_width,
));
}
out
}
fn prefixed_wrapped_line(
content: Line<'static>,
initial_prefix: Vec<Span<'static>>,
subsequent_prefix: &[Span<'static>],
max_width: u16,
) -> Vec<Line<'static>> {
let line_style = content.style;
let mut out = Vec::new();
let mut current_prefix = initial_prefix;
let mut current_spans = current_prefix.clone();
let mut current_width = 0usize;
let mut has_content = false;
for span in content.spans {
let style = span.style;
let mut chunk = String::new();
let mut chunk_width = 0usize;
for ch in span.content.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
let prefix_width =
u16::try_from(spans_display_width(¤t_prefix)).unwrap_or(u16::MAX);
let content_width = usize::from(max_width.saturating_sub(prefix_width).max(1));
if current_width + chunk_width > 0
&& current_width + chunk_width + ch_width > content_width
{
if !chunk.is_empty() {
current_spans.push(Span::styled(std::mem::take(&mut chunk), style));
chunk_width = 0;
}
let mut line = Line::from(std::mem::replace(
&mut current_spans,
subsequent_prefix.to_vec(),
));
line.style = line_style;
out.push(line);
current_prefix = subsequent_prefix.to_vec();
current_width = 0;
}
chunk.push(ch);
chunk_width += ch_width;
has_content = true;
}
if !chunk.is_empty() {
current_width += chunk_width;
current_spans.push(Span::styled(chunk, style));
}
}
if !has_content || current_spans.len() > current_prefix.len() {
let mut line = Line::from(current_spans);
line.style = line_style;
out.push(line);
}
out
}
fn spans_display_width(spans: &[Span<'static>]) -> usize {
spans
.iter()
.flat_map(|span| span.content.chars())
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
.sum()
}
fn format_elapsed_seconds_compact(elapsed_seconds: u64) -> String {
if elapsed_seconds < 60 {
return format!("{elapsed_seconds}s");
}
if elapsed_seconds < 3600 {
let minutes = elapsed_seconds / 60;
let seconds = elapsed_seconds % 60;
return format!("{minutes}m {seconds:02}s");
}
let hours = elapsed_seconds / 3600;
let minutes = (elapsed_seconds % 3600) / 60;
let seconds = elapsed_seconds % 60;
format!("{hours}h {minutes:02}m {seconds:02}s")
}
fn display_width(text: &str) -> usize {
text.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
.sum()
}
fn truncate_display_width(text: &str, max_width: usize) -> String {
let mut out = String::new();
let mut width = 0usize;
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width > max_width {
break;
}
out.push(ch);
width += ch_width;
}
out
}
fn render_assistant_cell_lines(cell: &AssistantActivityData, max_width: u16) -> Vec<Line<'static>> {
let body_text = cell.content.trim();
let mut lines: Vec<Line<'static>> = Vec::new();
if !body_text.is_empty() {
let md_lines = render_markdown_with_width(
body_text,
Color::White,
Some(body_markdown_width(max_width)),
);
lines.extend(prefixed_body_lines(md_lines, max_width));
}
lines
}
fn thinking_collapsed_preview(content: &str) -> String {
let mut lines: Vec<&str> = content.lines().collect();
if lines.len() > 3 {
lines.truncate(2);
lines.push("...");
}
lines.join("\n")
}
fn render_thinking_cell_lines(
cell: &ThinkingActivityData,
max_width: u16,
expanded: bool,
) -> Vec<Line<'static>> {
let mut lines = vec![activity_header_with_styles(
"Thinking".to_string(),
dim_style(),
Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC),
)];
let body_text = if expanded {
cell.content.clone()
} else {
thinking_collapsed_preview(&cell.content)
};
let md_lines = render_markdown_with_width(
&body_text,
Color::Gray,
Some(body_markdown_width(max_width)),
)
.into_iter()
.map(|mut line| {
line.spans = line
.spans
.into_iter()
.map(|span| {
Span::styled(
span.content.to_string(),
span.style.add_modifier(Modifier::DIM | Modifier::ITALIC),
)
})
.collect();
line
})
.collect::<Vec<_>>();
lines.extend(prefixed_body_lines(md_lines, max_width));
lines
}
fn render_user_cell_lines(cell: &UserActivityData, max_width: u16) -> Vec<Line<'static>> {
let mut source_lines = user_source_lines(cell);
source_lines.extend(
cell.image_attachments
.iter()
.enumerate()
.map(|(index, attachment)| image_attachment_line(index, attachment)),
);
let mut rendered = Vec::new();
rendered.push(blank_user_message_line());
rendered.extend(user_prompt_lines(source_lines, max_width));
rendered.push(blank_user_message_line());
rendered
}
fn user_source_lines(cell: &UserActivityData) -> Vec<Line<'static>> {
cell.content
.trim_end_matches(['\r', '\n'])
.lines()
.map(|line| Line::from(line.to_string()))
.collect()
}
fn image_attachment_line(index: usize, attachment: &MessageImageAttachment) -> Line<'static> {
let kind = image_attachment_kind(&attachment.uri);
let label = attachment.label.trim();
let display_label = if label.is_empty() { "image" } else { label };
let mut spans = vec![
Span::styled(
format!("[{}: {}]", index + 1, kind),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(display_label.to_string(), Style::default().fg(Color::Gray)),
];
if !attachment.mime_type.trim().is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("({})", attachment.mime_type),
dim_style(),
));
}
Line::from(spans)
}
fn transcript_image_attachment_line(
index: usize,
attachment: &MessageImageAttachment,
) -> Line<'static> {
let kind = image_attachment_kind(&attachment.uri);
let label = attachment.label.trim();
let display_label = if label.is_empty() { "image" } else { label };
let mut spans = vec![
Span::styled(
format!("[{}: {}]", index + 1, kind),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(display_label.to_string(), Style::default().fg(Color::Gray)),
];
if !attachment.uri.trim().is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
attachment.uri.trim().to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED),
));
}
if !attachment.mime_type.trim().is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("({})", attachment.mime_type),
dim_style(),
));
}
Line::from(spans)
}
fn image_attachment_kind(uri: &str) -> &'static str {
if uri.starts_with("http://") || uri.starts_with("https://") || uri.starts_with("data:") {
"remote image"
} else {
"local image"
}
}
fn render_generic_app_cell_lines(cell: &GenericAppActivityData) -> Vec<Line<'static>> {
vec![activity_header(format!("App: {}", cell.title))]
}
fn render_coding_open_project_cell_lines(
cell: &CodingOpenProjectActivityData,
max_width: u16,
) -> Vec<Line<'static>> {
let title = format!("Opened Project: {}", cell.project_root);
let mut lines = vec![activity_header(title)];
let detail = cell
.detail_lines
.iter()
.take(6)
.cloned()
.map(Line::from)
.collect::<Vec<_>>();
lines.extend(prefixed_detail_lines(detail, max_width));
lines
}
fn render_explored_cell_lines(cell: &ExploredActivityData, max_width: u16) -> Vec<Line<'static>> {
let mut lines = vec![activity_header(cell.title.clone())];
let mut detail = Vec::new();
let mut index = 0;
while index < cell.calls.len() {
let explored_call = &cell.calls[index];
if matches!(
explored_call_action(explored_call),
Some(ExploredCallActivityAction::Read)
) {
let mut names = vec![explored_read_target(explored_call)];
index += 1;
while index < cell.calls.len()
&& matches!(
explored_call_action(&cell.calls[index]),
Some(ExploredCallActivityAction::Read)
)
{
names.push(explored_read_target(&cell.calls[index]));
index += 1;
}
names.dedup();
detail.push(coding_action_line("Read", names.join(", ")));
} else {
detail.push(explored_call_line(explored_call));
index += 1;
}
}
lines.extend(prefixed_detail_lines(detail, max_width));
lines
}
fn explored_call_line(call: &ExploredCallActivityData) -> Line<'static> {
let tool_name = call.tool_name.trim();
let summary = call.summary.trim();
match explored_call_action(call) {
Some(ExploredCallActivityAction::Read) => {
coding_action_line("Read", explored_read_target(call))
}
Some(ExploredCallActivityAction::Search) => {
coding_action_line("Search", explored_search_target(call))
}
Some(ExploredCallActivityAction::List) => coding_action_line(
"List",
call.target
.as_deref()
.map_or_else(|| summary.to_string(), compact_coding_summary_path),
),
Some(ExploredCallActivityAction::Run) => coding_action_line(
"Run",
call.target
.as_deref()
.map_or_else(|| summary.to_string(), ToString::to_string),
),
None if tool_name == "Read" => {
coding_action_line("Read", compact_coding_summary_path(summary))
}
None if tool_name == "Search" => {
coding_action_line("Search", format_coding_search_summary(summary))
}
_ if summary.is_empty() => coding_action_line(tool_name, String::new()),
_ => coding_action_line(tool_name, summary.to_string()),
}
}
fn explored_call_action(call: &ExploredCallActivityData) -> Option<ExploredCallActivityAction> {
call.action.clone().or_else(|| match call.tool_name.trim() {
"Read" => Some(ExploredCallActivityAction::Read),
"Search" => Some(ExploredCallActivityAction::Search),
"List" => Some(ExploredCallActivityAction::List),
"Run" => Some(ExploredCallActivityAction::Run),
_ => None,
})
}
fn explored_read_target(call: &ExploredCallActivityData) -> String {
call.target.as_deref().map_or_else(
|| compact_coding_summary_path(&call.summary),
compact_coding_summary_path,
)
}
fn explored_search_target(call: &ExploredCallActivityData) -> String {
if let Some(query) = call.target.as_deref() {
return match call.secondary_target.as_deref() {
Some(path) if !path.trim().is_empty() => {
format!("{} in {}", query.trim(), compact_coding_summary_path(path))
}
_ => query.trim().to_string(),
};
}
format_coding_search_summary(&call.summary)
}
fn coding_action_line(title: &str, detail: String) -> Line<'static> {
let mut spans = vec![Span::styled(
title.to_string(),
Style::default().fg(Color::Cyan),
)];
if !detail.is_empty() {
spans.push(Span::raw(ACTIVITY_TITLE_GAP));
spans.push(Span::raw(detail));
}
Line::from(spans)
}
fn format_coding_search_summary(summary: &str) -> String {
let (query_part, path) = summary
.rsplit_once(" in ")
.map_or((summary, None), |(query, path)| (query, Some(path)));
let query = strip_coding_result_count(query_part);
path.map_or_else(
|| query.to_string(),
|path| format!("{} in {}", query, compact_coding_summary_path(path)),
)
}
fn strip_coding_result_count(summary: &str) -> &str {
summary
.rsplit_once(" — ")
.map_or_else(|| summary.trim(), |(query, _)| query.trim())
}
fn compact_coding_summary_path(summary: &str) -> String {
let target = summary
.split_once(" -> ")
.map_or(summary, |(target, _)| target)
.trim();
let path = target.split_once(":L").map_or(target, |(path, _)| path);
let path = path.split_once('#').map_or(path, |(path, _)| path).trim();
std::path::Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(path)
.to_string()
}
fn render_coding_edit_cell_lines(
cell: &CodingEditActivityData,
max_width: u16,
) -> Vec<Line<'static>> {
let diff_files = cell.diff_files.as_slice();
let diff_backgrounds = diff_scope_backgrounds();
let old_lineno_width = diff_files
.iter()
.flat_map(|file| file.diff_lines.iter().filter_map(|line| line.old_lineno))
.max()
.unwrap_or(0)
.to_string()
.len()
.max(1);
let new_lineno_width = diff_files
.iter()
.flat_map(|file| file.diff_lines.iter().filter_map(|line| line.new_lineno))
.max()
.unwrap_or(0)
.to_string()
.len()
.max(1);
let mut lines = vec![activity_header(coding_edit_title(cell))];
for (index, file) in diff_files.iter().enumerate() {
if index > 0 {
lines.push(Line::from(""));
}
let mut file_lines = if cell.diff_files.len() == 1 {
Vec::new()
} else {
vec![render_patch_file_header(file)]
};
file_lines.extend(render_patch_file_diff_lines(
file,
diff_backgrounds,
old_lineno_width,
new_lineno_width,
));
lines.extend(prefixed_detail_lines(file_lines, max_width));
}
lines
}
fn coding_edit_title(cell: &CodingEditActivityData) -> String {
if let [file] = cell.diff_files.as_slice() {
return patch_single_file_title(file);
}
if !cell.diff_files.is_empty() {
let file_noun = if cell.diff_files.len() == 1 {
"File"
} else {
"Files"
};
return format!("Edited {} {}", cell.diff_files.len(), file_noun);
}
if let Some(file) = cell.file.as_deref().filter(|file| !file.trim().is_empty()) {
return format!(
"Edited {} (+{} -{})",
file, cell.added_lines, cell.removed_lines
);
}
format!(
"Edited Code (+{} -{})",
cell.added_lines, cell.removed_lines
)
}
fn render_workflow_cell_lines(
cell: &super::WorkflowActivityData,
max_width: u16,
) -> Vec<Line<'static>> {
let status = format!("{:?}", cell.status).to_ascii_lowercase();
let mut lines = vec![Line::from(vec![
Span::styled("Workflow", bold_style()),
Span::raw(" "),
Span::styled(cell.workflow_id.clone(), Style::default().fg(Color::Cyan)),
Span::raw(" — "),
Span::styled(status, dim_style()),
Span::styled(" [w inspect]", dim_style()),
])];
if let Some(snapshot) = cell.snapshot.as_ref() {
let actors = snapshot
.workers
.iter()
.map(workflow_snapshot_actor_id)
.collect::<std::collections::HashSet<_>>();
let summary = format!(
"{} actor{} · {} run{} · {}",
actors.len(),
if actors.len() == 1 { "" } else { "s" },
snapshot.workers.len(),
if snapshot.workers.len() == 1 { "" } else { "s" },
snapshot.run_id,
);
lines.extend(prefixed_detail_lines(vec![Line::from(summary)], max_width));
}
if !cell.message.trim().is_empty() {
lines.extend(prefixed_detail_lines(
vec![Line::from(cell.message.clone())],
max_width,
));
}
if cell.snapshot.is_none()
&& let Some(output) = cell.output.as_ref()
{
lines.extend(prefixed_detail_lines(
vec![Line::from(output.to_string())],
max_width,
));
}
lines
}
fn render_runtime_status_cell_lines(
cell: &RuntimeStatusActivityData,
max_width: u16,
) -> Vec<Line<'static>> {
let elapsed_seconds = cell
.active_runtime_started_at_ms
.map_or(0, |started_at_ms| {
let now_ms = unix_time_millis();
now_ms.saturating_sub(started_at_ms).max(0).cast_unsigned() / 1000
});
runtime_status_cell_lines_at(cell, elapsed_seconds, max_width)
}
fn runtime_status_cell_lines_at(
cell: &RuntimeStatusActivityData,
elapsed_seconds: u64,
max_width: u16,
) -> Vec<Line<'static>> {
let marker = if cell.reduced_motion == ReducedMotion::Full {
activity_marker(cell.active_runtime_started_at_ms)
} else {
Span::raw(ACTIVITY_BULLET)
};
let title = if cell.label.trim().is_empty() {
"Working"
} else {
cell.label.trim()
};
let mut spans = if cell.reduced_motion == ReducedMotion::Full {
shimmer_text(title, cell.active_runtime_started_at_ms)
} else {
vec![Span::styled(title.to_string(), bold_style())]
};
spans.extend([
Span::styled(
format!(" ({} • ", format_elapsed_seconds_compact(elapsed_seconds)),
dim_style(),
),
Span::styled("esc", bold_style()),
Span::styled(" to interrupt)", dim_style()),
]);
if let Some(detail) = cell
.detail
.as_deref()
.filter(|detail| !detail.trim().is_empty())
{
spans.push(Span::styled(" — ", dim_style()));
spans.push(Span::styled(detail.trim().to_string(), dim_style()));
}
prefixed_wrapped_line(
Line::from(spans),
vec![marker, Span::raw(ACTIVITY_TITLE_GAP)],
&[Span::styled(COMMAND_CONTINUATION_PREFIX, dim_style())],
max_width,
)
}
fn render_terminal_wait_cell_lines(
cell: &TerminalWaitActivityData,
max_width: u16,
) -> Vec<Line<'static>> {
render_wait_activity_lines(&cell.title, &cell.body_lines, 6, max_width)
}
fn render_error_cell_lines(cell: &ErrorActivityData, max_width: u16) -> Vec<Line<'static>> {
render_error_lines(&cell.title, &cell.body_lines, 12, max_width)
}
fn render_warning_cell_lines(cell: &ErrorActivityData, max_width: u16) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(vec![
Span::styled(
"⚠",
Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(ACTIVITY_TITLE_GAP),
Span::styled(
cell.title.clone(),
Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::BOLD),
),
])];
lines.extend(prefixed_body_lines(
cell.body_lines
.iter()
.take(12)
.map(|line| Line::from(Span::styled(line.clone(), Style::default().fg(Color::Gray))))
.collect(),
max_width,
));
lines
}
fn render_browser_cell_lines(cell: &BrowserActivityData, max_width: u16) -> Vec<Line<'static>> {
let mut lines = vec![activity_header(format!(
"Captured URL: {}",
cell.url
.as_deref()
.map_or_else(|| "unknown".to_string(), compact_browser_url)
))];
let mut stats = Vec::new();
if let Some(line_count) = cell.line_count {
stats.push(format!("{line_count} lines"));
}
if let Some(ref_count) = cell.ref_count {
stats.push(format!("{ref_count} refs"));
}
if !stats.is_empty() {
lines.extend(prefixed_detail_lines(
vec![Line::from(Span::styled(
stats.join(" · "),
Style::default().fg(Color::Gray),
))],
max_width,
));
}
lines
}
fn render_live_browser_cell_lines(
cell: &LiveBrowserActivityData,
max_width: u16,
) -> Vec<Line<'static>> {
let title = cell.url.as_deref().map_or_else(
|| cell.title.clone(),
|url| format!("Opening URL: {}", compact_browser_url(url)),
);
let mut lines = vec![activity_header_with_marker(
activity_marker(None),
title,
bold_style(),
)];
lines.extend(prefixed_detail_lines(
cell.body_lines
.iter()
.take(1)
.cloned()
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::Gray))))
.collect(),
max_width,
));
lines
}
fn render_web_search_cell_lines(
cell: &WebSearchActivityData,
max_width: u16,
) -> Vec<Line<'static>> {
let title = match cell.action {
WebSearchActivityAction::Searching => format!("Searching the web: {}", cell.query),
WebSearchActivityAction::Searched => format!("Searched the web: {}", cell.query),
};
let marker = match cell.action {
WebSearchActivityAction::Searching => activity_marker(None),
WebSearchActivityAction::Searched => Span::styled(ACTIVITY_BULLET, dim_style()),
};
let mut lines = vec![activity_header_with_marker(marker, title, bold_style())];
let mut detail = Vec::new();
if let Some(url) = cell.url.as_deref() {
detail.push(Line::from(Span::styled(
compact_browser_url(url),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED),
)));
}
detail.extend(
cell.body_lines
.iter()
.take(4)
.map(|line| Line::from(Span::styled(line.clone(), Style::default().fg(Color::Gray)))),
);
lines.extend(prefixed_detail_lines(detail, max_width));
lines
}
fn render_exec_cell_lines(cell: &ExecResultActivityData, max_width: u16) -> Vec<Line<'static>> {
let output = cell.command_output();
let exit_code = output.meta.exit_code;
let running = output.meta.is_running();
let indicator_style = if running {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else if exit_code == Some(0) {
Style::default()
.fg(Color::LightGreen)
.add_modifier(Modifier::BOLD)
} else if exit_code.is_some() {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
};
let mut lines = command_header_lines_from_line(
exec_header_title(cell, &output.meta),
highlighted_shell_command_line(&output.command),
indicator_style,
max_width,
);
lines.extend(exec_output_detail_lines(
&cell.output_lines,
"(no output)",
max_width,
));
lines
}
fn exec_header_title(cell: &ExecResultActivityData, meta: &TerminalExecutionMeta) -> &'static str {
if meta.is_running() {
return "Running";
}
match cell.terminal_action {
Some(TerminalActivityAction::Execute)
if cell.terminal_origin == Some(TerminalActivityOrigin::User) =>
{
"You ran"
}
Some(TerminalActivityAction::Continue) => "Continued",
Some(TerminalActivityAction::Poll) => "Checked",
Some(TerminalActivityAction::Terminate) => "Terminated",
Some(TerminalActivityAction::Execute) | None => "Ran",
}
}
fn exit_marker_line(exit_code: i32) -> Line<'static> {
let (marker, style) = if exit_code == 0 {
(
"✓",
Style::default()
.fg(Color::LightGreen)
.add_modifier(Modifier::BOLD),
)
} else {
(
"✗",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
};
Line::from(vec![
Span::styled(marker, style),
Span::raw(format!(" exit={exit_code}")),
])
}
fn render_live_exec_cell_lines(cell: &LiveExecActivityData, max_width: u16) -> Vec<Line<'static>> {
let mut lines = command_header_lines_with_marker(
activity_marker(cell.started_at_ms),
"Running",
highlighted_shell_command_line(&cell.title),
max_width,
);
lines.extend(exec_output_detail_lines(
&cell.output_lines,
"running...",
max_width,
));
lines
}
fn highlighted_shell_command_line(command: &str) -> Line<'static> {
highlight_shell_command(command)
.map_or_else(|| Line::from(Span::raw(command.to_string())), Line::from)
}
fn render_patch_cell_lines(cell: &PatchActivityData, max_width: u16) -> Vec<Line<'static>> {
let files = cell.files.as_slice();
let diff_backgrounds = diff_scope_backgrounds();
let old_lineno_width = files
.iter()
.flat_map(|file| file.diff_lines.iter().filter_map(|line| line.old_lineno))
.max()
.unwrap_or(0)
.to_string()
.len()
.max(1);
let new_lineno_width = files
.iter()
.flat_map(|file| file.diff_lines.iter().filter_map(|line| line.new_lineno))
.max()
.unwrap_or(0)
.to_string()
.len()
.max(1);
let mut lines = if let [file] = cell.files.as_slice() {
vec![activity_header(patch_single_file_title(file))]
} else {
let file_noun = if cell.files.len() == 1 {
"File"
} else {
"Files"
};
vec![activity_header(format!(
"Edited {} {}",
cell.files.len(),
file_noun
))]
};
for (index, file) in files.iter().enumerate() {
if index > 0 {
lines.push(Line::from(""));
}
let mut file_lines = if cell.files.len() == 1 {
Vec::new()
} else {
vec![render_patch_file_header(file)]
};
file_lines.extend(render_patch_file_diff_lines(
file,
diff_backgrounds,
old_lineno_width,
new_lineno_width,
));
lines.extend(prefixed_detail_lines(file_lines, max_width));
}
lines
}
fn render_telegram_cell_lines(cell: &TelegramActivityData, max_width: u16) -> Vec<Line<'static>> {
render_message_activity_lines(&MessageActivityInput {
title: &cell.title,
detail_lines: &cell.detail_lines,
message_lines: &cell.message_lines,
detail_limit: 6,
message_limit: 6,
markdown: false,
max_width,
})
}
fn render_reply_cell_lines(cell: &ReplyActivityData, max_width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if let Some(elapsed) = cell.elapsed_seconds {
let label = format!("─ Worked for {} ─", format_elapsed_seconds_compact(elapsed));
let width = usize::from(max_width);
let label_width = display_width(&label);
let text = if label_width >= width {
truncate_display_width(&label, width)
} else {
format!("{label}{}", "─".repeat(width.saturating_sub(label_width)))
};
lines.push(Line::from(Span::styled(text, dim_style())));
lines.push(Line::from(""));
}
if cell.disposition == crate::activity_event::ReplyDisposition::Resolved
&& cell.subject == crate::activity_event::ReplySubject::Message
{
lines.extend(render_agent_message_reply_lines(
&cell.message_lines,
max_width,
));
return lines;
}
let (title, color) = match cell.disposition {
crate::activity_event::ReplyDisposition::Resolved => {
(resolved_reply_title(cell), Color::LightGreen)
}
crate::activity_event::ReplyDisposition::Dismissed => ("Dismissed", Color::DarkGray),
crate::activity_event::ReplyDisposition::Failed => ("Failed", Color::Red),
};
lines.push(activity_header_with_styles(
title,
Style::default().fg(color).add_modifier(Modifier::BOLD),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
if !cell.message_lines.is_empty() {
let joined = cell.message_lines.join("\n");
let md_lines = render_markdown_with_width(
&joined,
Color::White,
Some(detail_markdown_width(max_width)),
);
lines.extend(prefixed_detail_lines(md_lines, max_width));
}
lines
}
fn render_agent_message_reply_lines(
message_lines: &[String],
max_width: u16,
) -> Vec<Line<'static>> {
let joined = message_lines.join("\n");
let body_text = joined.trim();
let mut lines: Vec<Line<'static>> = Vec::new();
if !body_text.is_empty() {
let md_lines = render_markdown_with_width(
body_text,
Color::White,
Some(body_markdown_width(max_width)),
);
lines.extend(prefixed_body_lines(md_lines, max_width));
}
lines
}
const fn resolved_reply_title(cell: &ReplyActivityData) -> &'static str {
match cell.subject {
crate::activity_event::ReplySubject::Message => "Resolved Message",
crate::activity_event::ReplySubject::Notice => "Resolved Notice",
}
}
fn render_plan_cell_lines(cell: &PlanActivityData, max_width: u16) -> Vec<Line<'static>> {
let mut lines = vec![plan_header_line(cell.kind)];
if let Some(explanation) = cell.explanation.as_deref()
&& !explanation.trim().is_empty()
{
let note_lines = render_markdown_with_width(
explanation.trim(),
Color::Gray,
Some(detail_markdown_width(max_width)),
)
.into_iter()
.map(|mut line| {
line.spans = line
.spans
.into_iter()
.map(|span| Span::styled(span.content.to_string(), span.style.fg(Color::Gray)))
.collect();
line
})
.collect::<Vec<_>>();
lines.extend(prefixed_detail_lines(note_lines, max_width));
}
let mut steps = Vec::new();
if cell.steps.is_empty() {
steps.push(Line::from(Span::styled(
"No active plan.",
Style::default().fg(Color::DarkGray),
)));
} else {
for step in cell.steps.iter().take(8) {
let (marker, marker_style, text_style) = match step.status {
PlanStepDisplayStatus::InProgress => (
"□ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
PlanStepDisplayStatus::Pending => (
"□ ",
Style::default().fg(Color::DarkGray),
Style::default().fg(Color::Gray),
),
PlanStepDisplayStatus::Completed => (
"✔ ",
Style::default().fg(Color::DarkGray),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::CROSSED_OUT),
),
};
steps.push(Line::from(vec![
Span::styled(marker, marker_style),
Span::styled(step.text.clone(), text_style),
]));
}
}
lines.extend(prefixed_detail_lines(steps, max_width));
lines
}
fn plan_header_line(kind: PlanActivityKind) -> Line<'static> {
match kind {
PlanActivityKind::Proposed => activity_header_with_styles(
"Proposed Plan",
dim_style().bg(Color::Rgb(42, 47, 55)),
bold_style().bg(Color::Rgb(42, 47, 55)),
),
PlanActivityKind::Updated => activity_header("Updated Plan"),
}
}
struct MessageActivityInput<'a> {
title: &'a str,
detail_lines: &'a [String],
message_lines: &'a [String],
detail_limit: usize,
message_limit: usize,
markdown: bool,
max_width: u16,
}
fn render_message_activity_lines(input: &MessageActivityInput<'_>) -> Vec<Line<'static>> {
let mut lines = vec![activity_header(input.title.to_string())];
lines.extend(prefixed_body_lines(
input
.detail_lines
.iter()
.take(input.detail_limit)
.map(|line| Line::from(Span::styled(line.clone(), Style::default().fg(Color::Gray))))
.collect(),
input.max_width,
));
if input.markdown && !input.message_lines.is_empty() {
let joined = input
.message_lines
.iter()
.take(input.message_limit)
.cloned()
.collect::<Vec<_>>()
.join("\n");
let md_lines = render_markdown_with_width(
&joined,
Color::White,
Some(detail_markdown_width(input.max_width)),
);
lines.extend(prefixed_detail_lines(md_lines, input.max_width));
} else {
lines.extend(prefixed_detail_lines(
input
.message_lines
.iter()
.take(input.message_limit)
.map(|line| {
Line::from(Span::styled(
line.clone(),
Style::default().fg(Color::White),
))
})
.collect(),
input.max_width,
));
}
lines
}
fn render_wait_activity_lines(
title: &str,
body_lines: &[String],
limit: usize,
max_width: u16,
) -> Vec<Line<'static>> {
let mut lines = vec![activity_header(title.to_string())];
lines.extend(prefixed_detail_lines(
body_lines
.iter()
.take(limit)
.map(|line| Line::from(Span::styled(line.clone(), Style::default().fg(Color::Gray))))
.collect(),
max_width,
));
lines
}
fn render_error_lines(
title: &str,
body_lines: &[String],
limit: usize,
max_width: u16,
) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(vec![
Span::styled(
"■",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw(ACTIVITY_TITLE_GAP),
Span::styled(
title.to_string(),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
])];
lines.extend(prefixed_body_lines(
body_lines
.iter()
.flat_map(|line| {
let expanded = line.lines().map(ToString::to_string).collect::<Vec<_>>();
if expanded.is_empty() {
vec![String::new()]
} else {
expanded
}
})
.take(limit)
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::LightRed))))
.collect(),
max_width,
));
lines
}
fn render_coding_review_cell_lines(cell: &CodingReviewActivityData) -> Vec<Line<'static>> {
let title = if cell.title.trim().is_empty() {
"Review".to_string()
} else {
cell.title.clone()
};
vec![activity_header(title)]
}
fn render_patch_file_header(file: &PatchFileActivityDescriptor) -> Line<'static> {
let mut spans = Vec::new();
spans.push(Span::styled(
file.path.clone(),
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled("(", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("+{}", file.added_lines),
Style::default().fg(Color::LightGreen),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("-{}", file.removed_lines),
Style::default().fg(Color::LightRed),
));
spans.push(Span::styled(")", Style::default().fg(Color::DarkGray)));
Line::from(spans)
}
fn patch_single_file_title(file: &PatchFileActivityDescriptor) -> String {
format!(
"Edited {} (+{} -{})",
file.path, file.added_lines, file.removed_lines
)
}
fn render_patch_file_diff_lines(
file: &PatchFileActivityDescriptor,
diff_backgrounds: DiffScopeBackgrounds,
old_lineno_width: usize,
new_lineno_width: usize,
) -> Vec<Line<'static>> {
let highlighted = highlight_patch_lines(&file.path, &file.diff_lines);
file.diff_lines
.iter()
.enumerate()
.map(|(index, line)| {
render_patch_diff_line(
line,
highlighted
.get(index)
.and_then(|spans| spans.as_ref())
.cloned(),
diff_backgrounds,
old_lineno_width,
new_lineno_width,
)
})
.collect()
}
fn render_patch_diff_line(
line: &PatchDiffLineActivityDescriptor,
highlighted_spans: Option<Vec<Span<'static>>>,
_diff_backgrounds: DiffScopeBackgrounds,
old_lineno_width: usize,
new_lineno_width: usize,
) -> Line<'static> {
if matches!(line.kind, PatchDiffLineKind::HunkBreak) {
return Line::from(vec![Span::styled(
format!(
"{:>old_width$} {:>new_width$} ⋮",
"",
"",
old_width = old_lineno_width,
new_width = new_lineno_width
),
Style::default().fg(Color::DarkGray),
)]);
}
let (gutter, text_style, background) = match line.kind {
PatchDiffLineKind::Context => (" ", Style::default().fg(Color::Gray), None),
PatchDiffLineKind::Delete => (
"-",
Style::default().fg(Color::LightRed),
Some(PATCH_DIFF_DELETE_BACKGROUND),
),
PatchDiffLineKind::Add => (
"+",
Style::default().fg(Color::LightGreen),
Some(PATCH_DIFF_ADD_BACKGROUND),
),
PatchDiffLineKind::HunkBreak => unreachable!(),
};
let old_lineno = line
.old_lineno
.map(|lineno| lineno.to_string())
.unwrap_or_default();
let new_lineno = line
.new_lineno
.map(|lineno| lineno.to_string())
.unwrap_or_default();
let mut spans = vec![
Span::styled(
format!("{old_lineno:>old_lineno_width$}"),
patch_diff_style(Style::default().fg(Color::DarkGray), background),
),
Span::styled(" ", patch_diff_style(Style::default(), background)),
Span::styled(
format!("{new_lineno:>new_lineno_width$}"),
patch_diff_style(Style::default().fg(Color::DarkGray), background),
),
Span::styled(" ", patch_diff_style(Style::default(), background)),
Span::styled(
gutter,
patch_diff_style(text_style.add_modifier(Modifier::BOLD), background),
),
Span::styled(" ", patch_diff_style(Style::default(), background)),
];
if let Some(highlighted_spans) = highlighted_spans {
for span in highlighted_spans {
let style = patch_diff_style(span.style, background);
spans.push(Span::styled(span.content.to_string(), style));
}
} else {
let style = patch_diff_style(text_style, background);
spans.push(Span::styled(line.text.clone(), style));
}
let mut line = Line::from(spans);
line.style = patch_diff_style(Style::default(), background);
line
}
const fn patch_diff_style(style: Style, background: Option<Color>) -> Style {
match background {
Some(color) => style.bg(color),
None => style,
}
}
fn compact_browser_url(url: &str) -> String {
const MAX_CHARS: usize = 88;
let compact = url.trim().replace('\n', "");
let mut chars = compact.chars();
let head = chars.by_ref().take(MAX_CHARS).collect::<String>();
if chars.next().is_some() {
format!("{head}...")
} else {
head
}
}
fn exec_output_detail_lines(
output_lines: &[String],
empty_text: &str,
max_width: u16,
) -> Vec<Line<'static>> {
let logical_lines = if output_lines.is_empty() {
vec![Line::from(Span::styled(
empty_text.to_string(),
dim_style(),
))]
} else {
output_lines
.iter()
.map(|line| {
let style = if line.starts_with("… +") {
dim_style()
} else {
Style::default().fg(Color::Gray)
};
Line::from(Span::styled(line.clone(), style))
})
.collect()
};
let prefixed = prefixed_detail_lines(logical_lines, max_width);
truncate_prefixed_lines_middle(prefixed, 4, 4)
}
fn truncate_prefixed_lines_middle(
lines: Vec<Line<'static>>,
head: usize,
tail: usize,
) -> Vec<Line<'static>> {
if lines.len() <= head + tail + 1 {
return lines;
}
let omitted = lines.len().saturating_sub(head + tail);
let mut result = Vec::new();
result.extend(lines.iter().take(head).cloned());
result.push(Line::from(vec![
Span::raw(DETAIL_SUBSEQUENT_PREFIX),
Span::styled(
format!("… +{omitted} lines (ctrl + t to view transcript)"),
dim_style(),
),
]));
result.extend(lines.iter().skip(lines.len().saturating_sub(tail)).cloned());
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::activity_event::{
PatchDiffLineKind, PatchFileOperation, ReplyDisposition, ReplySubject,
};
use crate::dashboard::assistant_activity_cell;
#[test]
fn workflow_transcript_groups_sequential_runs_by_actor() {
let snapshot = crate::workflow::WorkflowRunSnapshot {
run_id: "run-actors".to_string(),
workflow_id: "research".to_string(),
status: crate::workflow::WorkflowNodeStatus::Completed,
started_at_ms: 1,
completed_at_ms: Some(4),
input: serde_json::json!({}),
output: None,
error: None,
await_groups: Vec::new(),
transitions: Vec::new(),
workers: vec![
crate::workflow::WorkflowWorkerSnapshot {
worker_id: "worker-1".to_string(),
actor_id: "researcher-actor-1".to_string(),
await_group_id: "await-1".to_string(),
role: "researcher".to_string(),
model: "main".to_string(),
status: crate::workflow::WorkflowNodeStatus::Completed,
started_at_ms: 1,
completed_at_ms: Some(2),
agent_run_time_ms: 12,
input: serde_json::json!({}),
output: None,
error: None,
activity_count: 0,
activity_revision: 0,
activity: Vec::new(),
},
crate::workflow::WorkflowWorkerSnapshot {
worker_id: "worker-2".to_string(),
actor_id: "researcher-actor-2".to_string(),
await_group_id: "await-1".to_string(),
role: "researcher".to_string(),
model: "efficient".to_string(),
status: crate::workflow::WorkflowNodeStatus::Completed,
started_at_ms: 2,
completed_at_ms: Some(3),
agent_run_time_ms: 18,
input: serde_json::json!({}),
output: None,
error: None,
activity_count: 0,
activity_revision: 0,
activity: Vec::new(),
},
crate::workflow::WorkflowWorkerSnapshot {
worker_id: "worker-3".to_string(),
actor_id: "researcher-actor-1".to_string(),
await_group_id: "await-2".to_string(),
role: "researcher".to_string(),
model: "main".to_string(),
status: crate::workflow::WorkflowNodeStatus::Completed,
started_at_ms: 3,
completed_at_ms: Some(4),
agent_run_time_ms: 24,
input: serde_json::json!({}),
output: None,
error: None,
activity_count: 0,
activity_revision: 0,
activity: Vec::new(),
},
],
};
let rendered = workflow_snapshot_outline_lines(&snapshot)
.iter()
.map(line_text)
.collect::<Vec<_>>();
assert!(rendered.iter().any(|line| {
line.contains("researcher #1 · 2 attempts") && line.contains("Worked for 36ms")
}));
assert!(rendered.iter().any(|line| {
line.contains("researcher #2 · 1 attempt") && line.contains("Worked for 18ms")
}));
assert_eq!(
rendered
.iter()
.filter(|line| line.contains("researcher #"))
.count(),
2
);
}
#[test]
fn assistant_cell_renders_code_block_with_code_style() {
let body = "\
Here is some code:
```rust
fn main() {
println!(\"hello\");
}
```
That's it.";
let cell = AssistantActivityData {
content: body.to_string(),
};
let lines = render_assistant_cell_lines(&cell, 80);
for (i, line) in lines.iter().enumerate() {
eprintln!("LINE {}: {}", i, line_text(line));
for (j, span) in line.spans.iter().enumerate() {
eprintln!(
" SPAN {}: content={:?} fg={:?}",
j, span.content, span.style.fg
);
}
}
let fence_lines: Vec<_> = lines
.iter()
.filter(|line| line_text(line).trim_start().starts_with("```"))
.collect();
assert!(
fence_lines.is_empty(),
"expected fence delimiter lines to be hidden, got {fence_lines:?}"
);
let code_line = lines
.iter()
.find(|line| line_text(line).contains("fn main()"));
assert!(
code_line.is_some(),
"expected to find code line 'fn main()' in rendered output"
);
let code_line = code_line.unwrap();
let code_span = code_line
.spans
.iter()
.find(|s| s.style.fg.is_some())
.expect("at least one span in the code line should have syntect fg");
assert!(
code_span.style.fg.is_some(),
"code span '{}' should have syntect fg, got None",
code_span.content
);
let unique_fgs: std::collections::HashSet<Color> =
code_line.spans.iter().filter_map(|s| s.style.fg).collect();
assert!(
unique_fgs.len() >= 2,
"code block should have >= 2 distinct colours, got {unique_fgs:?}"
);
let plain_text_spans: Vec<_> = lines
.iter()
.flat_map(|line| line.spans.iter())
.filter(|span| span.content.contains("That's") || span.content.contains("it."))
.collect();
assert!(
!plain_text_spans.is_empty(),
"expected to find plain text line in rendered output"
);
for span in plain_text_spans {
assert_eq!(
span.style.fg,
Some(Color::White),
"plain text span '{}' should have White fg (base_color)",
span.content
);
}
}
#[test]
fn thinking_cell_renders_body_as_markdown() {
let collapsed = ThinkingActivityData {
content: "Some `code` and **bold** text".to_string(),
};
let collapsed_lines = render_thinking_cell_lines(&collapsed, 80, false);
let code_span = collapsed_lines
.iter()
.flat_map(|line| line.spans.iter())
.find(|span| span.content.as_ref() == "code")
.expect("inline code should be rendered as a separate markdown span");
assert_eq!(code_span.style.fg, Some(Color::Yellow));
assert!(collapsed_lines.iter().skip(1).all(|line| {
line.spans
.first()
.is_some_and(|span| span.content.as_ref() == ACTIVITY_BODY_INDENT)
}));
let expanded = ThinkingActivityData {
content: "Preview only\nExpanded body with `details`".to_string(),
};
let expanded_rendered = render_thinking_cell_lines(&expanded, 80, true)
.iter()
.map(line_text)
.collect::<Vec<_>>();
assert!(
expanded_rendered
.iter()
.any(|line| line.contains("Expanded body with details"))
);
assert!(
expanded_rendered
.iter()
.any(|line| line.contains("Preview only"))
);
}
#[test]
fn thinking_cell_wraps_multiline_body_with_bar_prefix() {
let cell = ThinkingActivityData {
content: [
"Planning code reviews",
"",
"I need to keep going and review the events before finalizing everything. I should probably run git status, and then commit and push.",
]
.join("\n"),
};
let rendered = render_thinking_cell_lines(&cell, 34, false)
.iter()
.map(line_text)
.collect::<Vec<_>>();
assert!(
rendered.len() > 4,
"long thinking body should be pre-wrapped into multiple prefixed lines: {rendered:?}"
);
assert!(
rendered
.iter()
.skip(1)
.all(|line| line.starts_with(ACTIVITY_BODY_INDENT)),
"every body and continuation line should keep the activity body prefix: {rendered:?}"
);
assert!(
rendered
.iter()
.skip(1)
.all(|line| line_display_width(line) <= 34),
"pre-wrapped thinking lines should fit the requested width: {rendered:?}"
);
}
#[test]
fn thinking_cell_wraps_wide_unicode_with_aligned_prefix() {
let cell = ThinkingActivityData {
content: [
"处理中文对齐问题需要按终端显示宽度换行,否则左侧边线会错位",
"English text followed by 中文字符 should still align",
]
.join("\n"),
};
let rendered = render_thinking_cell_lines(&cell, 34, false)
.iter()
.map(line_text)
.collect::<Vec<_>>();
assert!(
rendered.len() > 3,
"wide unicode thinking body should be wrapped: {rendered:?}"
);
assert!(
rendered
.iter()
.skip(1)
.all(|line| line.starts_with(ACTIVITY_BODY_INDENT)),
"every wide-unicode continuation line should keep the activity body prefix: {rendered:?}"
);
assert!(
rendered
.iter()
.skip(1)
.all(|line| line_display_width(line) <= 34),
"wide-unicode thinking lines should fit the requested terminal width: {rendered:?}"
);
}
#[test]
fn explored_renders_activity_exploration_lines() {
let cell = ExploredActivityData {
stable_id: "explored".to_string(),
title: "Explored".to_string(),
calls: vec![
ExploredCallActivityData {
tool_name: "Read".to_string(),
action: Some(ExploredCallActivityAction::Read),
target: Some("src/dashboard/mod.rs".to_string()),
secondary_target: None,
summary: "src/dashboard/mod.rs:L1268+83".to_string(),
detail_lines: vec!["83 lines".to_string()],
detail_title: None,
},
ExploredCallActivityData {
tool_name: "Read".to_string(),
action: Some(ExploredCallActivityAction::Read),
target: Some("src/dashboard/mod.rs".to_string()),
secondary_target: None,
summary: "src/dashboard/mod.rs:L286+25".to_string(),
detail_lines: vec!["25 lines".to_string()],
detail_title: None,
},
ExploredCallActivityData {
tool_name: "Search".to_string(),
action: Some(ExploredCallActivityAction::Search),
target: Some(
"push_command_input_display_text|command_input_display_text|display_text"
.to_string(),
),
secondary_target: Some("src/dashboard/mod.rs".to_string()),
summary: "push_command_input_display_text|command_input_display_text|display_text — 3 targets in src/dashboard/mod.rs".to_string(),
detail_lines: Vec::new(),
detail_title: None,
},
ExploredCallActivityData {
tool_name: "Read".to_string(),
action: Some(ExploredCallActivityAction::Read),
target: Some("src/dashboard/mod.rs".to_string()),
secondary_target: None,
summary: "src/dashboard/mod.rs".to_string(),
detail_lines: Vec::new(),
detail_title: None,
},
],
};
let rendered = render_explored_cell_lines(&cell, 80)
.iter()
.map(line_text)
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec![
"• Explored",
" └ Read mod.rs",
" Search push_command_input_display_text|command_input_display_text|display_te",
" xt in mod.rs",
" Read mod.rs",
]
);
assert!(
rendered.iter().all(|line| !line.contains("Read×")),
"exploration should not render an aggregate action count: {rendered:?}"
);
assert!(
rendered.iter().all(|line| !line.contains("lines")),
"exploration should not render read line-count details: {rendered:?}"
);
}
#[test]
fn explored_renders_all_calls_without_preview_truncation() {
let cell = ExploredActivityData {
stable_id: "explored".to_string(),
title: "Explored".to_string(),
calls: (0..14)
.map(|index| ExploredCallActivityData {
tool_name: "Search".to_string(),
action: Some(ExploredCallActivityAction::Search),
target: Some(format!("needle-{index:02}")),
secondary_target: Some("src/dashboard".to_string()),
summary: format!("needle-{index:02} — 1 target in src/dashboard"),
detail_lines: Vec::new(),
detail_title: None,
})
.collect(),
};
let rendered = render_explored_cell_lines(&cell, 120)
.iter()
.map(line_text)
.collect::<Vec<_>>();
assert!(
rendered.iter().any(|line| line.contains("needle-13")),
"exploration should render calls beyond the old preview limit: {rendered:?}"
);
assert!(
rendered.iter().all(|line| !line.contains("more calls")),
"exploration should not emit a preview truncation marker: {rendered:?}"
);
}
fn line_text(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
#[test]
fn user_prompt_wraps_when_initial_and_continuation_prefixes_differ() {
let cell = UserActivityData {
content: "A long user message must wrap across multiple lines without panicking when the first-line prefix has more spans than the continuation prefix.".to_string(),
image_attachments: Vec::new(),
};
let rendered = render_user_cell_lines(&cell, 32)
.iter()
.map(line_text)
.collect::<Vec<_>>();
assert!(
rendered.len() > 3,
"long user content should produce multiple wrapped lines: {rendered:?}"
);
assert!(
rendered[1].starts_with(USER_PROMPT_PREFIX),
"first user line should keep the prompt prefix: {rendered:?}"
);
assert!(
rendered
.iter()
.skip(2)
.take(rendered.len().saturating_sub(3))
.all(|line| line.starts_with(ACTIVITY_BODY_INDENT)),
"continuation lines should use the body indent: {rendered:?}"
);
assert!(
rendered.iter().all(|line| line_display_width(line) <= 32),
"wrapped user lines should fit the requested width: {rendered:?}"
);
}
#[test]
fn runtime_status_cell_full_motion_applies_working_shimmer() {
let cell = RuntimeStatusActivityData {
label: String::new(),
detail: None,
active_runtime_started_at_ms: Some(unix_time_millis()),
reduced_motion: ReducedMotion::Full,
};
let rendered = runtime_status_cell_lines_at(&cell, 82, 80);
let line = rendered
.first()
.expect("runtime status should render a line");
let text = line_text(line);
assert!(
text.contains(" Working (1m 22s • esc to interrupt)"),
"runtime status should keep the visible Working text: {text}"
);
let shimmer_span_count = line
.spans
.iter()
.filter(|span| {
span.content.chars().count() == 1 && "Working".contains(span.content.as_ref())
})
.count();
assert_eq!(shimmer_span_count, "Working".chars().count());
}
#[test]
fn shimmer_highlight_moves_from_left_to_right() {
let char_count = "Working".chars().count();
let left = 0;
let right = char_count - 1;
assert!(
shimmer_intensity(left, char_count, 800) > shimmer_intensity(right, char_count, 800)
);
assert!(
shimmer_intensity(right, char_count, 1200) > shimmer_intensity(left, char_count, 1200)
);
}
#[test]
fn runtime_status_cell_renders_working_tail_indicator() {
let cell = RuntimeStatusActivityData {
label: String::new(),
detail: Some("model request".to_string()),
active_runtime_started_at_ms: Some(1_000),
reduced_motion: ReducedMotion::Reduced,
};
let rendered = runtime_status_cell_lines_at(&cell, 82, 80)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec!["• Working (1m 22s • esc to interrupt) — model request"]
);
}
fn line_display_width(line: &str) -> usize {
line.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
.sum()
}
#[test]
fn patch_activity_cell_renders_diff_lines() {
let cell = PatchActivityData {
summary_line: "1 file(s) changed (+1 -1)".to_string(),
files: vec![PatchFileActivityDescriptor {
path: "src/app.rs".to_string(),
operation: PatchFileOperation::Update,
added_lines: 1,
removed_lines: 1,
diff_lines: vec![
PatchDiffLineActivityDescriptor {
kind: PatchDiffLineKind::Context,
old_lineno: Some(1),
new_lineno: Some(1),
text: "fn main() {".to_string(),
},
PatchDiffLineActivityDescriptor {
kind: PatchDiffLineKind::Delete,
old_lineno: Some(2),
new_lineno: None,
text: " println!(\"old\");".to_string(),
},
PatchDiffLineActivityDescriptor {
kind: PatchDiffLineKind::Add,
old_lineno: None,
new_lineno: Some(2),
text: " println!(\"new\");".to_string(),
},
],
}],
};
let lines = render_patch_cell_lines(&cell, 80);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert!(
rendered
.iter()
.any(|line| line.contains("• Edited src/app.rs (+1 -1)"))
);
assert!(
rendered
.iter()
.all(|line| !line.contains(" └ src/app.rs (+1 -1)")),
"single-file patches should omit the repeated file header: {rendered:?}"
);
assert!(rendered.iter().any(|line| line.contains("1 1 fn main()")));
assert!(rendered.iter().any(|line| line.contains("2 -")));
assert!(rendered.iter().any(|line| line.contains(" 2 +")));
}
#[test]
fn patch_and_coding_edit_cells_render_all_diff_lines() {
let diff_lines = (1..=24)
.map(|line_no| PatchDiffLineActivityDescriptor {
kind: PatchDiffLineKind::Add,
old_lineno: None,
new_lineno: Some(line_no),
text: format!("added_line_{line_no}();"),
})
.collect::<Vec<_>>();
let file = PatchFileActivityDescriptor {
path: "src/large.rs".to_string(),
operation: PatchFileOperation::Update,
added_lines: diff_lines.len(),
removed_lines: 0,
diff_lines,
};
let patch_cell = PatchActivityData {
summary_line: "1 file changed".to_string(),
files: vec![file.clone()],
};
let coding_cell = CodingEditActivityData {
stable_id: "edit-large".to_string(),
title: "Code Edit".to_string(),
tool_name: Some("edit_code".to_string()),
tool_app: Some("Coding".to_string()),
selector: "hash-anchored edit".to_string(),
file: Some("src/large.rs".to_string()),
added_lines: file.added_lines,
removed_lines: 0,
propagation_count: 0,
impact_lines: Vec::new(),
diff_files: vec![file],
};
for rendered in [
render_patch_cell_lines(&patch_cell, 80),
render_coding_edit_cell_lines(&coding_cell, 80),
]
.into_iter()
.map(|lines| lines.iter().map(line_text).collect::<Vec<_>>())
{
assert!(
rendered
.iter()
.any(|line| line.contains("added_line_24();")),
"last diff line should render instead of being truncated: {rendered:?}"
);
assert!(
rendered.iter().all(|line| !line.contains("more line")),
"diff cells should not render a hidden-line summary: {rendered:?}"
);
}
}
#[test]
fn patch_and_coding_edit_cells_render_all_files() {
let files = (1..=5)
.map(|file_no| PatchFileActivityDescriptor {
path: format!("src/file_{file_no}.rs"),
operation: PatchFileOperation::Update,
added_lines: 1,
removed_lines: 0,
diff_lines: vec![PatchDiffLineActivityDescriptor {
kind: PatchDiffLineKind::Add,
old_lineno: None,
new_lineno: Some(1),
text: format!("file_{file_no}();"),
}],
})
.collect::<Vec<_>>();
let patch_cell = PatchActivityData {
summary_line: "5 files changed".to_string(),
files: files.clone(),
};
let coding_cell = CodingEditActivityData {
stable_id: "edit-many-files".to_string(),
title: "Code Edit".to_string(),
tool_name: Some("edit_code".to_string()),
tool_app: Some("Coding".to_string()),
selector: "hash-anchored edit".to_string(),
file: None,
added_lines: 5,
removed_lines: 0,
propagation_count: 0,
impact_lines: Vec::new(),
diff_files: files,
};
for rendered in [
render_patch_cell_lines(&patch_cell, 80),
render_coding_edit_cell_lines(&coding_cell, 80),
]
.into_iter()
.map(|lines| lines.iter().map(line_text).collect::<Vec<_>>())
{
assert!(
rendered.iter().any(|line| line.contains("src/file_5.rs")),
"last file should render instead of being truncated: {rendered:?}"
);
assert!(
rendered.iter().all(|line| !line.contains("more files")),
"diff cells should not render a hidden-file summary: {rendered:?}"
);
}
}
#[test]
fn coding_edit_cell_renders_compact_diff_without_internal_details() {
let cell = CodingEditActivityData {
stable_id: "edit-1".to_string(),
title: "Code Edit".to_string(),
tool_name: None,
tool_app: None,
selector: "hash-anchored edit".to_string(),
file: Some("src/app.rs".to_string()),
added_lines: 1,
removed_lines: 1,
propagation_count: 7,
impact_lines: vec!["src/app.rs::run - propagation review".to_string()],
diff_files: vec![PatchFileActivityDescriptor {
path: "src/app.rs".to_string(),
operation: PatchFileOperation::Update,
added_lines: 1,
removed_lines: 1,
diff_lines: vec![
PatchDiffLineActivityDescriptor {
kind: PatchDiffLineKind::Delete,
old_lineno: Some(1),
new_lineno: None,
text: "old();".to_string(),
},
PatchDiffLineActivityDescriptor {
kind: PatchDiffLineKind::Add,
old_lineno: None,
new_lineno: Some(1),
text: "new();".to_string(),
},
],
}],
};
let lines = render_coding_edit_cell_lines(&cell, 80);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>();
assert!(
rendered
.iter()
.any(|line| line.contains("• Edited src/app.rs (+1 -1)"))
);
assert!(rendered.iter().any(|line| line.contains("1 - old();")));
assert!(rendered.iter().any(|line| line.contains(" 1 + new();")));
assert!(
rendered.iter().all(|line| !line.contains("hash-anchored")),
"selector should not be visible in compact edit cell: {rendered:?}"
);
assert!(
rendered.iter().all(|line| !line.contains("propagation")),
"review impact should not be visible in compact edit cell: {rendered:?}"
);
assert!(
rendered.iter().all(|line| !line.contains("Impact")),
"impact heading should not be visible in compact edit cell: {rendered:?}"
);
for pattern in ["1 - old();", " 1 + new();"] {
let line = lines
.iter()
.find(|line| line_text(line).contains(pattern))
.expect("changed line should be rendered");
assert!(
line.style.bg.is_some() || line.spans.iter().any(|span| span.style.bg.is_some()),
"changed line should keep diff background: {line:?}"
);
}
let mut buffer = Buffer::empty(Rect::new(0, 0, 80, 12));
let mut cache = CachedActivityLines::new();
let mut user_hyperlink_areas = Vec::new();
let mut selectable_regions = Vec::new();
render_activity_feed_cached(
&mut buffer,
Rect::new(0, 0, 80, 12),
ActivityFeedRenderArgs {
cells: &[SessionActivityEvent::CodingEdit(cell)],
live_cells: &[],
expanded_thinking: &HashSet::new(),
scroll_offset: 0,
cache: &mut cache,
user_hyperlink_areas: &mut user_hyperlink_areas,
selection: &SelectionRegistry::default(),
selectable_regions: &mut selectable_regions,
},
);
for (pattern, expected_bg) in [
("old();", PATCH_DIFF_DELETE_BACKGROUND),
("new();", PATCH_DIFF_ADD_BACKGROUND),
] {
let y = (0..buffer.area.height)
.find(|y| buffer_row_text(&buffer, *y).contains(pattern))
.expect("changed row should be rendered into buffer");
assert!(
(0..buffer.area.width).any(|x| buffer
.cell((x, y))
.is_some_and(|cell| cell.bg == expected_bg)),
"changed buffer row should keep diff background: {}",
buffer_row_text(&buffer, y)
);
}
}
fn buffer_row_text(buffer: &Buffer, y: u16) -> String {
let mut out = String::new();
for x in 0..buffer.area.width {
if let Some(cell) = buffer.cell((x, y)) {
out.push_str(cell.symbol());
}
}
out
}
#[test]
fn activity_feed_registers_selectable_regions_for_visible_cells() {
let cell = assistant_activity_cell("selectable activity text").expect("assistant cell");
let mut buffer = Buffer::empty(Rect::new(0, 0, 60, 6));
let mut cache = CachedActivityLines::new();
let mut user_hyperlink_areas = Vec::new();
let mut selectable_regions = Vec::new();
render_activity_feed_cached(
&mut buffer,
Rect::new(0, 0, 60, 6),
ActivityFeedRenderArgs {
cells: &[cell],
live_cells: &[],
expanded_thinking: &HashSet::new(),
scroll_offset: 0,
cache: &mut cache,
user_hyperlink_areas: &mut user_hyperlink_areas,
selection: &SelectionRegistry::default(),
selectable_regions: &mut selectable_regions,
},
);
assert!(!selectable_regions.is_empty());
let mut registry = SelectionRegistry::default();
registry.set_regions(selectable_regions);
assert!(registry.begin(1, 0));
assert!(registry.drag_to(16, 0));
assert!(
registry
.selected_text()
.as_deref()
.is_some_and(|text| !text.trim().is_empty())
);
}
#[test]
fn activity_cell_selection_group_survives_partial_scroll() {
let cell =
assistant_activity_cell("first line\nsecond line\nthird line").expect("assistant cell");
let mut cache = CachedActivityLines::new();
let mut top_regions = Vec::new();
render_activity_feed_cached(
&mut Buffer::empty(Rect::new(0, 0, 60, 3)),
Rect::new(0, 0, 60, 3),
ActivityFeedRenderArgs {
cells: std::slice::from_ref(&cell),
live_cells: &[],
expanded_thinking: &HashSet::new(),
scroll_offset: 0,
cache: &mut cache,
user_hyperlink_areas: &mut Vec::new(),
selection: &SelectionRegistry::default(),
selectable_regions: &mut top_regions,
},
);
let mut scrolled_regions = Vec::new();
render_activity_feed_cached(
&mut Buffer::empty(Rect::new(0, 0, 60, 3)),
Rect::new(0, 0, 60, 3),
ActivityFeedRenderArgs {
cells: &[cell],
live_cells: &[],
expanded_thinking: &HashSet::new(),
scroll_offset: 1,
cache: &mut cache,
user_hyperlink_areas: &mut Vec::new(),
selection: &SelectionRegistry::default(),
selectable_regions: &mut scrolled_regions,
},
);
assert_eq!(top_regions[0].id, scrolled_regions[0].id);
assert_eq!(top_regions[0].group, scrolled_regions[0].group);
}
#[test]
fn activity_selection_highlight_survives_redraw() {
let cell = assistant_activity_cell("selectable activity text").expect("assistant cell");
let mut cache = CachedActivityLines::new();
let mut regions = Vec::new();
render_activity_feed_cached(
&mut Buffer::empty(Rect::new(0, 0, 60, 6)),
Rect::new(0, 0, 60, 6),
ActivityFeedRenderArgs {
cells: std::slice::from_ref(&cell),
live_cells: &[],
expanded_thinking: &HashSet::new(),
scroll_offset: 0,
cache: &mut cache,
user_hyperlink_areas: &mut Vec::new(),
selection: &SelectionRegistry::default(),
selectable_regions: &mut regions,
},
);
let mut registry = SelectionRegistry::default();
registry.set_regions(regions);
assert!(registry.begin(1, 0));
assert!(registry.drag_to(20, 0));
assert!(registry.end_drag());
let mut buffer = Buffer::empty(Rect::new(0, 0, 60, 6));
let mut next_regions = Vec::new();
render_activity_feed_cached(
&mut buffer,
Rect::new(0, 0, 60, 6),
ActivityFeedRenderArgs {
cells: &[cell],
live_cells: &[],
expanded_thinking: &HashSet::new(),
scroll_offset: 0,
cache: &mut cache,
user_hyperlink_areas: &mut Vec::new(),
selection: ®istry,
selectable_regions: &mut next_regions,
},
);
assert!(
(0..buffer.area.height).any(|y| (0..buffer.area.width).any(|x| buffer
.cell((x, y))
.is_some_and(|cell| cell.bg == Color::DarkGray)))
);
}
#[test]
fn exec_output_truncation_points_to_transcript() {
let output_lines = (1..=20)
.map(|index| format!("output line {index}"))
.collect::<Vec<_>>();
let rendered = exec_output_detail_lines(&output_lines, "(no output)", 80)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert!(
rendered
.iter()
.any(|line| line.contains("ctrl + t to view transcript")),
"truncated exec output should point to transcript mode: {rendered:?}"
);
}
#[test]
fn transcript_includes_exec_command_output_and_exit_marker() {
let transcript = activity_transcript_text(
&[SessionActivityEvent::ExecResult(ExecResultActivityData {
title: "cargo check".to_string(),
terminal_action: Some(TerminalActivityAction::Execute),
terminal_origin: None,
meta: Some("exit=0 cwd=C:/repo".to_string()),
output_lines: vec!["Finished dev profile".to_string()],
})],
&[],
);
assert!(transcript.contains("$ cargo check"));
assert!(transcript.contains("Finished dev profile"));
assert!(transcript.contains("✓ exit=0"));
}
#[test]
fn transcript_lines_preserve_styled_exec_content() {
let lines = activity_transcript_lines(
&[SessionActivityEvent::ExecResult(ExecResultActivityData {
title: "cargo check".to_string(),
terminal_action: Some(TerminalActivityAction::Execute),
terminal_origin: None,
meta: Some("exit=0 cwd=C:/repo".to_string()),
output_lines: vec!["Finished dev profile".to_string()],
})],
&[],
80,
);
let command_line = lines
.iter()
.find(|line| line_text(line).contains("$ cargo check"))
.expect("transcript should include shell command");
assert!(
command_line
.spans
.iter()
.any(|span| span.style.fg.is_some()),
"shell command transcript should preserve styled spans: {command_line:?}"
);
assert!(
lines
.iter()
.any(|line| line_text(line).contains("✓ exit=0")),
"transcript should include styled exit marker"
);
}
#[test]
fn exec_header_reflects_terminal_action_and_running_status() {
let continued = render_exec_cell_lines(
&ExecResultActivityData {
title: "stdin".to_string(),
terminal_action: Some(TerminalActivityAction::Continue),
terminal_origin: None,
meta: Some("main exited exit=0 cwd=C:/repo".to_string()),
output_lines: Vec::new(),
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert_eq!(
continued.first().map(String::as_str),
Some("• Continued stdin")
);
let running = render_exec_cell_lines(
&ExecResultActivityData {
title: "cargo test".to_string(),
terminal_action: Some(TerminalActivityAction::Execute),
terminal_origin: None,
meta: Some("main running exit=- cwd=C:/repo".to_string()),
output_lines: Vec::new(),
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert_eq!(
running.first().map(String::as_str),
Some("• Running cargo test")
);
let user_command = render_exec_cell_lines(
&ExecResultActivityData {
title: "ls -la".to_string(),
terminal_action: Some(TerminalActivityAction::Execute),
terminal_origin: Some(TerminalActivityOrigin::User),
meta: Some("main exited exit=0 cwd=C:/repo".to_string()),
output_lines: Vec::new(),
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert_eq!(
user_command.first().map(String::as_str),
Some("• You ran ls -la")
);
}
#[test]
fn error_activity_cell_uses_error_marker() {
let rendered = render_error_cell_lines(
&ErrorActivityData {
title: "Command failed".to_string(),
body_lines: vec!["exit=1".to_string()],
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert_eq!(
rendered.first().map(String::as_str),
Some("■ Command failed")
);
}
#[test]
fn error_activity_cell_preserves_multiline_diagnostic_layout() {
let rendered = render_error_cell_lines(
&ErrorActivityData {
title: "coding::edit_code failed".to_string(),
body_lines: vec![
"scope-engine edit_code failed\n 1 | import type { TFunction }\n | ^"
.to_string(),
],
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert!(rendered.iter().any(|line| line.contains("1 | import type")));
assert!(rendered.iter().any(|line| line.contains("| ^")));
}
#[test]
fn warning_activity_cell_uses_warning_marker() {
let rendered = render_warning_cell_lines(
&ErrorActivityData {
title: "Config drift detected".to_string(),
body_lines: vec!["using fallback".to_string()],
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert_eq!(
rendered.first().map(String::as_str),
Some("⚠ Config drift detected")
);
}
#[test]
fn web_search_activity_cell_renders_searching_and_searched_states() {
let searching = render_web_search_cell_lines(
&WebSearchActivityData {
action: WebSearchActivityAction::Searching,
query: "ratatui hyperlinks".to_string(),
url: None,
body_lines: Vec::new(),
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert!(
searching
.first()
.is_some_and(|line| line.contains("Searching the web: ratatui hyperlinks")),
"searching web cell should include the query: {searching:?}"
);
let searched = render_web_search_cell_lines(
&WebSearchActivityData {
action: WebSearchActivityAction::Searched,
query: "ratatui hyperlinks".to_string(),
url: Some("https://docs.rs/ratatui".to_string()),
body_lines: vec!["found docs".to_string()],
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert!(
searched
.iter()
.any(|line| line.contains("https://docs.rs/ratatui")),
"searched web cell should include the result url: {searched:?}"
);
}
#[test]
fn plan_activity_cell_renders_explanation_and_empty_state() {
let rendered = render_plan_cell_lines(
&PlanActivityData {
kind: PlanActivityKind::Updated,
explanation: Some("Need to finish the setup first.".to_string()),
steps: Vec::new(),
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert!(rendered.iter().any(|line| line.contains("Need to finish")));
assert!(rendered.iter().any(|line| line.contains("No active plan.")));
let transcript = activity_transcript_text(
&[SessionActivityEvent::PlanResult(PlanActivityData {
kind: PlanActivityKind::Updated,
explanation: Some("Need to finish the setup first.".to_string()),
steps: Vec::new(),
})],
&[],
);
assert!(transcript.contains("note: Need to finish the setup first."));
assert!(transcript.contains("(empty plan)"));
let proposed = render_plan_cell_lines(
&PlanActivityData {
kind: PlanActivityKind::Proposed,
explanation: None,
steps: Vec::new(),
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert_eq!(
proposed.first().map(String::as_str),
Some("• Proposed Plan")
);
}
#[test]
fn reply_activity_cell_labels_notice_subjects() {
let notice = render_reply_cell_lines(
&ReplyActivityData {
disposition: ReplyDisposition::Resolved,
subject: ReplySubject::Notice,
message_lines: Vec::new(),
elapsed_seconds: None,
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert_eq!(
notice.first().map(String::as_str),
Some("• Resolved Notice")
);
assert!(notice.iter().any(|line| line.contains("Resolved Notice")));
}
#[test]
fn user_activity_cell_renders_full_message() {
let body = (1..=12)
.map(|index| format!("[定位段 {index:03}] marker-{index:03}"))
.collect::<Vec<_>>()
.join("\n");
let cell = UserActivityData {
content: format!("Title\n{body}"),
image_attachments: Vec::new(),
};
let rendered = render_user_cell_lines(&cell, 80)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert_eq!(rendered.first().map(String::as_str), Some(""));
assert_eq!(rendered.last().map(String::as_str), Some(""));
assert!(rendered.iter().any(|line| line.contains("marker-012")));
}
#[test]
fn user_activity_cell_renders_image_labels() {
let cell = UserActivityData {
content: "inspect this".to_string(),
image_attachments: vec![MessageImageAttachment {
label: "dashboard screenshot".to_string(),
uri: "C:/tmp/dashboard.png".to_string(),
mime_type: "image/png".to_string(),
description: None,
}],
};
let rendered = render_user_cell_lines(&cell, 80)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert!(
rendered
.iter()
.any(|line| line.contains("[1: local image] dashboard screenshot (image/png)")),
"user image attachments should be visible in TUI user cells: {rendered:?}"
);
let transcript = activity_transcript_text(&[SessionActivityEvent::User(cell)], &[]);
assert!(transcript.contains("[1: local image] dashboard screenshot C:/tmp/dashboard.png"));
}
#[test]
fn reply_activity_cell_renders_agent_message_with_activity_marker() {
let message_lines = (1..=12)
.map(|index| format!("[定位段 {index:03}] marker-{index:03}"))
.collect::<Vec<_>>();
let rendered = render_reply_cell_lines(
&ReplyActivityData {
disposition: ReplyDisposition::Resolved,
subject: ReplySubject::Message,
message_lines,
elapsed_seconds: None,
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert!(
rendered
.first()
.is_some_and(|line| line.starts_with(ACTIVITY_BODY_INDENT)
&& line.contains("[定位段 001] marker-001")),
"resolved message reply first line should use body indent: {rendered:?}"
);
let second_line = rendered
.iter()
.find(|line| line.contains("marker-002"))
.expect("reply body should render subsequent message lines");
assert!(
second_line.starts_with(ACTIVITY_BODY_INDENT),
"resolved message body should use the agent body indent: {rendered:?}"
);
assert!(
rendered
.iter()
.all(|line| !line.starts_with(USER_PROMPT_PREFIX)),
"resolved message reply should not render like a user prompt: {rendered:?}"
);
assert!(
!second_line.starts_with(" "),
"reply body should not keep the old three-space indent: {rendered:?}"
);
assert!(rendered.iter().any(|line| line.contains("marker-012")));
}
#[test]
fn reply_activity_cell_renders_one_full_width_worked_duration() {
let rendered = render_reply_cell_lines(
&ReplyActivityData {
disposition: ReplyDisposition::Resolved,
subject: ReplySubject::Message,
message_lines: vec!["done".to_string()],
elapsed_seconds: Some(5),
},
80,
)
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert!(rendered[0].starts_with("─ Worked for 5s ─"));
assert_eq!(display_width(&rendered[0]), 80);
assert!(rendered[1].is_empty());
assert!(rendered[2].contains("done"));
assert_eq!(
rendered
.iter()
.filter(|line| line.contains("Worked for"))
.count(),
1
);
}
}