| 1 | //! Sidebar rendering — Pinned / Activity / Agents / Context panels. |
| 2 | //! |
| 3 | //! Extracted from `tui/ui.rs` (P1.2). The sidebar appears to the right of |
| 4 | //! the chat transcript when the available width allows it. Each section |
| 5 | //! reads from `App` snapshots; mutation lives in the main app loop. |
| 6 | |
| 7 | use std::time::Instant; |
| 8 | |
| 9 | use crate::localization::Locale; |
| 10 | use crate::tui::app::HuntVerdict; |
| 11 | |
| 12 | use ratatui::{ |
| 13 | style::Style, |
| 14 | text::{Line, Span}, |
| 15 | }; |
| 16 | |
| 17 | use crate::palette; |
| 18 | use crate::tools::subagent::{AgentWorkerStatus, SubAgentStatus, localized_whale_display_names}; |
| 19 | use crate::tools::todo::TodoStatus; |
| 20 | |
| 21 | use super::app::{AgentCurrentActivity, AgentCurrentActivityStatus, App, SidebarRowAction}; |
| 22 | use super::history::{HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; |
| 23 | use super::ui_text::truncate_line_to_width; |
| 24 | |
| 25 | /// Tolerance for floating-point cost comparison in the sidebar breakdown. |
| 26 | /// Must be large enough that accumulated f64 error across hundreds of turns |
| 27 | /// does not prematurely hide the session+agents breakdown. |
| 28 | const COST_EQ_TOLERANCE: f64 = 1e-6; |
| 29 | const TASK_STOP_TARGET_LABEL: &str = "[x]"; |
| 30 | const TASK_STOP_TARGET_SUFFIX: &str = " [x]"; |
| 31 | #[derive(Debug, Clone)] |
| 32 | struct SidebarWorkChecklistItem { |
| 33 | id: u32, |
| 34 | content: String, |
| 35 | status: TodoStatus, |
| 36 | } |
| 37 | |
| 38 | #[derive(Debug, Clone, Default)] |
| 39 | pub(crate) struct SidebarWorkSummary { |
| 40 | goal_objective: Option<String>, |
| 41 | goal_token_budget: Option<u32>, |
| 42 | goal_completed: bool, |
| 43 | goal_started_at: Option<Instant>, |
| 44 | /// When the goal went terminal. While `Some`, the elapsed line freezes at |
| 45 | /// `goal_finished_at - goal_started_at` instead of ticking every frame. |
| 46 | goal_finished_at: Option<Instant>, |
| 47 | tokens_used: u32, |
| 48 | checklist_completion_pct: u8, |
| 49 | checklist_items: Vec<SidebarWorkChecklistItem>, |
| 50 | state_updating: bool, |
| 51 | pause_indicator: Option<String>, |
| 52 | workflow_paused: bool, |
| 53 | } |
| 54 | |
| 55 | impl SidebarWorkSummary { |
| 56 | pub(crate) fn has_useful_content(&self) -> bool { |
| 57 | self.goal_objective |
| 58 | .as_deref() |
| 59 | .is_some_and(|s| !s.trim().is_empty()) |
| 60 | || !self.checklist_items.is_empty() |
| 61 | || self.state_updating |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /// Objective of the active goal, if any. Paused goals keep showing their |
| 66 | /// quarry; the work summary uses this so a completed goal can still render |
| 67 | /// with its DONE state. |
| 68 | pub(crate) fn live_goal_objective(app: &App) -> Option<String> { |
| 69 | if app.paused || app.paused_quarry.is_some() { |
| 70 | app.hunt |
| 71 | .quarry |
| 72 | .clone() |
| 73 | .or_else(|| app.paused_quarry.clone()) |
| 74 | } else { |
| 75 | app.hunt.quarry.clone() |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | pub(crate) fn sidebar_work_summary(app: &mut App) -> SidebarWorkSummary { |
| 80 | fn live_pause_indicator(app: &App) -> Option<String> { |
| 81 | if app.paused && app.is_loading { |
| 82 | Some("(Pausing)".to_string()) |
| 83 | } else if app.paused || app.paused_quarry.is_some() { |
| 84 | Some("(Paused)".to_string()) |
| 85 | } else if app.hunt.verdict == HuntVerdict::Wounded { |
| 86 | Some(match app.hunt.pause_reason { |
| 87 | Some(reason) => format!("(Paused: {})", reason.label()), |
| 88 | None => "(Paused)".to_string(), |
| 89 | }) |
| 90 | } else { |
| 91 | None |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | fn apply_live_goal_state(summary: &mut SidebarWorkSummary, app: &App) { |
| 96 | summary.goal_objective = live_goal_objective(app); |
| 97 | summary.goal_token_budget = app.hunt.token_budget; |
| 98 | summary.goal_completed = app.hunt.verdict == HuntVerdict::Hunted; |
| 99 | summary.goal_started_at = app.hunt.started_at; |
| 100 | summary.goal_finished_at = app.hunt.finished_at; |
| 101 | summary.tokens_used = app.session.total_conversation_tokens; |
| 102 | summary.pause_indicator = live_pause_indicator(app); |
| 103 | summary.workflow_paused = |
| 104 | app.paused || app.paused_quarry.is_some() || app.hunt.verdict == HuntVerdict::Wounded; |
| 105 | } |
| 106 | |
| 107 | let fresh = (|| { |
| 108 | let todos = app.todos.try_lock().ok()?; |
| 109 | let snapshot = todos.snapshot(); |
| 110 | let checklist_completion_pct = snapshot.completion_pct; |
| 111 | let checklist_items = snapshot |
| 112 | .items |
| 113 | .into_iter() |
| 114 | .map(|item| SidebarWorkChecklistItem { |
| 115 | id: item.id, |
| 116 | content: item.content, |
| 117 | status: item.status, |
| 118 | }) |
| 119 | .collect(); |
| 120 | |
| 121 | let mut summary = SidebarWorkSummary { |
| 122 | goal_objective: live_goal_objective(app), |
| 123 | goal_token_budget: app.hunt.token_budget, |
| 124 | goal_completed: app.hunt.verdict == HuntVerdict::Hunted, |
| 125 | goal_started_at: app.hunt.started_at, |
| 126 | goal_finished_at: app.hunt.finished_at, |
| 127 | tokens_used: app.session.total_conversation_tokens, |
| 128 | checklist_completion_pct, |
| 129 | checklist_items, |
| 130 | // Strategy/plan remains compatibility state for saved sessions, |
| 131 | // but it is not a second user-facing progress surface. |
| 132 | state_updating: false, |
| 133 | pause_indicator: live_pause_indicator(app), |
| 134 | workflow_paused: app.paused |
| 135 | || app.paused_quarry.is_some() |
| 136 | || app.hunt.verdict == HuntVerdict::Wounded, |
| 137 | }; |
| 138 | apply_live_goal_state(&mut summary, app); |
| 139 | Some(summary) |
| 140 | })(); |
| 141 | |
| 142 | if let Some(summary) = fresh { |
| 143 | app.cached_work_summary = Some(summary.clone()); |
| 144 | return summary; |
| 145 | } |
| 146 | |
| 147 | if let Some(cached) = app.cached_work_summary.as_ref() { |
| 148 | let mut summary = cached.clone(); |
| 149 | apply_live_goal_state(&mut summary, app); |
| 150 | return summary; |
| 151 | } |
| 152 | |
| 153 | let mut summary = SidebarWorkSummary { |
| 154 | state_updating: true, |
| 155 | ..SidebarWorkSummary::default() |
| 156 | }; |
| 157 | apply_live_goal_state(&mut summary, app); |
| 158 | summary |
| 159 | } |
| 160 | |
| 161 | /// Default-options shorthand for [`work_panel_lines_with_opts`]. |
| 162 | /// |
| 163 | /// Production callers all pass real [`WorkPanelOpts`] since the goal title |
| 164 | /// moved to the strip, so this only survives to keep the tests readable. |
| 165 | #[cfg(test)] |
| 166 | pub(crate) fn work_panel_lines( |
| 167 | summary: &SidebarWorkSummary, |
| 168 | content_width: usize, |
| 169 | max_rows: usize, |
| 170 | palette_mode: palette::PaletteMode, |
| 171 | ui_theme: &palette::UiTheme, |
| 172 | ) -> Vec<Line<'static>> { |
| 173 | work_panel_lines_with_opts( |
| 174 | summary, |
| 175 | content_width, |
| 176 | max_rows, |
| 177 | palette_mode, |
| 178 | ui_theme, |
| 179 | WorkPanelOpts::default(), |
| 180 | ) |
| 181 | } |
| 182 | |
| 183 | /// Options for the Pinned work panel body. |
| 184 | #[derive(Debug, Clone, Copy, Default)] |
| 185 | pub(crate) struct WorkPanelOpts { |
| 186 | /// When true, skip the primary `Goal: …` objective line. Used on Top |
| 187 | /// placement where that line is already the strip title — repeating it |
| 188 | /// in the body wastes a scarce row. |
| 189 | pub omit_goal_objective: bool, |
| 190 | } |
| 191 | |
| 192 | pub(crate) fn work_panel_lines_with_opts( |
| 193 | summary: &SidebarWorkSummary, |
| 194 | content_width: usize, |
| 195 | max_rows: usize, |
| 196 | palette_mode: palette::PaletteMode, |
| 197 | ui_theme: &palette::UiTheme, |
| 198 | opts: WorkPanelOpts, |
| 199 | ) -> Vec<Line<'static>> { |
| 200 | let _ = palette_mode; |
| 201 | let mut lines: Vec<Line<'static>> = Vec::with_capacity(max_rows.max(4)); |
| 202 | |
| 203 | push_work_goal_lines( |
| 204 | summary, |
| 205 | content_width, |
| 206 | max_rows, |
| 207 | &mut lines, |
| 208 | ui_theme, |
| 209 | opts.omit_goal_objective, |
| 210 | ); |
| 211 | |
| 212 | if summary.state_updating && lines.len() < max_rows { |
| 213 | lines.push(Line::from(Span::styled( |
| 214 | "Work state updating...", |
| 215 | Style::default().fg(ui_theme.text_muted), |
| 216 | ))); |
| 217 | } |
| 218 | |
| 219 | push_work_checklist_lines(summary, content_width, max_rows, &mut lines, ui_theme); |
| 220 | |
| 221 | if lines.is_empty() { |
| 222 | lines.push(Line::from(Span::styled( |
| 223 | work_panel_empty_hint(content_width), |
| 224 | Style::default().fg(ui_theme.text_muted).italic(), |
| 225 | ))); |
| 226 | } |
| 227 | |
| 228 | lines |
| 229 | } |
| 230 | |
| 231 | /// Humanized elapsed time for a goal. Once the goal is terminal (`finished` |
| 232 | /// is `Some`), the elapsed is frozen at `finished - started` so a completed or |
| 233 | /// escaped goal stops ticking in the sidebar; otherwise it grows live. |
| 234 | fn goal_elapsed_for_summary(started: Instant, finished: Option<Instant>) -> String { |
| 235 | let elapsed = match finished { |
| 236 | Some(end) => end.saturating_duration_since(started), |
| 237 | None => started.elapsed(), |
| 238 | }; |
| 239 | crate::elapsed::format_elapsed_secs(elapsed.as_secs()) |
| 240 | } |
| 241 | |
| 242 | fn push_work_goal_lines( |
| 243 | summary: &SidebarWorkSummary, |
| 244 | content_width: usize, |
| 245 | max_rows: usize, |
| 246 | lines: &mut Vec<Line<'static>>, |
| 247 | theme: &palette::UiTheme, |
| 248 | omit_objective: bool, |
| 249 | ) { |
| 250 | let Some(objective) = summary.goal_objective.as_deref() else { |
| 251 | return; |
| 252 | }; |
| 253 | if objective.trim().is_empty() || lines.len() >= max_rows { |
| 254 | return; |
| 255 | } |
| 256 | |
| 257 | if !omit_objective { |
| 258 | let icon = if summary.goal_completed { |
| 259 | crate::tui::glyphs::DONE |
| 260 | } else if summary.workflow_paused { |
| 261 | crate::tui::glyphs::PAUSED |
| 262 | } else { |
| 263 | crate::tui::glyphs::ATTENTION |
| 264 | }; |
| 265 | let status_style = if summary.goal_completed { |
| 266 | Style::default() |
| 267 | .fg(theme.success) |
| 268 | .add_modifier(ratatui::style::Modifier::BOLD) |
| 269 | } else { |
| 270 | Style::default() |
| 271 | .fg(theme.warning) |
| 272 | .add_modifier(ratatui::style::Modifier::BOLD) |
| 273 | }; |
| 274 | // Show the full goal objective — this is goal mode's primary status |
| 275 | // surface. Prefix with "Goal:" so the compact row is clearly labelled |
| 276 | // as a goal-mode objective, not a generic session title. |
| 277 | let label = if let Some(indicator) = summary.pause_indicator.as_deref() { |
| 278 | format!("Goal: {objective} {indicator}") |
| 279 | } else { |
| 280 | format!("Goal: {objective}") |
| 281 | }; |
| 282 | |
| 283 | lines.push(Line::from(Span::styled( |
| 284 | format!( |
| 285 | "{} {}", |
| 286 | icon, |
| 287 | truncate_line_to_width(&label, content_width.saturating_sub(2).max(1)) |
| 288 | ), |
| 289 | status_style, |
| 290 | ))); |
| 291 | } |
| 292 | |
| 293 | // Elapsed time |
| 294 | if let Some(started) = summary.goal_started_at |
| 295 | && lines.len() < max_rows |
| 296 | { |
| 297 | let elapsed = goal_elapsed_for_summary(started, summary.goal_finished_at); |
| 298 | let elapsed_str = if summary.goal_completed { |
| 299 | format!("completed in {elapsed}") |
| 300 | } else { |
| 301 | format!("elapsed: {elapsed}") |
| 302 | }; |
| 303 | lines.push(Line::from(Span::styled( |
| 304 | truncate_line_to_width(&elapsed_str, content_width), |
| 305 | Style::default().fg(theme.text_muted), |
| 306 | ))); |
| 307 | } |
| 308 | |
| 309 | if let Some(budget) = summary.goal_token_budget |
| 310 | && lines.len() < max_rows |
| 311 | { |
| 312 | let pct = if budget > 0 { |
| 313 | ((summary.tokens_used as f64 / budget as f64) * 100.0).min(100.0) |
| 314 | } else { |
| 315 | 0.0 |
| 316 | }; |
| 317 | let bar_width = content_width.min(20); |
| 318 | let filled = ((pct / 100.0) * bar_width as f64) as usize; |
| 319 | let bar = format!( |
| 320 | "[{}{}] {:.0}%", |
| 321 | "█".repeat(filled), |
| 322 | "░".repeat(bar_width.saturating_sub(filled)), |
| 323 | pct |
| 324 | ); |
| 325 | lines.push(Line::from(Span::styled( |
| 326 | truncate_line_to_width( |
| 327 | &format!("tokens: {}/{} {}", summary.tokens_used, budget, bar), |
| 328 | content_width, |
| 329 | ), |
| 330 | Style::default().fg(theme.text_muted), |
| 331 | ))); |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | fn push_work_checklist_lines( |
| 336 | summary: &SidebarWorkSummary, |
| 337 | content_width: usize, |
| 338 | max_rows: usize, |
| 339 | lines: &mut Vec<Line<'static>>, |
| 340 | theme: &palette::UiTheme, |
| 341 | ) { |
| 342 | if summary.checklist_items.is_empty() || lines.len() >= max_rows { |
| 343 | return; |
| 344 | } |
| 345 | |
| 346 | let total = summary.checklist_items.len(); |
| 347 | let settled = summary |
| 348 | .checklist_items |
| 349 | .iter() |
| 350 | .filter(|item| item.status.is_settled()) |
| 351 | .count(); |
| 352 | lines.push(Line::from(vec![ |
| 353 | Span::styled( |
| 354 | format!("{}%", summary.checklist_completion_pct), |
| 355 | Style::default().fg(theme.success).bold(), |
| 356 | ), |
| 357 | Span::styled( |
| 358 | format!(" settled ({settled}/{total})"), |
| 359 | Style::default().fg(theme.text_muted), |
| 360 | ), |
| 361 | ])); |
| 362 | |
| 363 | let available_item_rows = max_rows |
| 364 | .saturating_sub(lines.len()) |
| 365 | .min(summary.checklist_items.len()); |
| 366 | let max_items = |
| 367 | if summary.checklist_items.len() > available_item_rows && available_item_rows > 1 { |
| 368 | available_item_rows - 1 |
| 369 | } else { |
| 370 | available_item_rows |
| 371 | }; |
| 372 | let start = checklist_window_start(&summary.checklist_items, max_items); |
| 373 | let end = start |
| 374 | .saturating_add(max_items) |
| 375 | .min(summary.checklist_items.len()); |
| 376 | for item in summary.checklist_items[start..end].iter() { |
| 377 | let (prefix, style) = match item.status { |
| 378 | TodoStatus::Pending => ("[ ]", Style::default().fg(theme.text_muted)), |
| 379 | TodoStatus::InProgress => ( |
| 380 | "[~]", |
| 381 | Style::default() |
| 382 | .fg(theme.warning) |
| 383 | .add_modifier(ratatui::style::Modifier::BOLD), |
| 384 | ), |
| 385 | TodoStatus::Completed => ("[✓]", Style::default().fg(theme.success)), |
| 386 | TodoStatus::Cancelled => ( |
| 387 | "[-]", |
| 388 | Style::default() |
| 389 | .fg(theme.error_fg) |
| 390 | .add_modifier(ratatui::style::Modifier::CROSSED_OUT), |
| 391 | ), |
| 392 | }; |
| 393 | let text = format!("{prefix} #{} {}", item.id, item.content); |
| 394 | lines.push(Line::from(Span::styled( |
| 395 | truncate_line_to_width(&text, content_width), |
| 396 | style, |
| 397 | ))); |
| 398 | } |
| 399 | |
| 400 | let earlier = start; |
| 401 | let later = summary.checklist_items.len().saturating_sub(end); |
| 402 | let remaining = earlier.saturating_add(later); |
| 403 | if remaining > 0 && lines.len() < max_rows { |
| 404 | let label = match (earlier, later) { |
| 405 | (0, later) => format!("+{later} more To-do items"), |
| 406 | (earlier, 0) => format!("+{earlier} earlier To-do items"), |
| 407 | (earlier, later) => format!("+{earlier} earlier, +{later} later"), |
| 408 | }; |
| 409 | lines.push(Line::from(Span::styled( |
| 410 | label, |
| 411 | Style::default().fg(theme.text_muted), |
| 412 | ))); |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | fn checklist_window_start(items: &[SidebarWorkChecklistItem], max_items: usize) -> usize { |
| 417 | if max_items >= items.len() { |
| 418 | return 0; |
| 419 | } |
| 420 | let Some(active_idx) = items |
| 421 | .iter() |
| 422 | .position(|item| item.status == TodoStatus::InProgress) |
| 423 | else { |
| 424 | return 0; |
| 425 | }; |
| 426 | active_idx |
| 427 | .saturating_sub(max_items / 2) |
| 428 | .min(items.len().saturating_sub(max_items)) |
| 429 | } |
| 430 | |
| 431 | #[must_use] |
| 432 | fn work_panel_empty_hint(content_width: usize) -> String { |
| 433 | truncate_line_to_width("No active work", content_width) |
| 434 | } |
| 435 | |
| 436 | fn label_with_stop_target(label: &str, content_width: usize) -> String { |
| 437 | if content_width == 0 { |
| 438 | return String::new(); |
| 439 | } |
| 440 | let suffix_width = unicode_width::UnicodeWidthStr::width(TASK_STOP_TARGET_SUFFIX); |
| 441 | if content_width <= suffix_width { |
| 442 | return truncate_line_to_width(TASK_STOP_TARGET_LABEL, content_width); |
| 443 | } |
| 444 | let base = truncate_line_to_width(label, content_width.saturating_sub(suffix_width)); |
| 445 | format!("{base}{TASK_STOP_TARGET_SUFFIX}") |
| 446 | } |
| 447 | |
| 448 | /// Minimal projection of the data the sub-agent sidebar needs. Lifted out |
| 449 | /// of `render_sidebar_subagents` so the rendering can be snapshot-tested |
| 450 | /// without a full `App`. |
| 451 | #[derive(Debug, Clone, Default)] |
| 452 | pub struct SidebarSubagentSummary { |
| 453 | pub cached_total: usize, |
| 454 | pub cached_running: usize, |
| 455 | pub progress_only_count: usize, |
| 456 | pub fanout_total: Option<usize>, |
| 457 | pub fanout_running: usize, |
| 458 | pub foreground_rlm_running: bool, |
| 459 | pub role_counts: std::collections::BTreeMap<String, usize>, |
| 460 | } |
| 461 | |
| 462 | #[derive(Debug, Clone, Default)] |
| 463 | pub struct SidebarAgentRow { |
| 464 | pub id: String, |
| 465 | pub parent_run_id: Option<String>, |
| 466 | pub spawn_depth: u32, |
| 467 | pub name: String, |
| 468 | pub model: Option<String>, |
| 469 | pub status: String, |
| 470 | pub objective: Option<String>, |
| 471 | pub git_branch: Option<String>, |
| 472 | pub progress: Option<String>, |
| 473 | pub steps_taken: u32, |
| 474 | pub duration_ms: Option<u64>, |
| 475 | /// A resident transcript currently contains visible exact evidence. This |
| 476 | /// conservative signal prevents the sidebar from advertising a dead Open. |
| 477 | pub transcript_available: bool, |
| 478 | pub expanded: bool, |
| 479 | } |
| 480 | |
| 481 | pub(crate) fn foreground_rlm_running(app: &App) -> bool { |
| 482 | app.active_cell.as_ref().is_some_and(|active| { |
| 483 | active.entries().iter().any(|entry| { |
| 484 | matches!( |
| 485 | entry, |
| 486 | HistoryCell::Tool(ToolCell::Generic(generic)) |
| 487 | if matches!( |
| 488 | generic.name.as_str(), |
| 489 | "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm" |
| 490 | ) && generic.status == ToolStatus::Running |
| 491 | ) |
| 492 | }) |
| 493 | }) |
| 494 | } |
| 495 | |
| 496 | pub(crate) fn sidebar_agent_rows(app: &App) -> Vec<SidebarAgentRow> { |
| 497 | let cached_ids: std::collections::HashSet<&str> = app |
| 498 | .subagent_cache |
| 499 | .iter() |
| 500 | .map(|agent| agent.agent_id.as_str()) |
| 501 | .collect(); |
| 502 | let display_names = localized_whale_display_names( |
| 503 | app.subagent_cache |
| 504 | .iter() |
| 505 | .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())), |
| 506 | app.ui_locale.tag(), |
| 507 | ); |
| 508 | let mut rows: Vec<SidebarAgentRow> = app |
| 509 | .subagent_cache |
| 510 | .iter() |
| 511 | .map(|agent| { |
| 512 | let current_activity = app |
| 513 | .agent_progress_meta |
| 514 | .get(&agent.agent_id) |
| 515 | .and_then(|meta| meta.current_activity.as_ref()); |
| 516 | let progress = current_activity.map(sidebar_current_activity_text); |
| 517 | // Generated whales are locale-derived from the neutral agent id; |
| 518 | // never replay a persisted label from another language. |
| 519 | let display_name = display_names |
| 520 | .get(&agent.agent_id) |
| 521 | .cloned() |
| 522 | .or_else(|| app.agent_label_map.get(&agent.agent_id).cloned()) |
| 523 | .unwrap_or_else(|| agent.name.clone()); |
| 524 | SidebarAgentRow { |
| 525 | id: agent.agent_id.clone(), |
| 526 | parent_run_id: agent.parent_run_id.clone(), |
| 527 | spawn_depth: agent.spawn_depth, |
| 528 | name: display_name, |
| 529 | model: Some(agent.model.clone()).filter(|model| !model.trim().is_empty()), |
| 530 | status: current_activity |
| 531 | .map(|activity| sidebar_current_activity_status_text(activity.status)) |
| 532 | .or_else(|| agent.worker_status.map(sidebar_worker_status_text)) |
| 533 | .unwrap_or_else(|| subagent_status_text(&agent.status)) |
| 534 | .to_string(), |
| 535 | objective: Some(agent.assignment.objective.clone()) |
| 536 | .filter(|objective| !objective.trim().is_empty()), |
| 537 | git_branch: agent.git_branch.clone(), |
| 538 | progress, |
| 539 | steps_taken: agent.steps_taken, |
| 540 | duration_ms: Some(agent.duration_ms), |
| 541 | transcript_available: crate::tui::mouse_ui::resident_agent_transcript_available( |
| 542 | app, |
| 543 | &agent.agent_id, |
| 544 | ), |
| 545 | expanded: app.expanded_sidebar_agents.contains(&agent.agent_id), |
| 546 | } |
| 547 | }) |
| 548 | .collect(); |
| 549 | |
| 550 | rows.extend( |
| 551 | app.agent_progress |
| 552 | .iter() |
| 553 | .filter(|(id, _)| !cached_ids.contains(id.as_str())) |
| 554 | .map(|(id, _progress)| { |
| 555 | // Progress-only rows do not carry a generated whale name yet; |
| 556 | // keep their existing stable Agent-N placeholder until the |
| 557 | // manager snapshot arrives. |
| 558 | let display_name = app |
| 559 | .agent_label_map |
| 560 | .get(id.as_str()) |
| 561 | .cloned() |
| 562 | .unwrap_or_else(|| id.clone()); |
| 563 | let meta = app.agent_progress_meta.get(id.as_str()); |
| 564 | let spawn_depth = meta.map(|meta| meta.spawn_depth).unwrap_or_default(); |
| 565 | let current_activity = meta.and_then(|meta| meta.current_activity.as_ref()); |
| 566 | SidebarAgentRow { |
| 567 | id: id.clone(), |
| 568 | parent_run_id: meta.and_then(|meta| meta.parent_run_id.clone()), |
| 569 | spawn_depth, |
| 570 | name: display_name, |
| 571 | model: None, |
| 572 | status: current_activity |
| 573 | .map(|activity| sidebar_current_activity_status_text(activity.status)) |
| 574 | .unwrap_or(sidebar_worker_status_text(AgentWorkerStatus::Running)) |
| 575 | .to_string(), |
| 576 | objective: None, |
| 577 | git_branch: None, |
| 578 | progress: current_activity.map(sidebar_current_activity_text), |
| 579 | steps_taken: 0, |
| 580 | duration_ms: None, |
| 581 | transcript_available: crate::tui::mouse_ui::resident_agent_transcript_available( |
| 582 | app, id, |
| 583 | ), |
| 584 | expanded: app.expanded_sidebar_agents.contains(id), |
| 585 | } |
| 586 | }), |
| 587 | ); |
| 588 | |
| 589 | sort_sidebar_agent_rows_as_tree(rows) |
| 590 | } |
| 591 | |
| 592 | fn sort_sidebar_agent_rows_as_tree(rows: Vec<SidebarAgentRow>) -> Vec<SidebarAgentRow> { |
| 593 | let known_ids: std::collections::HashSet<String> = |
| 594 | rows.iter().map(|row| row.id.clone()).collect(); |
| 595 | let mut children: std::collections::HashMap<String, Vec<usize>> = |
| 596 | std::collections::HashMap::new(); |
| 597 | let mut roots = Vec::new(); |
| 598 | |
| 599 | for (idx, row) in rows.iter().enumerate() { |
| 600 | if let Some(parent) = row.parent_run_id.as_deref() |
| 601 | && known_ids.contains(parent) |
| 602 | { |
| 603 | children.entry(parent.to_string()).or_default().push(idx); |
| 604 | continue; |
| 605 | } |
| 606 | roots.push(idx); |
| 607 | } |
| 608 | |
| 609 | fn push_tree( |
| 610 | idx: usize, |
| 611 | rows: &[SidebarAgentRow], |
| 612 | children: &std::collections::HashMap<String, Vec<usize>>, |
| 613 | seen: &mut std::collections::HashSet<usize>, |
| 614 | order: &mut Vec<usize>, |
| 615 | ) { |
| 616 | if !seen.insert(idx) { |
| 617 | return; |
| 618 | } |
| 619 | order.push(idx); |
| 620 | if let Some(child_indices) = children.get(&rows[idx].id) { |
| 621 | for child_idx in child_indices { |
| 622 | push_tree(*child_idx, rows, children, seen, order); |
| 623 | } |
| 624 | } |
| 625 | } |
| 626 | |
| 627 | let mut order = Vec::with_capacity(rows.len()); |
| 628 | let mut seen = std::collections::HashSet::new(); |
| 629 | for idx in roots { |
| 630 | push_tree(idx, &rows, &children, &mut seen, &mut order); |
| 631 | } |
| 632 | for idx in 0..rows.len() { |
| 633 | push_tree(idx, &rows, &children, &mut seen, &mut order); |
| 634 | } |
| 635 | |
| 636 | // Materialize by move instead of cloning each row a second time (#3898): |
| 637 | // `seen` guarantees every index lands in `order` exactly once, so each |
| 638 | // slot is taken exactly once and no row is dropped. |
| 639 | let mut slots: Vec<Option<SidebarAgentRow>> = rows.into_iter().map(Some).collect(); |
| 640 | order |
| 641 | .into_iter() |
| 642 | .map(|idx| slots[idx].take().expect("each row emitted exactly once")) |
| 643 | .collect() |
| 644 | } |
| 645 | |
| 646 | fn subagent_status_text(status: &SubAgentStatus) -> &'static str { |
| 647 | match status { |
| 648 | SubAgentStatus::Running => "running", |
| 649 | SubAgentStatus::Completed => "done", |
| 650 | SubAgentStatus::Interrupted(_) => "interrupted", |
| 651 | SubAgentStatus::Failed(_) => "failed", |
| 652 | SubAgentStatus::Cancelled => "canceled", |
| 653 | SubAgentStatus::BudgetExhausted => "budget", |
| 654 | } |
| 655 | } |
| 656 | |
| 657 | fn sidebar_worker_status_text(status: AgentWorkerStatus) -> &'static str { |
| 658 | match status { |
| 659 | AgentWorkerStatus::Queued => "queued", |
| 660 | AgentWorkerStatus::Starting => "starting", |
| 661 | AgentWorkerStatus::Running => "running", |
| 662 | AgentWorkerStatus::WaitingForUser => "waiting", |
| 663 | AgentWorkerStatus::ModelWait => "model wait", |
| 664 | AgentWorkerStatus::RunningTool => "tool", |
| 665 | AgentWorkerStatus::Completed => "done", |
| 666 | AgentWorkerStatus::Failed => "failed", |
| 667 | AgentWorkerStatus::Cancelled => "canceled", |
| 668 | AgentWorkerStatus::Interrupted => "interrupted", |
| 669 | } |
| 670 | } |
| 671 | |
| 672 | fn sidebar_current_activity_status_text(status: AgentCurrentActivityStatus) -> &'static str { |
| 673 | match status { |
| 674 | AgentCurrentActivityStatus::Queued => "queued", |
| 675 | AgentCurrentActivityStatus::Starting => "starting", |
| 676 | AgentCurrentActivityStatus::Running => "running", |
| 677 | AgentCurrentActivityStatus::ModelWait => "model wait", |
| 678 | AgentCurrentActivityStatus::RunningTool => "tool", |
| 679 | AgentCurrentActivityStatus::Waiting => "waiting", |
| 680 | AgentCurrentActivityStatus::Done => "done", |
| 681 | AgentCurrentActivityStatus::Failed => "failed", |
| 682 | AgentCurrentActivityStatus::Canceled => "canceled", |
| 683 | AgentCurrentActivityStatus::Interrupted => "interrupted", |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | pub(crate) fn cached_agent_activity_is_live( |
| 688 | app: &App, |
| 689 | agent: &crate::tools::subagent::SubAgentResult, |
| 690 | ) -> bool { |
| 691 | if let Some(status) = app |
| 692 | .agent_progress_meta |
| 693 | .get(&agent.agent_id) |
| 694 | .and_then(|meta| meta.current_activity.as_ref()) |
| 695 | .map(|activity| activity.status) |
| 696 | { |
| 697 | return matches!( |
| 698 | status, |
| 699 | AgentCurrentActivityStatus::Queued |
| 700 | | AgentCurrentActivityStatus::Starting |
| 701 | | AgentCurrentActivityStatus::Running |
| 702 | | AgentCurrentActivityStatus::ModelWait |
| 703 | | AgentCurrentActivityStatus::RunningTool |
| 704 | | AgentCurrentActivityStatus::Waiting |
| 705 | ); |
| 706 | } |
| 707 | if let Some(status) = agent.worker_status { |
| 708 | return matches!( |
| 709 | status, |
| 710 | AgentWorkerStatus::Queued |
| 711 | | AgentWorkerStatus::Starting |
| 712 | | AgentWorkerStatus::Running |
| 713 | | AgentWorkerStatus::WaitingForUser |
| 714 | | AgentWorkerStatus::ModelWait |
| 715 | | AgentWorkerStatus::RunningTool |
| 716 | ); |
| 717 | } |
| 718 | matches!(agent.status, SubAgentStatus::Running) |
| 719 | } |
| 720 | |
| 721 | fn sidebar_current_activity_text(activity: &AgentCurrentActivity) -> String { |
| 722 | let mut parts = vec![sidebar_current_activity_status_text(activity.status).to_string()]; |
| 723 | if let Some(tool) = activity.current_tool.as_deref() { |
| 724 | parts.push(tool.to_string()); |
| 725 | } |
| 726 | if let Some(step) = activity.step { |
| 727 | parts.push(format!("step {step}")); |
| 728 | } |
| 729 | if let Some(detail) = activity.detail.as_deref() |
| 730 | && detail != parts[0] |
| 731 | { |
| 732 | parts.push(detail.to_string()); |
| 733 | } |
| 734 | parts.join(" · ") |
| 735 | } |
| 736 | |
| 737 | /// Build sub-agent sidebar lines from summary + per-agent rows. Used by the |
| 738 | /// rail's Agents panel (`work_surface::panels`) and the snapshot tests in |
| 739 | /// this module. |
| 740 | pub(crate) fn subagent_panel_lines( |
| 741 | summary: &SidebarSubagentSummary, |
| 742 | rows: &[SidebarAgentRow], |
| 743 | locale: Locale, |
| 744 | content_width: usize, |
| 745 | max_rows: usize, |
| 746 | theme: &palette::UiTheme, |
| 747 | ) -> Vec<Line<'static>> { |
| 748 | subagent_panel_rows(summary, rows, locale, content_width, max_rows, theme).0 |
| 749 | } |
| 750 | |
| 751 | /// Render an indented sidebar detail line that never exceeds `content_width` |
| 752 | /// display cells, counting the indent itself (#4094). The earlier inline |
| 753 | /// `format!(" {}", truncate(.., width - 2))` overflowed by the indent width at |
| 754 | /// very narrow terminals (`content_width < 3`, where `saturating_sub(2).max(1)` |
| 755 | /// still leaves room for a glyph that the 2-space prefix then pushes past the |
| 756 | /// column). This keeps the whole line — indent included — within the column. |
| 757 | fn indented_detail_line(indent: &str, body: &str, content_width: usize) -> String { |
| 758 | let indent_width = unicode_width::UnicodeWidthStr::width(indent); |
| 759 | if content_width <= indent_width { |
| 760 | // No room for the indent; clip the body to the whole column so we never |
| 761 | // overflow, even if that means dropping the indent at pathological widths. |
| 762 | return truncate_line_to_width(body, content_width); |
| 763 | } |
| 764 | format!( |
| 765 | "{indent}{}", |
| 766 | truncate_line_to_width(body, content_width - indent_width) |
| 767 | ) |
| 768 | } |
| 769 | |
| 770 | /// #4094: reference to a worker's transcript projection, surfaced as a |
| 771 | /// `handle_read` handle instead of dumping the (possibly huge) transcript |
| 772 | /// inline — the inline dump is the freeze/emptiness risk this issue tracks. |
| 773 | /// The child transcript is addressable as the `agent:<id>/full_transcript` var |
| 774 | /// handle (see `subagent_session_projection`); its JSON names the private |
| 775 | /// complete artifact, while clicking Open loads that artifact directly. |
| 776 | /// |
| 777 | /// Returns `None` for workers that have not produced anything inspectable yet, |
| 778 | /// so an empty transcript is never advertised. This is the one place a raw |
| 779 | /// agent id is intentionally surfaced in the detail panel (cf. #3030): here it |
| 780 | /// is a functional, copyable handle on its own dedicated line, not incidental |
| 781 | /// id noise mixed into the dossier. |
| 782 | fn subagent_output_handle(row: &SidebarAgentRow) -> Option<String> { |
| 783 | if !row.transcript_available { |
| 784 | return None; |
| 785 | } |
| 786 | Some(format!("agent:{}/full_transcript", row.id)) |
| 787 | } |
| 788 | |
| 789 | /// Build the Agents panel lines together with a parallel per-line |
| 790 | /// click-action vector (#3028). Agent label rows open the current-session |
| 791 | /// Fleet worker view (`/fleet workers`, formerly spelled `/fleet status` |
| 792 | /// before that name moved to the durable ledger in #4022); header, role-mix, |
| 793 | /// detail, and RLM lines are not clickable. |
| 794 | fn subagent_panel_rows( |
| 795 | summary: &SidebarSubagentSummary, |
| 796 | rows: &[SidebarAgentRow], |
| 797 | _locale: Locale, |
| 798 | content_width: usize, |
| 799 | max_rows: usize, |
| 800 | theme: &palette::UiTheme, |
| 801 | ) -> (Vec<Line<'static>>, Vec<Option<SidebarRowAction>>) { |
| 802 | let mut lines: Vec<Line<'static>> = Vec::with_capacity(max_rows.max(4)); |
| 803 | let mut actions: Vec<Option<SidebarRowAction>> = Vec::with_capacity(max_rows.max(4)); |
| 804 | |
| 805 | let fanout_total = summary.fanout_total.unwrap_or(0); |
| 806 | if summary.cached_total == 0 |
| 807 | && summary.progress_only_count == 0 |
| 808 | && fanout_total == 0 |
| 809 | && !summary.foreground_rlm_running |
| 810 | { |
| 811 | lines.push(Line::from(Span::styled( |
| 812 | "No agents", |
| 813 | Style::default().fg(theme.text_muted), |
| 814 | ))); |
| 815 | actions.push(None); |
| 816 | return (lines, actions); |
| 817 | } |
| 818 | |
| 819 | let (live_running, total) = if let Some(total) = summary.fanout_total { |
| 820 | (summary.fanout_running, total) |
| 821 | } else { |
| 822 | ( |
| 823 | summary.cached_running + summary.progress_only_count, |
| 824 | summary.cached_total + summary.progress_only_count, |
| 825 | ) |
| 826 | }; |
| 827 | let done = total.saturating_sub(live_running); |
| 828 | let header = if live_running > 0 { |
| 829 | vec![ |
| 830 | Span::styled( |
| 831 | format!("{live_running} running"), |
| 832 | Style::default().fg(theme.accent_primary).bold(), |
| 833 | ), |
| 834 | Span::styled(format!(" / {total}"), Style::default().fg(theme.text_muted)), |
| 835 | ] |
| 836 | } else { |
| 837 | vec![Span::styled( |
| 838 | format!("{done} done"), |
| 839 | Style::default().fg(theme.success), |
| 840 | )] |
| 841 | }; |
| 842 | // #4094: the running/done status is the single most useful line, so it must |
| 843 | // never overflow the sidebar at narrow widths. When the two-tone header |
| 844 | // fits it renders as-is; when the column is too narrow it collapses into one |
| 845 | // truncated span so the status is clipped, never spilled past the column. |
| 846 | let header_width: usize = header |
| 847 | .iter() |
| 848 | .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref())) |
| 849 | .sum(); |
| 850 | if header_width > content_width.max(1) { |
| 851 | let flat: String = header.iter().map(|span| span.content.as_ref()).collect(); |
| 852 | lines.push(Line::from(Span::styled( |
| 853 | truncate_line_to_width(&flat, content_width.max(1)), |
| 854 | Style::default().fg(theme.text_muted), |
| 855 | ))); |
| 856 | } else { |
| 857 | lines.push(Line::from(header)); |
| 858 | } |
| 859 | actions.push(None); |
| 860 | |
| 861 | if !summary.role_counts.is_empty() { |
| 862 | let mix: Vec<String> = summary |
| 863 | .role_counts |
| 864 | .iter() |
| 865 | .map(|(role, count)| format!("{count} {role}")) |
| 866 | .collect(); |
| 867 | let role_line = mix.join(" \u{00B7} "); |
| 868 | lines.push(Line::from(Span::styled( |
| 869 | truncate_line_to_width(&role_line, content_width.max(1)), |
| 870 | Style::default().fg(theme.text_dim), |
| 871 | ))); |
| 872 | actions.push(None); |
| 873 | } |
| 874 | |
| 875 | for row in rows { |
| 876 | if lines.len() >= max_rows { |
| 877 | break; |
| 878 | } |
| 879 | let (marker, color) = agent_status_marker(row.status.as_str(), theme); |
| 880 | let tree_prefix = agent_tree_prefix(row); |
| 881 | let label = format!( |
| 882 | "{tree_prefix}{marker} {}", |
| 883 | sidebar_agent_row_label(row, content_width.max(1)) |
| 884 | ); |
| 885 | let label = if sidebar_agent_status_is_running(row.status.as_str()) { |
| 886 | label_with_stop_target(&label, content_width.max(1)) |
| 887 | } else { |
| 888 | truncate_line_to_width(&label, content_width.max(1)) |
| 889 | }; |
| 890 | lines.push(Line::from(Span::styled(label, Style::default().fg(color)))); |
| 891 | actions.push(Some(SidebarRowAction::ToggleAgentDetails { |
| 892 | agent_id: row.id.clone(), |
| 893 | })); |
| 894 | |
| 895 | // Auto-collapse finished sub-agents so the sidebar stays compact when |
| 896 | // work is done or terminally stopped. |
| 897 | if sidebar_agent_status_is_terminal(row.status.as_str()) && !row.expanded { |
| 898 | continue; |
| 899 | } |
| 900 | |
| 901 | if !row.expanded { |
| 902 | continue; |
| 903 | } |
| 904 | |
| 905 | if lines.len() >= max_rows { |
| 906 | break; |
| 907 | } |
| 908 | // Expanded detail: a compact but never-empty dossier for the worker |
| 909 | // (#4094). Status is always shown first so the expanded panel is never |
| 910 | // blank while a worker is active; objective/elapsed/model/steps/ |
| 911 | // progress/branch follow when known. Raw ids stay out of the compact |
| 912 | // line (#3030) — the full id remains available in the hover text. |
| 913 | let mut detail_parts = Vec::new(); |
| 914 | detail_parts.push(row.status.clone()); |
| 915 | if let Some(objective) = row.objective.as_deref() |
| 916 | && !objective.trim().is_empty() |
| 917 | { |
| 918 | detail_parts.push(summarize_tool_output(objective)); |
| 919 | } |
| 920 | if let Some(model) = row.model.as_deref() { |
| 921 | detail_parts.push(format!("model {model}")); |
| 922 | } |
| 923 | if let Some(duration) = row.duration_ms { |
| 924 | detail_parts.push(crate::elapsed::format_elapsed_ms(duration)); |
| 925 | } |
| 926 | if row.steps_taken > 0 { |
| 927 | detail_parts.push(format!("{} step(s)", row.steps_taken)); |
| 928 | } |
| 929 | if let Some(progress) = row.progress.as_deref() |
| 930 | && !progress.trim().is_empty() |
| 931 | { |
| 932 | detail_parts.push(summarize_tool_output(progress)); |
| 933 | } |
| 934 | if let Some(branch) = row.git_branch.as_deref() { |
| 935 | detail_parts.push(format!("branch {branch}")); |
| 936 | } |
| 937 | lines.push(Line::from(Span::styled( |
| 938 | indented_detail_line(" ", &detail_parts.join(" \u{00B7} "), content_width.max(1)), |
| 939 | Style::default().fg(theme.text_dim), |
| 940 | ))); |
| 941 | // Clicking the expanded dossier opens the bounded Agent Details |
| 942 | // projection. The label row above keeps its expand/collapse toggle. |
| 943 | actions.push(Some(SidebarRowAction::OpenAgentDetail { |
| 944 | agent_id: row.id.clone(), |
| 945 | })); |
| 946 | |
| 947 | // #4094: hand the user a copyable bounded projection instead of |
| 948 | // dumping the transcript inline — the inline dump is this issue's |
| 949 | // freeze/emptiness risk. Clicking the row opens the complete private |
| 950 | // artifact; handle_read exposes bounded slices and its artifact path. |
| 951 | // Guarded by `max_rows` so the panel stays bounded, and width-clamped so |
| 952 | // narrow terminals never overflow. |
| 953 | if let Some(handle) = subagent_output_handle(row) { |
| 954 | if lines.len() >= max_rows { |
| 955 | break; |
| 956 | } |
| 957 | lines.push(Line::from(Span::styled( |
| 958 | indented_detail_line( |
| 959 | " ", |
| 960 | &format!("\u{25B8} complete chat: open \u{00B7} handle_read {handle}"), |
| 961 | content_width.max(1), |
| 962 | ), |
| 963 | Style::default().fg(theme.text_muted), |
| 964 | ))); |
| 965 | actions.push(Some(SidebarRowAction::OpenAgentTranscript { |
| 966 | agent_id: row.id.clone(), |
| 967 | })); |
| 968 | } |
| 969 | } |
| 970 | |
| 971 | if summary.foreground_rlm_running { |
| 972 | lines.push(Line::from(vec![ |
| 973 | Span::styled("RLM", Style::default().fg(theme.accent_primary).bold()), |
| 974 | Span::styled( |
| 975 | " foreground work active", |
| 976 | Style::default().fg(theme.text_dim), |
| 977 | ), |
| 978 | ])); |
| 979 | actions.push(None); |
| 980 | } |
| 981 | |
| 982 | debug_assert_eq!(lines.len(), actions.len()); |
| 983 | (lines, actions) |
| 984 | } |
| 985 | |
| 986 | fn agent_tree_prefix(row: &SidebarAgentRow) -> String { |
| 987 | if row.parent_run_id.is_none() && row.spawn_depth <= 1 { |
| 988 | return String::new(); |
| 989 | } |
| 990 | let depth = row.spawn_depth.max(2).saturating_sub(2).min(6); |
| 991 | format!("{}└─ ", " ".repeat(depth as usize)) |
| 992 | } |
| 993 | |
| 994 | fn sidebar_agent_status_is_terminal(status: &str) -> bool { |
| 995 | matches!( |
| 996 | status, |
| 997 | "done" | "canceled" | "failed" | "interrupted" | "budget" |
| 998 | ) |
| 999 | } |
| 1000 | |
| 1001 | fn sidebar_agent_status_is_running(status: &str) -> bool { |
| 1002 | matches!( |
| 1003 | status, |
| 1004 | "running" | "queued" | "starting" | "waiting" | "model wait" | "tool" |
| 1005 | ) |
| 1006 | } |
| 1007 | |
| 1008 | fn sidebar_agent_row_label(row: &SidebarAgentRow, max_width: usize) -> String { |
| 1009 | let detail = row |
| 1010 | .objective |
| 1011 | .as_deref() |
| 1012 | .filter(|objective| !objective.trim().is_empty()) |
| 1013 | .map(summarize_tool_output) |
| 1014 | .or_else(|| { |
| 1015 | // Progress is only a live substitute for a missing objective; |
| 1016 | // terminal rows would resurface stale in-flight detail. |
| 1017 | if sidebar_agent_status_is_terminal(row.status.as_str()) { |
| 1018 | return None; |
| 1019 | } |
| 1020 | row.progress |
| 1021 | .as_deref() |
| 1022 | .filter(|progress| !progress.trim().is_empty()) |
| 1023 | .map(summarize_tool_output) |
| 1024 | }); |
| 1025 | match detail { |
| 1026 | Some(detail) => truncate_line_to_width(&format!("{} — {}", row.name, detail), max_width), |
| 1027 | None => truncate_line_to_width(&row.name, max_width), |
| 1028 | } |
| 1029 | } |
| 1030 | |
| 1031 | fn agent_status_marker( |
| 1032 | status: &str, |
| 1033 | theme: &palette::UiTheme, |
| 1034 | ) -> (&'static str, ratatui::style::Color) { |
| 1035 | match status { |
| 1036 | "running" => ("[~]", theme.warning), |
| 1037 | "done" => ("[✓]", theme.success), |
| 1038 | "failed" => ("[!]", theme.error_fg), |
| 1039 | "canceled" | "interrupted" => ("[-]", theme.text_muted), |
| 1040 | _ => ("[ ]", theme.text_muted), |
| 1041 | } |
| 1042 | } |
| 1043 | |
| 1044 | /// Session-context panel (#504) — consolidated session state overview. |
| 1045 | /// |
| 1046 | /// Surfaces at-a-glance: working set, token usage / context %, running |
| 1047 | /// cost, MCP server count, LSP toggle state, cycle count, and memory |
| 1048 | /// file size + mtime. Each section is a compact one-liner so the panel |
| 1049 | /// reads as a dashboard rather than a scrolling list. |
| 1050 | /// Context panel line builder, lifted out of the legacy sidebar's |
| 1051 | /// `render_context_panel` for the unified rail (0.9.4): workspace, token |
| 1052 | /// usage, session cost, MCP, LSP, and memory rows. |
| 1053 | pub(crate) fn context_panel_lines(app: &App, content_width: usize) -> Vec<Line<'static>> { |
| 1054 | let theme = &app.ui_theme; |
| 1055 | let mut lines: Vec<Line<'static>> = Vec::with_capacity(8); |
| 1056 | |
| 1057 | // ── Working set ────────────────────────────────────────────── |
| 1058 | let ws_name = app |
| 1059 | .workspace |
| 1060 | .file_name() |
| 1061 | .and_then(|s| s.to_str()) |
| 1062 | .unwrap_or("(root)") |
| 1063 | .to_string(); |
| 1064 | lines.push(Line::from(vec![ |
| 1065 | Span::styled( |
| 1066 | truncate_line_to_width(&ws_name, content_width.max(1)), |
| 1067 | Style::default().fg(theme.accent_primary).bold(), |
| 1068 | ), |
| 1069 | Span::styled( |
| 1070 | format!(" {}", app.workspace_context.as_deref().unwrap_or("")), |
| 1071 | Style::default().fg(theme.text_dim), |
| 1072 | ), |
| 1073 | ])); |
| 1074 | |
| 1075 | // ── Token usage ────────────────────────────────────────────── |
| 1076 | // Context % is disclosed in the header; the sidebar keeps the raw token |
| 1077 | // counts for at-a-glance reference without duplicating the bar. |
| 1078 | let total_tokens = app.session.total_conversation_tokens; |
| 1079 | let window = crate::route_budget::route_context_window_tokens( |
| 1080 | app.api_provider, |
| 1081 | app.effective_model_for_budget(), |
| 1082 | app.active_route_limits, |
| 1083 | ); |
| 1084 | lines.push(Line::from(Span::styled( |
| 1085 | format!("context: {total_tokens}/{window} tokens"), |
| 1086 | Style::default().fg(theme.text_muted), |
| 1087 | ))); |
| 1088 | |
| 1089 | // ── Session cost ───────────────────────────────────────────── |
| 1090 | let cost_line = context_panel_cost_line(app); |
| 1091 | lines.push(Line::from(Span::styled( |
| 1092 | cost_line, |
| 1093 | Style::default().fg(theme.text_muted), |
| 1094 | ))); |
| 1095 | |
| 1096 | // ── MCP servers ────────────────────────────────────────────── |
| 1097 | if app.mcp_configured_count > 0 { |
| 1098 | let reload_hint = if app.mcp_reload_required { |
| 1099 | " (reload needed)" |
| 1100 | } else { |
| 1101 | "" |
| 1102 | }; |
| 1103 | lines.push(Line::from(Span::styled( |
| 1104 | format!("mcp: {} server(s){}", app.mcp_configured_count, reload_hint), |
| 1105 | Style::default().fg(theme.text_muted), |
| 1106 | ))); |
| 1107 | } |
| 1108 | |
| 1109 | // ── LSP ────────────────────────────────────────────────────── |
| 1110 | let lsp_label = if app.lsp_enabled { "on" } else { "off" }; |
| 1111 | lines.push(Line::from(Span::styled( |
| 1112 | format!("lsp: {lsp_label}"), |
| 1113 | Style::default().fg(theme.text_muted), |
| 1114 | ))); |
| 1115 | |
| 1116 | // ── Memory ─────────────────────────────────────────────────── |
| 1117 | if app.use_memory { |
| 1118 | // Cached by `workspace_context::refresh_if_needed` on its TTL tick. |
| 1119 | // This used to `stat` inline, on every frame the panel was visible |
| 1120 | // (#3908). Before the first refresh lands there is nothing to show, |
| 1121 | // which reads the same as an unreadable file. |
| 1122 | let size_hint = app |
| 1123 | .memory_size_hint |
| 1124 | .clone() |
| 1125 | .unwrap_or_else(|| "—".to_string()); |
| 1126 | lines.push(Line::from(Span::styled( |
| 1127 | format!("memory: {} ({})", app.memory_path.display(), size_hint), |
| 1128 | Style::default().fg(theme.text_muted), |
| 1129 | ))); |
| 1130 | } |
| 1131 | |
| 1132 | lines |
| 1133 | } |
| 1134 | |
| 1135 | fn context_panel_cost_line(app: &App) -> String { |
| 1136 | let displayed_total = app.displayed_session_cost_for_currency(app.cost_currency); |
| 1137 | let chip = app.cumulative_usage_chip(); |
| 1138 | match &chip { |
| 1139 | crate::route_billing::UsageChip::Money(_) |
| 1140 | if crate::route_billing::has_priced_metered_basis( |
| 1141 | app.billing_presentation, |
| 1142 | app.api_provider, |
| 1143 | &app.model, |
| 1144 | ) => |
| 1145 | { |
| 1146 | let session_cost = app.session_cost_for_currency(app.cost_currency); |
| 1147 | let agent_cost = app.subagent_cost_for_currency(app.cost_currency); |
| 1148 | let real_total = session_cost + agent_cost; |
| 1149 | // Only show the additive breakdown when it matches the displayed |
| 1150 | // total; when the high-water mark is in effect (post-reconciliation), |
| 1151 | // the breakdown would not sum to the displayed value (#244). |
| 1152 | if (displayed_total - real_total).abs() < COST_EQ_TOLERANCE { |
| 1153 | format!( |
| 1154 | "cost: {} (session {} + agents {})", |
| 1155 | app.format_cost_amount(displayed_total), |
| 1156 | app.format_cost_amount(session_cost), |
| 1157 | app.format_cost_amount(agent_cost) |
| 1158 | ) |
| 1159 | } else { |
| 1160 | crate::route_billing::format_usage_line(&chip) |
| 1161 | } |
| 1162 | } |
| 1163 | _ => crate::route_billing::format_usage_line(&chip), |
| 1164 | } |
| 1165 | } |
| 1166 | |
| 1167 | #[cfg(test)] |
| 1168 | mod tests { |
| 1169 | use super::{ |
| 1170 | SidebarAgentRow, SidebarSubagentSummary, SidebarWorkChecklistItem, SidebarWorkSummary, |
| 1171 | cached_agent_activity_is_live, context_panel_cost_line, sidebar_agent_rows, |
| 1172 | sidebar_work_summary, subagent_output_handle, subagent_panel_lines, subagent_panel_rows, |
| 1173 | work_panel_empty_hint, work_panel_lines, |
| 1174 | }; |
| 1175 | use crate::config::Config; |
| 1176 | use crate::localization::Locale; |
| 1177 | use crate::palette; |
| 1178 | use crate::palette::PaletteMode; |
| 1179 | use crate::tools::todo::TodoStatus; |
| 1180 | use crate::tui::app::{ |
| 1181 | AgentCurrentActivity, AgentCurrentActivityStatus, AgentProgressMeta, App, HuntVerdict, |
| 1182 | SidebarHoverSection, SidebarHoverState, SidebarRowAction, TuiOptions, |
| 1183 | }; |
| 1184 | use ratatui::text::Line; |
| 1185 | use std::path::PathBuf; |
| 1186 | |
| 1187 | fn create_test_app() -> App { |
| 1188 | let options = TuiOptions { |
| 1189 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 1190 | }; |
| 1191 | App::new(options, &Config::default()) |
| 1192 | } |
| 1193 | |
| 1194 | fn lines_to_text(lines: &[Line<'static>]) -> Vec<String> { |
| 1195 | lines |
| 1196 | .iter() |
| 1197 | .map(|line| { |
| 1198 | line.spans |
| 1199 | .iter() |
| 1200 | .map(|s| s.content.as_ref()) |
| 1201 | .collect::<String>() |
| 1202 | }) |
| 1203 | .collect() |
| 1204 | } |
| 1205 | |
| 1206 | #[test] |
| 1207 | fn context_panel_cost_line_shows_na_for_unpriced_zero_cost_model() { |
| 1208 | let mut app = create_test_app(); |
| 1209 | app.model = "unknown-provider/unknown-model".to_string(); |
| 1210 | app.billing_presentation = crate::route_billing::BillingPresentation::Metered; |
| 1211 | |
| 1212 | assert_eq!(context_panel_cost_line(&app), "cost: unknown"); |
| 1213 | } |
| 1214 | |
| 1215 | #[test] |
| 1216 | fn context_panel_cost_line_does_not_inherit_api_pricing_for_codex_oauth() { |
| 1217 | let mut app = create_test_app(); |
| 1218 | app.api_provider = crate::config::ApiProvider::OpenaiCodex; |
| 1219 | app.model = "gpt-5.5".to_string(); |
| 1220 | app.billing_presentation = |
| 1221 | crate::route_billing::BillingPresentation::Subscription("Codex OAuth quota"); |
| 1222 | app.accrue_session_cost_estimate(crate::pricing::CostEstimate::usd_only(12.34)); |
| 1223 | |
| 1224 | let line = context_panel_cost_line(&app); |
| 1225 | assert_eq!(line, "usage: Codex OAuth quota"); |
| 1226 | assert!(!line.contains('$'), "OAuth must not invent dollars: {line}"); |
| 1227 | } |
| 1228 | |
| 1229 | #[test] |
| 1230 | fn context_panel_cost_line_marks_unpriced_metered_as_unknown() { |
| 1231 | let mut app = create_test_app(); |
| 1232 | app.api_provider = crate::config::ApiProvider::NvidiaNim; |
| 1233 | app.model = "deepseek-ai/deepseek-v4-pro".to_string(); |
| 1234 | app.billing_presentation = crate::route_billing::BillingPresentation::Metered; |
| 1235 | |
| 1236 | assert_eq!(context_panel_cost_line(&app), "cost: unknown"); |
| 1237 | } |
| 1238 | |
| 1239 | /// A route priced only in USD, displayed in CNY mode, must show the USD |
| 1240 | /// figure. The alternative — rendering the CNY accumulator, which is a |
| 1241 | /// structural zero for a USD-only route — would report ¥0.00 as if the |
| 1242 | /// turn were free. |
| 1243 | /// |
| 1244 | /// The USD amount and the coverage that qualifies it are recorded through |
| 1245 | /// the same audit production uses. Bumping the raw accumulator instead |
| 1246 | /// would leave the session with money and no evidence of what it covers, |
| 1247 | /// which is a different (and separately tested) state. |
| 1248 | #[test] |
| 1249 | fn context_panel_cost_line_uses_usd_for_usd_only_model_in_cny_mode() { |
| 1250 | let mut app = create_test_app(); |
| 1251 | app.model = "kimi-k2.6".to_string(); |
| 1252 | // This test is about METERED currency rendering; pin the route class |
| 1253 | // and a metered provider so the session default (which may be a |
| 1254 | // subscription/OAuth route with no API pricing basis) cannot change |
| 1255 | // what is under test (TUI-DOG-010). |
| 1256 | app.api_provider = crate::config::ApiProvider::Moonshot; |
| 1257 | app.billing_presentation = crate::route_billing::BillingPresentation::Metered; |
| 1258 | app.cost_currency = crate::pricing::CostCurrency::Cny; |
| 1259 | app.record_turn_cost_audit(&usd_only_priced_audit(0.42)); |
| 1260 | app.accrue_session_cost_estimate(crate::pricing::CostEstimate::usd_only(0.42)); |
| 1261 | |
| 1262 | let line = context_panel_cost_line(&app); |
| 1263 | |
| 1264 | assert!(line.contains("$0.42"), "expected USD amount, got {line:?}"); |
| 1265 | assert!( |
| 1266 | !line.contains('¥'), |
| 1267 | "must not render CNY zero, got {line:?}" |
| 1268 | ); |
| 1269 | } |
| 1270 | |
| 1271 | /// The same session, before any turn has been priced, must not present the |
| 1272 | /// USD accumulator as a CNY figure or as a total. With no coverage at all |
| 1273 | /// there is nothing to report. |
| 1274 | #[test] |
| 1275 | fn context_panel_cost_line_reports_unknown_before_any_turn_is_priced() { |
| 1276 | let mut app = create_test_app(); |
| 1277 | app.model = "kimi-k2.6".to_string(); |
| 1278 | app.api_provider = crate::config::ApiProvider::Moonshot; |
| 1279 | app.billing_presentation = crate::route_billing::BillingPresentation::Metered; |
| 1280 | app.cost_currency = crate::pricing::CostCurrency::Cny; |
| 1281 | |
| 1282 | // Money with no audit behind it: the accumulator moved, coverage did |
| 1283 | // not. This is exactly the shape a legacy/unaudited path produces. |
| 1284 | app.accrue_session_cost_estimate(crate::pricing::CostEstimate::usd_only(0.42)); |
| 1285 | |
| 1286 | let line = context_panel_cost_line(&app); |
| 1287 | assert!( |
| 1288 | !line.contains("0.42"), |
| 1289 | "an unqualified accumulator is not a reportable total: {line:?}" |
| 1290 | ); |
| 1291 | assert!(!line.contains('¥'), "must not render CNY zero: {line:?}"); |
| 1292 | } |
| 1293 | |
| 1294 | /// A route priced in CNY reports CNY in CNY mode — the fallback to USD is |
| 1295 | /// for USD-only coverage, not a blanket preference for dollars. |
| 1296 | #[test] |
| 1297 | fn context_panel_cost_line_keeps_cny_when_the_route_is_priced_in_cny() { |
| 1298 | let mut app = create_test_app(); |
| 1299 | app.model = "deepseek-v4-flash".to_string(); |
| 1300 | app.api_provider = crate::config::ApiProvider::Deepseek; |
| 1301 | app.billing_presentation = crate::route_billing::BillingPresentation::Metered; |
| 1302 | app.cost_currency = crate::pricing::CostCurrency::Cny; |
| 1303 | app.record_turn_cost_audit(&dual_currency_priced_audit(0.42, 3.0)); |
| 1304 | app.accrue_session_cost_estimate(crate::pricing::CostEstimate { |
| 1305 | usd: 0.42, |
| 1306 | cny: 3.0, |
| 1307 | }); |
| 1308 | |
| 1309 | let line = context_panel_cost_line(&app); |
| 1310 | assert!(line.contains('¥'), "expected a CNY amount, got {line:?}"); |
| 1311 | assert!(!line.contains('$'), "must not fall back to USD: {line:?}"); |
| 1312 | } |
| 1313 | |
| 1314 | /// An authoritatively priced turn that cost nothing is a *known* zero, and |
| 1315 | /// is reported separately from a route whose spend could not be |
| 1316 | /// established. Both currencies are checked so neither can be the one that |
| 1317 | /// quietly reports a fabricated zero. |
| 1318 | #[test] |
| 1319 | fn context_panel_cost_line_separates_a_priced_zero_from_unknown_spend() { |
| 1320 | let mut priced_zero = create_test_app(); |
| 1321 | priced_zero.api_provider = crate::config::ApiProvider::Deepseek; |
| 1322 | priced_zero.model = "deepseek-v4-flash".to_string(); |
| 1323 | priced_zero.billing_presentation = crate::route_billing::BillingPresentation::Metered; |
| 1324 | priced_zero.record_turn_cost_audit(&dual_currency_priced_audit(0.0, 0.0)); |
| 1325 | let priced_zero_line = context_panel_cost_line(&priced_zero); |
| 1326 | |
| 1327 | let mut unknown = create_test_app(); |
| 1328 | unknown.api_provider = crate::config::ApiProvider::Deepseek; |
| 1329 | unknown.model = "deepseek-v4-flash".to_string(); |
| 1330 | unknown.billing_presentation = crate::route_billing::BillingPresentation::Metered; |
| 1331 | unknown.record_turn_cost_audit(&unpriced_audit()); |
| 1332 | let unknown_line = context_panel_cost_line(&unknown); |
| 1333 | |
| 1334 | assert_eq!(unknown_line, "cost: unknown"); |
| 1335 | assert_ne!( |
| 1336 | priced_zero_line, unknown_line, |
| 1337 | "a provider-reported zero must not render as missing data" |
| 1338 | ); |
| 1339 | } |
| 1340 | |
| 1341 | fn usd_only_priced_audit(usd: f64) -> crate::pricing::TurnCostAudit { |
| 1342 | crate::pricing::TurnCostAudit { |
| 1343 | estimate: Some(crate::pricing::CostEstimate::usd_only(usd)), |
| 1344 | provenance: Some(codewhale_config::pricing::PricingProvenance::ModelsDevBundled), |
| 1345 | unpriced_classes: Vec::new(), |
| 1346 | unpriced_reason: None, |
| 1347 | live_pricing_defect: None, |
| 1348 | usd_priced: true, |
| 1349 | cny_priced: false, |
| 1350 | } |
| 1351 | } |
| 1352 | |
| 1353 | fn dual_currency_priced_audit(usd: f64, cny: f64) -> crate::pricing::TurnCostAudit { |
| 1354 | crate::pricing::TurnCostAudit { |
| 1355 | estimate: Some(crate::pricing::CostEstimate { usd, cny }), |
| 1356 | provenance: Some(codewhale_config::pricing::PricingProvenance::ModelsDevBundled), |
| 1357 | unpriced_classes: Vec::new(), |
| 1358 | unpriced_reason: None, |
| 1359 | live_pricing_defect: None, |
| 1360 | usd_priced: true, |
| 1361 | cny_priced: true, |
| 1362 | } |
| 1363 | } |
| 1364 | |
| 1365 | fn unpriced_audit() -> crate::pricing::TurnCostAudit { |
| 1366 | crate::pricing::TurnCostAudit { |
| 1367 | estimate: None, |
| 1368 | provenance: None, |
| 1369 | unpriced_classes: Vec::new(), |
| 1370 | unpriced_reason: Some(crate::pricing::UnpricedReason::NoPricingRow), |
| 1371 | live_pricing_defect: None, |
| 1372 | usd_priced: false, |
| 1373 | cny_priced: false, |
| 1374 | } |
| 1375 | } |
| 1376 | |
| 1377 | #[test] |
| 1378 | fn work_panel_empty_hint_stays_quiet_and_truncates() { |
| 1379 | let hint = work_panel_empty_hint(10); |
| 1380 | assert!( |
| 1381 | hint.chars().count() <= 10, |
| 1382 | "hint width {} > 10: {hint:?}", |
| 1383 | hint.chars().count() |
| 1384 | ); |
| 1385 | assert!( |
| 1386 | !hint.contains("update_plan"), |
| 1387 | "hint should be quiet: {hint:?}" |
| 1388 | ); |
| 1389 | } |
| 1390 | |
| 1391 | #[test] |
| 1392 | fn work_panel_renders_checklist_as_primary_progress_surface_while_incomplete() { |
| 1393 | let summary = SidebarWorkSummary { |
| 1394 | checklist_completion_pct: 33, |
| 1395 | checklist_items: vec![ |
| 1396 | SidebarWorkChecklistItem { |
| 1397 | id: 1, |
| 1398 | content: "Plan it out".to_string(), |
| 1399 | status: TodoStatus::Completed, |
| 1400 | }, |
| 1401 | SidebarWorkChecklistItem { |
| 1402 | id: 2, |
| 1403 | content: "Wire the thing".to_string(), |
| 1404 | status: TodoStatus::InProgress, |
| 1405 | }, |
| 1406 | SidebarWorkChecklistItem { |
| 1407 | id: 3, |
| 1408 | content: "Run gates".to_string(), |
| 1409 | status: TodoStatus::Pending, |
| 1410 | }, |
| 1411 | ], |
| 1412 | ..SidebarWorkSummary::default() |
| 1413 | }; |
| 1414 | |
| 1415 | let text = lines_to_text(&work_panel_lines( |
| 1416 | &summary, |
| 1417 | 80, |
| 1418 | 16, |
| 1419 | PaletteMode::Dark, |
| 1420 | &palette::UI_THEME, |
| 1421 | )); |
| 1422 | |
| 1423 | assert!( |
| 1424 | text[0].starts_with("33% settled (1/3)"), |
| 1425 | "checklist should lead: {text:?}" |
| 1426 | ); |
| 1427 | assert!( |
| 1428 | text.iter().any(|line| line.contains("[~] #2 Wire")), |
| 1429 | "in-progress checklist item should be visible: {text:?}" |
| 1430 | ); |
| 1431 | assert!( |
| 1432 | !text.iter().any(|line| line.contains("50% settled")), |
| 1433 | "strategy progress must not render as a second progress bar when checklist exists: {text:?}" |
| 1434 | ); |
| 1435 | assert!( |
| 1436 | !text.iter().any(|line| line.contains("Strategy")) |
| 1437 | && !text.iter().any(|line| line.contains("route ")), |
| 1438 | "legacy strategy state must not render beside canonical To-do: {text:?}" |
| 1439 | ); |
| 1440 | } |
| 1441 | |
| 1442 | #[test] |
| 1443 | fn work_panel_keeps_active_checklist_item_visible_when_truncated() { |
| 1444 | let summary = SidebarWorkSummary { |
| 1445 | checklist_completion_pct: 38, |
| 1446 | checklist_items: (1..=8) |
| 1447 | .map(|id| SidebarWorkChecklistItem { |
| 1448 | id, |
| 1449 | content: format!("Release task {id}"), |
| 1450 | status: if id <= 3 { |
| 1451 | TodoStatus::Completed |
| 1452 | } else if id == 5 { |
| 1453 | TodoStatus::InProgress |
| 1454 | } else { |
| 1455 | TodoStatus::Pending |
| 1456 | }, |
| 1457 | }) |
| 1458 | .collect(), |
| 1459 | ..SidebarWorkSummary::default() |
| 1460 | }; |
| 1461 | |
| 1462 | let text = lines_to_text(&work_panel_lines( |
| 1463 | &summary, |
| 1464 | 80, |
| 1465 | 6, |
| 1466 | PaletteMode::Dark, |
| 1467 | &palette::UI_THEME, |
| 1468 | )); |
| 1469 | |
| 1470 | assert!( |
| 1471 | text.iter() |
| 1472 | .any(|line| line.contains("[~] #5 Release task 5")), |
| 1473 | "active checklist item should stay visible in a short Work panel: {text:?}" |
| 1474 | ); |
| 1475 | assert!( |
| 1476 | text.iter().any(|line| line.contains("earlier")) |
| 1477 | || text.iter().any(|line| line.contains("later")), |
| 1478 | "truncation should explain omitted checklist rows: {text:?}" |
| 1479 | ); |
| 1480 | } |
| 1481 | |
| 1482 | #[test] |
| 1483 | fn work_panel_never_renders_legacy_strategy_state() { |
| 1484 | let empty_text = lines_to_text(&work_panel_lines( |
| 1485 | &SidebarWorkSummary::default(), |
| 1486 | 80, |
| 1487 | 16, |
| 1488 | PaletteMode::Dark, |
| 1489 | &palette::UI_THEME, |
| 1490 | )); |
| 1491 | assert!( |
| 1492 | !empty_text.iter().any(|line| line.contains("Strategy")), |
| 1493 | "empty plan state should not show strategy: {empty_text:?}" |
| 1494 | ); |
| 1495 | |
| 1496 | let summary = SidebarWorkSummary::default(); |
| 1497 | let text = lines_to_text(&work_panel_lines( |
| 1498 | &summary, |
| 1499 | 80, |
| 1500 | 16, |
| 1501 | PaletteMode::Dark, |
| 1502 | &palette::UI_THEME, |
| 1503 | )); |
| 1504 | assert!( |
| 1505 | !text.iter().any(|line| line.contains("Strategy")) |
| 1506 | && !text |
| 1507 | .iter() |
| 1508 | .any(|line| line.contains("High-level sequencing")), |
| 1509 | "legacy plan state must not create a second panel: {text:?}" |
| 1510 | ); |
| 1511 | } |
| 1512 | |
| 1513 | #[test] |
| 1514 | fn metadata_only_plan_does_not_count_as_visible_work_content() { |
| 1515 | use crate::tools::plan::UpdatePlanArgs; |
| 1516 | |
| 1517 | let mut app = create_test_app(); |
| 1518 | { |
| 1519 | let mut plan = app.plan_state.try_lock().expect("plan lock"); |
| 1520 | plan.update(UpdatePlanArgs { |
| 1521 | objective: Some("Ship the catalog lane".to_string()), |
| 1522 | critical_files: vec!["provider_lake.rs".to_string()], |
| 1523 | ..UpdatePlanArgs::default() |
| 1524 | }); |
| 1525 | } |
| 1526 | |
| 1527 | let summary = sidebar_work_summary(&mut app); |
| 1528 | assert!(!summary.has_useful_content()); |
| 1529 | } |
| 1530 | |
| 1531 | #[test] |
| 1532 | fn sidebar_work_summary_caches_on_success() { |
| 1533 | let mut app = create_test_app(); |
| 1534 | { |
| 1535 | let mut todos = app.todos.try_lock().expect("todos lock"); |
| 1536 | todos.add("cache test".to_string(), TodoStatus::InProgress); |
| 1537 | } |
| 1538 | |
| 1539 | let summary = sidebar_work_summary(&mut app); |
| 1540 | |
| 1541 | assert!(!summary.state_updating, "should not be updating"); |
| 1542 | assert_eq!(summary.checklist_items.len(), 1); |
| 1543 | assert!( |
| 1544 | app.cached_work_summary.is_some(), |
| 1545 | "cache should be populated" |
| 1546 | ); |
| 1547 | } |
| 1548 | |
| 1549 | #[test] |
| 1550 | fn sidebar_work_summary_falls_back_to_cache_when_todos_lock_busy() { |
| 1551 | let mut app = create_test_app(); |
| 1552 | { |
| 1553 | let mut todos = app.todos.try_lock().expect("todos lock"); |
| 1554 | todos.add("will be cached".to_string(), TodoStatus::Completed); |
| 1555 | } |
| 1556 | let _first = sidebar_work_summary(&mut app); |
| 1557 | assert!(app.cached_work_summary.is_some()); |
| 1558 | |
| 1559 | let held_arc = app.todos.clone(); |
| 1560 | let _held = held_arc.try_lock().expect("hold todos lock"); |
| 1561 | |
| 1562 | let summary = sidebar_work_summary(&mut app); |
| 1563 | |
| 1564 | assert!(!summary.state_updating, "should fall back to cache"); |
| 1565 | assert!( |
| 1566 | summary |
| 1567 | .checklist_items |
| 1568 | .iter() |
| 1569 | .any(|item| item.content == "will be cached"), |
| 1570 | "cached item should be present" |
| 1571 | ); |
| 1572 | } |
| 1573 | |
| 1574 | #[test] |
| 1575 | fn sidebar_work_summary_returns_updating_when_no_cache_and_locks_busy() { |
| 1576 | let mut app = create_test_app(); |
| 1577 | let held_arc = app.todos.clone(); |
| 1578 | let _held = held_arc.try_lock().expect("hold todos lock"); |
| 1579 | |
| 1580 | let summary = sidebar_work_summary(&mut app); |
| 1581 | |
| 1582 | assert!(summary.state_updating, "should be updating without cache"); |
| 1583 | } |
| 1584 | |
| 1585 | #[test] |
| 1586 | fn sidebar_work_summary_keeps_live_fields_on_cache_fallback() { |
| 1587 | let mut app = create_test_app(); |
| 1588 | app.hunt.quarry = Some("test quarry".to_string()); |
| 1589 | app.hunt.verdict = HuntVerdict::Hunted; |
| 1590 | { |
| 1591 | let mut todos = app.todos.try_lock().expect("todos lock"); |
| 1592 | todos.add("item".to_string(), TodoStatus::Pending); |
| 1593 | } |
| 1594 | let _first = sidebar_work_summary(&mut app); |
| 1595 | |
| 1596 | app.hunt.quarry = Some("updated quarry".to_string()); |
| 1597 | app.hunt.verdict = HuntVerdict::Hunting; |
| 1598 | let held_arc = app.todos.clone(); |
| 1599 | let _held = held_arc.try_lock().expect("hold todos lock"); |
| 1600 | |
| 1601 | let summary = sidebar_work_summary(&mut app); |
| 1602 | |
| 1603 | assert_eq!(summary.goal_objective.as_deref(), Some("updated quarry")); |
| 1604 | assert!(!summary.goal_completed, "verdict should be live"); |
| 1605 | } |
| 1606 | |
| 1607 | #[test] |
| 1608 | fn sidebar_work_summary_uses_paused_quarry_when_goal_is_cleared() { |
| 1609 | let mut app = create_test_app(); |
| 1610 | app.hunt.quarry = None; |
| 1611 | app.paused = true; |
| 1612 | app.paused_quarry = Some("Scan nested git repositories".to_string()); |
| 1613 | |
| 1614 | let summary = sidebar_work_summary(&mut app); |
| 1615 | |
| 1616 | assert_eq!( |
| 1617 | summary.goal_objective.as_deref(), |
| 1618 | Some("Scan nested git repositories") |
| 1619 | ); |
| 1620 | assert_eq!(summary.pause_indicator.as_deref(), Some("(Paused)")); |
| 1621 | assert!(summary.workflow_paused); |
| 1622 | } |
| 1623 | |
| 1624 | #[test] |
| 1625 | fn sidebar_names_goal_pause_reason() { |
| 1626 | let mut app = create_test_app(); |
| 1627 | app.hunt.quarry = Some("Finish within budget".to_string()); |
| 1628 | app.hunt.verdict = HuntVerdict::Wounded; |
| 1629 | app.hunt.pause_reason = Some(crate::tools::goal::GoalPauseReason::BudgetLimit); |
| 1630 | |
| 1631 | let summary = sidebar_work_summary(&mut app); |
| 1632 | |
| 1633 | assert_eq!( |
| 1634 | summary.pause_indicator.as_deref(), |
| 1635 | Some("(Paused: budget limit)") |
| 1636 | ); |
| 1637 | assert!(summary.workflow_paused); |
| 1638 | } |
| 1639 | |
| 1640 | #[test] |
| 1641 | fn work_panel_renders_paused_command_goal() { |
| 1642 | let mut app = create_test_app(); |
| 1643 | app.hunt.quarry = None; |
| 1644 | app.paused = false; |
| 1645 | app.paused_quarry = Some("Deploy to staging".to_string()); |
| 1646 | |
| 1647 | let summary = sidebar_work_summary(&mut app); |
| 1648 | let text = lines_to_text(&work_panel_lines( |
| 1649 | &summary, |
| 1650 | 80, |
| 1651 | 8, |
| 1652 | PaletteMode::Dark, |
| 1653 | &palette::UI_THEME, |
| 1654 | )); |
| 1655 | |
| 1656 | assert!( |
| 1657 | text.first().is_some_and(|line| line.contains('⏸')), |
| 1658 | "paused command should use pause icon: {text:?}" |
| 1659 | ); |
| 1660 | assert!( |
| 1661 | text.first() |
| 1662 | .is_some_and(|line| line.contains("Deploy to staging")), |
| 1663 | "paused command title should remain visible: {text:?}" |
| 1664 | ); |
| 1665 | assert!( |
| 1666 | text.first().is_some_and(|line| line.contains("(Paused)")), |
| 1667 | "paused state should be visible: {text:?}" |
| 1668 | ); |
| 1669 | } |
| 1670 | |
| 1671 | #[test] |
| 1672 | fn navigator_empty_state_says_no_agents() { |
| 1673 | let summary = SidebarSubagentSummary::default(); |
| 1674 | let lines = subagent_panel_lines(&summary, &[], Locale::En, 32, 8, &palette::UI_THEME); |
| 1675 | let text = lines_to_text(&lines); |
| 1676 | assert_eq!(text, vec!["No agents".to_string()]); |
| 1677 | } |
| 1678 | |
| 1679 | #[test] |
| 1680 | fn navigator_uses_fanout_total_when_fanout_has_seeded_slots() { |
| 1681 | let summary = SidebarSubagentSummary { |
| 1682 | cached_total: 1, |
| 1683 | cached_running: 1, |
| 1684 | progress_only_count: 0, |
| 1685 | fanout_total: Some(6), |
| 1686 | fanout_running: 1, |
| 1687 | foreground_rlm_running: false, |
| 1688 | role_counts: std::collections::BTreeMap::new(), |
| 1689 | }; |
| 1690 | |
| 1691 | let text = lines_to_text(&subagent_panel_lines( |
| 1692 | &summary, |
| 1693 | &[], |
| 1694 | Locale::En, |
| 1695 | 64, |
| 1696 | 8, |
| 1697 | &palette::UI_THEME, |
| 1698 | )); |
| 1699 | |
| 1700 | assert!(text[0].contains("1 running"), "header: {:?}", text[0]); |
| 1701 | assert!(text[0].contains("/ 6"), "fanout total: {:?}", text[0]); |
| 1702 | } |
| 1703 | |
| 1704 | #[test] |
| 1705 | fn navigator_settled_state_says_done() { |
| 1706 | let mut role_counts = std::collections::BTreeMap::new(); |
| 1707 | role_counts.insert("general".to_string(), 1); |
| 1708 | let summary = SidebarSubagentSummary { |
| 1709 | cached_total: 1, |
| 1710 | cached_running: 0, |
| 1711 | progress_only_count: 0, |
| 1712 | fanout_total: None, |
| 1713 | fanout_running: 0, |
| 1714 | foreground_rlm_running: false, |
| 1715 | role_counts, |
| 1716 | }; |
| 1717 | let text = lines_to_text(&subagent_panel_lines( |
| 1718 | &summary, |
| 1719 | &[], |
| 1720 | Locale::En, |
| 1721 | 32, |
| 1722 | 8, |
| 1723 | &palette::UI_THEME, |
| 1724 | )); |
| 1725 | assert!(text[0].contains("1 done"), "settled header: {:?}", text[0]); |
| 1726 | } |
| 1727 | |
| 1728 | #[test] |
| 1729 | fn navigator_truncates_long_role_mix_to_content_width() { |
| 1730 | // Build a wide role mix; assert it doesn't blow past content_width. |
| 1731 | let mut role_counts = std::collections::BTreeMap::new(); |
| 1732 | for role in ["general", "explore", "plan", "review", "custom", "extra"] { |
| 1733 | role_counts.insert(role.to_string(), 1); |
| 1734 | } |
| 1735 | let summary = SidebarSubagentSummary { |
| 1736 | cached_total: 6, |
| 1737 | cached_running: 6, |
| 1738 | progress_only_count: 0, |
| 1739 | fanout_total: None, |
| 1740 | fanout_running: 0, |
| 1741 | foreground_rlm_running: false, |
| 1742 | role_counts, |
| 1743 | }; |
| 1744 | let lines = subagent_panel_lines(&summary, &[], Locale::En, 16, 8, &palette::UI_THEME); |
| 1745 | let role_line: &str = lines[1] |
| 1746 | .spans |
| 1747 | .first() |
| 1748 | .map(|s| s.content.as_ref()) |
| 1749 | .unwrap_or(""); |
| 1750 | assert!( |
| 1751 | role_line.chars().count() <= 16, |
| 1752 | "role line {role_line:?} exceeded content_width" |
| 1753 | ); |
| 1754 | } |
| 1755 | |
| 1756 | #[test] |
| 1757 | fn navigator_shows_foreground_rlm_work_when_no_subagents_exist() { |
| 1758 | let summary = SidebarSubagentSummary { |
| 1759 | foreground_rlm_running: true, |
| 1760 | ..SidebarSubagentSummary::default() |
| 1761 | }; |
| 1762 | let text = lines_to_text(&subagent_panel_lines( |
| 1763 | &summary, |
| 1764 | &[], |
| 1765 | Locale::En, |
| 1766 | 64, |
| 1767 | 8, |
| 1768 | &palette::UI_THEME, |
| 1769 | )); |
| 1770 | |
| 1771 | assert!(!text[0].contains("No agents"), "header: {text:?}"); |
| 1772 | assert!( |
| 1773 | text.iter() |
| 1774 | .any(|line| line.contains("RLM foreground work active")), |
| 1775 | "RLM work must be visible in Agents panel: {text:?}" |
| 1776 | ); |
| 1777 | } |
| 1778 | |
| 1779 | // ---- Sidebar hover tooltip tests ---- |
| 1780 | |
| 1781 | #[test] |
| 1782 | fn sidebar_hover_state_default_is_empty() { |
| 1783 | let state = SidebarHoverState::default(); |
| 1784 | assert!(state.sections.is_empty()); |
| 1785 | } |
| 1786 | |
| 1787 | #[test] |
| 1788 | fn sidebar_hover_section_stores_lines() { |
| 1789 | use ratatui::layout::Rect; |
| 1790 | let section = SidebarHoverSection { |
| 1791 | content_area: Rect::new(1, 1, 38, 8), |
| 1792 | lines: vec!["line 1".to_string(), "line 2".to_string()], |
| 1793 | rows: vec![], |
| 1794 | }; |
| 1795 | assert_eq!(section.lines.len(), 2); |
| 1796 | assert_eq!(section.lines[0], "line 1"); |
| 1797 | assert!(section.content_area.x > 0); |
| 1798 | } |
| 1799 | |
| 1800 | #[test] |
| 1801 | fn hover_line_matching_respects_content_area_offset() { |
| 1802 | use ratatui::layout::Rect; |
| 1803 | let section = SidebarHoverSection { |
| 1804 | content_area: Rect::new(62, 2, 36, 6), |
| 1805 | lines: vec![ |
| 1806 | "first".to_string(), |
| 1807 | "second".to_string(), |
| 1808 | "third".to_string(), |
| 1809 | ], |
| 1810 | rows: vec![], |
| 1811 | }; |
| 1812 | |
| 1813 | // Mouse within content area, first line |
| 1814 | let line_idx = (2u16.saturating_sub(section.content_area.y)) as usize; |
| 1815 | assert_eq!(section.lines[line_idx], "first"); |
| 1816 | |
| 1817 | // Mouse within content area, second line |
| 1818 | let line_idx = (3u16.saturating_sub(section.content_area.y)) as usize; |
| 1819 | assert_eq!(section.lines[line_idx], "second"); |
| 1820 | |
| 1821 | // Mouse outside content area (above) — row < content_area.y |
| 1822 | assert!((1u16) < section.content_area.y); |
| 1823 | } |
| 1824 | |
| 1825 | /// Display width of a single rendered sidebar line, styling stripped. |
| 1826 | fn subagent_line_width(line: &Line<'static>) -> usize { |
| 1827 | lines_to_text(std::slice::from_ref(line)) |
| 1828 | .first() |
| 1829 | .map(|s| unicode_width::UnicodeWidthStr::width(s.as_str())) |
| 1830 | .unwrap_or(0) |
| 1831 | } |
| 1832 | |
| 1833 | /// Summary for a single cached worker with an explicit running count. |
| 1834 | fn single_worker_summary(running: usize) -> SidebarSubagentSummary { |
| 1835 | SidebarSubagentSummary { |
| 1836 | cached_total: 1, |
| 1837 | cached_running: running, |
| 1838 | ..SidebarSubagentSummary::default() |
| 1839 | } |
| 1840 | } |
| 1841 | |
| 1842 | #[test] |
| 1843 | fn subagent_output_handle_gated_on_inspectable_output() { |
| 1844 | // #4094/#2889: lifecycle and step counts are not exact transcript |
| 1845 | // evidence. Only a successfully inspected resident transcript may |
| 1846 | // advertise the explicit Open route. |
| 1847 | let fresh = SidebarAgentRow { |
| 1848 | id: "agent_fresh".to_string(), |
| 1849 | name: "scout".to_string(), |
| 1850 | status: "starting".to_string(), |
| 1851 | steps_taken: 0, |
| 1852 | expanded: true, |
| 1853 | ..SidebarAgentRow::default() |
| 1854 | }; |
| 1855 | assert!( |
| 1856 | subagent_output_handle(&fresh).is_none(), |
| 1857 | "a zero-step non-terminal worker must not advertise a handle" |
| 1858 | ); |
| 1859 | |
| 1860 | let working = SidebarAgentRow { |
| 1861 | steps_taken: 4, |
| 1862 | status: "running".to_string(), |
| 1863 | transcript_available: true, |
| 1864 | ..fresh.clone() |
| 1865 | }; |
| 1866 | assert_eq!( |
| 1867 | subagent_output_handle(&working).as_deref(), |
| 1868 | Some("agent:agent_fresh/full_transcript"), |
| 1869 | "a worker with exact resident evidence should expose the transcript handle" |
| 1870 | ); |
| 1871 | |
| 1872 | // A terminal state without exact evidence must not look actionable. |
| 1873 | let failed_immediately = SidebarAgentRow { |
| 1874 | steps_taken: 0, |
| 1875 | status: "failed".to_string(), |
| 1876 | ..fresh.clone() |
| 1877 | }; |
| 1878 | assert!( |
| 1879 | subagent_output_handle(&failed_immediately).is_none(), |
| 1880 | "a terminal worker without exact evidence must not advertise Open" |
| 1881 | ); |
| 1882 | } |
| 1883 | |
| 1884 | // ── #3030: stable labels instead of raw internal ids ─────────────────── |
| 1885 | |
| 1886 | #[test] |
| 1887 | fn ensure_agent_label_assigns_stable_sequential_labels() { |
| 1888 | let mut app = create_test_app(); |
| 1889 | assert_eq!(app.ensure_agent_label("agent_aaa111"), "Agent 1"); |
| 1890 | assert_eq!(app.ensure_agent_label("agent_bbb222"), "Agent 2"); |
| 1891 | // Re-seeing a known agent keeps its original label. |
| 1892 | assert_eq!(app.ensure_agent_label("agent_aaa111"), "Agent 1"); |
| 1893 | assert_eq!(app.agent_counter, 2); |
| 1894 | // Read-only lookup falls back to the raw id for unknown agents. |
| 1895 | assert_eq!(app.agent_display_label("agent_bbb222"), "Agent 2"); |
| 1896 | assert_eq!(app.agent_display_label("agent_zzz999"), "agent_zzz999"); |
| 1897 | } |
| 1898 | |
| 1899 | fn cached_agent( |
| 1900 | agent_id: &str, |
| 1901 | nickname: Option<&str>, |
| 1902 | ) -> crate::tools::subagent::SubAgentResult { |
| 1903 | crate::tools::subagent::SubAgentResult { |
| 1904 | name: "implementation-worker".to_string(), |
| 1905 | agent_id: agent_id.to_string(), |
| 1906 | context_mode: "fresh".to_string(), |
| 1907 | fork_context: false, |
| 1908 | workspace: None, |
| 1909 | git_branch: None, |
| 1910 | agent_type: crate::tools::subagent::FleetRole::Worker, |
| 1911 | assignment: crate::tools::subagent::SubAgentAssignment { |
| 1912 | objective: "task".to_string(), |
| 1913 | role: Some("worker".to_string()), |
| 1914 | }, |
| 1915 | model: String::new(), |
| 1916 | nickname: nickname.map(str::to_string), |
| 1917 | status: crate::tools::subagent::SubAgentStatus::Running, |
| 1918 | worker_status: None, |
| 1919 | runtime_permissions: None, |
| 1920 | parent_run_id: None, |
| 1921 | spawn_depth: 0, |
| 1922 | result: None, |
| 1923 | steps_taken: 1, |
| 1924 | checkpoint: None, |
| 1925 | needs_input: None, |
| 1926 | duration_ms: 100, |
| 1927 | from_prior_session: false, |
| 1928 | } |
| 1929 | } |
| 1930 | |
| 1931 | #[test] |
| 1932 | fn sidebar_agent_rows_use_worker_status_from_cached_agents() { |
| 1933 | let mut app = create_test_app(); |
| 1934 | let mut agent = cached_agent("agent_model_wait", Some("Blue")); |
| 1935 | agent.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::ModelWait); |
| 1936 | app.subagent_cache.push(agent); |
| 1937 | |
| 1938 | let rows = sidebar_agent_rows(&app); |
| 1939 | |
| 1940 | assert_eq!(rows.len(), 1); |
| 1941 | assert_eq!(rows[0].status, "model wait"); |
| 1942 | assert_eq!(rows[0].progress.as_deref(), None); |
| 1943 | } |
| 1944 | |
| 1945 | #[test] |
| 1946 | fn sidebar_agent_rows_project_typed_lifecycle_fixtures() { |
| 1947 | let mut app = create_test_app(); |
| 1948 | let fixtures = [ |
| 1949 | ( |
| 1950 | "agent_running", |
| 1951 | "Running", |
| 1952 | crate::tools::subagent::SubAgentStatus::Running, |
| 1953 | crate::tools::subagent::AgentWorkerStatus::RunningTool, |
| 1954 | AgentCurrentActivityStatus::RunningTool, |
| 1955 | "tool", |
| 1956 | ), |
| 1957 | ( |
| 1958 | "agent_waiting", |
| 1959 | "Waiting", |
| 1960 | crate::tools::subagent::SubAgentStatus::Interrupted("approval".to_string()), |
| 1961 | crate::tools::subagent::AgentWorkerStatus::WaitingForUser, |
| 1962 | AgentCurrentActivityStatus::Waiting, |
| 1963 | "waiting", |
| 1964 | ), |
| 1965 | ( |
| 1966 | "agent_failed", |
| 1967 | "Failed", |
| 1968 | crate::tools::subagent::SubAgentStatus::Failed("verification".to_string()), |
| 1969 | crate::tools::subagent::AgentWorkerStatus::Failed, |
| 1970 | AgentCurrentActivityStatus::Failed, |
| 1971 | "failed", |
| 1972 | ), |
| 1973 | ( |
| 1974 | "agent_done", |
| 1975 | "Done", |
| 1976 | crate::tools::subagent::SubAgentStatus::Completed, |
| 1977 | crate::tools::subagent::AgentWorkerStatus::Completed, |
| 1978 | AgentCurrentActivityStatus::Done, |
| 1979 | "done", |
| 1980 | ), |
| 1981 | ]; |
| 1982 | for (id, nickname, status, worker_status, activity_status, _) in &fixtures { |
| 1983 | let mut agent = cached_agent(id, Some(nickname)); |
| 1984 | agent.status = status.clone(); |
| 1985 | agent.worker_status = Some(*worker_status); |
| 1986 | app.subagent_cache.push(agent); |
| 1987 | app.agent_progress_meta.insert( |
| 1988 | (*id).to_string(), |
| 1989 | AgentProgressMeta { |
| 1990 | current_activity: Some(AgentCurrentActivity::bounded( |
| 1991 | *activity_status, |
| 1992 | (*id == "agent_waiting").then_some("approval required".to_string()), |
| 1993 | (*id == "agent_running").then_some("read_file".to_string()), |
| 1994 | Some(2), |
| 1995 | )), |
| 1996 | ..AgentProgressMeta::default() |
| 1997 | }, |
| 1998 | ); |
| 1999 | } |
| 2000 | |
| 2001 | let rows = sidebar_agent_rows(&app); |
| 2002 | for (id, _, _, _, _, expected_status) in fixtures { |
| 2003 | let row = rows |
| 2004 | .iter() |
| 2005 | .find(|row| row.id == id) |
| 2006 | .expect("typed lifecycle row"); |
| 2007 | assert_eq!(row.status, expected_status); |
| 2008 | } |
| 2009 | let waiting = app |
| 2010 | .subagent_cache |
| 2011 | .iter() |
| 2012 | .find(|agent| agent.agent_id == "agent_waiting") |
| 2013 | .expect("waiting agent"); |
| 2014 | assert!(cached_agent_activity_is_live(&app, waiting)); |
| 2015 | } |
| 2016 | |
| 2017 | #[test] |
| 2018 | fn sidebar_progress_only_rows_never_infer_status_from_display_text() { |
| 2019 | let mut app = create_test_app(); |
| 2020 | app.ensure_agent_label("agent_queued"); |
| 2021 | app.agent_progress.insert( |
| 2022 | "agent_queued".to_string(), |
| 2023 | "queued waiting failed completed".to_string(), |
| 2024 | ); |
| 2025 | |
| 2026 | let rows = sidebar_agent_rows(&app); |
| 2027 | |
| 2028 | assert_eq!(rows.len(), 1); |
| 2029 | assert_eq!(rows[0].name, "Agent 1"); |
| 2030 | assert_eq!(rows[0].status, "running"); |
| 2031 | assert_eq!(rows[0].progress, None); |
| 2032 | |
| 2033 | app.agent_progress_meta.insert( |
| 2034 | "agent_queued".to_string(), |
| 2035 | AgentProgressMeta { |
| 2036 | current_activity: Some(AgentCurrentActivity::bounded( |
| 2037 | AgentCurrentActivityStatus::Queued, |
| 2038 | Some("waiting for launch permit".to_string()), |
| 2039 | None, |
| 2040 | None, |
| 2041 | )), |
| 2042 | ..AgentProgressMeta::default() |
| 2043 | }, |
| 2044 | ); |
| 2045 | let rows = sidebar_agent_rows(&app); |
| 2046 | assert_eq!(rows[0].status, "queued"); |
| 2047 | assert_eq!( |
| 2048 | rows[0].progress.as_deref(), |
| 2049 | Some("queued · waiting for launch permit") |
| 2050 | ); |
| 2051 | } |
| 2052 | |
| 2053 | #[test] |
| 2054 | fn sidebar_agent_rows_preserve_explicit_names_and_derive_whales_from_locale() { |
| 2055 | let mut app = create_test_app(); |
| 2056 | let agent_id = "agent_cafe0123"; |
| 2057 | app.ensure_agent_label(agent_id); |
| 2058 | app.subagent_cache |
| 2059 | .push(cached_agent(agent_id, Some("doc-fixer"))); |
| 2060 | |
| 2061 | let rows = super::sidebar_agent_rows(&app); |
| 2062 | assert_eq!( |
| 2063 | rows[0].name, "doc-fixer", |
| 2064 | "an explicit custom nickname remains user-owned" |
| 2065 | ); |
| 2066 | |
| 2067 | // Without an explicit nickname, display is derived from the neutral id |
| 2068 | // in the active UI locale rather than from the old Agent-N label. |
| 2069 | app.subagent_cache[0].nickname = None; |
| 2070 | let rows = super::sidebar_agent_rows(&app); |
| 2071 | assert_eq!( |
| 2072 | rows[0].name, |
| 2073 | crate::tools::subagent::whale_name_for_id_in_locale(agent_id, "en") |
| 2074 | ); |
| 2075 | } |
| 2076 | |
| 2077 | #[test] |
| 2078 | fn english_sidebar_relocalizes_mixed_persisted_whale_names() { |
| 2079 | let mut app = create_test_app(); |
| 2080 | app.ui_locale = Locale::En; |
| 2081 | for (agent_id, legacy_locale) in [ |
| 2082 | ("agent_locale_a", "zh-Hans"), |
| 2083 | ("agent_locale_b", "ja"), |
| 2084 | ("agent_locale_c", "vi"), |
| 2085 | ] { |
| 2086 | let legacy_name = |
| 2087 | crate::tools::subagent::whale_name_for_id_in_locale(agent_id, legacy_locale); |
| 2088 | app.subagent_cache |
| 2089 | .push(cached_agent(agent_id, Some(&legacy_name))); |
| 2090 | } |
| 2091 | |
| 2092 | let rows = super::sidebar_agent_rows(&app); |
| 2093 | assert_eq!(rows.len(), 3); |
| 2094 | for row in rows { |
| 2095 | assert!( |
| 2096 | row.name.is_ascii(), |
| 2097 | "English Fleet display leaked a prior-locale whale: {}", |
| 2098 | row.name |
| 2099 | ); |
| 2100 | assert_eq!( |
| 2101 | row.name, |
| 2102 | crate::tools::subagent::whale_name_for_id_in_locale(&row.id, "en") |
| 2103 | ); |
| 2104 | } |
| 2105 | } |
| 2106 | |
| 2107 | // --- Unicode / CJK / terminal-width QA (issue #3488) ------------------- |
| 2108 | // The sub-agent overlay renders CJK display names next to ASCII ids, |
| 2109 | // numeric columns (step count, elapsed), status verbs, and branch lines. |
| 2110 | // These guard that a CJK name never shifts the status columns, corrupts the |
| 2111 | // panel border, or hides the running/completed state (#3488 dogfood case: |
| 2112 | // a worker named 抹香鲸). |
| 2113 | |
| 2114 | /// Build the exact dogfood fixture: a CJK-named running implementer with a |
| 2115 | /// mixed English/CJK objective, a long branch, step count, and elapsed time. |
| 2116 | fn cjk_running_implementer_row() -> SidebarAgentRow { |
| 2117 | SidebarAgentRow { |
| 2118 | id: "agent_e0b2dcf1".to_string(), |
| 2119 | parent_run_id: None, |
| 2120 | spawn_depth: 1, |
| 2121 | name: "抹香鲸".to_string(), |
| 2122 | model: Some("glm-5.2".to_string()), |
| 2123 | status: "running".to_string(), |
| 2124 | objective: Some( |
| 2125 | "QUESTION: Add Zhipu GLM as a first-class provider-scoped model (issue #3439)" |
| 2126 | .to_string(), |
| 2127 | ), |
| 2128 | git_branch: Some("codex/issue-3439-zhipu-glm-fixture".to_string()), |
| 2129 | progress: Some("step 10: finished tool edit_file ok".to_string()), |
| 2130 | steps_taken: 10, |
| 2131 | duration_ms: Some(124_838), |
| 2132 | transcript_available: false, |
| 2133 | expanded: true, |
| 2134 | } |
| 2135 | } |
| 2136 | |
| 2137 | #[test] |
| 2138 | fn subagent_panel_cjk_display_name_keeps_columns_and_state_at_narrow_and_medium_widths() { |
| 2139 | let summary = single_worker_summary(1); |
| 2140 | let rows = vec![cjk_running_implementer_row()]; |
| 2141 | |
| 2142 | // Across pathological single-cell widths up through a medium terminal, |
| 2143 | // every rendered line (count header, role-mix, label, dossier, handle) |
| 2144 | // must stay within the column budget by *display* width and never split |
| 2145 | // a wide glyph into a replacement char — which is what would corrupt the |
| 2146 | // panel border or visually drift the status columns. |
| 2147 | for content_width in [1usize, 2, 3, 5, 8, 12, 16, 20, 24, 40, 80] { |
| 2148 | let (lines, actions) = subagent_panel_rows( |
| 2149 | &summary, |
| 2150 | &rows, |
| 2151 | Locale::En, |
| 2152 | content_width, |
| 2153 | 8, |
| 2154 | &palette::UI_THEME, |
| 2155 | ); |
| 2156 | assert_eq!(lines.len(), actions.len(), "width {content_width}"); |
| 2157 | for line in &lines { |
| 2158 | assert!( |
| 2159 | subagent_line_width(line) <= content_width, |
| 2160 | "width {content_width}: line overflows by display width ({} cells)", |
| 2161 | subagent_line_width(line) |
| 2162 | ); |
| 2163 | let text = lines_to_text(std::slice::from_ref(line)).join(""); |
| 2164 | assert!( |
| 2165 | !text.contains('\u{FFFD}'), |
| 2166 | "width {content_width}: wide glyph split during truncation: {text:?}" |
| 2167 | ); |
| 2168 | } |
| 2169 | } |
| 2170 | |
| 2171 | // At medium/usable widths the CJK name must not hide the running state: |
| 2172 | // the status marker `[~]`, the compact stop target `[x]`, and the CJK |
| 2173 | // display name all survive, and the row still resolves to its agent id. |
| 2174 | for content_width in [40usize, 80] { |
| 2175 | let (lines, actions) = subagent_panel_rows( |
| 2176 | &summary, |
| 2177 | &rows, |
| 2178 | Locale::En, |
| 2179 | content_width, |
| 2180 | 8, |
| 2181 | &palette::UI_THEME, |
| 2182 | ); |
| 2183 | let text = lines_to_text(&lines); |
| 2184 | |
| 2185 | let label_idx = text |
| 2186 | .iter() |
| 2187 | .position(|line| line.contains("抹香鲸")) |
| 2188 | .unwrap_or_else(|| { |
| 2189 | panic!("width {content_width}: CJK display name dropped: {text:?}") |
| 2190 | }); |
| 2191 | assert!( |
| 2192 | text[label_idx].contains("[~]"), |
| 2193 | "width {content_width}: running marker hidden by CJK name: {text:?}" |
| 2194 | ); |
| 2195 | assert!( |
| 2196 | text[label_idx].ends_with("[x]"), |
| 2197 | "width {content_width}: stop target hidden by CJK name: {text:?}" |
| 2198 | ); |
| 2199 | assert!( |
| 2200 | !text[label_idx].contains('\u{FFFD}'), |
| 2201 | "width {content_width}: CJK name split: {text:?}" |
| 2202 | ); |
| 2203 | assert!( |
| 2204 | matches!( |
| 2205 | actions[label_idx], |
| 2206 | Some(SidebarRowAction::ToggleAgentDetails { ref agent_id }) |
| 2207 | if agent_id == "agent_e0b2dcf1" |
| 2208 | ), |
| 2209 | "width {content_width}: CJK row must still resolve to its agent id" |
| 2210 | ); |
| 2211 | } |
| 2212 | } |
| 2213 | } |
| 2214 |