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