vtcode_tui/core_tui/widgets/
sidebar.rs1use ratatui::{
2 buffer::Buffer,
3 layout::{Constraint, Layout, Rect},
4 text::{Line, Span},
5 widgets::{Clear, List, ListItem, Paragraph, Widget, Wrap},
6};
7
8use super::layout_mode::LayoutMode;
9use super::panel::{Panel, PanelStyles};
10use crate::ui::tui::session::styling::SessionStyles;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum SidebarSection {
15 Queue,
16 Context,
17 Tools,
18 Info,
19}
20
21pub struct SidebarWidget<'a> {
38 styles: &'a SessionStyles,
39 queue_items: Vec<String>,
40 context_info: Option<&'a str>,
41 recent_tools: Vec<String>,
42 active_section: Option<SidebarSection>,
43 mode: LayoutMode,
44}
45
46impl<'a> SidebarWidget<'a> {
47 pub fn new(styles: &'a SessionStyles) -> Self {
49 Self {
50 styles,
51 queue_items: Vec::new(),
52 context_info: None,
53 recent_tools: Vec::new(),
54 active_section: None,
55 mode: LayoutMode::Wide,
56 }
57 }
58
59 #[must_use]
61 pub fn queue_items(mut self, items: Vec<String>) -> Self {
62 self.queue_items = items;
63 self
64 }
65
66 #[must_use]
68 pub fn context_info(mut self, info: &'a str) -> Self {
69 self.context_info = Some(info);
70 self
71 }
72
73 #[must_use]
75 pub fn recent_tools(mut self, tools: Vec<String>) -> Self {
76 self.recent_tools = tools;
77 self
78 }
79
80 #[must_use]
82 pub fn active_section(mut self, section: SidebarSection) -> Self {
83 self.active_section = Some(section);
84 self
85 }
86
87 #[must_use]
89 pub fn mode(mut self, mode: LayoutMode) -> Self {
90 self.mode = mode;
91 self
92 }
93
94 fn render_queue_section(&self, area: Rect, buf: &mut Buffer) {
95 let is_active = self.active_section == Some(SidebarSection::Queue);
96 let inner = Panel::new(self.styles)
97 .title("Queue")
98 .active(is_active)
99 .mode(self.mode)
100 .render_and_get_inner(area, buf);
101
102 if inner.height == 0 || inner.width == 0 {
103 return;
104 }
105
106 if self.queue_items.is_empty() {
107 let empty_text = Paragraph::new("No queued items").style(self.styles.muted_style());
108 empty_text.render(inner, buf);
109 } else {
110 let items: Vec<ListItem> = self
111 .queue_items
112 .iter()
113 .enumerate()
114 .map(|(i, item)| {
115 let prefix = format!("{}. ", i + 1);
116 let line = Line::from(vec![
117 Span::styled(prefix, self.styles.accent_style()),
118 Span::styled(
119 truncate_string(item, inner.width.saturating_sub(4) as usize),
120 self.styles.default_style(),
121 ),
122 ]);
123 ListItem::new(line)
124 })
125 .collect();
126
127 let list = List::new(items);
128 list.render(inner, buf);
129 }
130 }
131
132 fn render_context_section(&self, area: Rect, buf: &mut Buffer) {
133 let is_active = self.active_section == Some(SidebarSection::Context);
134 let inner = Panel::new(self.styles)
135 .title("Context")
136 .active(is_active)
137 .mode(self.mode)
138 .render_and_get_inner(area, buf);
139
140 if inner.height == 0 || inner.width == 0 {
141 return;
142 }
143
144 let text = self.context_info.unwrap_or("No context info");
145 let paragraph = Paragraph::new(text)
146 .style(self.styles.default_style())
147 .wrap(Wrap { trim: true });
148 paragraph.render(inner, buf);
149 }
150
151 fn render_tools_section(&self, area: Rect, buf: &mut Buffer) {
152 let is_active = self.active_section == Some(SidebarSection::Tools);
153 let inner = Panel::new(self.styles)
154 .title("Recent Tools")
155 .active(is_active)
156 .mode(self.mode)
157 .render_and_get_inner(area, buf);
158
159 if inner.height == 0 || inner.width == 0 {
160 return;
161 }
162
163 if self.recent_tools.is_empty() {
164 let empty_text = Paragraph::new("No recent tools").style(self.styles.muted_style());
165 empty_text.render(inner, buf);
166 } else {
167 let items: Vec<ListItem> = self
168 .recent_tools
169 .iter()
170 .map(|tool| {
171 let line = Line::from(Span::styled(
172 format!(
173 "▸ {}",
174 truncate_string(tool, inner.width.saturating_sub(3) as usize)
175 ),
176 self.styles.default_style(),
177 ));
178 ListItem::new(line)
179 })
180 .collect();
181
182 let list = List::new(items);
183 list.render(inner, buf);
184 }
185 }
186}
187
188impl Widget for SidebarWidget<'_> {
189 fn render(self, area: Rect, buf: &mut Buffer) {
190 if area.height == 0 || area.width == 0 {
191 return;
192 }
193
194 if !self.mode.allow_sidebar() {
195 return;
196 }
197
198 Clear.render(area, buf);
199
200 let has_queue = !self.queue_items.is_empty();
202 let has_tools = !self.recent_tools.is_empty();
203
204 let constraints = match (has_queue, has_tools) {
205 (true, true) => vec![
206 Constraint::Percentage(40),
207 Constraint::Percentage(30),
208 Constraint::Percentage(30),
209 ],
210 (true, false) | (false, true) => {
211 vec![Constraint::Percentage(50), Constraint::Percentage(50)]
212 }
213 (false, false) => vec![Constraint::Percentage(100)],
214 };
215
216 let chunks = Layout::vertical(constraints).split(area);
217
218 let mut chunk_idx = 0;
219
220 if has_queue && chunk_idx < chunks.len() {
221 self.render_queue_section(chunks[chunk_idx], buf);
222 chunk_idx += 1;
223 }
224
225 if chunk_idx < chunks.len() {
226 self.render_context_section(chunks[chunk_idx], buf);
227 chunk_idx += 1;
228 }
229
230 if has_tools && chunk_idx < chunks.len() {
231 self.render_tools_section(chunks[chunk_idx], buf);
232 }
233 }
234}
235
236fn truncate_string(s: &str, max_width: usize) -> String {
238 if s.len() <= max_width {
239 s.to_string()
240 } else if max_width <= 3 {
241 s.chars().take(max_width).collect()
242 } else {
243 let target = max_width.saturating_sub(3);
244 let end = s
245 .char_indices()
246 .map(|(i, _)| i)
247 .rfind(|&i| i <= target)
248 .unwrap_or(0);
249 format!("{}...", &s[..end])
250 }
251}