| 1 | //! Per-row composition: the columns a sub-agent row resolves to at a given |
| 2 | //! width, and the style every row is painted with. |
| 3 | |
| 4 | use ratatui::style::{Modifier, Style}; |
| 5 | use unicode_width::UnicodeWidthStr; |
| 6 | |
| 7 | use crate::tui::app::App; |
| 8 | use crate::tui::ui_text::truncate_line_to_width; |
| 9 | use crate::tui::work_surface::model::{AgentRowFacts, WorkRow, WorkTone}; |
| 10 | |
| 11 | /// Gap between the agent-type column and the objective. |
| 12 | pub(super) const AGENT_ROLE_GUTTER: usize = 2; |
| 13 | /// Minimum gap between the objective and the right-aligned receipt. |
| 14 | const AGENT_RECEIPT_GUTTER: usize = 2; |
| 15 | /// Columns the objective must keep before an optional column may stay. Below |
| 16 | /// this the objective is a shrug — "Streaming d…" answers nothing — so the |
| 17 | /// optional column loses instead. |
| 18 | const AGENT_OBJECTIVE_MIN: usize = 24; |
| 19 | |
| 20 | /// How much of a sub-agent row survives at the current width. |
| 21 | /// |
| 22 | /// Degradation order, widest to narrowest: the token figure goes first, then |
| 23 | /// the elapsed time, then the agent-type column. The objective is the last |
| 24 | /// thing to go — a fleet row that cannot say what the agent is doing has |
| 25 | /// stopped being worth a row. |
| 26 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 27 | pub(super) enum AgentRowTier { |
| 28 | /// Type, objective, elapsed, tokens. |
| 29 | Full, |
| 30 | /// Type, objective, elapsed. |
| 31 | NoTokens, |
| 32 | /// Type, objective. |
| 33 | NoReceipt, |
| 34 | /// Objective only. |
| 35 | ObjectiveOnly, |
| 36 | } |
| 37 | |
| 38 | const AGENT_ROW_TIERS: [AgentRowTier; 4] = [ |
| 39 | AgentRowTier::Full, |
| 40 | AgentRowTier::NoTokens, |
| 41 | AgentRowTier::NoReceipt, |
| 42 | AgentRowTier::ObjectiveOnly, |
| 43 | ]; |
| 44 | |
| 45 | /// A sub-agent row resolved to painted columns. |
| 46 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
| 47 | pub(super) struct AgentRowText { |
| 48 | /// Agent-type column, padded to the shared width. Empty once dropped. |
| 49 | pub(super) role: String, |
| 50 | /// Status word column (`running`, `completed`, …), padded to the shared |
| 51 | /// width. Dropped only with the identity column: a fleet row that cannot |
| 52 | /// say its state in words has lost the fact the owner asked for back |
| 53 | /// (2026-08-04 regression report). |
| 54 | pub(super) status: String, |
| 55 | pub(super) objective: String, |
| 56 | /// `12m 33s · ↓ 111.9k tokens`. Empty once dropped. |
| 57 | pub(super) receipt: String, |
| 58 | /// Spaces separating the objective from the receipt. |
| 59 | pub(super) gap: usize, |
| 60 | } |
| 61 | |
| 62 | /// The right-aligned receipt at a given tier. A figure the runtime never |
| 63 | /// reported is absent, never zero: an agent with no usage envelope shows no |
| 64 | /// token count at all. |
| 65 | pub(super) fn agent_receipt(facts: &AgentRowFacts, tier: AgentRowTier) -> String { |
| 66 | let elapsed = facts |
| 67 | .elapsed_secs |
| 68 | .filter(|_| matches!(tier, AgentRowTier::Full | AgentRowTier::NoTokens)) |
| 69 | .map(crate::elapsed::format_elapsed_secs); |
| 70 | let tokens = facts |
| 71 | .tokens |
| 72 | .filter(|_| tier == AgentRowTier::Full) |
| 73 | .map(|tokens| { |
| 74 | format!( |
| 75 | "↓ {} tokens", |
| 76 | crate::tui::footer_ui::format_token_count_compact(tokens) |
| 77 | ) |
| 78 | }); |
| 79 | // Only paint a remaining chip when a real ledger reported unsettled |
| 80 | // work. `None` (no list) and `Some(0)` (list fully settled) stay quiet — |
| 81 | // a fabricated `0 left` is strip noise. |
| 82 | let todos_left = facts |
| 83 | .todos_remaining |
| 84 | .filter(|n| *n > 0) |
| 85 | .filter(|_| matches!(tier, AgentRowTier::Full | AgentRowTier::NoTokens)) |
| 86 | .map(|n| format!("{n} left")); |
| 87 | [elapsed, tokens, todos_left] |
| 88 | .into_iter() |
| 89 | .flatten() |
| 90 | .collect::<Vec<_>>() |
| 91 | .join(" · ") |
| 92 | } |
| 93 | |
| 94 | /// Ceiling on the shared identity column, as a fraction of the row. The |
| 95 | /// column is shared, so without a cap a single long nickname would widen it |
| 96 | /// for every row and starve every objective on the surface. An identity wider |
| 97 | /// than this is dropped for *that* row only. |
| 98 | const AGENT_IDENTITY_CAP_NUMERATOR: usize = 2; |
| 99 | const AGENT_IDENTITY_CAP_DENOMINATOR: usize = 5; |
| 100 | |
| 101 | /// Widest identity the shared column will carry at this row width. |
| 102 | pub(super) fn agent_identity_cap(width: usize) -> usize { |
| 103 | width |
| 104 | .saturating_mul(AGENT_IDENTITY_CAP_NUMERATOR) |
| 105 | .saturating_div(AGENT_IDENTITY_CAP_DENOMINATOR) |
| 106 | } |
| 107 | |
| 108 | /// Which spelling of a sub-agent's identity fits the column: its nickname |
| 109 | /// first, then its fleet role, then nothing. |
| 110 | /// |
| 111 | /// Identities are never truncated, only dropped. `Fluke the Deep…` and |
| 112 | /// `general-purpo…` both misidentify an agent, and roles that share a prefix |
| 113 | /// would become indistinguishable. |
| 114 | pub(super) fn agent_identity(row: &WorkRow, cap: usize) -> &str { |
| 115 | let Some(facts) = row.agent.as_ref() else { |
| 116 | return ""; |
| 117 | }; |
| 118 | for candidate in [row.label.as_str(), facts.role_label.as_str()] { |
| 119 | if !candidate.is_empty() && UnicodeWidthStr::width(candidate) <= cap { |
| 120 | return candidate; |
| 121 | } |
| 122 | } |
| 123 | "" |
| 124 | } |
| 125 | |
| 126 | /// Shared width of the identity column across the rows painted this frame, so |
| 127 | /// the objectives line up the way a fleet listing should read. Rows whose |
| 128 | /// identity exceeded the cap contribute nothing, so one outlier cannot widen |
| 129 | /// the column for everyone else. |
| 130 | pub(super) fn agent_identity_column(rows: &[&WorkRow], cap: usize) -> usize { |
| 131 | rows.iter() |
| 132 | .filter(|row| row.agent.is_some()) |
| 133 | .map(|row| UnicodeWidthStr::width(agent_identity(row, cap))) |
| 134 | .max() |
| 135 | .unwrap_or(0) |
| 136 | } |
| 137 | |
| 138 | /// Shared width of the status-word column across the rows painted this frame. |
| 139 | /// Statuses come from a fixed vocabulary, so no cap is needed. |
| 140 | pub(super) fn agent_status_column(rows: &[&WorkRow]) -> usize { |
| 141 | rows.iter() |
| 142 | .filter_map(|row| row.agent.as_ref()) |
| 143 | .map(|facts| UnicodeWidthStr::width(facts.status.as_str())) |
| 144 | .max() |
| 145 | .unwrap_or(0) |
| 146 | } |
| 147 | |
| 148 | /// Fit one sub-agent row into `width`, dropping optional columns in |
| 149 | /// [`AGENT_ROW_TIERS`] order until the objective has room to say something. |
| 150 | /// Every column truncates; nothing ever wraps. |
| 151 | pub(super) fn layout_agent_row( |
| 152 | width: usize, |
| 153 | prefix_width: usize, |
| 154 | identity: &str, |
| 155 | identity_column: usize, |
| 156 | status_column: usize, |
| 157 | facts: &AgentRowFacts, |
| 158 | ) -> AgentRowText { |
| 159 | for tier in AGENT_ROW_TIERS { |
| 160 | let receipt = agent_receipt(facts, tier); |
| 161 | let role = if tier == AgentRowTier::ObjectiveOnly || identity_column == 0 { |
| 162 | String::new() |
| 163 | } else { |
| 164 | // A row whose own identity was dropped still reserves the column, |
| 165 | // so every objective on the surface stays on the same axis. |
| 166 | let pad = identity_column.saturating_sub(UnicodeWidthStr::width(identity)); |
| 167 | format!("{identity}{}", " ".repeat(pad)) |
| 168 | }; |
| 169 | // The status word degrades with the identity: it survives the loss of |
| 170 | // tokens and elapsed, and yields only when the row is down to the |
| 171 | // objective alone. |
| 172 | let status = if tier == AgentRowTier::ObjectiveOnly || status_column == 0 { |
| 173 | String::new() |
| 174 | } else { |
| 175 | let pad = status_column.saturating_sub(UnicodeWidthStr::width(facts.status.as_str())); |
| 176 | format!("{}{}", facts.status, " ".repeat(pad)) |
| 177 | }; |
| 178 | let role_cost = if role.is_empty() { |
| 179 | 0 |
| 180 | } else { |
| 181 | UnicodeWidthStr::width(role.as_str()).saturating_add(AGENT_ROLE_GUTTER) |
| 182 | }; |
| 183 | let status_cost = if status.is_empty() { |
| 184 | 0 |
| 185 | } else { |
| 186 | UnicodeWidthStr::width(status.as_str()).saturating_add(AGENT_ROLE_GUTTER) |
| 187 | }; |
| 188 | let receipt_cost = if receipt.is_empty() { |
| 189 | 0 |
| 190 | } else { |
| 191 | UnicodeWidthStr::width(receipt.as_str()).saturating_add(AGENT_RECEIPT_GUTTER) |
| 192 | }; |
| 193 | let budget = width |
| 194 | .saturating_sub(prefix_width) |
| 195 | .saturating_sub(role_cost) |
| 196 | .saturating_sub(status_cost) |
| 197 | .saturating_sub(receipt_cost); |
| 198 | if budget < AGENT_OBJECTIVE_MIN && tier != AgentRowTier::ObjectiveOnly { |
| 199 | continue; |
| 200 | } |
| 201 | let objective = truncate_line_to_width(&facts.objective, budget); |
| 202 | let gap = width |
| 203 | .saturating_sub(prefix_width) |
| 204 | .saturating_sub(role_cost) |
| 205 | .saturating_sub(status_cost) |
| 206 | .saturating_sub(UnicodeWidthStr::width(objective.as_str())) |
| 207 | .saturating_sub(UnicodeWidthStr::width(receipt.as_str())); |
| 208 | return AgentRowText { |
| 209 | role, |
| 210 | status, |
| 211 | objective, |
| 212 | receipt, |
| 213 | gap, |
| 214 | }; |
| 215 | } |
| 216 | AgentRowText::default() |
| 217 | } |
| 218 | |
| 219 | /// Normal-text and muted styles for one sub-agent row. |
| 220 | /// |
| 221 | /// Three colour roles and no more: the objective is normal text, every |
| 222 | /// secondary figure (type, `(+N)`, elapsed, tokens) is muted, and |
| 223 | /// `accent_primary` means "this is the row you have selected" and nothing |
| 224 | /// else. Status is carried by the glyph, never by colour. |
| 225 | pub(super) fn agent_row_styles( |
| 226 | app: &App, |
| 227 | selected: bool, |
| 228 | hovered: bool, |
| 229 | opened: bool, |
| 230 | ) -> (Style, Style) { |
| 231 | let bg = if selected { |
| 232 | app.ui_theme.selection_bg |
| 233 | } else if hovered { |
| 234 | app.ui_theme.elevated_bg |
| 235 | } else { |
| 236 | app.ui_theme.surface_bg |
| 237 | }; |
| 238 | let mut normal = Style::default().fg(app.ui_theme.text_body).bg(bg); |
| 239 | let mut muted = Style::default().fg(app.ui_theme.text_muted).bg(bg); |
| 240 | if selected || opened { |
| 241 | normal = normal.fg(app.ui_theme.accent_primary); |
| 242 | muted = muted.fg(app.ui_theme.accent_primary); |
| 243 | } |
| 244 | if selected { |
| 245 | normal = normal.add_modifier(Modifier::BOLD); |
| 246 | muted = muted.add_modifier(Modifier::BOLD); |
| 247 | } |
| 248 | if opened { |
| 249 | normal = normal.add_modifier(Modifier::UNDERLINED); |
| 250 | muted = muted.add_modifier(Modifier::UNDERLINED); |
| 251 | } |
| 252 | (normal, muted) |
| 253 | } |
| 254 | |
| 255 | pub(super) fn row_style( |
| 256 | app: &App, |
| 257 | row: &WorkRow, |
| 258 | selected: bool, |
| 259 | hovered: bool, |
| 260 | opened: bool, |
| 261 | ) -> Style { |
| 262 | // Headings (group headers like `▾ Subagents 2`) are muted structure, not |
| 263 | // interaction — accent_primary is reserved for selection/focus. GrokBuild |
| 264 | // uses the same gray-on-header treatment. |
| 265 | let fg = match row.tone { |
| 266 | WorkTone::Heading => app.ui_theme.text_muted, |
| 267 | WorkTone::Live => app.ui_theme.status_working, |
| 268 | WorkTone::Attention => app.ui_theme.error_fg, |
| 269 | WorkTone::Success => app.ui_theme.success, |
| 270 | WorkTone::Muted => app.ui_theme.text_muted, |
| 271 | }; |
| 272 | let mut style = Style::default().fg(fg).bg(app.ui_theme.surface_bg); |
| 273 | if row.tone == WorkTone::Heading { |
| 274 | style = style.add_modifier(Modifier::BOLD); |
| 275 | } |
| 276 | if !row.selectable { |
| 277 | return style; |
| 278 | } |
| 279 | if opened { |
| 280 | style = style |
| 281 | .fg(app.ui_theme.accent_primary) |
| 282 | .add_modifier(Modifier::BOLD | Modifier::UNDERLINED); |
| 283 | } |
| 284 | if selected { |
| 285 | style = style |
| 286 | .bg(app.ui_theme.selection_bg) |
| 287 | .add_modifier(Modifier::BOLD); |
| 288 | } else if hovered { |
| 289 | style = style.bg(app.ui_theme.elevated_bg); |
| 290 | } |
| 291 | style |
| 292 | } |
| 293 |