use ratatui::style::{Modifier, Style};
use unicode_width::UnicodeWidthStr;
use crate::tui::app::App;
use crate::tui::ui_text::truncate_line_to_width;
use crate::tui::work_surface::model::{AgentRowFacts, WorkRow, WorkTone};
pub(super) const AGENT_ROLE_GUTTER: usize = 2;
const AGENT_RECEIPT_GUTTER: usize = 2;
const AGENT_OBJECTIVE_MIN: usize = 24;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum AgentRowTier {
Full,
NoTokens,
NoReceipt,
ObjectiveOnly,
}
const AGENT_ROW_TIERS: [AgentRowTier; 4] = [
AgentRowTier::Full,
AgentRowTier::NoTokens,
AgentRowTier::NoReceipt,
AgentRowTier::ObjectiveOnly,
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct AgentRowText {
pub(super) role: String,
pub(super) status: String,
pub(super) objective: String,
pub(super) receipt: String,
pub(super) gap: usize,
}
pub(super) fn agent_receipt(facts: &AgentRowFacts, tier: AgentRowTier) -> String {
let model = facts
.model
.as_deref()
.filter(|model| !model.is_empty())
.filter(|_| matches!(tier, AgentRowTier::Full | AgentRowTier::NoTokens))
.map(str::to_string);
let elapsed = facts
.elapsed_secs
.filter(|_| matches!(tier, AgentRowTier::Full | AgentRowTier::NoTokens))
.map(crate::elapsed::format_elapsed_secs);
let tokens = facts
.tokens
.filter(|_| tier == AgentRowTier::Full)
.map(|tokens| {
format!(
"↓ {} tokens",
crate::tui::footer_ui::format_token_count_compact(tokens)
)
});
let todos_left = facts
.todos_remaining
.filter(|n| *n > 0)
.filter(|_| matches!(tier, AgentRowTier::Full | AgentRowTier::NoTokens))
.map(|n| format!("{n} left"));
[model, elapsed, tokens, todos_left]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" · ")
}
const AGENT_IDENTITY_CAP_NUMERATOR: usize = 2;
const AGENT_IDENTITY_CAP_DENOMINATOR: usize = 5;
pub(super) fn agent_identity_cap(width: usize) -> usize {
width
.saturating_mul(AGENT_IDENTITY_CAP_NUMERATOR)
.saturating_div(AGENT_IDENTITY_CAP_DENOMINATOR)
}
pub(super) fn agent_identity(row: &WorkRow, cap: usize) -> &str {
let Some(facts) = row.agent.as_ref() else {
return "";
};
for candidate in [row.label.as_str(), facts.role_label.as_str()] {
if !candidate.is_empty() && UnicodeWidthStr::width(candidate) <= cap {
return candidate;
}
}
""
}
pub(super) fn agent_identity_column(rows: &[&WorkRow], cap: usize) -> usize {
rows.iter()
.filter(|row| row.agent.is_some())
.map(|row| UnicodeWidthStr::width(agent_identity(row, cap)))
.max()
.unwrap_or(0)
}
pub(super) fn agent_status_column(rows: &[&WorkRow]) -> usize {
rows.iter()
.filter_map(|row| row.agent.as_ref())
.map(|facts| UnicodeWidthStr::width(facts.status.as_str()))
.max()
.unwrap_or(0)
}
pub(super) fn layout_agent_row(
width: usize,
prefix_width: usize,
identity: &str,
identity_column: usize,
status_column: usize,
facts: &AgentRowFacts,
) -> AgentRowText {
for tier in AGENT_ROW_TIERS {
let receipt = agent_receipt(facts, tier);
let role = if tier == AgentRowTier::ObjectiveOnly || identity_column == 0 {
String::new()
} else {
let pad = identity_column.saturating_sub(UnicodeWidthStr::width(identity));
format!("{identity}{}", " ".repeat(pad))
};
let status = if tier == AgentRowTier::ObjectiveOnly || status_column == 0 {
String::new()
} else {
let pad = status_column.saturating_sub(UnicodeWidthStr::width(facts.status.as_str()));
format!("{}{}", facts.status, " ".repeat(pad))
};
let role_cost = if role.is_empty() {
0
} else {
UnicodeWidthStr::width(role.as_str()).saturating_add(AGENT_ROLE_GUTTER)
};
let status_cost = if status.is_empty() {
0
} else {
UnicodeWidthStr::width(status.as_str()).saturating_add(AGENT_ROLE_GUTTER)
};
let receipt_cost = if receipt.is_empty() {
0
} else {
UnicodeWidthStr::width(receipt.as_str()).saturating_add(AGENT_RECEIPT_GUTTER)
};
let budget = width
.saturating_sub(prefix_width)
.saturating_sub(role_cost)
.saturating_sub(status_cost)
.saturating_sub(receipt_cost);
if budget < AGENT_OBJECTIVE_MIN && tier != AgentRowTier::ObjectiveOnly {
continue;
}
let objective = truncate_line_to_width(&facts.objective, budget);
let gap = width
.saturating_sub(prefix_width)
.saturating_sub(role_cost)
.saturating_sub(status_cost)
.saturating_sub(UnicodeWidthStr::width(objective.as_str()))
.saturating_sub(UnicodeWidthStr::width(receipt.as_str()));
return AgentRowText {
role,
status,
objective,
receipt,
gap,
};
}
AgentRowText::default()
}
pub(super) fn agent_row_styles(
app: &App,
selected: bool,
hovered: bool,
opened: bool,
) -> (Style, Style) {
let bg = if selected {
app.ui_theme.selection_bg
} else if hovered {
app.ui_theme.elevated_bg
} else {
app.ui_theme.surface_bg
};
let mut normal = Style::default().fg(app.ui_theme.text_body).bg(bg);
let mut muted = Style::default().fg(app.ui_theme.text_muted).bg(bg);
if selected || opened {
normal = normal.fg(app.ui_theme.accent_primary);
muted = muted.fg(app.ui_theme.accent_primary);
}
if selected {
normal = normal.add_modifier(Modifier::BOLD);
muted = muted.add_modifier(Modifier::BOLD);
}
if opened {
normal = normal.add_modifier(Modifier::UNDERLINED);
muted = muted.add_modifier(Modifier::UNDERLINED);
}
(normal, muted)
}
pub(super) fn row_style(
app: &App,
row: &WorkRow,
selected: bool,
hovered: bool,
opened: bool,
) -> Style {
let fg = match row.tone {
WorkTone::Heading => app.ui_theme.text_muted,
WorkTone::Live => app.ui_theme.status_working,
WorkTone::Attention => app.ui_theme.error_fg,
WorkTone::Success => app.ui_theme.success,
WorkTone::Muted => app.ui_theme.text_muted,
};
let mut style = Style::default().fg(fg).bg(app.ui_theme.surface_bg);
if row.tone == WorkTone::Heading {
style = style.add_modifier(Modifier::BOLD);
}
if !row.selectable {
return style;
}
if opened {
style = style
.fg(app.ui_theme.accent_primary)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
}
if selected {
style = style
.bg(app.ui_theme.selection_bg)
.add_modifier(Modifier::BOLD);
} else if hovered {
style = style.bg(app.ui_theme.elevated_bg);
}
style
}