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