返回 CodeWhale
rows.rs
根目录 / crates / tui / src / tui / work_surface / render / rows.rs
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
349 lines RUST