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::{Panel, PanelStyles};
11use crate::ui::tui::session::styling::SessionStyles;
12
13/// Ellipsis character used to indicate truncated text (consistent with line_truncation module).
14const ELLIPSIS: &str = "…";
15
16#[derive(Clone)]
17struct SidebarListItem {
18    line: Line<'static>,
19}
20
21impl Widget for SidebarListItem {
22    fn render(self, area: Rect, buf: &mut Buffer) {
23        Paragraph::new(self.line)
24            .wrap(Wrap { trim: false })
25            .render(area, buf);
26    }
27}
28
29fn render_static_list(lines: Vec<Line<'static>>, area: Rect, buf: &mut Buffer) {
30    if area.width == 0 || area.height == 0 || lines.is_empty() {
31        return;
32    }
33
34    let rows = lines
35        .into_iter()
36        .map(|line| (SidebarListItem { line }, 1_u16))
37        .collect::<Vec<_>>();
38    let count = rows.len();
39    let builder = ListBuilder::new(move |context| rows[context.index].clone());
40    let widget = ListView::new(builder, count).infinite_scrolling(false);
41    let mut state = WidgetListState::default();
42    StatefulWidget::render(widget, area, buf, &mut state);
43}
44
45/// Sidebar section types for organizing content
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum SidebarSection {
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    context_info: Option<&'a str>,
74    recent_tools: Vec<String>,
75    active_section: Option<SidebarSection>,
76    mode: LayoutMode,
77}
78
79impl<'a> SidebarWidget<'a> {
80    /// Create a new sidebar widget
81    pub fn new(styles: &'a SessionStyles) -> Self {
82        Self {
83            styles,
84            queue_items: Vec::new(),
85            context_info: None,
86            recent_tools: Vec::new(),
87            active_section: None,
88            mode: LayoutMode::Wide,
89        }
90    }
91
92    /// Set queued items to display
93    #[must_use]
94    pub fn queue_items(mut self, items: Vec<String>) -> Self {
95        self.queue_items = items;
96        self
97    }
98
99    /// Set context info text
100    #[must_use]
101    pub fn context_info(mut self, info: &'a str) -> Self {
102        self.context_info = Some(info);
103        self
104    }
105
106    /// Set recent tool calls
107    #[must_use]
108    pub fn recent_tools(mut self, tools: Vec<String>) -> Self {
109        self.recent_tools = tools;
110        self
111    }
112
113    /// Set the active/focused section
114    #[must_use]
115    pub fn active_section(mut self, section: SidebarSection) -> Self {
116        self.active_section = Some(section);
117        self
118    }
119
120    /// Set the layout mode
121    #[must_use]
122    pub fn mode(mut self, mode: LayoutMode) -> Self {
123        self.mode = mode;
124        self
125    }
126
127    fn render_queue_section(&self, area: Rect, buf: &mut Buffer) {
128        let is_active = self.active_section == Some(SidebarSection::Queue);
129        let inner = Panel::new(self.styles)
130            .title("Queue")
131            .active(is_active)
132            .mode(self.mode)
133            .render_and_get_inner(area, buf);
134
135        if inner.height == 0 || inner.width == 0 {
136            return;
137        }
138
139        if self.queue_items.is_empty() {
140            let empty_text = Paragraph::new("No queued items").style(self.styles.muted_style());
141            empty_text.render(inner, buf);
142        } else {
143            let lines = self
144                .queue_items
145                .iter()
146                .enumerate()
147                .map(|(i, item)| {
148                    let prefix = format!("{}. ", i + 1);
149                    Line::from(vec![
150                        Span::styled(prefix, self.styles.accent_style()),
151                        Span::styled(
152                            truncate_string(item, inner.width.saturating_sub(4) as usize),
153                            self.styles.default_style(),
154                        ),
155                    ])
156                })
157                .collect();
158
159            render_static_list(lines, inner, buf);
160        }
161    }
162
163    fn render_context_section(&self, area: Rect, buf: &mut Buffer) {
164        let is_active = self.active_section == Some(SidebarSection::Context);
165        let inner = Panel::new(self.styles)
166            .title("Context")
167            .active(is_active)
168            .mode(self.mode)
169            .render_and_get_inner(area, buf);
170
171        if inner.height == 0 || inner.width == 0 {
172            return;
173        }
174
175        let text = self.context_info.unwrap_or("No context info");
176        let paragraph = Paragraph::new(text)
177            .style(self.styles.default_style())
178            .wrap(Wrap { trim: true });
179        paragraph.render(inner, buf);
180    }
181
182    fn render_tools_section(&self, area: Rect, buf: &mut Buffer) {
183        let is_active = self.active_section == Some(SidebarSection::Tools);
184        let inner = Panel::new(self.styles)
185            .title("Recent Tools")
186            .active(is_active)
187            .mode(self.mode)
188            .render_and_get_inner(area, buf);
189
190        if inner.height == 0 || inner.width == 0 {
191            return;
192        }
193
194        if self.recent_tools.is_empty() {
195            let empty_text = Paragraph::new("No recent tools").style(self.styles.muted_style());
196            empty_text.render(inner, buf);
197        } else {
198            let lines = self
199                .recent_tools
200                .iter()
201                .map(|tool| {
202                    Line::from(Span::styled(
203                        format!(
204                            "▸ {}",
205                            truncate_string(tool, inner.width.saturating_sub(3) as usize)
206                        ),
207                        self.styles.default_style(),
208                    ))
209                })
210                .collect();
211
212            render_static_list(lines, inner, buf);
213        }
214    }
215}
216
217impl Widget for SidebarWidget<'_> {
218    fn render(self, area: Rect, buf: &mut Buffer) {
219        if area.height == 0 || area.width == 0 {
220            return;
221        }
222
223        if !self.mode.allow_sidebar() {
224            return;
225        }
226
227        Clear.render(area, buf);
228
229        // Split sidebar into sections
230        let has_queue = !self.queue_items.is_empty();
231        let has_tools = !self.recent_tools.is_empty();
232
233        let constraints = match (has_queue, has_tools) {
234            (true, true) => vec![
235                Constraint::Percentage(40),
236                Constraint::Percentage(30),
237                Constraint::Percentage(30),
238            ],
239            (true, false) | (false, true) => {
240                vec![Constraint::Percentage(50), Constraint::Percentage(50)]
241            }
242            (false, false) => vec![Constraint::Percentage(100)],
243        };
244
245        let chunks = Layout::vertical(constraints).split(area);
246
247        let mut chunk_idx = 0;
248
249        if has_queue && chunk_idx < chunks.len() {
250            self.render_queue_section(chunks[chunk_idx], buf);
251            chunk_idx += 1;
252        }
253
254        if chunk_idx < chunks.len() {
255            self.render_context_section(chunks[chunk_idx], buf);
256            chunk_idx += 1;
257        }
258
259        if has_tools && chunk_idx < chunks.len() {
260            self.render_tools_section(chunks[chunk_idx], buf);
261        }
262    }
263}
264
265/// Truncate a string to fit within a given width
266fn truncate_string(s: &str, max_width: usize) -> String {
267    if s.len() <= max_width {
268        s.to_string()
269    } else if max_width <= ELLIPSIS.len() {
270        s.chars().take(max_width).collect()
271    } else {
272        let target = max_width.saturating_sub(ELLIPSIS.len());
273        let end = s
274            .char_indices()
275            .map(|(i, _)| i)
276            .rfind(|&i| i <= target)
277            .unwrap_or(0);
278        format!("{}{}", &s[..end], ELLIPSIS)
279    }
280}