1use ratatui::{
2 buffer::Buffer,
3 layout::{Constraint, Layout, Rect},
4 text::{Line, Span},
5 widgets::{Clear, Paragraph, StatefulWidget, Widget, Wrap},
6};
7use tui_widget_list::{ListBuilder, ListState as WidgetListState, ListView};
8
9use super::layout_mode::LayoutMode;
10use super::panel::{PanelStyles, new_panel};
11use crate::tui::core_tui::types::LocalAgentEntry;
12use crate::tui::ui::tui::session::styling::SessionStyles;
13
14use crate::design::constants::ELLIPSIS;
16
17#[derive(Clone)]
18struct SidebarListItem {
19 line: Line<'static>,
20}
21
22impl Widget for SidebarListItem {
23 fn render(self, area: Rect, buf: &mut Buffer) {
24 Paragraph::new(self.line).wrap(Wrap { trim: false }).render(area, buf);
25 }
26}
27
28fn render_static_list(lines: Vec<Line<'static>>, area: Rect, buf: &mut Buffer) {
29 if area.width == 0 || area.height == 0 || lines.is_empty() {
30 return;
31 }
32
33 let rows = lines
34 .into_iter()
35 .map(|line| (SidebarListItem { line }, 1_u16))
36 .collect::<Vec<_>>();
37 let count = rows.len();
38 let builder = ListBuilder::new(move |context| rows[context.index].clone());
39 let widget = ListView::new(builder, count).infinite_scrolling(false);
40 let mut state = WidgetListState::default();
41 StatefulWidget::render(widget, area, buf, &mut state);
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum SidebarSection {
47 LocalAgents,
48 Queue,
49 Context,
50 Tools,
51 Info,
52}
53
54pub struct SidebarWidget<'a> {
71 styles: &'a SessionStyles,
72 queue_items: Vec<String>,
73 local_agents: Vec<LocalAgentEntry>,
74 context_info: Option<&'a str>,
75 recent_tools: Vec<String>,
76 active_section: Option<SidebarSection>,
77 mode: LayoutMode,
78}
79
80impl<'a> SidebarWidget<'a> {
81 pub(crate) fn new(styles: &'a SessionStyles) -> Self {
83 Self {
84 styles,
85 queue_items: Vec::new(),
86 local_agents: Vec::new(),
87 context_info: None,
88 recent_tools: Vec::new(),
89 active_section: None,
90 mode: LayoutMode::Wide,
91 }
92 }
93
94 #[must_use]
96 pub(crate) fn queue_items(mut self, items: Vec<String>) -> Self {
97 self.queue_items = items;
98 self
99 }
100
101 #[must_use]
102 pub(crate) fn local_agents(mut self, entries: Vec<LocalAgentEntry>) -> Self {
103 self.local_agents = entries;
104 self
105 }
106
107 #[must_use]
109 pub(crate) fn context_info(mut self, info: &'a str) -> Self {
110 self.context_info = Some(info);
111 self
112 }
113
114 #[must_use]
116 pub fn recent_tools(mut self, tools: Vec<String>) -> Self {
117 self.recent_tools = tools;
118 self
119 }
120
121 #[must_use]
123 pub fn active_section(mut self, section: SidebarSection) -> Self {
124 self.active_section = Some(section);
125 self
126 }
127
128 #[must_use]
130 pub(crate) fn mode(mut self, mode: LayoutMode) -> Self {
131 self.mode = mode;
132 self
133 }
134
135 fn render_queue_section(&self, area: Rect, buf: &mut Buffer) {
136 let is_active = self.active_section == Some(SidebarSection::Queue);
137 let inner = new_panel(self.styles)
138 .title("Queue")
139 .active(is_active)
140 .mode(self.mode)
141 .render_and_get_inner(area, buf);
142
143 if inner.height == 0 || inner.width == 0 {
144 return;
145 }
146
147 if self.queue_items.is_empty() {
148 let empty_text = Paragraph::new("No queued items").style(self.styles.muted_style());
149 empty_text.render(inner, buf);
150 } else {
151 let lines = self
152 .queue_items
153 .iter()
154 .enumerate()
155 .map(|(i, item)| {
156 let prefix = format!("{}. ", i + 1);
157 Line::from(vec![
158 Span::styled(prefix, self.styles.accent_style()),
159 Span::styled(
160 truncate_string(item, inner.width.saturating_sub(4) as usize),
161 self.styles.default_style(),
162 ),
163 ])
164 })
165 .collect();
166
167 render_static_list(lines, inner, buf);
168 }
169 }
170
171 fn render_local_agents_section(&self, area: Rect, buf: &mut Buffer) {
172 let is_active = self.active_section == Some(SidebarSection::LocalAgents);
173 let inner = new_panel(self.styles)
174 .title("Local Agents")
175 .active(is_active)
176 .mode(self.mode)
177 .render_and_get_inner(area, buf);
178
179 if inner.height == 0 || inner.width == 0 {
180 return;
181 }
182
183 if self.local_agents.is_empty() {
184 let empty_text = Paragraph::new("No local agents").style(self.styles.muted_style());
185 empty_text.render(inner, buf);
186 } else {
187 let mut lines = self
188 .local_agents
189 .iter()
190 .take(4)
191 .map(|entry| {
192 Line::from(Span::styled(
193 truncate_string(
194 &format!("{} · {} · {}", entry.display_label, entry.kind.as_str(), entry.status),
195 inner.width.saturating_sub(2) as usize,
196 ),
197 self.styles.default_style(),
198 ))
199 })
200 .collect::<Vec<_>>();
201
202 if let Some(entry) = self.local_agents.first() {
203 lines.push(Line::from(String::new()));
204 lines.push(Line::from(Span::styled(
205 truncate_string(&entry.preview, inner.width.saturating_sub(2) as usize),
206 self.styles.muted_style(),
207 )));
208 }
209
210 render_static_list(lines, inner, buf);
211 }
212 }
213
214 fn render_context_section(&self, area: Rect, buf: &mut Buffer) {
215 let is_active = self.active_section == Some(SidebarSection::Context);
216 let inner = new_panel(self.styles)
217 .title("Context")
218 .active(is_active)
219 .mode(self.mode)
220 .render_and_get_inner(area, buf);
221
222 if inner.height == 0 || inner.width == 0 {
223 return;
224 }
225
226 let text = self.context_info.unwrap_or("No context info");
227 let paragraph = Paragraph::new(text)
228 .style(self.styles.default_style())
229 .wrap(Wrap { trim: true });
230 paragraph.render(inner, buf);
231 }
232
233 fn render_tools_section(&self, area: Rect, buf: &mut Buffer) {
234 let is_active = self.active_section == Some(SidebarSection::Tools);
235 let inner = new_panel(self.styles)
236 .title("Recent Tools")
237 .active(is_active)
238 .mode(self.mode)
239 .render_and_get_inner(area, buf);
240
241 if inner.height == 0 || inner.width == 0 {
242 return;
243 }
244
245 if self.recent_tools.is_empty() {
246 let empty_text = Paragraph::new("No recent tools").style(self.styles.muted_style());
247 empty_text.render(inner, buf);
248 } else {
249 let lines = self
250 .recent_tools
251 .iter()
252 .map(|tool| {
253 Line::from(Span::styled(
254 format!("▸ {}", truncate_string(tool, inner.width.saturating_sub(3) as usize)),
255 self.styles.default_style(),
256 ))
257 })
258 .collect();
259
260 render_static_list(lines, inner, buf);
261 }
262 }
263}
264
265impl Widget for SidebarWidget<'_> {
266 fn render(self, area: Rect, buf: &mut Buffer) {
267 if area.height == 0 || area.width == 0 {
268 return;
269 }
270
271 if !self.mode.allow_sidebar() {
272 return;
273 }
274
275 Clear.render(area, buf);
276
277 let has_local_agents = !self.local_agents.is_empty();
279 let has_queue = !self.queue_items.is_empty();
280 let has_tools = !self.recent_tools.is_empty();
281
282 let mut sections = Vec::<(SidebarSection, u32)>::new();
283 if has_local_agents {
284 sections.push((SidebarSection::LocalAgents, 7));
285 }
286 if has_queue {
287 sections.push((SidebarSection::Queue, 3));
288 }
289 sections.push((SidebarSection::Context, 2));
290 if has_tools {
291 sections.push((SidebarSection::Tools, 2));
292 }
293
294 let total_weight = sections.iter().map(|(_, weight)| *weight).sum::<u32>().max(1);
295 let constraints = sections
296 .iter()
297 .map(|(_, weight)| Constraint::Ratio(*weight, total_weight))
298 .collect::<Vec<_>>();
299 let chunks = Layout::vertical(constraints).split(area);
300
301 for ((section, _), chunk) in sections.into_iter().zip(chunks.iter()) {
302 match section {
303 SidebarSection::LocalAgents => self.render_local_agents_section(*chunk, buf),
304 SidebarSection::Queue => self.render_queue_section(*chunk, buf),
305 SidebarSection::Context => self.render_context_section(*chunk, buf),
306 SidebarSection::Tools => self.render_tools_section(*chunk, buf),
307 SidebarSection::Info => {}
308 }
309 }
310 }
311}
312
313fn truncate_string(s: &str, max_width: usize) -> String {
315 vtcode_commons::preview::truncate_with_ellipsis(s, max_width, ELLIPSIS)
316}
317
318#[cfg(test)]
319mod tests {
320 use super::SidebarWidget;
321 use crate::tui::core_tui::session::styling::SessionStyles;
322 use crate::tui::ui::tui::types::InlineTheme;
323 use ratatui::buffer::Buffer;
324 use ratatui::layout::Rect;
325 use ratatui::widgets::Widget;
326
327 #[test]
328 fn sidebar_renders_local_agent_entries() {
329 let styles = SessionStyles::new(InlineTheme::default());
330 let area = Rect::new(0, 0, 60, 16);
331 let mut buf = Buffer::empty(area);
332
333 SidebarWidget::new(&styles)
334 .local_agents(vec![
335 crate::tui::core_tui::types::LocalAgentEntry {
336 program_status: vtcode_commons::program_status::ProgramState::Idle,
337 updated_at: 0,
338 id: "thread-1".to_string(),
339 display_label: "rust-engineer".to_string(),
340 agent_name: "rust-engineer".to_string(),
341 color: Some("cyan".to_string()),
342 kind: crate::tui::core_tui::types::LocalAgentKind::Delegated,
343 status: "running".to_string(),
344 summary: None,
345 preview: "assistant: reviewing the workspace".to_string(),
346 transcript_path: None,
347 },
348 crate::tui::core_tui::types::LocalAgentEntry {
349 program_status: vtcode_commons::program_status::ProgramState::Idle,
350 updated_at: 0,
351 id: "bg-1".to_string(),
352 display_label: "reviewer".to_string(),
353 agent_name: "reviewer".to_string(),
354 color: None,
355 kind: crate::tui::core_tui::types::LocalAgentKind::Background,
356 status: "starting".to_string(),
357 summary: None,
358 preview: "waiting for output".to_string(),
359 transcript_path: None,
360 },
361 ])
362 .context_info("Ready")
363 .render(area, &mut buf);
364
365 let rendered = (0..area.height)
366 .map(|row| (0..area.width).map(|col| buf[(col, row)].symbol()).collect::<String>())
367 .collect::<Vec<_>>()
368 .join("\n");
369
370 assert!(rendered.contains("Local Agents"));
371 assert!(rendered.contains("rust-engineer"));
372 assert!(rendered.contains("reviewer"));
373 }
374
375 #[test]
376 fn sidebar_renders_local_agent_preview() {
377 let styles = SessionStyles::new(InlineTheme::default());
378 let area = Rect::new(0, 0, 60, 16);
379 let mut buf = Buffer::empty(area);
380
381 SidebarWidget::new(&styles)
382 .local_agents(vec![crate::tui::core_tui::types::LocalAgentEntry {
383 program_status: vtcode_commons::program_status::ProgramState::Idle,
384 updated_at: 0,
385 id: "thread-1".to_string(),
386 display_label: "rust-engineer".to_string(),
387 agent_name: "rust-engineer".to_string(),
388 color: Some("cyan".to_string()),
389 kind: crate::tui::core_tui::types::LocalAgentKind::Delegated,
390 status: "running".to_string(),
391 summary: None,
392 preview: "thinking: Inspecting the diff carefully".to_string(),
393 transcript_path: None,
394 }])
395 .context_info("Ready")
396 .render(area, &mut buf);
397
398 let rendered = (0..area.height)
399 .map(|row| (0..area.width).map(|col| buf[(col, row)].symbol()).collect::<String>())
400 .collect::<Vec<_>>()
401 .join("\n");
402
403 assert!(rendered.contains("Local Agents"));
404 assert!(rendered.contains("rust-engineer"));
405 assert!(rendered.contains("Inspecting the diff carefully"));
406 }
407}