返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / work_surface / render / mod.rs
1 //! Painting the work surface, and the two files it leans on.
2 //!
3 //! - [`layout`] answers *where and how tall* — placement fallback, the height
4 //! and cap arithmetic, and the side-rail split.
5 //! - [`rows`] answers *what one row says* — the sub-agent column layout, its
6 //! degradation tiers, and row styling.
7 //!
8 //! What stays here is the paint itself: the Top strip, the side-rail panel,
9 //! the divider and scrollbar chrome, and the strip header content (goal title,
10 //! to-do receipt) that height and paint must both agree on.
11
12 use std::collections::HashMap;
13
14 use ratatui::{
15 Frame,
16 layout::Rect,
17 prelude::Widget,
18 style::{Modifier, Style},
19 text::{Line, Span},
20 widgets::{Block, Paragraph},
21 };
22 use unicode_width::UnicodeWidthStr;
23
24 use crate::localization::MessageId;
25 use crate::tui::app::{App, SidebarHoverRow, SidebarHoverSection};
26 use crate::tui::ui_text::truncate_line_to_width;
27
28 use super::model::{
29 RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, visible_rows_for_panel,
30 };
31
32 mod layout;
33 mod rows;
34
35 pub use layout::{height, split_chat};
36
37 use rows::{
38 AGENT_ROLE_GUTTER, AgentRowTier, agent_identity, agent_identity_cap, agent_identity_column,
39 agent_receipt, agent_row_styles, agent_status_column, layout_agent_row, row_style,
40 };
41
42 pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
43 if area.width == 0 || area.height == 0 {
44 app.work_surface.last_area = None;
45 return;
46 }
47
48 if let Some(previous) = app.work_surface.last_area {
49 app.sidebar_hover
50 .sections
51 .retain(|section| section.content_area != previous);
52 }
53
54 let placement = app.work_surface.effective_placement;
55 // Off renders no rail; height()/split_chat() never hand us an area for it.
56 if placement == WorkSurfacePlacement::Off {
57 app.work_surface.last_area = None;
58 return;
59 }
60 let body_area = match placement {
61 WorkSurfacePlacement::Top => Rect {
62 height: area.height.saturating_sub(1),
63 ..area
64 },
65 WorkSurfacePlacement::Left => Rect {
66 width: area.width.saturating_sub(1),
67 ..area
68 },
69 WorkSurfacePlacement::Right => Rect {
70 x: area.x.saturating_add(1),
71 width: area.width.saturating_sub(1),
72 ..area
73 },
74 WorkSurfacePlacement::Off => unreachable!("off placement returned above"),
75 };
76
77 // Context is the one panel that is not a work-row surface: session facts
78 // render as a titled line list with nothing to click. Every other panel
79 // (Tasks, Agents, Pinned) routes through the row machinery below, so its
80 // rows keep hitboxes, selection, and primary actions — a work row is a
81 // door in every panel, not only in Tasks.
82 if app.work_surface.panel == RailPanel::Context {
83 render_panel(frame, area, body_area, app);
84 return;
85 }
86
87 let mut rows = visible_rows_for_panel(app);
88 if placement == WorkSurfacePlacement::Top {
89 // Literal work list only: selectable to-dos/agents plus the
90 // GrokBuild-style `▾ Subagents N` group header. Generic graph chrome
91 // from the side/inspector projection stays out.
92 rows.retain(|row| row.selectable || row.id.0.starts_with("section:"));
93 }
94 let todo_ordinals = if placement == WorkSurfacePlacement::Top {
95 todo_ordinals(&rows)
96 } else {
97 HashMap::new()
98 };
99 let ordinal_width = todo_ordinals.len().max(1).to_string().len();
100 let goal_title = (placement == WorkSurfacePlacement::Top)
101 .then(|| top_goal_title(app))
102 .flatten();
103 let todo_progress = (placement == WorkSurfacePlacement::Top)
104 .then(|| top_todo_progress(app, &rows))
105 .flatten();
106 // Pin goal title, then progress receipt, above the scrollable rows.
107 // At the minimum two-row surface keep one usable content row + divider.
108 let goal_height = u16::from(goal_title.is_some() && body_area.height >= 1);
109 let fold_progress = progress_shares_goal_row(body_area.width, goal_height > 0);
110 let progress_height = u16::from(
111 todo_progress.is_some()
112 && !fold_progress
113 && body_area.height.saturating_sub(goal_height) >= 2,
114 );
115 let header_height = goal_height.saturating_add(progress_height);
116 let list_height = body_area.height.saturating_sub(header_height);
117 let body_height = usize::from(list_height);
118 let overflow = rows.len() > body_height;
119 // A capped list owes the reader the size of what it is hiding, so the
120 // last painted row becomes `↓ N more`. The scrollbar shows position; only
121 // this shows how much work is off-screen.
122 let more_row = overflow && body_height >= 2;
123 let list_rows = if more_row {
124 body_height.saturating_sub(1)
125 } else {
126 body_height
127 };
128 let inset = u16::from(body_area.width >= 60);
129 let rail_width = u16::from(overflow);
130 let content_area = Rect {
131 x: body_area.x.saturating_add(inset),
132 y: body_area.y.saturating_add(header_height),
133 width: body_area
134 .width
135 .saturating_sub(inset.saturating_mul(2))
136 .saturating_sub(rail_width),
137 height: list_height,
138 };
139
140 app.work_surface.visible_rows = list_rows;
141 app.work_surface.total_rows = rows.len();
142 // A redraw may clamp an obsolete offset, but it must not reveal the
143 // remembered keyboard selection: doing so undoes mouse-wheel scrolling
144 // whenever that selection is above the viewport (#4594).
145 app.work_surface.clamp_viewport(&rows);
146 let max_offset = rows.len().saturating_sub(list_rows.max(1));
147 app.work_surface.scroll_offset = app.work_surface.scroll_offset.min(max_offset);
148
149 Block::default()
150 .style(Style::default().bg(app.ui_theme.surface_bg))
151 .render(area, frame.buffer_mut());
152
153 if let Some((goal_text, goal_style)) = goal_title.filter(|_| goal_height > 0) {
154 let full_width = usize::from(content_area.width);
155 // Wide strips carry the receipt right-aligned on the goal row rather
156 // than spending a second row announcing a count.
157 let receipt = todo_progress.as_deref().filter(|_| fold_progress);
158 let reserved = receipt
159 .map(|text| UnicodeWidthStr::width(text).saturating_add(2))
160 .unwrap_or(0);
161 let goal_text = truncate_line_to_width(&goal_text, full_width.saturating_sub(reserved));
162 let mut spans = vec![Span::styled(
163 goal_text.clone(),
164 goal_style.bg(app.ui_theme.surface_bg),
165 )];
166 if let Some(receipt) = receipt {
167 let gap = full_width
168 .saturating_sub(UnicodeWidthStr::width(goal_text.as_str()))
169 .saturating_sub(UnicodeWidthStr::width(receipt));
170 spans.push(Span::styled(
171 format!("{}{receipt}", " ".repeat(gap)),
172 Style::default()
173 .fg(app.ui_theme.text_muted)
174 .bg(app.ui_theme.surface_bg),
175 ));
176 }
177 Paragraph::new(Line::from(spans)).render(
178 Rect {
179 y: body_area.y,
180 height: 1,
181 ..content_area
182 },
183 frame.buffer_mut(),
184 );
185 }
186
187 if let Some(progress) = todo_progress.filter(|_| progress_height > 0) {
188 let progress = truncate_line_to_width(&progress, usize::from(content_area.width));
189 // Muted, not accent: accent_primary means "selected" everywhere else
190 // in the strip, and spending it on a static count makes the actual
191 // selection hard to find.
192 Paragraph::new(Line::from(Span::styled(
193 progress,
194 Style::default()
195 .fg(app.ui_theme.text_muted)
196 .bg(app.ui_theme.surface_bg),
197 )))
198 .render(
199 Rect {
200 y: body_area.y.saturating_add(goal_height),
201 height: 1,
202 ..content_area
203 },
204 frame.buffer_mut(),
205 );
206 }
207
208 let start = app.work_surface.scroll_offset;
209 let visible = rows.iter().skip(start).take(list_rows).collect::<Vec<_>>();
210 let identity_cap = agent_identity_cap(usize::from(content_area.width));
211 let identity_column = agent_identity_column(&visible, identity_cap);
212 let status_column = agent_status_column(&visible);
213 let mut lines = Vec::with_capacity(visible.len().saturating_add(1));
214 let mut hover_rows = Vec::new();
215 let mut hitboxes = Vec::new();
216 for (visible_index, row) in visible.iter().enumerate() {
217 let row_y = content_area.y.saturating_add(visible_index as u16);
218 let selected =
219 app.work_surface.focused && app.work_surface.selected.as_ref() == Some(&row.id);
220 let hovered = app.work_surface.hovered.as_ref() == Some(&row.id);
221 let opened = app.work_surface.opened.as_ref() == Some(&row.id);
222 let style = row_style(app, row, selected, hovered, opened);
223 let compact_owner = if placement == WorkSurfacePlacement::Top {
224 todo_ordinals
225 .get(&row.id.0)
226 .map(|ordinal| format!("{ordinal:>ordinal_width$} · "))
227 .unwrap_or_default()
228 } else {
229 String::new()
230 };
231 let mark = if opened && row.selectable {
232 "▾"
233 } else {
234 row.mark
235 };
236 let prefix = if row.tone == WorkTone::Heading {
237 format!("{} ", mark)
238 } else {
239 format!("{compact_owner}{mark} ")
240 };
241
242 // Sub-agent rows own their own column layout: glyph, agent type,
243 // objective, right-aligned elapsed and tokens. They stay ordinary
244 // rows in every other respect — same hitbox, same selection, same
245 // primary action.
246 if let Some(facts) = row.agent.as_ref() {
247 let laid_out = layout_agent_row(
248 usize::from(content_area.width),
249 UnicodeWidthStr::width(prefix.as_str()),
250 agent_identity(row, identity_cap),
251 identity_column,
252 status_column,
253 facts,
254 );
255 let (normal, muted) = agent_row_styles(app, selected, hovered, opened);
256 let display = format!(
257 "{prefix}{}{}{}{}{}{}{}",
258 laid_out.role,
259 if laid_out.role.is_empty() {
260 String::new()
261 } else {
262 " ".repeat(AGENT_ROLE_GUTTER)
263 },
264 laid_out.status,
265 if laid_out.status.is_empty() {
266 String::new()
267 } else {
268 " ".repeat(AGENT_ROLE_GUTTER)
269 },
270 laid_out.objective,
271 " ".repeat(laid_out.gap),
272 laid_out.receipt,
273 );
274 let mut spans = vec![Span::styled(prefix.clone(), normal)];
275 if !laid_out.role.is_empty() {
276 spans.push(Span::styled(
277 format!("{}{}", laid_out.role, " ".repeat(AGENT_ROLE_GUTTER)),
278 muted,
279 ));
280 }
281 if !laid_out.status.is_empty() {
282 spans.push(Span::styled(
283 format!("{}{}", laid_out.status, " ".repeat(AGENT_ROLE_GUTTER)),
284 muted,
285 ));
286 }
287 spans.push(Span::styled(laid_out.objective.clone(), normal));
288 spans.push(Span::styled(
289 format!("{}{}", " ".repeat(laid_out.gap), laid_out.receipt),
290 muted,
291 ));
292 lines.push(Line::from(spans));
293
294 hitboxes.push(WorkHitbox {
295 id: row.id.clone(),
296 row_y,
297 });
298 hover_rows.push(SidebarHoverRow {
299 row_y,
300 display_text: display,
301 full_text: format!("{} · {}", row.label, row.detail),
302 detail: Some(row.detail.clone()),
303 is_truncated: laid_out.objective != facts.objective
304 || laid_out.receipt != agent_receipt(facts, AgentRowTier::Full),
305 click_action: row.primary_action.clone(),
306 stop_action: None,
307 stop_zone_start_col: None,
308 stop_zone_end_col: None,
309 });
310 continue;
311 }
312
313 let detail_candidate = if row.tone != WorkTone::Heading && content_area.width >= 44 {
314 format!(" {}", row.detail)
315 } else {
316 String::new()
317 };
318 let prefix_width = UnicodeWidthStr::width(prefix.as_str());
319 let row_width = usize::from(content_area.width);
320 let label_budget = row_width.saturating_sub(prefix_width).max(1);
321 let label = truncate_line_to_width(&row.label, label_budget);
322 let detail_budget =
323 row_width.saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()));
324 let detail = if detail_budget >= 4 {
325 truncate_line_to_width(&detail_candidate, detail_budget)
326 } else {
327 String::new()
328 };
329 let detail_width = UnicodeWidthStr::width(detail.as_str());
330 let gap = usize::from(content_area.width)
331 .saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()) + detail_width);
332 let display = format!("{prefix}{label}{}{detail}", " ".repeat(gap));
333 lines.push(Line::from(Span::styled(display.clone(), style)));
334
335 hitboxes.push(WorkHitbox {
336 id: row.id.clone(),
337 row_y,
338 });
339
340 if row.selectable {
341 hover_rows.push(SidebarHoverRow {
342 row_y,
343 display_text: display,
344 full_text: format!("{} · {}", row.label, row.detail),
345 detail: Some(row.detail.clone()),
346 is_truncated: label != row.label || detail != detail_candidate,
347 click_action: row.primary_action.clone(),
348 stop_action: None,
349 stop_zone_start_col: None,
350 stop_zone_end_col: None,
351 });
352 }
353 }
354
355 if more_row {
356 // Right-aligned under the receipt column, muted like every other
357 // secondary figure. Scrolled to the bottom there is nothing below, so
358 // the reserved row stays blank rather than claiming a count of zero.
359 let remaining = rows
360 .len()
361 .saturating_sub(start.saturating_add(visible.len()));
362 let text = if remaining == 0 {
363 String::new()
364 } else {
365 truncate_line_to_width(
366 &format!("↓ {remaining} more"),
367 usize::from(content_area.width),
368 )
369 };
370 let pad = usize::from(content_area.width).saturating_sub(UnicodeWidthStr::width(&*text));
371 lines.push(Line::from(Span::styled(
372 format!("{}{text}", " ".repeat(pad)),
373 Style::default()
374 .fg(app.ui_theme.text_muted)
375 .bg(app.ui_theme.surface_bg),
376 )));
377 }
378
379 Paragraph::new(lines).render(content_area, frame.buffer_mut());
380 render_divider(frame, area, placement, app);
381 if overflow {
382 render_scrollbar(
383 frame,
384 Rect {
385 x: body_area.right().saturating_sub(1),
386 y: content_area.y,
387 width: 1,
388 height: content_area.height,
389 },
390 app.work_surface.scroll_offset,
391 list_rows,
392 rows.len(),
393 app,
394 );
395 }
396
397 app.work_surface.last_area = Some(area);
398 app.work_surface.hitboxes = hitboxes;
399 app.sidebar_hover.sections.push(SidebarHoverSection {
400 content_area,
401 lines: visible.iter().map(|row| row.label.clone()).collect(),
402 rows: hover_rows,
403 });
404 }
405
406 /// Render the Context panel as a titled line list in the same body area and
407 /// with the same divider and scrollbar the row surface would use. Context is
408 /// the only panel that renders here: its lines are session facts, not work
409 /// rows, so there is nothing to click and no hitboxes to record. Every panel
410 /// that shows work rows (Tasks, Agents, Pinned) goes through the row/hitbox
411 /// machinery in [`render`] instead.
412 fn render_panel(frame: &mut Frame, area: Rect, body_area: Rect, app: &mut App) {
413 let panel = app.work_surface.panel;
414 let placement = app.work_surface.effective_placement;
415
416 Block::default()
417 .style(Style::default().bg(app.ui_theme.surface_bg))
418 .render(area, frame.buffer_mut());
419
420 // Title row policy:
421 // - Top: only an active goal (`Goal: …`). Never panel chrome ("Pinned").
422 // - Left/Right: muted panel name — a full-height column needs naming.
423 let goal = (placement == WorkSurfacePlacement::Top)
424 .then(|| top_goal_title(app))
425 .flatten();
426 let side_panel_title = matches!(
427 placement,
428 WorkSurfacePlacement::Left | WorkSurfacePlacement::Right
429 );
430
431 let title_rows = if let Some((goal_text, goal_style)) = goal.as_ref() {
432 let goal_text = truncate_line_to_width(goal_text, usize::from(body_area.width).max(1));
433 Paragraph::new(Line::from(Span::styled(
434 goal_text,
435 goal_style.bg(app.ui_theme.surface_bg),
436 )))
437 .render(
438 Rect {
439 height: 1,
440 ..body_area
441 },
442 frame.buffer_mut(),
443 );
444 1_u16
445 } else if side_panel_title {
446 Paragraph::new(Line::from(Span::styled(
447 truncate_line_to_width(panel.title(), usize::from(body_area.width).max(1)),
448 Style::default()
449 .fg(app.ui_theme.text_muted)
450 .bg(app.ui_theme.surface_bg),
451 )))
452 .render(
453 Rect {
454 height: 1,
455 ..body_area
456 },
457 frame.buffer_mut(),
458 );
459 1_u16
460 } else {
461 0
462 };
463
464 let content_area = Rect {
465 y: body_area.y.saturating_add(title_rows),
466 height: body_area.height.saturating_sub(title_rows),
467 ..body_area
468 };
469 let body_height = usize::from(content_area.height);
470 let lines = super::panels::panel_lines(
471 app,
472 panel,
473 usize::from(content_area.width),
474 body_height.max(1),
475 goal.is_some(),
476 )
477 .unwrap_or_default();
478
479 let max_offset = lines.len().saturating_sub(body_height.max(1));
480 app.work_surface.scroll_offset = app.work_surface.scroll_offset.min(max_offset);
481 let overflow = lines.len() > body_height;
482 let visible: Vec<Line> = lines
483 .iter()
484 .skip(app.work_surface.scroll_offset)
485 .take(body_height)
486 .cloned()
487 .collect();
488 Paragraph::new(visible).render(content_area, frame.buffer_mut());
489
490 render_divider(frame, area, placement, app);
491 if overflow {
492 render_scrollbar(
493 frame,
494 Rect {
495 x: body_area.right().saturating_sub(1),
496 y: content_area.y,
497 width: 1,
498 height: content_area.height,
499 },
500 app.work_surface.scroll_offset,
501 body_height,
502 lines.len(),
503 app,
504 );
505 }
506
507 app.work_surface.last_area = Some(area);
508 app.work_surface.visible_rows = body_height;
509 app.work_surface.total_rows = lines.len();
510 app.work_surface.hitboxes.clear();
511 app.work_surface.selected = None;
512 app.work_surface.opened = None;
513 app.work_surface.hovered = None;
514 }
515
516 /// Active goal as the Top strip's only title. Uses the same
517 /// paused/active/terminal resolution as the ocean header chip so a goal set
518 /// via `create_goal` is either visible everywhere or nowhere. Returns
519 /// `None` when no live goal exists — Top then paints no title row at all.
520 pub(super) fn top_goal_title(app: &App) -> Option<(String, Style)> {
521 let (objective, paused) = crate::tui::footer_ui::active_goal_chip_state(app)?;
522 let flat = objective.trim().replace(['\n', '\r'], " ");
523 if flat.is_empty() {
524 return None;
525 }
526 let text = if paused {
527 format!("Goal (paused): {flat}")
528 } else {
529 format!("Goal: {flat}")
530 };
531 let style = if paused {
532 Style::default()
533 .fg(app.ui_theme.warning)
534 .add_modifier(Modifier::BOLD)
535 } else {
536 Style::default()
537 .fg(app.ui_theme.status_working)
538 .add_modifier(Modifier::BOLD)
539 };
540 Some((text, style))
541 }
542
543 fn todo_ordinals(rows: &[WorkRow]) -> HashMap<String, usize> {
544 rows.iter()
545 .filter(|row| row.id.0.starts_with("graph:"))
546 .enumerate()
547 .map(|(index, row)| (row.id.0.clone(), index.saturating_add(1)))
548 .collect()
549 }
550
551 /// Below this width the goal title and the receipt cannot both stay readable
552 /// on one row, so the receipt keeps its own row.
553 const PROGRESS_FOLD_MIN_WIDTH: u16 = 72;
554
555 /// Whether the to-do receipt rides on the goal-title row instead of claiming
556 /// a row of its own.
557 ///
558 /// [`height`] and [`render`] must agree on this or the strip paints into a row
559 /// it did not reserve, so the rule is a pure function of the strip width and
560 /// whether there is a goal title to share with.
561 pub(super) fn progress_shares_goal_row(width: u16, has_goal_title: bool) -> bool {
562 has_goal_title && width >= PROGRESS_FOLD_MIN_WIDTH
563 }
564
565 pub(super) fn top_todo_progress(app: &App, rows: &[WorkRow]) -> Option<String> {
566 let todos = rows
567 .iter()
568 .filter(|row| row.id.0.starts_with("graph:"))
569 .collect::<Vec<_>>();
570 let total = todos.len();
571 if total == 0 {
572 return None;
573 }
574 let completed = todos
575 .iter()
576 .filter(|row| row.tone == WorkTone::Success)
577 .count();
578 let remaining = total.saturating_sub(completed);
579 let label = format!("{} ·", app.tr(MessageId::SidebarTodoLabel));
580 Some(
581 app.tr(MessageId::WorkSurfaceTodoProgress)
582 .replace("{label}", &label)
583 .replace("{completed}", &completed.to_string())
584 .replace("{total}", &total.to_string())
585 .replace("{remaining}", &remaining.to_string()),
586 )
587 }
588
589 fn render_divider(frame: &mut Frame, area: Rect, placement: WorkSurfacePlacement, app: &App) {
590 let active = app.work_surface.resizing || app.work_surface.divider_hovered;
591 let color = if active {
592 app.ui_theme.accent_primary
593 } else {
594 app.ui_theme.border
595 };
596 match placement {
597 WorkSurfacePlacement::Off => {}
598 WorkSurfacePlacement::Top => {
599 let y = area.bottom().saturating_sub(1);
600 for x in area.left()..area.right() {
601 frame.buffer_mut()[(x, y)]
602 .set_symbol(if active { "━" } else { "─" })
603 .set_fg(color)
604 .set_bg(app.ui_theme.surface_bg);
605 }
606 }
607 WorkSurfacePlacement::Left | WorkSurfacePlacement::Right => {
608 let x = if placement == WorkSurfacePlacement::Left {
609 area.right().saturating_sub(1)
610 } else {
611 area.left()
612 };
613 for y in area.top()..area.bottom() {
614 frame.buffer_mut()[(x, y)]
615 .set_symbol(if active { "┃" } else { "│" })
616 .set_fg(color)
617 .set_bg(app.ui_theme.surface_bg);
618 }
619 }
620 }
621 }
622
623 fn render_scrollbar(
624 frame: &mut Frame,
625 area: Rect,
626 offset: usize,
627 visible: usize,
628 total: usize,
629 app: &App,
630 ) {
631 let rail_height = area.height;
632 if rail_height == 0 || total == 0 {
633 return;
634 }
635 let thumb_height = ((usize::from(rail_height) * visible) / total)
636 .max(1)
637 .min(usize::from(rail_height));
638 let max_offset = total.saturating_sub(visible).max(1);
639 let max_start = usize::from(rail_height).saturating_sub(thumb_height);
640 let thumb_start = offset.saturating_mul(max_start) / max_offset;
641 let x = area.right().saturating_sub(1);
642 for row in 0..usize::from(rail_height) {
643 let in_thumb = row >= thumb_start && row < thumb_start.saturating_add(thumb_height);
644 frame.buffer_mut()[(x, area.y.saturating_add(row as u16))]
645 // Match the transcript rail exactly: a fine border track with a
646 // brighter, narrow thumb. The old solid block looked like a
647 // separate native scrollbar bolted onto the work surface.
648 .set_symbol(if in_thumb { "┃" } else { "│" })
649 .set_fg(if in_thumb {
650 app.ui_theme.status_working
651 } else {
652 app.ui_theme.border
653 })
654 .set_bg(app.ui_theme.surface_bg);
655 }
656 }
657
657 lines RUST