返回 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::{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
293 lines RUST