vtcode_tui/core_tui/widgets/
session.rs1use ratatui::{
2 buffer::Buffer,
3 layout::{Constraint, Layout, Rect},
4 widgets::Widget,
5};
6
7use super::{
8 FilePaletteWidget, FooterWidget, HeaderWidget, LayoutMode, Panel, SidebarWidget,
9 TranscriptWidget, footer_hints,
10};
11use crate::ui::tui::session::{Session, render::apply_view_rows};
12
13pub struct SessionWidget<'a> {
40 session: &'a mut Session,
41 header_lines: Option<Vec<ratatui::text::Line<'static>>>,
42 header_area: Option<Rect>,
43 transcript_area: Option<Rect>,
44 navigation_area: Option<Rect>,
45 layout_mode: Option<LayoutMode>,
46}
47
48impl<'a> SessionWidget<'a> {
49 pub fn new(session: &'a mut Session) -> Self {
51 Self {
52 session,
53 header_lines: None,
54 header_area: None,
55 transcript_area: None,
56 navigation_area: None,
57 layout_mode: None,
58 }
59 }
60
61 #[must_use]
63 pub fn header_lines(mut self, lines: Vec<ratatui::text::Line<'static>>) -> Self {
64 self.header_lines = Some(lines);
65 self
66 }
67
68 #[must_use]
70 pub fn header_area(mut self, area: Rect) -> Self {
71 self.header_area = Some(area);
72 self
73 }
74
75 #[must_use]
77 pub fn transcript_area(mut self, area: Rect) -> Self {
78 self.transcript_area = Some(area);
79 self
80 }
81
82 #[must_use]
84 pub fn navigation_area(mut self, area: Rect) -> Self {
85 self.navigation_area = Some(area);
86 self
87 }
88
89 #[must_use]
91 pub fn layout_mode(mut self, mode: LayoutMode) -> Self {
92 self.layout_mode = Some(mode);
93 self
94 }
95
96 fn compute_layout(&mut self, area: Rect, mode: LayoutMode) -> SessionLayout {
99 let footer_h = mode.footer_height();
100 let max_header_pct = mode.max_header_percent();
101
102 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
104 lines.clone()
105 } else {
106 self.session.header_lines()
107 };
108
109 let natural_header_h = self
110 .session
111 .header_height_from_lines(area.width, &header_lines);
112 let max_header_h = ((area.height as f32) * max_header_pct) as u16;
113 let header_h = natural_header_h.min(max_header_h).max(1);
114
115 let main_h = area.height.saturating_sub(header_h + footer_h);
117
118 let [header_area, main_area, footer_area] = Layout::vertical([
119 Constraint::Length(header_h),
120 Constraint::Length(main_h),
121 Constraint::Length(footer_h),
122 ])
123 .split(area)[..] else {
124 return SessionLayout {
125 header: Rect::ZERO,
126 main: Rect::ZERO,
127 sidebar: None,
128 footer: Rect::ZERO,
129 mode,
130 };
131 };
132
133 let show_sidebar = mode.allow_sidebar() && self.session.appearance.should_show_sidebar();
136 if show_sidebar {
137 let sidebar_pct = mode.sidebar_width_percent();
138 let [left, right] = Layout::horizontal([
139 Constraint::Percentage(100 - sidebar_pct),
140 Constraint::Percentage(sidebar_pct),
141 ])
142 .split(main_area)[..] else {
143 return SessionLayout {
144 header: header_area,
145 main: main_area,
146 sidebar: None,
147 footer: footer_area,
148 mode,
149 };
150 };
151 return SessionLayout {
152 header: header_area,
153 main: left,
154 sidebar: Some(right),
155 footer: footer_area,
156 mode,
157 };
158 }
159
160 SessionLayout {
161 header: header_area,
162 main: main_area,
163 sidebar: None,
164 footer: footer_area,
165 mode,
166 }
167 }
168}
169
170struct SessionLayout {
172 header: Rect,
173 main: Rect,
174 sidebar: Option<Rect>,
175 footer: Rect,
176 #[allow(dead_code)]
177 mode: LayoutMode,
178}
179
180impl Widget for &mut SessionWidget<'_> {
181 fn render(self, area: Rect, buf: &mut Buffer) {
182 if area.width == 0 || area.height == 0 {
183 return;
184 }
185
186 let mode = self
188 .layout_mode
189 .unwrap_or_else(|| self.session.resolved_layout_mode(area));
190
191 let layout_height = area.height.saturating_sub(self.session.input_height);
193 let layout_area = Rect::new(area.x, area.y, area.width, layout_height);
194 if layout_area.height == 0 || layout_area.width == 0 {
195 self.render_overlays(area, buf);
196 return;
197 }
198
199 self.session.poll_log_entries();
201
202 let layout = self.compute_layout(layout_area, mode);
204
205 if layout.header.height != self.session.header_rows {
207 self.session.header_rows = layout.header.height;
208 crate::ui::tui::session::render::recalculate_transcript_rows(self.session);
209 }
210
211 apply_view_rows(self.session, layout.main.height);
213
214 let _overlays_active = self.session.file_palette_active;
216
217 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
219 lines.clone()
220 } else {
221 self.session.header_lines()
222 };
223 HeaderWidget::new(self.session)
224 .lines(header_lines)
225 .render(layout.header, buf);
226
227 let has_logs = self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
229
230 if has_logs {
231 let chunks = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
232 .split(layout.main);
233 TranscriptWidget::new(self.session).render(chunks[0], buf);
234 self.render_logs(chunks[1], buf, mode);
235 } else {
236 TranscriptWidget::new(self.session).render(layout.main, buf);
237 }
238
239 if let Some(sidebar_area) = layout.sidebar {
241 self.render_sidebar(sidebar_area, buf, mode);
242 }
243
244 if mode.show_footer() && layout.footer.height > 0 {
246 self.render_footer(layout.footer, buf, mode);
247 }
248
249 self.render_overlays(area, buf);
251 }
252}
253
254impl<'a> SessionWidget<'a> {
255 fn render_logs(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
256 use ratatui::widgets::{Paragraph, Wrap};
257
258 let inner = Panel::new(&self.session.styles)
259 .title("Logs")
260 .active(false)
261 .mode(mode)
262 .render_and_get_inner(area, buf);
263
264 if inner.height == 0 || inner.width == 0 {
265 return;
266 }
267
268 let paragraph =
269 Paragraph::new((*self.session.log_text()).clone()).wrap(Wrap { trim: false });
270 paragraph.render(inner, buf);
271 }
272
273 fn render_sidebar(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
274 let queue_items: Vec<String> =
275 if let Some(cached) = &self.session.queued_inputs_preview_cache {
276 cached.clone()
277 } else {
278 let items: Vec<String> = self
279 .session
280 .queued_inputs
281 .iter()
282 .take(5)
283 .map(|input| {
284 let preview: String = input.chars().take(50).collect();
285 if input.len() > 50 {
286 format!("{}...", preview)
287 } else {
288 preview
289 }
290 })
291 .collect();
292 self.session.queued_inputs_preview_cache = Some(items.clone());
293 items
294 };
295
296 let context_info = self
297 .session
298 .input_status_right
299 .as_deref()
300 .unwrap_or("Ready");
301
302 SidebarWidget::new(&self.session.styles)
303 .queue_items(queue_items)
304 .context_info(context_info)
305 .mode(mode)
306 .render(area, buf);
307 }
308
309 fn render_footer(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
310 let left_status = self.session.input_status_left.as_deref().unwrap_or("");
311 let right_status = self.session.input_status_right.as_deref().unwrap_or("");
312
313 let hint = if self.session.thinking_spinner.is_active {
314 footer_hints::PROCESSING
315 } else if self.session.file_palette_active || self.session.history_picker_state.active {
316 footer_hints::MODAL
317 } else if self.session.input_manager.content().is_empty() {
318 footer_hints::IDLE
319 } else {
320 footer_hints::EDITING
321 };
322
323 let input_status_visible = self
324 .session
325 .input_status_left
326 .as_ref()
327 .is_some_and(|value| !value.trim().is_empty())
328 || self
329 .session
330 .input_status_right
331 .as_ref()
332 .is_some_and(|value| !value.trim().is_empty());
333 let shimmer_phase = if input_status_visible {
334 None
335 } else {
336 Some(self.session.shimmer_state.phase())
337 };
338
339 let mut footer = FooterWidget::new(&self.session.styles)
340 .left_status(left_status)
341 .right_status(right_status)
342 .hint(hint)
343 .mode(mode);
344
345 if let Some(phase) = shimmer_phase {
346 footer = footer.shimmer_phase(phase);
347 }
348
349 footer.render(area, buf);
350 }
351
352 fn render_overlays(&mut self, viewport: Rect, buf: &mut Buffer) {
353 if self.session.file_palette_active
355 && let Some(palette) = self.session.file_palette.as_ref()
356 {
357 FilePaletteWidget::new(self.session, palette, viewport).render(viewport, buf);
358 }
359
360 if self.session.history_picker_state.active {
362 super::HistoryPickerWidget::new(
363 self.session,
364 &self.session.history_picker_state,
365 viewport,
366 )
367 .render(buf);
368 }
369 }
370}
371
372#[allow(dead_code)]
373fn has_input_status(session: &Session) -> bool {
374 let left_present = session
375 .input_status_left
376 .as_ref()
377 .is_some_and(|value| !value.trim().is_empty());
378 if left_present {
379 return true;
380 }
381 session
382 .input_status_right
383 .as_ref()
384 .is_some_and(|value| !value.trim().is_empty())
385}