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