返回 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 strip, the side rail, the dock
9 //! tab row, the divider and scrollbar chrome, and the strip header content
10 //! (goal title, to-do receipt) that height and paint must both agree on.
11 //! Every view — work rows and fact rows alike — goes through the one row
12 //! loop below; there is no second line-list renderer.
13
14 use std::collections::HashMap;
15
16 use ratatui::{
17 Frame,
18 layout::Rect,
19 prelude::Widget,
20 style::{Modifier, Style},
21 text::{Line, Span},
22 widgets::{Block, Paragraph},
23 };
24 use unicode_width::UnicodeWidthStr;
25
26 use crate::tui::app::{App, SidebarHoverRow, SidebarHoverSection};
27 use crate::tui::ui_text::truncate_line_to_width;
28 use codewhale_localization::MessageId;
29 use codewhale_palette::{ChromeInk, chrome_style};
30
31 use super::model::{
32 DockTabHitbox, DockTabTarget, RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone,
33 visible_rows_for, visible_rows_for_panel,
34 };
35
36 mod layout;
37 mod rows;
38
39 pub(crate) use layout::collapse_strip;
40 pub use layout::{height, split_chat};
41
42 use rows::{
43 AGENT_ROLE_GUTTER, AgentRowTier, agent_identity, agent_identity_cap, agent_identity_column,
44 agent_receipt, agent_row_styles, agent_status_column, layout_agent_row, row_style,
45 };
46
47 pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
48 if area.width == 0 || area.height == 0 {
49 collapse_strip(app);
50 return;
51 }
52
53 if let Some(previous) = app.work_surface.last_area {
54 app.sidebar_hover
55 .sections
56 .retain(|section| section.content_area != previous);
57 }
58
59 let placement = app.work_surface.effective_placement;
60 // Off renders no rail; height()/split_chat() never hand us an area for it.
61 if placement == WorkSurfacePlacement::Off {
62 collapse_strip(app);
63 return;
64 }
65 let body_area = match placement {
66 // Bottom mirrors Top's body/divider split; only the divider edge
67 // differs (below-content for Top, above-content for Bottom).
68 WorkSurfacePlacement::Top => Rect {
69 y: area.y.saturating_add(1),
70 height: area.height.saturating_sub(2),
71 ..area
72 },
73 WorkSurfacePlacement::Bottom => Rect {
74 y: area.y.saturating_add(2),
75 height: area.height.saturating_sub(2),
76 ..area
77 },
78 WorkSurfacePlacement::Left => Rect {
79 width: area.width.saturating_sub(1),
80 ..area
81 },
82 WorkSurfacePlacement::Right => Rect {
83 x: area.x.saturating_add(1),
84 width: area.width.saturating_sub(1),
85 ..area
86 },
87 WorkSurfacePlacement::Off => unreachable!("off placement returned above"),
88 };
89
90 if !placement.is_strip() {
91 app.work_surface.dock_tabs.clear();
92 app.work_surface.pressed_tab = None;
93 app.work_surface.hovered_tab = None;
94 }
95
96 super::model::resolve_view(app);
97 let rows = visible_rows_for_panel(app);
98 let todo_ordinals = if placement.is_strip() {
99 todo_ordinals(&rows)
100 } else {
101 HashMap::new()
102 };
103 let ordinal_width = todo_ordinals.len().max(1).to_string().len();
104 let goal_title = placement.is_strip().then(|| top_goal_title(app)).flatten();
105 let todo_progress = placement
106 .is_strip()
107 .then(|| top_todo_progress(app, &rows))
108 .flatten();
109 // Pin goal title, then progress receipt, above the scrollable rows.
110 // A compact strip keeps its last usable row for content, ahead of headers.
111 let goal_height = u16::from(goal_title.is_some() && body_area.height >= 2);
112 let fold_progress = progress_shares_goal_row(body_area.width, goal_height > 0);
113 let progress_height = u16::from(
114 todo_progress.is_some()
115 && !fold_progress
116 && body_area.height.saturating_sub(goal_height) >= 2,
117 );
118 let header_height = goal_height.saturating_add(progress_height);
119 let list_height = body_area.height.saturating_sub(header_height);
120 let body_height = usize::from(list_height);
121 let overflow = rows.len() > body_height;
122 // A capped list owes the reader the size of what it is hiding, so the
123 // last painted row becomes `↓ N more`. The scrollbar shows position; only
124 // this shows how much work is off-screen.
125 let more_row = overflow && body_height >= 2;
126 let list_rows = if more_row {
127 body_height.saturating_sub(1)
128 } else {
129 body_height
130 };
131 let inset = u16::from(body_area.width >= 16);
132 let rail_width = u16::from(overflow);
133 let content_area = Rect {
134 x: body_area.x.saturating_add(inset),
135 y: body_area.y.saturating_add(header_height),
136 width: body_area
137 .width
138 .saturating_sub(inset.saturating_mul(2))
139 .saturating_sub(rail_width),
140 height: list_height,
141 };
142
143 app.work_surface.visible_rows = list_rows;
144 app.work_surface.total_rows = rows.len();
145 // A redraw may clamp an obsolete offset, but it must not reveal the
146 // remembered keyboard selection: doing so undoes mouse-wheel scrolling
147 // whenever that selection is above the viewport (#4594).
148 app.work_surface.clamp_viewport(&rows);
149 let max_offset = rows.len().saturating_sub(list_rows.max(1));
150 app.work_surface.scroll_offset = app.work_surface.scroll_offset.min(max_offset);
151
152 Block::default()
153 .style(Style::default().bg(app.ui_theme.panel_bg))
154 .render(area, frame.buffer_mut());
155 render_dock_tabs(frame, area, app);
156 register_dock_targets(app);
157
158 if let Some((goal_text, goal_style)) = goal_title.filter(|_| goal_height > 0) {
159 let full_width = usize::from(content_area.width);
160 // Wide strips carry the receipt right-aligned on the goal row rather
161 // than spending a second row announcing a count.
162 let receipt = todo_progress.as_deref().filter(|_| fold_progress);
163 let reserved = receipt
164 .map(|text| UnicodeWidthStr::width(text).saturating_add(2))
165 .unwrap_or(0);
166 let goal_text = truncate_line_to_width(&goal_text, full_width.saturating_sub(reserved));
167 let mut spans = vec![Span::styled(
168 goal_text.clone(),
169 goal_style.bg(app.ui_theme.panel_bg),
170 )];
171 if let Some(receipt) = receipt {
172 let gap = full_width
173 .saturating_sub(UnicodeWidthStr::width(goal_text.as_str()))
174 .saturating_sub(UnicodeWidthStr::width(receipt));
175 spans.push(Span::styled(
176 format!("{}{receipt}", " ".repeat(gap)),
177 Style::default()
178 .fg(app.ui_theme.text_muted)
179 .bg(app.ui_theme.panel_bg),
180 ));
181 }
182 Paragraph::new(Line::from(spans)).render(
183 Rect {
184 y: body_area.y,
185 height: 1,
186 ..content_area
187 },
188 frame.buffer_mut(),
189 );
190 }
191
192 if let Some(progress) = todo_progress.filter(|_| progress_height > 0) {
193 let progress = truncate_line_to_width(&progress, usize::from(content_area.width));
194 // Muted, not accent: accent_primary means "selected" everywhere else
195 // in the strip, and spending it on a static count makes the actual
196 // selection hard to find.
197 Paragraph::new(Line::from(Span::styled(
198 progress,
199 Style::default()
200 .fg(app.ui_theme.text_muted)
201 .bg(app.ui_theme.panel_bg),
202 )))
203 .render(
204 Rect {
205 y: body_area.y.saturating_add(goal_height),
206 height: 1,
207 ..content_area
208 },
209 frame.buffer_mut(),
210 );
211 }
212
213 let start = app.work_surface.scroll_offset;
214 let visible = rows.iter().skip(start).take(list_rows).collect::<Vec<_>>();
215 let identity_cap = agent_identity_cap(usize::from(content_area.width));
216 let identity_column = agent_identity_column(&visible, identity_cap);
217 let status_column = agent_status_column(&visible);
218 let mut lines = Vec::with_capacity(visible.len().saturating_add(1));
219 let mut hover_rows = Vec::new();
220 let mut hitboxes = Vec::new();
221 for (visible_index, row) in visible.iter().enumerate() {
222 let row_y = content_area.y.saturating_add(visible_index as u16);
223 let selected =
224 app.work_surface.focused && app.work_surface.selected.as_ref() == Some(&row.id);
225 let hovered = app.work_surface.hovered.as_ref() == Some(&row.id);
226 let opened = app.work_surface.opened.as_ref() == Some(&row.id);
227 let style = row_style(app, row, selected, hovered, opened);
228 let compact_owner = if placement.is_strip() {
229 todo_ordinals
230 .get(&row.id.0)
231 .map(|ordinal| format!("{ordinal:>ordinal_width$} · "))
232 .unwrap_or_default()
233 } else {
234 String::new()
235 };
236 let mark = if opened && row.selectable {
237 "▾"
238 } else {
239 row.mark
240 };
241 // Agent focus marker: while a worker is focused every row gains a
242 // two-cell gutter and the focused worker's row shows the selection
243 // glyph in it, so the addressed fork is visible at the left edge.
244 let focus_gutter = if app.agent_focus.is_some() {
245 let focused = row
246 .id
247 .0
248 .strip_prefix("worker:")
249 .is_some_and(|id| app.agent_focus.as_ref().is_some_and(|f| f.is(id)));
250 if focused {
251 "❯ ".to_string()
252 } else {
253 " ".to_string()
254 }
255 } else {
256 String::new()
257 };
258 let prefix = if row.tone == WorkTone::Heading {
259 format!("{focus_gutter}{} ", mark)
260 } else {
261 format!("{focus_gutter}{compact_owner}{mark} ")
262 };
263
264 // Sub-agent rows own their own column layout: glyph, agent type,
265 // objective, right-aligned elapsed and tokens. They stay ordinary
266 // rows in every other respect — same hitbox, same selection, same
267 // primary action.
268 if let Some(facts) = row.agent.as_ref() {
269 let queued = row
270 .id
271 .0
272 .strip_prefix("worker:")
273 .and_then(|id| crate::tui::agent_focus::queued_suffix(app, id))
274 .map(|queued| format!(" · {queued}"));
275 let queued_width = queued.as_deref().map(UnicodeWidthStr::width).unwrap_or(0);
276 let laid_out = layout_agent_row(
277 usize::from(content_area.width).saturating_sub(queued_width),
278 UnicodeWidthStr::width(prefix.as_str()),
279 agent_identity(row, identity_cap),
280 identity_column,
281 status_column,
282 facts,
283 );
284 let (normal, muted) = agent_row_styles(app, selected, hovered, opened);
285 let display = format!(
286 "{prefix}{}{}{}{}{}{}{}",
287 laid_out.role,
288 if laid_out.role.is_empty() {
289 String::new()
290 } else {
291 " ".repeat(AGENT_ROLE_GUTTER)
292 },
293 laid_out.status,
294 if laid_out.status.is_empty() {
295 String::new()
296 } else {
297 " ".repeat(AGENT_ROLE_GUTTER)
298 },
299 laid_out.objective,
300 " ".repeat(laid_out.gap),
301 laid_out.receipt,
302 );
303 let mut spans = vec![Span::styled(prefix.clone(), normal)];
304 if !laid_out.role.is_empty() {
305 spans.push(Span::styled(
306 format!("{}{}", laid_out.role, " ".repeat(AGENT_ROLE_GUTTER)),
307 muted,
308 ));
309 }
310 if !laid_out.status.is_empty() {
311 spans.push(Span::styled(
312 format!("{}{}", laid_out.status, " ".repeat(AGENT_ROLE_GUTTER)),
313 muted,
314 ));
315 }
316 spans.push(Span::styled(laid_out.objective.clone(), normal));
317 spans.push(Span::styled(
318 format!("{}{}", " ".repeat(laid_out.gap), laid_out.receipt),
319 muted,
320 ));
321 if let Some(queued) = queued.as_deref() {
322 // Truthful `· N queued`: follow-ups the running child has not
323 // yet folded into its next round. Accent so it reads as live
324 // pending work, not as part of the receipt.
325 spans.push(Span::styled(
326 queued.to_string(),
327 Style::default()
328 .fg(app.ui_theme.accent_action)
329 .bg(normal.bg.unwrap_or(app.ui_theme.panel_bg)),
330 ));
331 }
332 lines.push(Line::from(spans));
333
334 hitboxes.push(WorkHitbox {
335 id: row.id.clone(),
336 row_y,
337 });
338 hover_rows.push(SidebarHoverRow {
339 row_y,
340 display_text: display,
341 full_text: format!("{} · {}", row.label, row.detail),
342 detail: Some(row.detail.clone()),
343 is_truncated: laid_out.objective != facts.objective
344 || laid_out.receipt != agent_receipt(facts, AgentRowTier::Full),
345 click_action: row.primary_action.clone(),
346 stop_action: None,
347 stop_zone_start_col: None,
348 stop_zone_end_col: None,
349 });
350 continue;
351 }
352
353 let detail_candidate = if row.tone != WorkTone::Heading && content_area.width >= 44 {
354 format!(" {}", row.detail)
355 } else {
356 String::new()
357 };
358 let prefix_width = UnicodeWidthStr::width(prefix.as_str());
359 let row_width = usize::from(content_area.width);
360 let label_budget = row_width.saturating_sub(prefix_width).max(1);
361 let label = truncate_line_to_width(&row.label, label_budget);
362 let detail_budget =
363 row_width.saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()));
364 let detail = if detail_budget >= 4 {
365 truncate_line_to_width(&detail_candidate, detail_budget)
366 } else {
367 String::new()
368 };
369 let detail_width = UnicodeWidthStr::width(detail.as_str());
370 let gap = usize::from(content_area.width)
371 .saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()) + detail_width);
372 let display = format!("{prefix}{label}{}{detail}", " ".repeat(gap));
373 lines.push(Line::from(Span::styled(display.clone(), style)));
374
375 hitboxes.push(WorkHitbox {
376 id: row.id.clone(),
377 row_y,
378 });
379
380 if row.selectable {
381 hover_rows.push(SidebarHoverRow {
382 row_y,
383 display_text: display,
384 full_text: format!("{} · {}", row.label, row.detail),
385 detail: Some(row.detail.clone()),
386 is_truncated: label != row.label || detail != detail_candidate,
387 click_action: row.primary_action.clone(),
388 stop_action: None,
389 stop_zone_start_col: None,
390 stop_zone_end_col: None,
391 });
392 }
393 }
394
395 if visible.is_empty() && app.work_surface.explicit_view && content_area.height > 0 {
396 // An explicitly opened view with nothing in it says so, once, so
397 // cycling never lands on a blank band.
398 lines.push(Line::from(Span::styled(
399 truncate_line_to_width(
400 empty_view_hint(app.work_surface.panel),
401 usize::from(content_area.width),
402 ),
403 Style::default()
404 .fg(app.ui_theme.text_muted)
405 .bg(app.ui_theme.panel_bg),
406 )));
407 }
408
409 if more_row {
410 // Right-aligned under the receipt column, muted like every other
411 // secondary figure. Scrolled to the bottom there is nothing below, so
412 // the reserved row stays blank rather than claiming a count of zero.
413 let remaining = rows
414 .len()
415 .saturating_sub(start.saturating_add(visible.len()));
416 let text = if remaining == 0 {
417 String::new()
418 } else {
419 truncate_line_to_width(
420 &format!("↓ {remaining} more"),
421 usize::from(content_area.width),
422 )
423 };
424 let pad = usize::from(content_area.width).saturating_sub(UnicodeWidthStr::width(&*text));
425 lines.push(Line::from(Span::styled(
426 format!("{}{text}", " ".repeat(pad)),
427 Style::default()
428 .fg(app.ui_theme.text_muted)
429 .bg(app.ui_theme.panel_bg),
430 )));
431 }
432
433 Paragraph::new(lines).render(content_area, frame.buffer_mut());
434 render_divider(frame, area, placement, app);
435 if overflow {
436 render_scrollbar(
437 frame,
438 Rect {
439 x: body_area.right().saturating_sub(1),
440 y: content_area.y,
441 width: 1,
442 height: content_area.height,
443 },
444 app.work_surface.scroll_offset,
445 list_rows,
446 rows.len(),
447 app,
448 );
449 }
450
451 app.work_surface.last_area = Some(area);
452 app.work_surface.hitboxes = hitboxes;
453 app.sidebar_hover.sections.push(SidebarHoverSection {
454 content_area,
455 lines: visible.iter().map(|row| row.label.clone()).collect(),
456 rows: hover_rows,
457 });
458 // The tab badges projected the other views on the way here; the rows
459 // a click resolves against are the ones this frame painted.
460 app.work_surface.latest_rows = rows;
461 }
462
463 /// What an explicitly opened, empty view says on its one row.
464 fn empty_view_hint(panel: RailPanel) -> &'static str {
465 match panel {
466 RailPanel::Agents => "no agents have run this session",
467 RailPanel::Tasks => "no to-dos yet",
468 RailPanel::Background => "nothing running in the background",
469 RailPanel::Files => "no files touched this session",
470 RailPanel::Notepad => "Enter to write a note",
471 RailPanel::Context => "context budget unknown",
472 RailPanel::Git => "not a git repository",
473 RailPanel::Price => "no priced turns yet",
474 }
475 }
476
477 /// Active goal as the Top strip's only title. Uses the same
478 /// paused/active/terminal resolution as the ocean header chip so a goal set
479 /// via `create_goal` is either visible everywhere or nowhere. Returns
480 /// `None` when no live goal exists — Top then paints no title row at all.
481 pub(super) fn top_goal_title(app: &App) -> Option<(String, Style)> {
482 let (objective, paused) = crate::tui::footer_ui::active_goal_chip_state(app)?;
483 let flat = objective.trim().replace(['\n', '\r'], " ");
484 if flat.is_empty() {
485 return None;
486 }
487 let text = if paused {
488 format!("Goal (paused): {flat}")
489 } else {
490 format!("Goal: {flat}")
491 };
492 let style = if paused {
493 Style::default()
494 .fg(app.ui_theme.warning)
495 .add_modifier(Modifier::BOLD)
496 } else {
497 Style::default()
498 .fg(app.ui_theme.status_working)
499 .add_modifier(Modifier::BOLD)
500 };
501 Some((text, style))
502 }
503
504 fn todo_ordinals(rows: &[WorkRow]) -> HashMap<String, usize> {
505 rows.iter()
506 .filter(|row| row.id.0.starts_with("graph:"))
507 .enumerate()
508 .map(|(index, row)| (row.id.0.clone(), index.saturating_add(1)))
509 .collect()
510 }
511
512 /// Below this width the goal title and the receipt cannot both stay readable
513 /// on one row, so the receipt keeps its own row.
514 const PROGRESS_FOLD_MIN_WIDTH: u16 = 72;
515
516 /// Whether the to-do receipt rides on the goal-title row instead of claiming
517 /// a row of its own.
518 ///
519 /// [`height`] and [`render`] must agree on this or the strip paints into a row
520 /// it did not reserve, so the rule is a pure function of the strip width and
521 /// whether there is a goal title to share with.
522 pub(super) fn progress_shares_goal_row(width: u16, has_goal_title: bool) -> bool {
523 has_goal_title && width >= PROGRESS_FOLD_MIN_WIDTH
524 }
525
526 pub(super) fn top_todo_progress(app: &App, rows: &[WorkRow]) -> Option<String> {
527 let todos = rows
528 .iter()
529 .filter(|row| row.id.0.starts_with("graph:"))
530 .collect::<Vec<_>>();
531 let total = todos.len();
532 if total == 0 {
533 return None;
534 }
535 let completed = todos
536 .iter()
537 .filter(|row| row.tone == WorkTone::Success)
538 .count();
539 let remaining = total.saturating_sub(completed);
540 let label = format!("{} ·", app.tr(MessageId::SidebarTodoLabel));
541 Some(
542 app.tr(MessageId::WorkSurfaceTodoProgress)
543 .replace("{label}", &label)
544 .replace("{completed}", &completed.to_string())
545 .replace("{total}", &total.to_string())
546 .replace("{remaining}", &remaining.to_string()),
547 )
548 }
549
550 fn render_divider(frame: &mut Frame, area: Rect, placement: WorkSurfacePlacement, app: &App) {
551 let active = app.work_surface.resizing || app.work_surface.divider_hovered;
552 let color = if active {
553 app.ui_theme.accent_primary
554 } else {
555 app.ui_theme.border
556 };
557 match placement {
558 WorkSurfacePlacement::Off => {}
559 WorkSurfacePlacement::Top => {
560 let y = area.bottom().saturating_sub(1);
561 for x in area.left()..area.right() {
562 frame.buffer_mut()[(x, y)]
563 .set_symbol(if active { "━" } else { "─" })
564 .set_fg(color)
565 .set_bg(app.ui_theme.panel_bg);
566 }
567 }
568 WorkSurfacePlacement::Bottom => {
569 let y = area.top();
570 for x in area.left()..area.right() {
571 frame.buffer_mut()[(x, y)]
572 .set_symbol(if active { "━" } else { "─" })
573 .set_fg(color)
574 .set_bg(app.ui_theme.panel_bg);
575 }
576 }
577 WorkSurfacePlacement::Left | WorkSurfacePlacement::Right => {
578 let x = if placement == WorkSurfacePlacement::Left {
579 area.right().saturating_sub(1)
580 } else {
581 area.left()
582 };
583 for y in area.top()..area.bottom() {
584 frame.buffer_mut()[(x, y)]
585 .set_symbol(if active { "┃" } else { "│" })
586 .set_fg(color)
587 .set_bg(app.ui_theme.panel_bg);
588 }
589 }
590 }
591 }
592
593 #[derive(Debug, Clone)]
594 struct DockTab {
595 target: DockTabTarget,
596 label: std::borrow::Cow<'static, str>,
597 count: usize,
598 }
599
600 fn render_dock_tabs(frame: &mut Frame, area: Rect, app: &mut App) {
601 let width = usize::from(area.width);
602 let mut entries = Vec::new();
603 for panel in RailPanel::ORDER {
604 let count = dock_tab_count(app, panel);
605 let useful =
606 count.is_some_and(|count| count > 0) || super::views::view_always_has_content(panel);
607 if useful || panel == app.work_surface.panel {
608 entries.push(DockTab {
609 target: DockTabTarget::Panel(panel),
610 label: match panel {
611 RailPanel::Tasks => "Tasks",
612 RailPanel::Agents => "Fleet",
613 RailPanel::Background => "Jobs",
614 RailPanel::Files => "Files",
615 RailPanel::Notepad => "Notes",
616 RailPanel::Context => "Context",
617 RailPanel::Git => "Git",
618 RailPanel::Price => "Cost",
619 }
620 .into(),
621 count: count.unwrap_or(0),
622 });
623 }
624 }
625
626 let close_mark = if crate::tui::color_compat::ascii_safe_enabled() {
627 "x"
628 } else {
629 "×"
630 };
631 let close = if area.width >= 60 {
632 format!(" Esc {close_mark} ")
633 } else {
634 format!(" {close_mark} ")
635 };
636 let close_width = close.width().min(width);
637 let mut show_counts = true;
638 let fits = |tabs: &[DockTab], counts: bool| {
639 tabs.iter()
640 .map(|tab| {
641 UnicodeWidthStr::width(tab.label.as_ref())
642 + if counts && tab.count > 0 {
643 1 + tab.count.to_string().len()
644 } else {
645 0
646 }
647 + 2
648 })
649 .sum::<usize>()
650 .saturating_add(tabs.len().saturating_sub(1).saturating_mul(2))
651 .saturating_add(close_width + 2)
652 <= width
653 };
654 if !fits(&entries, true) {
655 show_counts = false;
656 }
657 // Shed from the right (price, git, context, notepad, files… in reverse
658 // cycle order), never the active tab: a narrow dock keeps the work views.
659 while !fits(&entries, show_counts) && entries.len() > 1 {
660 let remove = entries
661 .iter()
662 .rposition(|tab| tab.target != DockTabTarget::Panel(app.work_surface.panel));
663 let Some(index) = remove else { break };
664 entries.remove(index);
665 }
666
667 let tab_y = if app.work_surface.effective_placement == WorkSurfacePlacement::Bottom {
668 area.y
669 .saturating_add(1)
670 .min(area.bottom().saturating_sub(1))
671 } else {
672 area.y
673 };
674 let tab_area = Rect {
675 x: area.x,
676 y: tab_y,
677 width: area.width,
678 height: 1,
679 };
680 let close_area = Rect {
681 x: tab_area.right().saturating_sub(close_width as u16),
682 y: tab_y,
683 width: close_width as u16,
684 height: 1,
685 };
686 app.work_surface.dock_tabs.clear();
687 for tab in &entries {
688 let label = if show_counts && tab.count > 0 {
689 format!("{} {}", tab.label, tab.count)
690 } else {
691 tab.label.to_string()
692 };
693 let tab_width = u16::try_from(UnicodeWidthStr::width(label.as_str()).saturating_add(2))
694 .unwrap_or(u16::MAX)
695 .min(tab_area.width);
696 let x = tab_area.x.saturating_add(
697 app.work_surface
698 .dock_tabs
699 .last()
700 .map(|hitbox| hitbox.area.right().saturating_sub(tab_area.x) + 2)
701 .unwrap_or(1),
702 );
703 if x.saturating_add(tab_width) > close_area.x {
704 break;
705 }
706 let hitbox = Rect {
707 x,
708 y: tab_y,
709 width: tab_width,
710 height: 1,
711 };
712 let active = tab.target == DockTabTarget::Panel(app.work_surface.panel);
713 let pressed = app.work_surface.pressed_tab == Some(tab.target);
714 let hovered = app.work_surface.hovered_tab == Some(tab.target);
715 let style = if active || pressed {
716 Style::default()
717 .fg(app.ui_theme.text_body)
718 .bg(app.ui_theme.selection_bg)
719 .add_modifier(Modifier::BOLD)
720 } else if hovered {
721 Style::default()
722 .fg(app.ui_theme.text_body)
723 .bg(app.ui_theme.elevated_bg)
724 .add_modifier(Modifier::UNDERLINED)
725 } else {
726 chrome_style(&app.ui_theme, ChromeInk::Metadata)
727 };
728 Paragraph::new(Line::from(Span::styled(format!(" {label} "), style)))
729 .render(hitbox, frame.buffer_mut());
730 app.work_surface.dock_tabs.push(DockTabHitbox {
731 target: tab.target,
732 area: hitbox,
733 });
734 }
735 let close_style = if app.work_surface.hovered_tab == Some(DockTabTarget::Close) {
736 chrome_style(&app.ui_theme, ChromeInk::Info)
737 .bg(app.ui_theme.elevated_bg)
738 .add_modifier(Modifier::UNDERLINED)
739 } else {
740 chrome_style(&app.ui_theme, ChromeInk::MetadataHint)
741 };
742 Paragraph::new(Line::from(Span::styled(close, close_style)))
743 .render(close_area, frame.buffer_mut());
744 app.work_surface.dock_tabs.push(DockTabHitbox {
745 target: DockTabTarget::Close,
746 area: close_area,
747 });
748 }
749
750 /// The badge on a view's tab: how many rows of *work* it holds. `None` for
751 /// the fact views (context, git, price), which never badge.
752 fn dock_tab_count(app: &mut App, panel: RailPanel) -> Option<usize> {
753 match panel {
754 RailPanel::Agents => Some(
755 visible_rows_for(app, panel)
756 .iter()
757 .filter(|row| row.id.0.starts_with("worker:"))
758 .count(),
759 ),
760 RailPanel::Tasks => Some(
761 visible_rows_for(app, panel)
762 .iter()
763 .filter(|row| row.id.0.starts_with("graph:"))
764 .count(),
765 ),
766 RailPanel::Background => Some(
767 visible_rows_for(app, panel)
768 .iter()
769 .filter(|row| row.selectable)
770 .count(),
771 ),
772 RailPanel::Files => Some(super::views::files_touched_count(app)),
773 RailPanel::Notepad => Some(usize::from(super::views::notepad_has_text(app))),
774 RailPanel::Context | RailPanel::Git | RailPanel::Price => None,
775 }
776 }
777
778 fn register_dock_targets(app: &mut App) {
779 let targets = app.work_surface.dock_tabs.clone();
780 for hitbox in targets {
781 let (id, action) = match hitbox.target {
782 DockTabTarget::Panel(panel) => {
783 use crate::tui::tideline::InteractionTargetId as Id;
784 let id = match panel {
785 RailPanel::Agents => Id::DOCK_TAB_AGENTS,
786 RailPanel::Tasks => Id::DOCK_TAB_TASKS,
787 RailPanel::Background => Id::DOCK_TAB_BACKGROUND,
788 RailPanel::Files => Id::DOCK_TAB_FILES,
789 RailPanel::Notepad => Id::DOCK_TAB_NOTEPAD,
790 RailPanel::Context => Id::DOCK_TAB_CONTEXT,
791 RailPanel::Git => Id::DOCK_TAB_GIT,
792 RailPanel::Price => Id::DOCK_TAB_PRICE,
793 };
794 (
795 id,
796 crate::tui::tideline::InteractionAction::ShowDockPanel(panel),
797 )
798 }
799 DockTabTarget::Close => (
800 crate::tui::tideline::InteractionTargetId::DOCK_CLOSE,
801 crate::tui::tideline::InteractionAction::DismissDock,
802 ),
803 };
804 app.viewport
805 .interaction_targets
806 .register(crate::tui::tideline::InteractionTarget {
807 id,
808 area: hitbox.area,
809 focus: crate::tui::tideline::InteractionFocus::Direct,
810 keyboard_action: Some(action),
811 mouse_action: Some(action),
812 inspect_detail: crate::tui::tideline::InspectDetail::Route,
813 });
814 }
815 }
816
817 fn render_scrollbar(
818 frame: &mut Frame,
819 area: Rect,
820 offset: usize,
821 visible: usize,
822 total: usize,
823 app: &App,
824 ) {
825 let rail_height = area.height;
826 if rail_height == 0 || total == 0 {
827 return;
828 }
829 let thumb_height = ((usize::from(rail_height) * visible) / total)
830 .max(1)
831 .min(usize::from(rail_height));
832 let max_offset = total.saturating_sub(visible).max(1);
833 let max_start = usize::from(rail_height).saturating_sub(thumb_height);
834 let thumb_start = offset.saturating_mul(max_start) / max_offset;
835 let x = area.right().saturating_sub(1);
836 for row in 0..usize::from(rail_height) {
837 let in_thumb = row >= thumb_start && row < thumb_start.saturating_add(thumb_height);
838 frame.buffer_mut()[(x, area.y.saturating_add(row as u16))]
839 // Match the transcript rail exactly: a fine border track with a
840 // brighter, narrow thumb. The old solid block looked like a
841 // separate native scrollbar bolted onto the work surface.
842 .set_symbol(if in_thumb { "┃" } else { "│" })
843 .set_fg(if in_thumb {
844 app.ui_theme.status_working
845 } else {
846 app.ui_theme.border
847 })
848 .set_bg(app.ui_theme.panel_bg);
849 }
850 }
851
851 lines RUST