Skip to main content

vtcode_tui/core_tui/widgets/
session.rs

1use 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
13/// Root compositor widget that orchestrates rendering of the entire session UI
14///
15/// This widget follows the compositional pattern recommended by Ratatui where
16/// a single root widget manages the layout and delegates rendering to child widgets.
17///
18/// It handles:
19/// - Responsive layout based on terminal size (Compact/Standard/Wide)
20/// - Layout calculation (header, main, footer regions)
21/// - Coordinating child widget rendering
22/// - Modal and palette overlay management
23/// - Sidebar rendering in wide mode
24///
25/// # Layout Modes
26///
27/// - **Compact** (< 80 cols): Minimal chrome, no borders, no sidebar
28/// - **Standard** (80-119 cols): Borders, titles, optional logs panel
29/// - **Wide** (>= 120 cols): Full layout with sidebar for queue/context
30///
31/// # Example
32/// ```ignore
33/// SessionWidget::new(session)
34///     .header_lines(lines)
35///     .header_area(header_area)
36///     .transcript_area(transcript_area)
37///     .render(area, buf);
38/// ```
39pub 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    /// Create a new SessionWidget with required parameters
50    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    /// Set the header lines to render
62    #[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    /// Set the header area
69    #[must_use]
70    pub fn header_area(mut self, area: Rect) -> Self {
71        self.header_area = Some(area);
72        self
73    }
74
75    /// Set the transcript area
76    #[must_use]
77    pub fn transcript_area(mut self, area: Rect) -> Self {
78        self.transcript_area = Some(area);
79        self
80    }
81
82    /// Set the navigation area
83    #[must_use]
84    pub fn navigation_area(mut self, area: Rect) -> Self {
85        self.navigation_area = Some(area);
86        self
87    }
88
89    /// Override the layout mode (auto-detected by default)
90    #[must_use]
91    pub fn layout_mode(mut self, mode: LayoutMode) -> Self {
92        self.layout_mode = Some(mode);
93        self
94    }
95
96    /// Compute the layout regions based on viewport and layout mode
97    /// Compute the layout regions based on viewport and layout mode
98    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        // Compute header height
103        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        // Main region constraints
116        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        // In wide mode, split main into transcript and sidebar
134        // Respect appearance config for sidebar visibility
135        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
170/// Computed layout regions for the session UI
171struct 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        // Determine layout mode from viewport or override
187        let mode = self
188            .layout_mode
189            .unwrap_or_else(|| self.session.resolved_layout_mode(area));
190
191        // Reserve input height so transcript/header never render under input
192        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        // Pull log entries
200        self.session.poll_log_entries();
201
202        // Compute responsive layout
203        let layout = self.compute_layout(layout_area, mode);
204
205        // Update header rows if changed
206        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        // Update view rows for transcript
212        apply_view_rows(self.session, layout.main.height);
213
214        // Check if overlays are active (dim background panels when true)
215        let _overlays_active = self.session.file_palette_active;
216
217        // Render header
218        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        // Render main content area (transcript + optional logs)
228        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        // Render sidebar in wide mode
240        if let Some(sidebar_area) = layout.sidebar {
241            self.render_sidebar(sidebar_area, buf, mode);
242        }
243
244        // Render footer only in wide mode (preserves transcript space in smaller terminals)
245        if mode.show_footer() && layout.footer.height > 0 {
246            self.render_footer(layout.footer, buf, mode);
247        }
248
249        // Render overlays (modals, palettes, etc.)
250        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        // Render file palette using builder pattern
354        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        // Render history picker using builder pattern
361        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}