返回 CodeWhale
panels.rs
根目录 / crates / tui / src / tui / work_surface / panels.rs
1 //! Line-list rail panels, ported from the legacy classic-shell sidebar
2 //! during the 0.9.4 rail unification (spec step 2). Only **Context** still
3 //! renders this way: its lines are session facts, not work rows, so there
4 //! is nothing to click. Tasks, Agents, and Pinned all render through the
5 //! row/hitbox machinery in `render.rs` — a work row is a selectable,
6 //! clickable object in every panel, and a panel is just a subset of the one
7 //! work list.
8 //!
9 //! On Top placement the strip auto-fits its content the way Tasks always
10 //! did (and the way GrokBuild's tasks pane does): a two-agent fan-out is
11 //! two rows, not a fixed four-row band with a chrome title. Auto-fit
12 //! governs HEIGHT only, never membership — a settled to-do or finished
13 //! sub-agent still occupies a row (quiet completion, not eviction). The
14 //! only Top title is an active **goal** (not panel names like "Pinned").
15 //!
16 //! The line builders themselves still live in `tui::sidebar` (they are
17 //! `pub(crate)` there) while the sidebar module is wound down. The Agents
18 //! and Pinned arms below are retained for that wind-down but are no longer
19 //! reachable from the rail, which routes those panels through rows.
20
21 use ratatui::text::Line;
22
23 use crate::tui::app::App;
24 use crate::tui::sidebar::{self, SidebarSubagentSummary, WorkPanelOpts};
25 use crate::tui::subagent_routing::active_fanout_counts;
26
27 use super::model::RailPanel;
28
29 /// Cap used when measuring natural content height so a pathological
30 /// checklist cannot allocate unbounded lines during layout. The strip's
31 /// real cap (`top_cap`) still clamps the visible window.
32 const NATURAL_HEIGHT_PROBE: usize = 64;
33
34 /// Display lines for a non-Tasks rail panel, or `None` for Tasks (which the
35 /// caller renders through the row machinery instead).
36 ///
37 /// `omit_goal_objective` is set on Top when the goal is already the strip
38 /// title, so Pinned does not repeat `Goal: …` in the body.
39 pub(crate) fn panel_lines(
40 app: &mut App,
41 panel: RailPanel,
42 content_width: usize,
43 max_rows: usize,
44 omit_goal_objective: bool,
45 ) -> Option<Vec<Line<'static>>> {
46 let content_width = content_width.max(1);
47 let max_rows = max_rows.max(1);
48 match panel {
49 RailPanel::Tasks => None,
50 RailPanel::Agents => Some(agents_panel_lines(app, content_width, max_rows)),
51 RailPanel::Context => Some(sidebar::context_panel_lines(app, content_width)),
52 RailPanel::Pinned => Some(pinned_panel_lines(
53 app,
54 content_width,
55 max_rows,
56 omit_goal_objective,
57 )),
58 }
59 }
60
61 /// Whether a non-Tasks panel has anything worth spending a top-strip row on.
62 /// Empty projections collapse to zero the way Tasks does — an empty panel is
63 /// not a panel. Context always has session facts, so it always has content.
64 pub(crate) fn panel_has_useful_content(app: &mut App, panel: RailPanel) -> bool {
65 match panel {
66 RailPanel::Tasks => true,
67 RailPanel::Pinned => sidebar::sidebar_work_summary(app).has_useful_content(),
68 RailPanel::Agents => agents_have_useful_content(app),
69 RailPanel::Context => true,
70 }
71 }
72
73 /// Natural content row count for height auto-fit. Does not include the
74 /// divider row or the optional Top goal title that `height()` adds.
75 pub(crate) fn panel_content_row_count(
76 app: &mut App,
77 panel: RailPanel,
78 content_width: usize,
79 omit_goal_objective: bool,
80 ) -> usize {
81 panel_lines(
82 app,
83 panel,
84 content_width,
85 NATURAL_HEIGHT_PROBE,
86 omit_goal_objective,
87 )
88 .map(|lines| lines.len())
89 .unwrap_or(0)
90 }
91
92 fn agents_have_useful_content(app: &App) -> bool {
93 if !app.subagent_cache.is_empty() {
94 return true;
95 }
96 if !app.agent_progress.is_empty() {
97 return true;
98 }
99 if active_fanout_counts(app).is_some_and(|(_, total)| total > 0) {
100 return true;
101 }
102 sidebar::foreground_rlm_running(app)
103 }
104
105 /// Agents panel: cached sub-agents plus progress-only and fanout signals.
106 /// The summary projection is lifted from the legacy `render_sidebar_subagents`
107 /// so the panel keeps its exact content in the rail.
108 fn agents_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec<Line<'static>> {
109 let cached_ids: std::collections::HashSet<&str> = app
110 .subagent_cache
111 .iter()
112 .map(|agent| agent.agent_id.as_str())
113 .collect();
114 let progress_only_count = app
115 .agent_progress
116 .keys()
117 .filter(|id| !cached_ids.contains(id.as_str()))
118 .count();
119 let cached_running = app
120 .subagent_cache
121 .iter()
122 .filter(|agent| sidebar::cached_agent_activity_is_live(app, agent))
123 .count();
124 let role_counts: std::collections::BTreeMap<String, usize> =
125 app.subagent_cache
126 .iter()
127 .fold(std::collections::BTreeMap::new(), |mut acc, agent| {
128 *acc.entry(agent.agent_type.as_str().to_string())
129 .or_insert(0) += 1;
130 acc
131 });
132 let (fanout_running, fanout_total) = active_fanout_counts(app)
133 .map(|(running, total)| (running, Some(total)))
134 .unwrap_or((0, None));
135 let summary = SidebarSubagentSummary {
136 cached_total: app.subagent_cache.len(),
137 cached_running,
138 progress_only_count,
139 fanout_total,
140 fanout_running,
141 foreground_rlm_running: sidebar::foreground_rlm_running(app),
142 role_counts,
143 };
144 let rows = sidebar::sidebar_agent_rows(app);
145 sidebar::subagent_panel_lines(
146 &summary,
147 &rows,
148 app.ui_locale,
149 content_width,
150 max_rows,
151 &app.ui_theme,
152 )
153 }
154
155 /// Pinned panel: the durable work summary (goal + checklist) the legacy
156 /// sidebar showed in Pinned focus.
157 fn pinned_panel_lines(
158 app: &mut App,
159 content_width: usize,
160 max_rows: usize,
161 omit_goal_objective: bool,
162 ) -> Vec<Line<'static>> {
163 let summary = sidebar::sidebar_work_summary(app);
164 sidebar::work_panel_lines_with_opts(
165 &summary,
166 content_width,
167 max_rows,
168 app.ui_theme.mode,
169 &app.ui_theme,
170 WorkPanelOpts {
171 omit_goal_objective,
172 },
173 )
174 }
175
175 lines RUST