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 if let (Some(header_area), Some(transcript_area)) = (self.header_area, self.transcript_area)
191 {
192 self.session.poll_log_entries();
193
194 if header_area.width > 0 && header_area.height > 0 {
195 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
196 lines.clone()
197 } else {
198 self.session.header_lines()
199 };
200 HeaderWidget::new(self.session)
201 .lines(header_lines)
202 .render(header_area, buf);
203 }
204
205 if transcript_area.width > 0 && transcript_area.height > 0 {
206 apply_view_rows(self.session, transcript_area.height);
207 let has_logs =
208 self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
209 if has_logs {
210 let chunks =
211 Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
212 .split(transcript_area);
213 TranscriptWidget::new(self.session).render(chunks[0], buf);
214 self.render_logs(chunks[1], buf, mode);
215 } else {
216 TranscriptWidget::new(self.session).render(transcript_area, buf);
217 }
218 }
219
220 if let Some(sidebar_area) = self.navigation_area
221 && sidebar_area.width > 0
222 && sidebar_area.height > 0
223 {
224 self.render_sidebar(sidebar_area, buf, mode);
225 }
226 return;
227 }
228
229 let layout_height = area.height.saturating_sub(self.session.input_height);
231 let layout_area = Rect::new(area.x, area.y, area.width, layout_height);
232 if layout_area.height == 0 || layout_area.width == 0 {
233 return;
234 }
235
236 self.session.poll_log_entries();
238
239 let layout = self.compute_layout(layout_area, mode);
241
242 if layout.header.height != self.session.header_rows {
244 self.session.header_rows = layout.header.height;
245 crate::ui::tui::session::render::recalculate_transcript_rows(self.session);
246 }
247
248 apply_view_rows(self.session, layout.main.height);
250
251 let _overlays_active = self.session.file_palette_active;
253
254 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
256 lines.clone()
257 } else {
258 self.session.header_lines()
259 };
260 HeaderWidget::new(self.session)
261 .lines(header_lines)
262 .render(layout.header, buf);
263
264 let has_logs = self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
266
267 if has_logs {
268 let chunks = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
269 .split(layout.main);
270 TranscriptWidget::new(self.session).render(chunks[0], buf);
271 self.render_logs(chunks[1], buf, mode);
272 } else {
273 TranscriptWidget::new(self.session).render(layout.main, buf);
274 }
275
276 if let Some(sidebar_area) = layout.sidebar {
278 self.render_sidebar(sidebar_area, buf, mode);
279 }
280
281 if mode.show_footer() && layout.footer.height > 0 {
283 self.render_footer(layout.footer, buf, mode);
284 }
285 }
286}
287
288impl<'a> SessionWidget<'a> {
289 fn render_logs(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
290 use ratatui::widgets::{Paragraph, Wrap};
291
292 let inner = Panel::new(&self.session.styles)
293 .title("Logs")
294 .active(false)
295 .mode(mode)
296 .render_and_get_inner(area, buf);
297
298 if inner.height == 0 || inner.width == 0 {
299 return;
300 }
301
302 let paragraph =
303 Paragraph::new((*self.session.log_text()).clone()).wrap(Wrap { trim: false });
304 paragraph.render(inner, buf);
305 }
306
307 fn render_sidebar(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
308 let queue_items: Vec<String> =
309 if let Some(cached) = &self.session.queued_inputs_preview_cache {
310 cached.clone()
311 } else {
312 let items: Vec<String> = self
313 .session
314 .queued_inputs
315 .iter()
316 .take(5)
317 .map(|input| {
318 let preview: String = input.chars().take(50).collect();
319 if input.len() > 50 {
320 format!("{}...", preview)
321 } else {
322 preview
323 }
324 })
325 .collect();
326 self.session.queued_inputs_preview_cache = Some(items.clone());
327 items
328 };
329
330 let context_info = self
331 .session
332 .input_status_right
333 .as_deref()
334 .unwrap_or("Ready");
335
336 SidebarWidget::new(&self.session.styles)
337 .queue_items(queue_items)
338 .context_info(context_info)
339 .mode(mode)
340 .render(area, buf);
341 }
342
343 fn render_footer(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
344 let left_status = self.session.input_status_left.as_deref().unwrap_or("");
345 let right_status = self.session.input_status_right.as_deref().unwrap_or("");
346
347 let hint = if self.session.thinking_spinner.is_active {
348 footer_hints::PROCESSING
349 } else if self.session.file_palette_active || self.session.history_picker_state.active {
350 footer_hints::MODAL
351 } else if self.session.input_manager.content().is_empty() {
352 footer_hints::IDLE
353 } else {
354 footer_hints::EDITING
355 };
356
357 let input_status_visible = self
358 .session
359 .input_status_left
360 .as_ref()
361 .is_some_and(|value| !value.trim().is_empty())
362 || self
363 .session
364 .input_status_right
365 .as_ref()
366 .is_some_and(|value| !value.trim().is_empty());
367 let shimmer_phase = if input_status_visible {
368 None
369 } else {
370 Some(self.session.shimmer_state.phase())
371 };
372
373 let mut footer = FooterWidget::new(&self.session.styles)
374 .left_status(left_status)
375 .right_status(right_status)
376 .hint(hint)
377 .mode(mode);
378
379 if let Some(phase) = shimmer_phase {
380 footer = footer.shimmer_phase(phase);
381 }
382
383 footer.render(area, buf);
384 }
385}
386
387#[allow(dead_code)]
388fn has_input_status(session: &Session) -> bool {
389 let left_present = session
390 .input_status_left
391 .as_ref()
392 .is_some_and(|value| !value.trim().is_empty());
393 if left_present {
394 return true;
395 }
396 session
397 .input_status_right
398 .as_ref()
399 .is_some_and(|value| !value.trim().is_empty())
400}