返回 CodeWhale
sidebar.rs
根目录 / crates / tui / src / tui / sidebar.rs
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
2214 lines RUST