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