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