Skip to main content

vtcode_tui/core_tui/widgets/
sidebar.rs

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::core_tui::types::LocalAgentEntry;
12use crate::ui::tui::session::styling::SessionStyles;
13
14/// Ellipsis character used to indicate truncated text.
15use 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/// Sidebar section types for organizing content
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum SidebarSection {
49    LocalAgents,
50    Queue,
51    Context,
52    Tools,
53    Info,
54}
55
56/// Widget for rendering the sidebar in wide mode
57///
58/// The sidebar provides quick access to:
59/// - Queued inputs/tasks
60/// - Context information
61/// - Recent tool calls
62/// - Session info
63///
64/// # Example
65/// ```ignore
66/// SidebarWidget::new(&styles)
67///     .queue_items(&queue)
68///     .context_info("12K tokens | 45% context")
69///     .active_section(SidebarSection::Queue)
70///     .render(sidebar_area, buf);
71/// ```
72pub 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    /// Create a new sidebar widget
84    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    /// Set queued items to display
97    #[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    /// Set context info text
110    #[must_use]
111    pub fn context_info(mut self, info: &'a str) -> Self {
112        self.context_info = Some(info);
113        self
114    }
115
116    /// Set recent tool calls
117    #[must_use]
118    pub fn recent_tools(mut self, tools: Vec<String>) -> Self {
119        self.recent_tools = tools;
120        self
121    }
122
123    /// Set the active/focused section
124    #[must_use]
125    pub fn active_section(mut self, section: SidebarSection) -> Self {
126        self.active_section = Some(section);
127        self
128    }
129
130    /// Set the layout mode
131    #[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        // Split sidebar into sections
288        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
327/// Truncate a string to fit within a given display-column width.
328fn 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        // Reserve 1 display column for the ellipsis character.
340        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}