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;
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    footer_hint_override: Option<&'static str>,
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            footer_hint_override: None,
59        }
60    }
61
62    /// Set the header lines to render
63    #[must_use]
64    pub fn header_lines(mut self, lines: Vec<ratatui::text::Line<'static>>) -> Self {
65        self.header_lines = Some(lines);
66        self
67    }
68
69    /// Set the header area
70    #[must_use]
71    pub fn header_area(mut self, area: Rect) -> Self {
72        self.header_area = Some(area);
73        self
74    }
75
76    /// Set the transcript area
77    #[must_use]
78    pub fn transcript_area(mut self, area: Rect) -> Self {
79        self.transcript_area = Some(area);
80        self
81    }
82
83    /// Set the navigation area
84    #[must_use]
85    pub fn navigation_area(mut self, area: Rect) -> Self {
86        self.navigation_area = Some(area);
87        self
88    }
89
90    /// Override the layout mode (auto-detected by default)
91    #[must_use]
92    pub fn layout_mode(mut self, mode: LayoutMode) -> Self {
93        self.layout_mode = Some(mode);
94        self
95    }
96
97    /// Override the footer hint text (useful for app-specific panels).
98    #[must_use]
99    pub fn footer_hint_override(mut self, hint: &'static str) -> Self {
100        self.footer_hint_override = Some(hint);
101        self
102    }
103
104    /// Compute the layout regions based on viewport and layout mode
105    /// Compute the layout regions based on viewport and layout mode
106    fn compute_layout(&mut self, area: Rect, mode: LayoutMode) -> SessionLayout {
107        let footer_h = mode.footer_height();
108        let max_header_pct = mode.max_header_percent();
109
110        // Compute header height
111        let header_lines = if let Some(lines) = self.header_lines.as_ref() {
112            lines.clone()
113        } else {
114            self.session.header_lines()
115        };
116
117        let natural_header_h = self
118            .session
119            .header_height_from_lines(area.width, &header_lines);
120        let max_header_h = ((area.height as f32) * max_header_pct) as u16;
121        let header_h = natural_header_h.min(max_header_h).max(1);
122
123        // Main region constraints
124        let main_h = area.height.saturating_sub(header_h + footer_h);
125
126        let [header_area, main_area, footer_area] = Layout::vertical([
127            Constraint::Length(header_h),
128            Constraint::Length(main_h),
129            Constraint::Length(footer_h),
130        ])
131        .split(area)[..] else {
132            return SessionLayout {
133                header: Rect::ZERO,
134                main: Rect::ZERO,
135                sidebar: None,
136                footer: Rect::ZERO,
137                mode,
138            };
139        };
140
141        // In wide mode, split main into transcript and sidebar
142        // Respect appearance config for sidebar visibility
143        let show_sidebar = mode.allow_sidebar() && self.session.appearance.should_show_sidebar();
144        if show_sidebar {
145            let sidebar_pct = mode.sidebar_width_percent();
146            let [left, right] = Layout::horizontal([
147                Constraint::Percentage(100 - sidebar_pct),
148                Constraint::Percentage(sidebar_pct),
149            ])
150            .split(main_area)[..] else {
151                return SessionLayout {
152                    header: header_area,
153                    main: main_area,
154                    sidebar: None,
155                    footer: footer_area,
156                    mode,
157                };
158            };
159            return SessionLayout {
160                header: header_area,
161                main: left,
162                sidebar: Some(right),
163                footer: footer_area,
164                mode,
165            };
166        }
167
168        SessionLayout {
169            header: header_area,
170            main: main_area,
171            sidebar: None,
172            footer: footer_area,
173            mode,
174        }
175    }
176}
177
178/// Computed layout regions for the session UI
179struct SessionLayout {
180    header: Rect,
181    main: Rect,
182    sidebar: Option<Rect>,
183    footer: Rect,
184    #[allow(dead_code)]
185    mode: LayoutMode,
186}
187
188impl Widget for &mut SessionWidget<'_> {
189    fn render(self, area: Rect, buf: &mut Buffer) {
190        if area.width == 0 || area.height == 0 {
191            return;
192        }
193
194        // Determine layout mode from viewport or override
195        let mode = self
196            .layout_mode
197            .unwrap_or_else(|| self.session.resolved_layout_mode(area));
198
199        if let (Some(header_area), Some(transcript_area)) = (self.header_area, self.transcript_area)
200        {
201            self.session.poll_log_entries();
202
203            if header_area.width > 0 && header_area.height > 0 {
204                let header_lines = if let Some(lines) = self.header_lines.as_ref() {
205                    lines.clone()
206                } else {
207                    self.session.header_lines()
208                };
209                HeaderWidget::new(self.session)
210                    .lines(header_lines)
211                    .render(header_area, buf);
212            }
213
214            if transcript_area.width > 0 && transcript_area.height > 0 {
215                self.session.apply_view_rows(transcript_area.height);
216                let has_logs =
217                    self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
218                if has_logs {
219                    let chunks =
220                        Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
221                            .split(transcript_area);
222                    TranscriptWidget::new(self.session).render(chunks[0], buf);
223                    self.render_logs(chunks[1], buf, mode);
224                } else {
225                    TranscriptWidget::new(self.session).render(transcript_area, buf);
226                }
227            }
228
229            if let Some(sidebar_area) = self.navigation_area
230                && sidebar_area.width > 0
231                && sidebar_area.height > 0
232            {
233                self.render_sidebar(sidebar_area, buf, mode);
234            }
235            return;
236        }
237
238        // Reserve input height so transcript/header never render under input
239        let layout_height = area.height.saturating_sub(self.session.input_height);
240        let layout_area = Rect::new(area.x, area.y, area.width, layout_height);
241        if layout_area.height == 0 || layout_area.width == 0 {
242            return;
243        }
244
245        // Pull log entries
246        self.session.poll_log_entries();
247
248        // Compute responsive layout
249        let layout = self.compute_layout(layout_area, mode);
250
251        // Update header rows if changed
252        if layout.header.height != self.session.header_rows {
253            self.session.header_rows = layout.header.height;
254            self.session.recalculate_transcript_rows();
255        }
256
257        // Update view rows for transcript
258        self.session.apply_view_rows(layout.main.height);
259
260        // Render header
261        let header_lines = if let Some(lines) = self.header_lines.as_ref() {
262            lines.clone()
263        } else {
264            self.session.header_lines()
265        };
266        HeaderWidget::new(self.session)
267            .lines(header_lines)
268            .render(layout.header, buf);
269
270        // Render main content area (transcript + optional logs)
271        let has_logs = self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
272
273        if has_logs {
274            let chunks = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
275                .split(layout.main);
276            TranscriptWidget::new(self.session).render(chunks[0], buf);
277            self.render_logs(chunks[1], buf, mode);
278        } else {
279            TranscriptWidget::new(self.session).render(layout.main, buf);
280        }
281
282        // Render sidebar in wide mode
283        if let Some(sidebar_area) = layout.sidebar {
284            self.render_sidebar(sidebar_area, buf, mode);
285        }
286
287        // Render footer only in wide mode (preserves transcript space in smaller terminals)
288        if mode.show_footer() && layout.footer.height > 0 {
289            self.render_footer(layout.footer, buf, mode);
290        }
291    }
292}
293
294impl<'a> SessionWidget<'a> {
295    fn render_logs(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
296        use ratatui::widgets::{Paragraph, Wrap};
297
298        let inner = Panel::new(&self.session.styles)
299            .title("Logs")
300            .active(false)
301            .mode(mode)
302            .render_and_get_inner(area, buf);
303
304        if inner.height == 0 || inner.width == 0 {
305            return;
306        }
307
308        let paragraph =
309            Paragraph::new((*self.session.log_text()).clone()).wrap(Wrap { trim: false });
310        paragraph.render(inner, buf);
311    }
312
313    fn render_sidebar(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
314        let queue_items: Vec<String> =
315            if let Some(cached) = &self.session.queued_inputs_preview_cache {
316                cached.clone()
317            } else {
318                let items: Vec<String> = self
319                    .session
320                    .queued_inputs
321                    .iter()
322                    .take(5)
323                    .map(|input| {
324                        let preview: String = input.chars().take(50).collect();
325                        if input.len() > 50 {
326                            format!("{}...", preview)
327                        } else {
328                            preview
329                        }
330                    })
331                    .collect();
332                self.session.queued_inputs_preview_cache = Some(items.clone());
333                items
334            };
335
336        let context_info = self
337            .session
338            .input_status_right
339            .as_deref()
340            .unwrap_or("Ready");
341
342        SidebarWidget::new(&self.session.styles)
343            .queue_items(queue_items)
344            .context_info(context_info)
345            .mode(mode)
346            .render(area, buf);
347    }
348
349    fn render_footer(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
350        let left_status = self.session.input_status_left.as_deref().unwrap_or("");
351        let right_status = self.session.input_status_right.as_deref().unwrap_or("");
352
353        let hint = if let Some(hint) = self.footer_hint_override {
354            hint
355        } else if self.session.thinking_spinner.is_active {
356            footer_hints::PROCESSING
357        } else if self.session.has_active_overlay() {
358            footer_hints::MODAL
359        } else if self.session.input_manager.content().is_empty() {
360            footer_hints::IDLE
361        } else {
362            footer_hints::EDITING
363        };
364
365        let input_status_visible = self
366            .session
367            .input_status_left
368            .as_ref()
369            .is_some_and(|value| !value.trim().is_empty())
370            || self
371                .session
372                .input_status_right
373                .as_ref()
374                .is_some_and(|value| !value.trim().is_empty());
375        let shimmer_phase = if input_status_visible {
376            None
377        } else {
378            Some(self.session.shimmer_state.phase())
379        };
380
381        let mut footer = FooterWidget::new(&self.session.styles)
382            .left_status(left_status)
383            .right_status(right_status)
384            .hint(hint)
385            .mode(mode);
386
387        if let Some(phase) = shimmer_phase {
388            footer = footer.shimmer_phase(phase);
389        }
390
391        footer.render(area, buf);
392    }
393}
394
395#[allow(dead_code)]
396fn has_input_status(session: &Session) -> bool {
397    let left_present = session
398        .input_status_left
399        .as_ref()
400        .is_some_and(|value| !value.trim().is_empty());
401    if left_present {
402        return true;
403    }
404    session
405        .input_status_right
406        .as_ref()
407        .is_some_and(|value| !value.trim().is_empty())
408}