| 1 | //! Frozen native body renderer, acceptance only. |
| 2 | use super::*; |
| 3 | |
| 4 | pub(super) fn render(frame: &mut Frame, area: Rect, app: &mut App) { |
| 5 | if area.width == 0 || area.height == 0 { |
| 6 | collapse_strip(app); |
| 7 | return; |
| 8 | } |
| 9 | |
| 10 | if let Some(previous) = app.work_surface.last_area { |
| 11 | app.sidebar_hover |
| 12 | .sections |
| 13 | .retain(|section| section.content_area != previous); |
| 14 | } |
| 15 | |
| 16 | let placement = app.work_surface.effective_placement; |
| 17 | // Off renders no rail; height()/split_chat() never hand us an area for it. |
| 18 | if placement == WorkSurfacePlacement::Off { |
| 19 | collapse_strip(app); |
| 20 | return; |
| 21 | } |
| 22 | let body_area = match placement { |
| 23 | // Bottom mirrors Top's body/divider split; only the divider edge |
| 24 | // differs (below-content for Top, above-content for Bottom). |
| 25 | WorkSurfacePlacement::Top => Rect { |
| 26 | y: area.y.saturating_add(1), |
| 27 | height: area.height.saturating_sub(2), |
| 28 | ..area |
| 29 | }, |
| 30 | WorkSurfacePlacement::Bottom => Rect { |
| 31 | y: area.y.saturating_add(2), |
| 32 | height: area.height.saturating_sub(2), |
| 33 | ..area |
| 34 | }, |
| 35 | WorkSurfacePlacement::Left => Rect { |
| 36 | width: area.width.saturating_sub(1), |
| 37 | ..area |
| 38 | }, |
| 39 | WorkSurfacePlacement::Right => Rect { |
| 40 | x: area.x.saturating_add(1), |
| 41 | width: area.width.saturating_sub(1), |
| 42 | ..area |
| 43 | }, |
| 44 | WorkSurfacePlacement::Off => unreachable!("off placement returned above"), |
| 45 | }; |
| 46 | |
| 47 | if !placement.is_strip() { |
| 48 | app.work_surface.dock_tabs.clear(); |
| 49 | app.work_surface.pressed_tab = None; |
| 50 | app.work_surface.hovered_tab = None; |
| 51 | } |
| 52 | |
| 53 | super::super::model::resolve_view(app); |
| 54 | let rows = visible_rows_for_panel(app); |
| 55 | let todo_ordinals = if placement.is_strip() { |
| 56 | todo_ordinals(&rows) |
| 57 | } else { |
| 58 | HashMap::new() |
| 59 | }; |
| 60 | let ordinal_width = todo_ordinals.len().max(1).to_string().len(); |
| 61 | let goal_title = placement.is_strip().then(|| top_goal_title(app)).flatten(); |
| 62 | let todo_progress = placement |
| 63 | .is_strip() |
| 64 | .then(|| top_todo_progress(app, &rows)) |
| 65 | .flatten(); |
| 66 | // Pin goal title, then progress receipt, above the scrollable rows. |
| 67 | // A compact strip keeps its last usable row for content, ahead of headers. |
| 68 | let goal_height = u16::from(goal_title.is_some() && body_area.height >= 2); |
| 69 | let fold_progress = progress_shares_goal_row(body_area.width, goal_height > 0); |
| 70 | let progress_height = u16::from( |
| 71 | todo_progress.is_some() |
| 72 | && !fold_progress |
| 73 | && body_area.height.saturating_sub(goal_height) >= 2, |
| 74 | ); |
| 75 | let header_height = goal_height.saturating_add(progress_height); |
| 76 | let list_height = body_area.height.saturating_sub(header_height); |
| 77 | let body_height = usize::from(list_height); |
| 78 | let overflow = rows.len() > body_height; |
| 79 | // A capped list owes the reader the size of what it is hiding, so the |
| 80 | // last painted row becomes `↓ N more`. The scrollbar shows position; only |
| 81 | // this shows how much work is off-screen. |
| 82 | let more_row = overflow && body_height >= 2; |
| 83 | let list_rows = if more_row { |
| 84 | body_height.saturating_sub(1) |
| 85 | } else { |
| 86 | body_height |
| 87 | }; |
| 88 | let inset = u16::from(body_area.width >= 16); |
| 89 | let rail_width = u16::from(overflow); |
| 90 | let content_area = Rect { |
| 91 | x: body_area.x.saturating_add(inset), |
| 92 | y: body_area.y.saturating_add(header_height), |
| 93 | width: body_area |
| 94 | .width |
| 95 | .saturating_sub(inset.saturating_mul(2)) |
| 96 | .saturating_sub(rail_width), |
| 97 | height: list_height, |
| 98 | }; |
| 99 | |
| 100 | app.work_surface.visible_rows = list_rows; |
| 101 | app.work_surface.total_rows = rows.len(); |
| 102 | // A redraw may clamp an obsolete offset, but it must not reveal the |
| 103 | // remembered keyboard selection: doing so undoes mouse-wheel scrolling |
| 104 | // whenever that selection is above the viewport (#4594). |
| 105 | app.work_surface.clamp_viewport(&rows); |
| 106 | let max_offset = rows.len().saturating_sub(list_rows.max(1)); |
| 107 | app.work_surface.scroll_offset = app.work_surface.scroll_offset.min(max_offset); |
| 108 | |
| 109 | Block::default() |
| 110 | .style(Style::default().bg(app.ui_theme.panel_bg)) |
| 111 | .render(area, frame.buffer_mut()); |
| 112 | render_dock_tabs(frame, area, app); |
| 113 | register_dock_targets(app); |
| 114 | |
| 115 | if let Some((goal_text, goal_style)) = goal_title.filter(|_| goal_height > 0) { |
| 116 | let full_width = usize::from(content_area.width); |
| 117 | // Wide strips carry the receipt right-aligned on the goal row rather |
| 118 | // than spending a second row announcing a count. |
| 119 | let receipt = todo_progress.as_deref().filter(|_| fold_progress); |
| 120 | let reserved = receipt |
| 121 | .map(|text| UnicodeWidthStr::width(text).saturating_add(2)) |
| 122 | .unwrap_or(0); |
| 123 | let goal_text = truncate_line_to_width(&goal_text, full_width.saturating_sub(reserved)); |
| 124 | let mut spans = vec![Span::styled( |
| 125 | goal_text.clone(), |
| 126 | goal_style.bg(app.ui_theme.panel_bg), |
| 127 | )]; |
| 128 | if let Some(receipt) = receipt { |
| 129 | let gap = full_width |
| 130 | .saturating_sub(UnicodeWidthStr::width(goal_text.as_str())) |
| 131 | .saturating_sub(UnicodeWidthStr::width(receipt)); |
| 132 | spans.push(Span::styled( |
| 133 | format!("{}{receipt}", " ".repeat(gap)), |
| 134 | Style::default() |
| 135 | .fg(app.ui_theme.text_muted) |
| 136 | .bg(app.ui_theme.panel_bg), |
| 137 | )); |
| 138 | } |
| 139 | Paragraph::new(Line::from(spans)).render( |
| 140 | Rect { |
| 141 | y: body_area.y, |
| 142 | height: 1, |
| 143 | ..content_area |
| 144 | }, |
| 145 | frame.buffer_mut(), |
| 146 | ); |
| 147 | } |
| 148 | |
| 149 | if let Some(progress) = todo_progress.filter(|_| progress_height > 0) { |
| 150 | let progress = truncate_line_to_width(&progress, usize::from(content_area.width)); |
| 151 | // Muted, not accent: accent_primary means "selected" everywhere else |
| 152 | // in the strip, and spending it on a static count makes the actual |
| 153 | // selection hard to find. |
| 154 | Paragraph::new(Line::from(Span::styled( |
| 155 | progress, |
| 156 | Style::default() |
| 157 | .fg(app.ui_theme.text_muted) |
| 158 | .bg(app.ui_theme.panel_bg), |
| 159 | ))) |
| 160 | .render( |
| 161 | Rect { |
| 162 | y: body_area.y.saturating_add(goal_height), |
| 163 | height: 1, |
| 164 | ..content_area |
| 165 | }, |
| 166 | frame.buffer_mut(), |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | let start = app.work_surface.scroll_offset; |
| 171 | let visible = rows.iter().skip(start).take(list_rows).collect::<Vec<_>>(); |
| 172 | let identity_cap = agent_identity_cap(usize::from(content_area.width)); |
| 173 | let identity_column = agent_identity_column(&visible, identity_cap); |
| 174 | let status_column = agent_status_column(&visible); |
| 175 | let mut lines = Vec::with_capacity(visible.len().saturating_add(1)); |
| 176 | let mut hover_rows = Vec::new(); |
| 177 | let mut hitboxes = Vec::new(); |
| 178 | for (visible_index, row) in visible.iter().enumerate() { |
| 179 | let row_y = content_area.y.saturating_add(visible_index as u16); |
| 180 | let selected = |
| 181 | app.work_surface.focused && app.work_surface.selected.as_ref() == Some(&row.id); |
| 182 | let hovered = app.work_surface.hovered.as_ref() == Some(&row.id); |
| 183 | let opened = app.work_surface.opened.as_ref() == Some(&row.id); |
| 184 | let style = row_style(app, row, selected, hovered, opened); |
| 185 | let compact_owner = if placement.is_strip() { |
| 186 | todo_ordinals |
| 187 | .get(&row.id.0) |
| 188 | .map(|ordinal| format!("{ordinal:>ordinal_width$} · ")) |
| 189 | .unwrap_or_default() |
| 190 | } else { |
| 191 | String::new() |
| 192 | }; |
| 193 | let mark = if opened && row.selectable { |
| 194 | "▾" |
| 195 | } else { |
| 196 | row.mark |
| 197 | }; |
| 198 | // Agent focus marker: while a worker is focused every row gains a |
| 199 | // two-cell gutter and the focused worker's row shows the selection |
| 200 | // glyph in it, so the addressed fork is visible at the left edge. |
| 201 | let focus_gutter = if app.agent_focus.is_some() { |
| 202 | let focused = row |
| 203 | .id |
| 204 | .0 |
| 205 | .strip_prefix("worker:") |
| 206 | .is_some_and(|id| app.agent_focus.as_ref().is_some_and(|f| f.is(id))); |
| 207 | if focused { |
| 208 | "❯ ".to_string() |
| 209 | } else { |
| 210 | " ".to_string() |
| 211 | } |
| 212 | } else { |
| 213 | String::new() |
| 214 | }; |
| 215 | let prefix = if row.tone == WorkTone::Heading { |
| 216 | format!("{focus_gutter}{} ", mark) |
| 217 | } else { |
| 218 | format!("{focus_gutter}{compact_owner}{mark} ") |
| 219 | }; |
| 220 | |
| 221 | // Sub-agent rows own their own column layout: glyph, agent type, |
| 222 | // objective, right-aligned elapsed and tokens. They stay ordinary |
| 223 | // rows in every other respect — same hitbox, same selection, same |
| 224 | // primary action. |
| 225 | if let Some(facts) = row.agent.as_ref() { |
| 226 | let queued = row |
| 227 | .id |
| 228 | .0 |
| 229 | .strip_prefix("worker:") |
| 230 | .and_then(|id| crate::tui::agent_focus::queued_suffix(app, id)) |
| 231 | .map(|queued| format!(" · {queued}")); |
| 232 | let queued_width = queued.as_deref().map(UnicodeWidthStr::width).unwrap_or(0); |
| 233 | let laid_out = layout_agent_row( |
| 234 | usize::from(content_area.width).saturating_sub(queued_width), |
| 235 | UnicodeWidthStr::width(prefix.as_str()), |
| 236 | agent_identity(row, identity_cap), |
| 237 | identity_column, |
| 238 | status_column, |
| 239 | facts, |
| 240 | ); |
| 241 | let (normal, muted) = agent_row_styles(app, selected, hovered, opened); |
| 242 | let display = format!( |
| 243 | "{prefix}{}{}{}{}{}{}{}", |
| 244 | laid_out.role, |
| 245 | if laid_out.role.is_empty() { |
| 246 | String::new() |
| 247 | } else { |
| 248 | " ".repeat(AGENT_ROLE_GUTTER) |
| 249 | }, |
| 250 | laid_out.status, |
| 251 | if laid_out.status.is_empty() { |
| 252 | String::new() |
| 253 | } else { |
| 254 | " ".repeat(AGENT_ROLE_GUTTER) |
| 255 | }, |
| 256 | laid_out.objective, |
| 257 | " ".repeat(laid_out.gap), |
| 258 | laid_out.receipt, |
| 259 | ); |
| 260 | let mut spans = vec![Span::styled(prefix.clone(), normal)]; |
| 261 | if !laid_out.role.is_empty() { |
| 262 | spans.push(Span::styled( |
| 263 | format!("{}{}", laid_out.role, " ".repeat(AGENT_ROLE_GUTTER)), |
| 264 | muted, |
| 265 | )); |
| 266 | } |
| 267 | if !laid_out.status.is_empty() { |
| 268 | spans.push(Span::styled( |
| 269 | format!("{}{}", laid_out.status, " ".repeat(AGENT_ROLE_GUTTER)), |
| 270 | muted, |
| 271 | )); |
| 272 | } |
| 273 | spans.push(Span::styled(laid_out.objective.clone(), normal)); |
| 274 | spans.push(Span::styled( |
| 275 | format!("{}{}", " ".repeat(laid_out.gap), laid_out.receipt), |
| 276 | muted, |
| 277 | )); |
| 278 | if let Some(queued) = queued.as_deref() { |
| 279 | // Truthful `· N queued`: follow-ups the running child has not |
| 280 | // yet folded into its next round. Amber: a message is waiting |
| 281 | // on a busy agent, the one thing on this row that needs you. |
| 282 | spans.push(Span::styled( |
| 283 | queued.to_string(), |
| 284 | Style::default() |
| 285 | .fg(app.ui_theme.warning) |
| 286 | .bg(normal.bg.unwrap_or(app.ui_theme.panel_bg)), |
| 287 | )); |
| 288 | } |
| 289 | lines.push(Line::from(spans)); |
| 290 | |
| 291 | hitboxes.push(WorkHitbox { |
| 292 | id: row.id.clone(), |
| 293 | row_y, |
| 294 | }); |
| 295 | hover_rows.push(SidebarHoverRow { |
| 296 | row_y, |
| 297 | display_text: display, |
| 298 | full_text: format!("{} · {}", row.label, row.detail), |
| 299 | detail: Some(row.detail.clone()), |
| 300 | is_truncated: laid_out.objective != facts.objective |
| 301 | || laid_out.receipt != agent_receipt(facts, AgentRowTier::Full), |
| 302 | click_action: row.primary_action.clone(), |
| 303 | stop_action: None, |
| 304 | stop_zone_start_col: None, |
| 305 | stop_zone_end_col: None, |
| 306 | }); |
| 307 | continue; |
| 308 | } |
| 309 | |
| 310 | let detail_candidate = if row.tone != WorkTone::Heading && content_area.width >= 44 { |
| 311 | format!(" {}", row.detail) |
| 312 | } else { |
| 313 | String::new() |
| 314 | }; |
| 315 | let prefix_width = UnicodeWidthStr::width(prefix.as_str()); |
| 316 | let row_width = usize::from(content_area.width); |
| 317 | let label_budget = row_width.saturating_sub(prefix_width).max(1); |
| 318 | let label = truncate_line_to_width(&row.label, label_budget); |
| 319 | let detail_budget = |
| 320 | row_width.saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str())); |
| 321 | let detail = if detail_budget >= 4 { |
| 322 | truncate_line_to_width(&detail_candidate, detail_budget) |
| 323 | } else { |
| 324 | String::new() |
| 325 | }; |
| 326 | let detail_width = UnicodeWidthStr::width(detail.as_str()); |
| 327 | let gap = usize::from(content_area.width) |
| 328 | .saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()) + detail_width); |
| 329 | let display = format!("{prefix}{label}{}{detail}", " ".repeat(gap)); |
| 330 | lines.push(Line::from(Span::styled(display.clone(), style))); |
| 331 | |
| 332 | hitboxes.push(WorkHitbox { |
| 333 | id: row.id.clone(), |
| 334 | row_y, |
| 335 | }); |
| 336 | |
| 337 | if row.selectable { |
| 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: label != row.label || detail != detail_candidate, |
| 344 | click_action: row.primary_action.clone(), |
| 345 | stop_action: None, |
| 346 | stop_zone_start_col: None, |
| 347 | stop_zone_end_col: None, |
| 348 | }); |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | if visible.is_empty() && app.work_surface.explicit_view && content_area.height > 0 { |
| 353 | // An explicitly opened view with nothing in it says so, once, so |
| 354 | // cycling never lands on a blank band. |
| 355 | lines.push(Line::from(Span::styled( |
| 356 | truncate_line_to_width( |
| 357 | empty_view_hint(app.work_surface.panel), |
| 358 | usize::from(content_area.width), |
| 359 | ), |
| 360 | Style::default() |
| 361 | .fg(app.ui_theme.text_muted) |
| 362 | .bg(app.ui_theme.panel_bg), |
| 363 | ))); |
| 364 | } |
| 365 | |
| 366 | if more_row { |
| 367 | // Right-aligned under the receipt column, muted like every other |
| 368 | // secondary figure. Scrolled to the bottom there is nothing below, so |
| 369 | // the reserved row stays blank rather than claiming a count of zero. |
| 370 | let remaining = rows |
| 371 | .len() |
| 372 | .saturating_sub(start.saturating_add(visible.len())); |
| 373 | let text = if remaining == 0 { |
| 374 | String::new() |
| 375 | } else { |
| 376 | truncate_line_to_width( |
| 377 | &format!("↓ {remaining} more"), |
| 378 | usize::from(content_area.width), |
| 379 | ) |
| 380 | }; |
| 381 | let pad = usize::from(content_area.width).saturating_sub(UnicodeWidthStr::width(&*text)); |
| 382 | lines.push(Line::from(Span::styled( |
| 383 | format!("{}{text}", " ".repeat(pad)), |
| 384 | Style::default() |
| 385 | .fg(app.ui_theme.text_muted) |
| 386 | .bg(app.ui_theme.panel_bg), |
| 387 | ))); |
| 388 | } |
| 389 | |
| 390 | Paragraph::new(lines).render(content_area, frame.buffer_mut()); |
| 391 | render_divider(frame, area, placement, app); |
| 392 | if overflow { |
| 393 | legacy_scrollbar( |
| 394 | frame, |
| 395 | Rect { |
| 396 | x: body_area.right().saturating_sub(1), |
| 397 | y: content_area.y, |
| 398 | width: 1, |
| 399 | height: content_area.height, |
| 400 | }, |
| 401 | app.work_surface.scroll_offset, |
| 402 | list_rows, |
| 403 | rows.len(), |
| 404 | app, |
| 405 | ); |
| 406 | } |
| 407 | |
| 408 | app.work_surface.last_area = Some(area); |
| 409 | app.work_surface.hitboxes = hitboxes; |
| 410 | app.sidebar_hover.sections.push(SidebarHoverSection { |
| 411 | content_area, |
| 412 | lines: visible.iter().map(|row| row.label.clone()).collect(), |
| 413 | rows: hover_rows, |
| 414 | }); |
| 415 | // The tab badges projected the other views on the way here; the rows |
| 416 | // a click resolves against are the ones this frame painted. |
| 417 | app.work_surface.latest_rows = rows; |
| 418 | } |
| 419 | |
| 420 | fn legacy_scrollbar( |
| 421 | frame: &mut Frame, |
| 422 | area: Rect, |
| 423 | offset: usize, |
| 424 | visible: usize, |
| 425 | total: usize, |
| 426 | app: &App, |
| 427 | ) { |
| 428 | let rail_height = area.height; |
| 429 | if rail_height == 0 || total == 0 { |
| 430 | return; |
| 431 | } |
| 432 | let thumb_height = ((usize::from(rail_height) * visible) / total) |
| 433 | .max(1) |
| 434 | .min(usize::from(rail_height)); |
| 435 | let max_offset = total.saturating_sub(visible).max(1); |
| 436 | let max_start = usize::from(rail_height).saturating_sub(thumb_height); |
| 437 | let thumb_start = offset.saturating_mul(max_start) / max_offset; |
| 438 | let x = area.right().saturating_sub(1); |
| 439 | for row in 0..usize::from(rail_height) { |
| 440 | let in_thumb = row >= thumb_start && row < thumb_start.saturating_add(thumb_height); |
| 441 | frame.buffer_mut()[(x, area.y.saturating_add(row as u16))] |
| 442 | // Match the transcript rail exactly: a fine border track with a |
| 443 | // brighter, narrow thumb. The old solid block looked like a |
| 444 | // separate native scrollbar bolted onto the work surface. |
| 445 | .set_symbol(if in_thumb { "┃" } else { "│" }) |
| 446 | .set_fg(if in_thumb { |
| 447 | app.ui_theme.status_working |
| 448 | } else { |
| 449 | app.ui_theme.border |
| 450 | }) |
| 451 | .set_bg(app.ui_theme.panel_bg); |
| 452 | } |
| 453 | } |
| 454 |