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