Skip to main content

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