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        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        // Reserve input height so transcript/header never render under input
230        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        // Pull log entries
237        self.session.poll_log_entries();
238
239        // Compute responsive layout
240        let layout = self.compute_layout(layout_area, mode);
241
242        // Update header rows if changed
243        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        // Update view rows for transcript
249        apply_view_rows(self.session, layout.main.height);
250
251        // Check if overlays are active (dim background panels when true)
252        let _overlays_active = self.session.file_palette_active;
253
254        // Render header
255        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        // Render main content area (transcript + optional logs)
265        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        // Render sidebar in wide mode
277        if let Some(sidebar_area) = layout.sidebar {
278            self.render_sidebar(sidebar_area, buf, mode);
279        }
280
281        // Render footer only in wide mode (preserves transcript space in smaller terminals)
282        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}