返回 DeepSeek-TUI-2026
sidebar.rs
根目录 / crates / tui / src / tui / sidebar.rs
1 //! Sidebar rendering — Plan / Todos / Tasks / Agents 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::fmt::Write;
8
9 use ratatui::{
10 Frame,
11 layout::{Constraint, Direction, Layout, Rect},
12 prelude::Widget,
13 style::{Style, Stylize},
14 text::{Line, Span},
15 widgets::{Block, Paragraph, Wrap},
16 };
17
18 use crate::deepseek_theme::active_theme;
19 use crate::palette;
20 use crate::tools::plan::StepStatus;
21 use crate::tools::subagent::SubAgentStatus;
22 use crate::tools::todo::TodoStatus;
23
24 use super::app::{App, SidebarFocus};
25 use super::history::{HistoryCell, ToolCell, ToolStatus};
26 use super::subagent_routing::active_fanout_counts;
27 use super::ui::truncate_line_to_width;
28
29 pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) {
30 if area.width < 24 || area.height < 8 {
31 // Paint a styled block over the area so stale cells from a previous
32 // (wider) frame don't persist as bleed-through artifacts (#400).
33 Block::default().render(area, f.buffer_mut());
34 return;
35 }
36
37 match app.sidebar_focus {
38 SidebarFocus::Auto => render_sidebar_auto(f, area, app),
39 SidebarFocus::Plan => render_sidebar_plan(f, area, app),
40 SidebarFocus::Todos => render_sidebar_todos(f, area, app),
41 SidebarFocus::Tasks => render_sidebar_tasks(f, area, app),
42 SidebarFocus::Agents => render_sidebar_subagents(f, area, app),
43 SidebarFocus::Context => render_context_panel(f, area, app),
44 }
45 }
46
47 /// Build the Auto-mode panel stack. Empty panels collapse to zero height so
48 /// non-empty ones get the full sidebar real estate. Without this, Plan got
49 /// clipped because Todos/Tasks/Agents each reserved 25% of the height even
50 /// when they had nothing to show. Plan is always rendered (it owns the
51 /// session-wide empty-state hint).
52 fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) {
53 #[derive(Clone, Copy)]
54 enum Panel {
55 Plan,
56 Todos,
57 Tasks,
58 Agents,
59 Context,
60 }
61
62 let todos_empty = app
63 .todos
64 .try_lock()
65 .map(|todos| todos.snapshot().items.is_empty())
66 .unwrap_or(false); // assume non-empty when locked so we don't hide updating data
67 let tasks_empty = app.runtime_turn_id.is_none() && app.task_panel.is_empty();
68 let agents_empty = app.subagent_cache.is_empty()
69 && app.agent_progress.is_empty()
70 && active_fanout_counts(app).is_none()
71 && !foreground_rlm_running(app);
72
73 let mut visible: Vec<Panel> = Vec::with_capacity(5);
74 visible.push(Panel::Plan);
75 if !todos_empty {
76 visible.push(Panel::Todos);
77 }
78 if !tasks_empty {
79 visible.push(Panel::Tasks);
80 }
81 if !agents_empty {
82 visible.push(Panel::Agents);
83 }
84 if app.context_panel {
85 visible.push(Panel::Context);
86 }
87
88 let constraints: Vec<Constraint> = match visible.len() {
89 1 => vec![Constraint::Min(0)],
90 2 => vec![Constraint::Percentage(50), Constraint::Min(0)],
91 3 => vec![
92 Constraint::Percentage(34),
93 Constraint::Percentage(33),
94 Constraint::Min(0),
95 ],
96 4 => vec![
97 Constraint::Percentage(25),
98 Constraint::Percentage(25),
99 Constraint::Percentage(25),
100 Constraint::Min(6),
101 ],
102 _ => vec![
103 Constraint::Percentage(20),
104 Constraint::Percentage(20),
105 Constraint::Percentage(20),
106 Constraint::Percentage(20),
107 Constraint::Min(6),
108 ],
109 };
110
111 let sections = Layout::default()
112 .direction(Direction::Vertical)
113 .constraints(constraints)
114 .split(area);
115
116 for (panel, rect) in visible.iter().zip(sections.iter()) {
117 match panel {
118 Panel::Plan => render_sidebar_plan(f, *rect, app),
119 Panel::Todos => render_sidebar_todos(f, *rect, app),
120 Panel::Tasks => render_sidebar_tasks(f, *rect, app),
121 Panel::Agents => render_sidebar_subagents(f, *rect, app),
122 Panel::Context => render_context_panel(f, *rect, app),
123 }
124 }
125 }
126
127 /// The Plan section is the **single source of truth for the
128 /// `update_plan` tool's output** (#408). It is intentionally distinct
129 /// from the Todos section: todos are checklist work items the user
130 /// or model is tracking; plan steps are the model's higher-level
131 /// strategy as recorded by `update_plan`. The panel also hosts two
132 /// session-wide indicators that don't fit the other sections — Goal
133 /// (`/goal`) and the cycle counter (#124) — because they share the
134 /// "what's the agent trying to do, big-picture" theme.
135 ///
136 /// When the panel is fully empty (no goal, no cycles, no plan) it
137 /// renders as a quiet section with a single dim hint at the bottom
138 /// rather than the blunt "No active plan" placeholder it used to show.
139 /// That kept the user wondering whether the panel was broken; the
140 /// hint instead tells them what the panel is for and how to populate
141 /// it.
142 fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) {
143 if area.height < 3 {
144 return;
145 }
146
147 let theme = active_theme();
148 let content_width = area.width.saturating_sub(4) as usize;
149 let mut lines: Vec<Line<'static>> = Vec::with_capacity(usize::from(area.height).max(4));
150
151 // === Goal Mode (#397) — gold outline matching todo items ===
152 if let Some(ref objective) = app.goal.goal_objective {
153 lines.push(Line::from(Span::styled(
154 format!(
155 "◆ {}",
156 truncate_line_to_width(objective, content_width.max(1))
157 ),
158 Style::default()
159 .fg(palette::STATUS_WARNING)
160 .add_modifier(ratatui::style::Modifier::BOLD),
161 )));
162 if let Some(budget) = app.goal.goal_token_budget {
163 let used = app.session.total_conversation_tokens;
164 let pct = if budget > 0 {
165 ((used as f64 / budget as f64) * 100.0).min(100.0)
166 } else {
167 0.0
168 };
169 let bar_width = content_width.min(20);
170 let filled = ((pct / 100.0) * bar_width as f64) as usize;
171 let bar = format!(
172 "[{}{}] {:.0}%",
173 "█".repeat(filled),
174 "░".repeat(bar_width.saturating_sub(filled)),
175 pct
176 );
177 lines.push(Line::from(Span::styled(
178 format!(" tokens: {used}/{budget} {}", bar),
179 Style::default().fg(palette::TEXT_MUTED),
180 )));
181 }
182 // Gold separator
183 lines.push(Line::from(Span::styled(
184 "─".repeat(content_width.min(24)),
185 Style::default().fg(palette::STATUS_WARNING),
186 )));
187 }
188
189 // Cycle indicator (issue #124). Only shown once a boundary has fired —
190 // first-time users with cycle_count == 0 don't need this row of chrome.
191 if app.cycle_count > 0 {
192 lines.push(Line::from(Span::styled(
193 format!(
194 "cycles: {} (active: {})",
195 app.cycle_count,
196 app.cycle_count.saturating_add(1)
197 ),
198 Style::default().fg(theme.plan_summary_color),
199 )));
200 }
201
202 match app.plan_state.try_lock() {
203 Ok(plan) => {
204 if plan.is_empty() {
205 // The blunt "No active plan" placeholder used to land
206 // here on every render with no plan steps, even when the
207 // user had a goal set or had cycled — making the panel
208 // look broken. After #408 we instead emit a quiet hint
209 // that explains what the panel is for, but only when
210 // *all* of the panel's signals are empty so we don't
211 // crowd a panel that already has a goal / cycle
212 // indicator above.
213 let nothing_above = app.goal.goal_objective.is_none() && app.cycle_count == 0;
214 if nothing_above {
215 lines.push(Line::from(Span::styled(
216 plan_panel_empty_hint(content_width.max(1)),
217 Style::default().fg(palette::TEXT_MUTED).italic(),
218 )));
219 }
220 } else {
221 let (pending, in_progress, completed) = plan.counts();
222 let total = pending + in_progress + completed;
223 lines.push(Line::from(vec![
224 Span::styled(
225 format!("{}%", plan.progress_percent()),
226 Style::default().fg(theme.plan_progress_color).bold(),
227 ),
228 Span::styled(
229 format!(" complete ({completed}/{total})"),
230 Style::default().fg(theme.plan_summary_color),
231 ),
232 ]));
233
234 if let Some(explanation) = plan.explanation() {
235 lines.push(Line::from(Span::styled(
236 truncate_line_to_width(explanation, content_width.max(1)),
237 Style::default().fg(theme.plan_explanation_color),
238 )));
239 }
240
241 let usable_rows = area.height.saturating_sub(3) as usize;
242 let max_steps = usable_rows.saturating_sub(lines.len());
243 for step in plan.steps().iter().take(max_steps) {
244 let (prefix, color) = match &step.status {
245 StepStatus::Pending => ("[ ]", theme.plan_pending_color),
246 StepStatus::InProgress => ("[~]", theme.plan_in_progress_color),
247 StepStatus::Completed => ("[x]", theme.plan_completed_color),
248 };
249 let mut text = format!("{prefix} {}", step.text);
250 let elapsed = step.elapsed_str();
251 if !elapsed.is_empty() {
252 let _ = write!(text, " ({elapsed})");
253 }
254 lines.push(Line::from(Span::styled(
255 truncate_line_to_width(&text, content_width.max(1)),
256 Style::default().fg(color),
257 )));
258 }
259
260 let remaining = plan.steps().len().saturating_sub(max_steps);
261 if remaining > 0 {
262 lines.push(Line::from(Span::styled(
263 format!("+{remaining} more steps"),
264 Style::default().fg(theme.plan_summary_color),
265 )));
266 }
267 }
268 }
269 Err(_) => {
270 lines.push(Line::from(Span::styled(
271 "Plan state updating...",
272 Style::default().fg(theme.plan_summary_color),
273 )));
274 }
275 }
276
277 render_sidebar_section(f, area, "Plan", lines);
278 }
279
280 /// One-line hint shown when the Plan section has nothing to display
281 /// (no goal, no cycle, no steps). Ellipsizes for narrow widths so
282 /// even a 24-column sidebar doesn't wrap mid-word. Visible across
283 /// modes — the panel's role doesn't change between Plan / Agent /
284 /// YOLO; only its content does.
285 #[must_use]
286 fn plan_panel_empty_hint(content_width: usize) -> String {
287 let full = "tracks update_plan / /goal / cycles";
288 truncate_line_to_width(full, content_width)
289 }
290
291 fn render_sidebar_todos(f: &mut Frame, area: Rect, app: &App) {
292 if area.height < 3 {
293 return;
294 }
295
296 let content_width = area.width.saturating_sub(4) as usize;
297 let mut lines: Vec<Line<'static>> = Vec::with_capacity(usize::from(area.height).max(4));
298
299 match app.todos.try_lock() {
300 Ok(todos) => {
301 let snapshot = todos.snapshot();
302 if snapshot.items.is_empty() {
303 lines.push(Line::from(Span::styled(
304 "No todos",
305 Style::default().fg(palette::TEXT_MUTED),
306 )));
307 } else {
308 let total = snapshot.items.len();
309 let completed = snapshot
310 .items
311 .iter()
312 .filter(|item| item.status == TodoStatus::Completed)
313 .count();
314 lines.push(Line::from(vec![
315 Span::styled(
316 format!("{}%", snapshot.completion_pct),
317 Style::default().fg(palette::STATUS_SUCCESS).bold(),
318 ),
319 Span::styled(
320 format!(" complete ({completed}/{total})"),
321 Style::default().fg(palette::TEXT_MUTED),
322 ),
323 ]));
324
325 let usable_rows = area.height.saturating_sub(3) as usize;
326 let max_items = usable_rows.saturating_sub(lines.len());
327 for item in snapshot.items.iter().take(max_items) {
328 let (prefix, color) = match item.status {
329 TodoStatus::Pending => ("[ ]", palette::TEXT_MUTED),
330 TodoStatus::InProgress => ("[~]", palette::STATUS_WARNING),
331 TodoStatus::Completed => ("[x]", palette::STATUS_SUCCESS),
332 };
333 let text = format!("{prefix} #{} {}", item.id, item.content);
334 lines.push(Line::from(Span::styled(
335 truncate_line_to_width(&text, content_width.max(1)),
336 Style::default().fg(color),
337 )));
338 }
339
340 let remaining = snapshot.items.len().saturating_sub(max_items);
341 if remaining > 0 {
342 lines.push(Line::from(Span::styled(
343 format!("+{remaining} more todos"),
344 Style::default().fg(palette::TEXT_MUTED),
345 )));
346 }
347 }
348 }
349 Err(_) => {
350 lines.push(Line::from(Span::styled(
351 "Todo list updating...",
352 Style::default().fg(palette::TEXT_MUTED),
353 )));
354 }
355 }
356
357 render_sidebar_section(f, area, "Todos", lines);
358 }
359
360 fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) {
361 if area.height < 3 {
362 return;
363 }
364
365 let content_width = area.width.saturating_sub(4) as usize;
366 let mut lines: Vec<Line<'static>> = Vec::with_capacity(usize::from(area.height).max(4));
367
368 if let Some(turn_id) = app.runtime_turn_id.as_ref() {
369 let status = app
370 .runtime_turn_status
371 .as_deref()
372 .unwrap_or("unknown")
373 .to_string();
374 lines.push(Line::from(Span::styled(
375 truncate_line_to_width(
376 &format!("turn {} ({status})", truncate_line_to_width(turn_id, 12)),
377 content_width.max(1),
378 ),
379 Style::default().fg(palette::DEEPSEEK_SKY),
380 )));
381 }
382
383 if app.task_panel.is_empty() {
384 lines.push(Line::from(Span::styled(
385 "No active tasks",
386 Style::default().fg(palette::TEXT_MUTED),
387 )));
388 } else {
389 let running = app
390 .task_panel
391 .iter()
392 .filter(|task| task.status == "running")
393 .count();
394 lines.push(Line::from(vec![
395 Span::styled(
396 if running == app.task_panel.len() {
397 format!("{running} running")
398 } else {
399 format!("{} active", app.task_panel.len())
400 },
401 Style::default().fg(palette::DEEPSEEK_SKY).bold(),
402 ),
403 Span::styled(
404 if running == app.task_panel.len() {
405 String::new()
406 } else {
407 format!(" ({running} running)")
408 },
409 Style::default().fg(palette::TEXT_MUTED),
410 ),
411 ]));
412
413 let usable_rows = area.height.saturating_sub(3) as usize;
414 let max_items = usable_rows.saturating_sub(lines.len());
415 for task in app.task_panel.iter().take(max_items) {
416 let color = match task.status.as_str() {
417 "queued" => palette::TEXT_MUTED,
418 "running" => palette::STATUS_WARNING,
419 "completed" => palette::STATUS_SUCCESS,
420 "failed" => palette::STATUS_ERROR,
421 "canceled" => palette::TEXT_DIM,
422 _ => palette::TEXT_MUTED,
423 };
424 let duration = task
425 .duration_ms
426 .map(|ms| format!("{:.1}s", ms as f64 / 1000.0))
427 .unwrap_or_else(|| "-".to_string());
428 let label = format!(
429 "{} {} {}",
430 truncate_line_to_width(&task.id, 10),
431 task.status,
432 duration
433 );
434 lines.push(Line::from(Span::styled(
435 truncate_line_to_width(&label, content_width.max(1)),
436 Style::default().fg(color),
437 )));
438 lines.push(Line::from(Span::styled(
439 format!(
440 " {}",
441 truncate_line_to_width(
442 &task.prompt_summary,
443 content_width.saturating_sub(2).max(1)
444 )
445 ),
446 Style::default().fg(palette::TEXT_DIM),
447 )));
448 }
449 }
450
451 render_sidebar_section(f, area, "Tasks", lines);
452 }
453
454 fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) {
455 if area.height < 3 {
456 return;
457 }
458
459 let content_width = area.width.saturating_sub(4) as usize;
460
461 // Demoted to navigator (issue #128): the in-transcript DelegateCard /
462 // FanoutCard now carries the live action tree and dot-grid. The sidebar
463 // shows just count + role-mix so the user can scan parallel work at a
464 // glance and scroll to the matching transcript card for detail.
465 let cached_ids: std::collections::HashSet<&str> = app
466 .subagent_cache
467 .iter()
468 .map(|agent| agent.agent_id.as_str())
469 .collect();
470 let progress_only_count = app
471 .agent_progress
472 .keys()
473 .filter(|id| !cached_ids.contains(id.as_str()))
474 .count();
475 let cached_running = app
476 .subagent_cache
477 .iter()
478 .filter(|agent| matches!(agent.status, SubAgentStatus::Running))
479 .count();
480 let role_counts: std::collections::BTreeMap<String, usize> =
481 app.subagent_cache
482 .iter()
483 .fold(std::collections::BTreeMap::new(), |mut acc, agent| {
484 *acc.entry(agent.agent_type.as_str().to_string())
485 .or_insert(0) += 1;
486 acc
487 });
488 let (fanout_running, fanout_total) = active_fanout_counts(app)
489 .map(|(running, total)| (running, Some(total)))
490 .unwrap_or((0, None));
491 let foreground_rlm_running = foreground_rlm_running(app);
492
493 let summary = SidebarSubagentSummary {
494 cached_total: app.subagent_cache.len(),
495 cached_running,
496 progress_only_count,
497 fanout_total,
498 fanout_running,
499 foreground_rlm_running,
500 role_counts,
501 };
502 let lines = subagent_navigator_lines(&summary, content_width);
503
504 render_sidebar_section(f, area, "Agents", lines);
505 }
506
507 /// Minimal projection of the data the sub-agent sidebar needs. Lifted out
508 /// of `render_sidebar_subagents` so the rendering can be snapshot-tested
509 /// without a full `App`.
510 #[derive(Debug, Clone, Default)]
511 pub struct SidebarSubagentSummary {
512 pub cached_total: usize,
513 pub cached_running: usize,
514 pub progress_only_count: usize,
515 pub fanout_total: Option<usize>,
516 pub fanout_running: usize,
517 pub foreground_rlm_running: bool,
518 pub role_counts: std::collections::BTreeMap<String, usize>,
519 }
520
521 fn foreground_rlm_running(app: &App) -> bool {
522 app.active_cell.as_ref().is_some_and(|active| {
523 active.entries().iter().any(|entry| {
524 matches!(
525 entry,
526 HistoryCell::Tool(ToolCell::Generic(generic))
527 if generic.name == "rlm" && generic.status == ToolStatus::Running
528 )
529 })
530 })
531 }
532
533 /// Build the demoted navigator lines from a summary projection. Public
534 /// for the snapshot test in this module.
535 pub fn subagent_navigator_lines(
536 summary: &SidebarSubagentSummary,
537 content_width: usize,
538 ) -> Vec<Line<'static>> {
539 let mut lines: Vec<Line<'static>> = Vec::with_capacity(4);
540
541 let fanout_total = summary.fanout_total.unwrap_or(0);
542 if summary.cached_total == 0
543 && summary.progress_only_count == 0
544 && fanout_total == 0
545 && !summary.foreground_rlm_running
546 {
547 lines.push(Line::from(Span::styled(
548 "No agents",
549 Style::default().fg(palette::TEXT_MUTED),
550 )));
551 return lines;
552 }
553
554 let (live_running, total) = if let Some(total) = summary.fanout_total {
555 (summary.fanout_running, total)
556 } else {
557 (
558 summary.cached_running + summary.progress_only_count,
559 summary.cached_total + summary.progress_only_count,
560 )
561 };
562 let done = total.saturating_sub(live_running);
563 let header = if live_running > 0 {
564 vec![
565 Span::styled(
566 format!("{live_running} running"),
567 Style::default().fg(palette::DEEPSEEK_SKY).bold(),
568 ),
569 Span::styled(
570 format!(" / {total}"),
571 Style::default().fg(palette::TEXT_MUTED),
572 ),
573 ]
574 } else {
575 vec![Span::styled(
576 format!("{done} done"),
577 Style::default().fg(palette::STATUS_SUCCESS),
578 )]
579 };
580 lines.push(Line::from(header));
581
582 if !summary.role_counts.is_empty() {
583 let mix: Vec<String> = summary
584 .role_counts
585 .iter()
586 .map(|(role, count)| format!("{count} {role}"))
587 .collect();
588 let role_line = mix.join(" \u{00B7} ");
589 lines.push(Line::from(Span::styled(
590 truncate_line_to_width(&role_line, content_width.max(1)),
591 Style::default().fg(palette::TEXT_DIM),
592 )));
593 }
594
595 if summary.foreground_rlm_running {
596 lines.push(Line::from(vec![
597 Span::styled("RLM", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
598 Span::styled(
599 " foreground work active",
600 Style::default().fg(palette::TEXT_DIM),
601 ),
602 ]));
603 }
604
605 lines.push(Line::from(Span::styled(
606 "(see transcript card for detail)",
607 Style::default().fg(palette::TEXT_MUTED).italic(),
608 )));
609
610 lines
611 }
612
613 /// Session-context panel (#504) — consolidated session state overview.
614 ///
615 /// Surfaces at-a-glance: working set, token usage / context %, running
616 /// cost, MCP server count, LSP toggle state, cycle count, and memory
617 /// file size + mtime. Each section is a compact one-liner so the panel
618 /// reads as a dashboard rather than a scrolling list.
619 fn render_context_panel(f: &mut Frame, area: Rect, app: &App) {
620 if area.height < 3 {
621 return;
622 }
623
624 let content_width = area.width.saturating_sub(4) as usize;
625 let mut lines: Vec<Line<'static>> = Vec::with_capacity(usize::from(area.height).max(4));
626
627 // ── Working set ──────────────────────────────────────────────
628 let ws_name = app
629 .workspace
630 .file_name()
631 .and_then(|s| s.to_str())
632 .unwrap_or("(root)")
633 .to_string();
634 lines.push(Line::from(vec![
635 Span::styled(
636 truncate_line_to_width(&ws_name, content_width.max(1)),
637 Style::default().fg(palette::DEEPSEEK_SKY).bold(),
638 ),
639 Span::styled(
640 format!(" {}", app.workspace_context.as_deref().unwrap_or("")),
641 Style::default().fg(palette::TEXT_DIM),
642 ),
643 ]));
644
645 // ── Token usage ──────────────────────────────────────────────
646 let total_tokens = app.session.total_conversation_tokens;
647 let window = crate::models::context_window_for_model(&app.model).unwrap_or(1_048_576);
648 let pct = if window > 0 {
649 ((total_tokens as f64 / window as f64) * 100.0).clamp(0.0, 100.0)
650 } else {
651 0.0
652 };
653 let bar_width = content_width.min(20);
654 let filled = ((pct / 100.0) * bar_width as f64) as usize;
655 let bar = format!(
656 "[{}{}] {:.0}%",
657 "█".repeat(filled),
658 "░".repeat(bar_width.saturating_sub(filled)),
659 pct
660 );
661 lines.push(Line::from(Span::styled(
662 format!(
663 "context: {}/{} tokens {}",
664 total_tokens,
665 window,
666 truncate_line_to_width(&bar, content_width.saturating_sub(32).max(8))
667 ),
668 Style::default().fg(palette::TEXT_MUTED),
669 )));
670
671 // ── Session cost ─────────────────────────────────────────────
672 let total_cost = app.displayed_session_cost_for_currency(app.cost_currency);
673 let session_cost = app.session_cost_for_currency(app.cost_currency);
674 let agent_cost = app.subagent_cost_for_currency(app.cost_currency);
675 lines.push(Line::from(Span::styled(
676 format!(
677 "cost: {} (session {} + agents {})",
678 app.format_cost_amount(total_cost),
679 app.format_cost_amount(session_cost),
680 app.format_cost_amount(agent_cost)
681 ),
682 Style::default().fg(palette::TEXT_MUTED),
683 )));
684
685 // ── MCP servers ──────────────────────────────────────────────
686 if app.mcp_configured_count > 0 {
687 let restart_hint = if app.mcp_restart_required {
688 " (restart needed)"
689 } else {
690 ""
691 };
692 lines.push(Line::from(Span::styled(
693 format!(
694 "mcp: {} server(s){}",
695 app.mcp_configured_count, restart_hint
696 ),
697 Style::default().fg(palette::TEXT_MUTED),
698 )));
699 }
700
701 // ── LSP ──────────────────────────────────────────────────────
702 let lsp_label = if app.lsp_enabled { "on" } else { "off" };
703 lines.push(Line::from(Span::styled(
704 format!("lsp: {}", lsp_label),
705 Style::default().fg(palette::TEXT_MUTED),
706 )));
707
708 // ── Cycles ───────────────────────────────────────────────────
709 if app.cycle_count > 0 {
710 lines.push(Line::from(Span::styled(
711 format!(
712 "cycles: {} crossed, {} briefing(s)",
713 app.cycle_count,
714 app.cycle_briefings.len()
715 ),
716 Style::default().fg(palette::TEXT_MUTED),
717 )));
718 }
719
720 // ── Memory ───────────────────────────────────────────────────
721 if app.use_memory {
722 let size_hint = std::fs::metadata(&app.memory_path)
723 .map(|m| m.len())
724 .map(|bytes| {
725 if bytes >= 1024 * 1024 {
726 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
727 } else if bytes >= 1024 {
728 format!("{:.1} KB", bytes as f64 / 1024.0)
729 } else {
730 format!("{} B", bytes)
731 }
732 })
733 .unwrap_or_else(|_| "—".to_string());
734 lines.push(Line::from(Span::styled(
735 format!("memory: {} ({})", app.memory_path.display(), size_hint),
736 Style::default().fg(palette::TEXT_MUTED),
737 )));
738 }
739
740 render_sidebar_section(f, area, "Session", lines);
741 }
742
743 fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec<Line<'static>>) {
744 if area.width < 4 || area.height < 3 {
745 // Clear stale cells before bailing out (#400).
746 Block::default().render(area, f.buffer_mut());
747 return;
748 }
749
750 let theme = active_theme();
751 // Truncate the panel title so it always fits within the section width
752 // even after a resize. The title occupies up to 4 chars of border chrome
753 // (two spaces + one space on each side), so the max title length is
754 // area.width.saturating_sub(4) when borders are enabled.
755 let max_title_width = area.width.saturating_sub(4).max(1) as usize;
756 let display_title = truncate_line_to_width(title, max_title_width);
757
758 // Constrain lines to the visible section area so a Paragraph wrap
759 // overflow can't write cells outside the Block bounds (#400). The
760 // border + padding consume 2 rows; budget the rest for content.
761 let visible_content_rows = area
762 .height
763 .saturating_sub(2) // top + bottom border
764 .saturating_sub(theme.section_padding.top + theme.section_padding.bottom)
765 as usize;
766 let lines: Vec<Line<'static>> =
767 if lines.len() > visible_content_rows && visible_content_rows > 0 {
768 lines.into_iter().take(visible_content_rows).collect()
769 } else {
770 lines
771 };
772
773 let section = Paragraph::new(lines).wrap(Wrap { trim: true }).block(
774 Block::default()
775 .title(Line::from(vec![Span::styled(
776 format!(" {display_title} "),
777 Style::default().fg(theme.section_title_color).bold(),
778 )]))
779 .borders(theme.section_borders)
780 .border_type(theme.section_border_type)
781 .border_style(Style::default().fg(theme.section_border_color))
782 .style(Style::default().bg(theme.section_bg))
783 .padding(theme.section_padding),
784 );
785
786 f.render_widget(section, area);
787 }
788
789 #[cfg(test)]
790 mod tests {
791 use super::{SidebarSubagentSummary, plan_panel_empty_hint, subagent_navigator_lines};
792 use ratatui::text::Line;
793
794 fn lines_to_text(lines: &[Line<'static>]) -> Vec<String> {
795 lines
796 .iter()
797 .map(|line| {
798 line.spans
799 .iter()
800 .map(|s| s.content.as_ref())
801 .collect::<String>()
802 })
803 .collect()
804 }
805
806 // ---- #408 Plan panel empty-state hint ----
807
808 #[test]
809 fn plan_panel_empty_hint_mentions_panels_role() {
810 // The hint replaces the old "No active plan" placeholder; it
811 // should explain what the panel tracks so the user can tell
812 // whether the panel is broken vs simply unused this turn.
813 let hint = plan_panel_empty_hint(80);
814 assert!(
815 hint.contains("update_plan"),
816 "hint should name the tool: {hint:?}"
817 );
818 assert!(
819 hint.contains("/goal") || hint.contains("goal"),
820 "hint should mention /goal: {hint:?}"
821 );
822 }
823
824 #[test]
825 fn plan_panel_empty_hint_truncates_to_narrow_widths() {
826 // Width 16 forces an ellipsis; the hint should still fit.
827 let hint = plan_panel_empty_hint(16);
828 assert!(
829 hint.chars().count() <= 16,
830 "hint width {} > 16: {hint:?}",
831 hint.chars().count()
832 );
833 }
834
835 #[test]
836 fn plan_panel_empty_hint_does_not_say_no_active_plan() {
837 // Regression guard: the placeholder used to say "No active
838 // plan" which made the panel look broken. The hint should
839 // never re-introduce that wording.
840 let hint = plan_panel_empty_hint(80);
841 assert!(
842 !hint.to_ascii_lowercase().contains("no active plan"),
843 "hint regressed to old placeholder: {hint:?}"
844 );
845 }
846
847 #[test]
848 fn navigator_empty_state_says_no_agents() {
849 let summary = SidebarSubagentSummary::default();
850 let lines = subagent_navigator_lines(&summary, 32);
851 let text = lines_to_text(&lines);
852 assert_eq!(text, vec!["No agents".to_string()]);
853 }
854
855 #[test]
856 fn navigator_running_state_renders_count_role_and_navigator_hint() {
857 // Two general agents (one running, one done) + one explore (running).
858 let mut role_counts = std::collections::BTreeMap::new();
859 role_counts.insert("general".to_string(), 2);
860 role_counts.insert("explore".to_string(), 1);
861 let summary = SidebarSubagentSummary {
862 cached_total: 3,
863 cached_running: 2,
864 progress_only_count: 0,
865 fanout_total: None,
866 fanout_running: 0,
867 foreground_rlm_running: false,
868 role_counts,
869 };
870 let text = lines_to_text(&subagent_navigator_lines(&summary, 64));
871 assert!(text[0].contains("2 running"), "header: {:?}", text[0]);
872 assert!(text[0].contains("/ 3"), "total in header: {:?}", text[0]);
873 assert!(
874 text[1].contains("1 explore") && text[1].contains("2 general"),
875 "role mix line: {:?}",
876 text[1]
877 );
878 assert!(
879 text.iter().any(|l| l.contains("transcript card")),
880 "navigator hint must defer to transcript: {text:?}",
881 );
882 }
883
884 #[test]
885 fn navigator_uses_fanout_total_when_fanout_has_seeded_slots() {
886 let summary = SidebarSubagentSummary {
887 cached_total: 1,
888 cached_running: 1,
889 progress_only_count: 0,
890 fanout_total: Some(6),
891 fanout_running: 1,
892 foreground_rlm_running: false,
893 role_counts: std::collections::BTreeMap::new(),
894 };
895
896 let text = lines_to_text(&subagent_navigator_lines(&summary, 64));
897
898 assert!(text[0].contains("1 running"), "header: {:?}", text[0]);
899 assert!(text[0].contains("/ 6"), "fanout total: {:?}", text[0]);
900 }
901
902 #[test]
903 fn navigator_settled_state_says_done() {
904 let mut role_counts = std::collections::BTreeMap::new();
905 role_counts.insert("general".to_string(), 1);
906 let summary = SidebarSubagentSummary {
907 cached_total: 1,
908 cached_running: 0,
909 progress_only_count: 0,
910 fanout_total: None,
911 fanout_running: 0,
912 foreground_rlm_running: false,
913 role_counts,
914 };
915 let text = lines_to_text(&subagent_navigator_lines(&summary, 32));
916 assert!(text[0].contains("1 done"), "settled header: {:?}", text[0]);
917 }
918
919 #[test]
920 fn navigator_truncates_long_role_mix_to_content_width() {
921 // Build a wide role mix; assert it doesn't blow past content_width.
922 let mut role_counts = std::collections::BTreeMap::new();
923 for role in ["general", "explore", "plan", "review", "custom", "extra"] {
924 role_counts.insert(role.to_string(), 1);
925 }
926 let summary = SidebarSubagentSummary {
927 cached_total: 6,
928 cached_running: 6,
929 progress_only_count: 0,
930 fanout_total: None,
931 fanout_running: 0,
932 foreground_rlm_running: false,
933 role_counts,
934 };
935 let lines = subagent_navigator_lines(&summary, 16);
936 let role_line: &str = lines[1]
937 .spans
938 .first()
939 .map(|s| s.content.as_ref())
940 .unwrap_or("");
941 assert!(
942 role_line.chars().count() <= 16,
943 "role line {role_line:?} exceeded content_width"
944 );
945 }
946
947 #[test]
948 fn navigator_shows_foreground_rlm_work_when_no_subagents_exist() {
949 let summary = SidebarSubagentSummary {
950 foreground_rlm_running: true,
951 ..SidebarSubagentSummary::default()
952 };
953 let text = lines_to_text(&subagent_navigator_lines(&summary, 64));
954
955 assert!(!text[0].contains("No agents"), "header: {:?}", text);
956 assert!(
957 text.iter()
958 .any(|line| line.contains("RLM foreground work active")),
959 "RLM work must be visible in Agents panel: {text:?}"
960 );
961 }
962 }
963
963 lines RUST