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::{Panel, PanelStyles};
11use crate::ui::tui::session::styling::SessionStyles;
12
13const 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum SidebarSection {
48 Queue,
49 Context,
50 Tools,
51 Info,
52}
53
54pub 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 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 #[must_use]
94 pub fn queue_items(mut self, items: Vec<String>) -> Self {
95 self.queue_items = items;
96 self
97 }
98
99 #[must_use]
101 pub fn context_info(mut self, info: &'a str) -> Self {
102 self.context_info = Some(info);
103 self
104 }
105
106 #[must_use]
108 pub fn recent_tools(mut self, tools: Vec<String>) -> Self {
109 self.recent_tools = tools;
110 self
111 }
112
113 #[must_use]
115 pub fn active_section(mut self, section: SidebarSection) -> Self {
116 self.active_section = Some(section);
117 self
118 }
119
120 #[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 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
265fn 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}