Skip to main content

vtcode_ui/tui/core_tui/widgets/
session.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Layout, Rect},
4    widgets::Widget,
5};
6
7use super::{FooterWidget, HeaderWidget, LayoutMode, SidebarWidget, TranscriptWidget, footer_hints, panel::new_panel};
8use crate::tui::core_tui::blocked_status;
9use crate::tui::ui::tui::session::{Session, pulse_spinner_frame_for_phase};
10
11/// Root compositor widget that orchestrates rendering of the entire session UI
12///
13/// This widget follows the compositional pattern recommended by Ratatui where
14/// a single root widget manages the layout and delegates rendering to child widgets.
15///
16/// It handles:
17/// - Responsive layout based on terminal size (Compact/Standard/Wide)
18/// - Layout calculation (header, main, footer regions)
19/// - Coordinating child widget rendering
20/// - Modal and palette overlay management
21/// - Sidebar rendering in wide mode
22///
23/// # Layout Modes
24///
25/// - **Compact** (< 80 cols): Minimal chrome, no borders, no sidebar
26/// - **Standard** (80-119 cols): Borders, titles, optional logs panel
27/// - **Wide** (>= 120 cols): Full layout with sidebar for queue/context
28///
29/// # Example
30/// ```ignore
31/// SessionWidget::new(session)
32///     .header_lines(lines)
33///     .header_area(header_area)
34///     .transcript_area(transcript_area)
35///     .render(area, buf);
36/// ```
37pub struct SessionWidget<'a> {
38    session: &'a mut Session,
39    header_lines: Option<std::sync::Arc<Vec<ratatui::text::Line<'static>>>>,
40    header_area: Option<Rect>,
41    transcript_area: Option<Rect>,
42    navigation_area: Option<Rect>,
43    layout_mode: Option<LayoutMode>,
44    footer_hint_override: Option<&'static str>,
45}
46
47impl<'a> SessionWidget<'a> {
48    /// Create a new SessionWidget with required parameters
49    pub(crate) 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            footer_hint_override: None,
58        }
59    }
60
61    /// Set the header lines to render
62    #[must_use]
63    pub(crate) fn header_lines(mut self, lines: Vec<ratatui::text::Line<'static>>) -> Self {
64        self.header_lines = Some(std::sync::Arc::new(lines));
65        self
66    }
67
68    /// Set pre-built header lines without cloning the `Vec`.
69    #[must_use]
70    pub(crate) fn header_lines_arc(mut self, lines: std::sync::Arc<Vec<ratatui::text::Line<'static>>>) -> Self {
71        self.header_lines = Some(lines);
72        self
73    }
74
75    /// Set the header area
76    #[must_use]
77    pub(crate) fn header_area(mut self, area: Rect) -> Self {
78        self.header_area = Some(area);
79        self
80    }
81
82    /// Set the transcript area
83    #[must_use]
84    pub(crate) fn transcript_area(mut self, area: Rect) -> Self {
85        self.transcript_area = Some(area);
86        self
87    }
88
89    /// Set the navigation area
90    #[must_use]
91    pub(crate) fn navigation_area(mut self, area: Rect) -> Self {
92        self.navigation_area = Some(area);
93        self
94    }
95
96    /// Override the layout mode (auto-detected by default)
97    #[must_use]
98    pub fn layout_mode(mut self, mode: LayoutMode) -> Self {
99        self.layout_mode = Some(mode);
100        self
101    }
102
103    /// Override the footer hint text (useful for app-specific panels).
104    #[must_use]
105    pub fn footer_hint_override(mut self, hint: &'static str) -> Self {
106        self.footer_hint_override = Some(hint);
107        self
108    }
109
110    /// Compute the layout regions based on viewport and layout mode
111    /// Compute the layout regions based on viewport and layout mode
112    fn compute_layout(&mut self, area: Rect, mode: LayoutMode) -> SessionLayout {
113        let footer_h = mode.footer_height();
114        let max_header_pct = mode.max_header_percent();
115
116        // Compute header height (no Vec clone — borrow the Arc slice)
117        let header_lines = if let Some(lines) = self.header_lines.as_ref() {
118            std::sync::Arc::clone(lines)
119        } else {
120            self.session.header_lines()
121        };
122
123        let natural_header_h = self.session.header_height_from_lines(area.width, header_lines.as_slice());
124        let max_header_h = ((area.height as f32) * max_header_pct) as u16;
125        let header_h = natural_header_h.min(max_header_h).max(1);
126
127        // Main region constraints
128        let main_h = area.height.saturating_sub(header_h + footer_h);
129
130        let [header_area, main_area, footer_area] = area
131            .try_layout(&Layout::vertical([
132                Constraint::Length(header_h),
133                Constraint::Length(main_h),
134                Constraint::Length(footer_h),
135            ]))
136            .unwrap_or([Rect::ZERO; 3]);
137
138        // In wide mode, split main into transcript and sidebar
139        // Respect appearance config for sidebar visibility
140        let show_sidebar = mode.allow_sidebar() && self.session.appearance.should_show_sidebar();
141        if show_sidebar {
142            let sidebar_pct = mode.sidebar_width_percent();
143            let [left, right] = main_area
144                .try_layout(&Layout::horizontal([
145                    Constraint::Percentage(100 - sidebar_pct),
146                    Constraint::Percentage(sidebar_pct),
147                ]))
148                .unwrap_or([main_area; 2]);
149            return SessionLayout {
150                header: header_area,
151                main: left,
152                sidebar: Some(right),
153                footer: footer_area,
154                mode,
155            };
156        }
157
158        SessionLayout {
159            header: header_area,
160            main: main_area,
161            sidebar: None,
162            footer: footer_area,
163            mode,
164        }
165    }
166}
167
168/// Computed layout regions for the session UI
169struct SessionLayout {
170    header: Rect,
171    main: Rect,
172    sidebar: Option<Rect>,
173    footer: Rect,
174    #[expect(
175        dead_code,
176        reason = "Intentional compatibility, platform, test, or API-shape suppression."
177    )]
178    mode: LayoutMode,
179}
180
181impl Widget for &mut SessionWidget<'_> {
182    #[cfg_attr(feature = "profiling", hotpath::measure)]
183    fn render(self, area: Rect, buf: &mut Buffer) {
184        self.session.set_progress_area(None);
185        if area.width == 0 || area.height == 0 {
186            return;
187        }
188
189        // Determine layout mode from viewport or override
190        let mode = self.layout_mode.unwrap_or_else(|| self.session.resolved_layout_mode(area));
191
192        if let (Some(header_area), Some(transcript_area)) = (self.header_area, self.transcript_area) {
193            self.session.poll_log_entries();
194
195            if header_area.width > 0 && header_area.height > 0 {
196                let header_lines = if let Some(lines) = self.header_lines.as_ref() {
197                    std::sync::Arc::clone(lines)
198                } else {
199                    self.session.header_lines()
200                };
201                HeaderWidget::new(self.session).lines(header_lines).render(header_area, buf);
202            }
203
204            if transcript_area.width > 0 && transcript_area.height > 0 {
205                // The explicit layout path already supplies the measured
206                // transcript rectangle. Updating `view_rows` here would
207                // temporarily recalculate `transcript_rows` from that
208                // rectangle as if it were the full viewport, which can make
209                // scroll-anchor restoration observe the wrong geometry.
210                let has_logs = self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
211                if has_logs {
212                    let [transcript, logs] = transcript_area
213                        .try_layout(&Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)]))
214                        .unwrap_or([transcript_area, Rect::ZERO]);
215                    TranscriptWidget::new(self.session).render(transcript, buf);
216                    self.render_logs(logs, buf, mode);
217                } else {
218                    TranscriptWidget::new(self.session).render(transcript_area, buf);
219                }
220            } else {
221                // Clear the stored hit-test area when a constrained layout leaves
222                // no room for the transcript (for example, beneath a tiny modal).
223                TranscriptWidget::new(self.session).render(transcript_area, buf);
224            }
225
226            if let Some(sidebar_area) = self.navigation_area
227                && sidebar_area.width > 0
228                && sidebar_area.height > 0
229            {
230                self.render_sidebar(sidebar_area, buf, mode);
231            }
232            return;
233        }
234
235        // Reserve input height so transcript/header never render under input
236        let layout_height = area.height.saturating_sub(self.session.input_height);
237        let layout_area = Rect::new(area.x, area.y, area.width, layout_height);
238        if layout_area.height == 0 || layout_area.width == 0 {
239            return;
240        }
241
242        // Pull log entries
243        self.session.poll_log_entries();
244
245        // Compute responsive layout
246        let layout = self.compute_layout(layout_area, mode);
247
248        // Update header rows if changed
249        if layout.header.height != self.session.header_rows {
250            self.session.header_rows = layout.header.height;
251            self.session.recalculate_transcript_rows();
252        }
253
254        // Update view rows for transcript
255        self.session.apply_view_rows(layout.main.height);
256
257        // Render header
258        let header_lines = if let Some(lines) = self.header_lines.as_ref() {
259            std::sync::Arc::clone(lines)
260        } else {
261            self.session.header_lines()
262        };
263        HeaderWidget::new(self.session).lines(header_lines).render(layout.header, buf);
264
265        // Render main content area (transcript + optional logs)
266        let has_logs = self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
267
268        if has_logs {
269            let [transcript, logs] = layout
270                .main
271                .try_layout(&Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)]))
272                .unwrap_or([layout.main, Rect::ZERO]);
273            TranscriptWidget::new(self.session).render(transcript, buf);
274            self.render_logs(logs, buf, mode);
275        } else {
276            TranscriptWidget::new(self.session).render(layout.main, buf);
277        }
278
279        // Render sidebar in wide mode
280        if let Some(sidebar_area) = layout.sidebar {
281            self.render_sidebar(sidebar_area, buf, mode);
282        }
283
284        // Render footer only in wide mode (preserves transcript space in smaller terminals)
285        if mode.show_footer() && layout.footer.height > 0 {
286            self.render_footer(layout.footer, buf, mode);
287        }
288    }
289}
290
291impl<'a> SessionWidget<'a> {
292    fn render_logs(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
293        use ratatui::widgets::{Paragraph, Wrap};
294
295        let inner = new_panel(&self.session.styles)
296            .title("Logs")
297            .active(false)
298            .mode(mode)
299            .render_and_get_inner(area, buf);
300
301        if inner.height == 0 || inner.width == 0 {
302            return;
303        }
304
305        let paragraph = Paragraph::new((*self.session.log_text()).clone()).wrap(Wrap { trim: false });
306        paragraph.render(inner, buf);
307    }
308
309    fn render_sidebar(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
310        let queue_items: Vec<String> = if let Some(cached) = &self.session.queued_inputs_preview_cache {
311            cached.clone()
312        } else {
313            let items: Vec<String> = self
314                .session
315                .queued_inputs
316                .iter()
317                .take(5)
318                .map(|input| {
319                    if input.chars().count() > 50 {
320                        let preview: String = input.chars().take(50).collect();
321                        format!("{preview}{}", crate::design::constants::ELLIPSIS)
322                    } else {
323                        input.clone()
324                    }
325                })
326                .collect();
327            self.session.queued_inputs_preview_cache = Some(items.clone());
328            items
329        };
330
331        let context_info = self.session.input_status_right.as_deref().unwrap_or("Ready");
332
333        SidebarWidget::new(&self.session.styles)
334            .local_agents(self.session.local_agents.clone())
335            .queue_items(queue_items)
336            .context_info(context_info)
337            .mode(mode)
338            .render(area, buf);
339    }
340
341    fn render_footer(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
342        let left_status = if self.session.progress_row_visible() {
343            self.session.progress_footer_status_text()
344        } else {
345            self.session
346                .status_left_text()
347                .filter(|status| !status.trim_start().starts_with("git:"))
348        }
349        .unwrap_or_else(|| {
350            if self.session.thinking_spinner.is_active && !self.session.progress_row_visible() {
351                "Thinking"
352            } else {
353                ""
354            }
355        });
356        let right_status = self.session.status_right_text().unwrap_or("");
357
358        // Blocked/recovery detection combines the first-class activity state
359        // with the status-text needles shared with the header badge (see
360        // blocked_status) so a reword cannot silently break the hint.
361        let is_blocked = matches!(self.session.activity_state, vtcode_commons::ui_protocol::ActivityState::Blocked)
362            || blocked_status::right_status_is_blocked(right_status)
363            || blocked_status::left_status_is_blocked(left_status)
364            || blocked_status::left_status_mentions_tools_disabled(left_status);
365        let is_recovery = matches!(self.session.activity_state, vtcode_commons::ui_protocol::ActivityState::Recovery)
366            || blocked_status::left_status_is_recovery(left_status);
367        let hint = if let Some(hint) = self.footer_hint_override {
368            hint
369        } else if is_blocked {
370            footer_hints::BLOCKED
371        } else if is_recovery {
372            footer_hints::RECOVERY
373        } else if self.session.thinking_spinner.is_active
374            || self.session.has_status_spinner()
375            || self.session.is_running_activity()
376        {
377            footer_hints::PROCESSING
378        } else if self.session.has_active_overlay() {
379            footer_hints::MODAL
380        } else if self.session.input_manager.content().is_empty() {
381            footer_hints::IDLE
382        } else {
383            footer_hints::EDITING
384        };
385
386        let shimmer_phase = footer_shimmer_phase(self.session);
387
388        let mut footer = FooterWidget::new(&self.session.styles)
389            .left_status(left_status)
390            .right_status(right_status)
391            .hint(hint)
392            .mode(mode);
393
394        if self.session.appearance.should_animate_progress_status()
395            && self.session.thinking_spinner.is_active
396            && !self.session.progress_row_visible()
397        {
398            footer = footer.spinner(self.session.thinking_spinner.current_frame());
399        } else if self.session.appearance.should_animate_progress_status()
400            && self.session.activity_state.is_stage()
401            && self.session.is_shimmer_active()
402            && !self.session.progress_row_visible()
403        {
404            footer = footer.spinner(pulse_spinner_frame_for_phase(self.session.shimmer_state.phase()));
405        }
406
407        if let Some(phase) = shimmer_phase {
408            footer = footer.shimmer_phase(phase);
409        }
410
411        footer.render(area, buf);
412    }
413}
414
415fn footer_shimmer_phase(session: &Session) -> Option<f32> {
416    (session.appearance.should_animate_progress_status() && session.is_shimmer_active())
417        .then_some(session.shimmer_state.phase())
418}
419
420#[expect(
421    dead_code,
422    reason = "Intentional compatibility, platform, test, or API-shape suppression."
423)]
424fn has_input_status(session: &Session) -> bool {
425    let left_present = session.input_status_left.as_ref().is_some_and(|value| !value.trim().is_empty());
426    if left_present {
427        return true;
428    }
429    session
430        .input_status_right
431        .as_ref()
432        .is_some_and(|value| !value.trim().is_empty())
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::tui::core_tui::types::{InlineCommand, InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme};
439    use std::sync::Arc;
440
441    fn segment(text: &str) -> InlineSegment {
442        InlineSegment {
443            text: text.to_string(),
444            style: Arc::new(InlineTextStyle::default()),
445        }
446    }
447
448    #[test]
449    fn compatibility_footer_keeps_git_status_while_transcript_owns_progress() {
450        use vtcode_commons::ui_protocol::{ActivityState, ProgressOperation, ProgressPhase, ProgressUpdate};
451
452        let mut session = Session::new(InlineTheme::default(), None, 24);
453        session.handle_command(InlineCommand::SetConfiguredInputStatus {
454            left: Some("topic/footer*".to_owned()),
455            right: Some("10:30".to_owned()),
456        });
457        session.handle_command(InlineCommand::SetActivityState(ActivityState::Building));
458        session.handle_command(InlineCommand::UpdateProgress(ProgressUpdate::Begin {
459            operation: ProgressOperation::start(),
460            phase: ProgressPhase::WaitingForModel,
461        }));
462        let area = Rect::new(0, 0, 120, 24);
463        let mut buf = Buffer::empty(area);
464        session.render_progress(Rect::new(0, 0, 120, 1), &mut buf);
465        SessionWidget::new(&mut session).render_footer(Rect::new(0, 1, 120, 4), &mut buf, LayoutMode::Wide);
466        let text: String = buf.content.iter().map(|cell| cell.symbol()).collect();
467        assert_eq!(text.matches("Waiting for model").count(), 1, "{text}");
468        assert!(text.contains("topic/footer*"), "{text}");
469        assert!(text.contains("10:30"), "{text}");
470        assert!(!text.contains("Building..."), "{text}");
471    }
472
473    #[test]
474    fn auto_layout_resize_recomputes_transcript_area_and_keeps_content_visible() {
475        let wide_area = Rect::new(0, 0, 120, 24);
476        let standard_area = Rect::new(0, 0, 100, 24);
477        let mut wide_buf = Buffer::empty(wide_area);
478        let mut standard_buf = Buffer::empty(standard_area);
479        let mut session = Session::new(InlineTheme::default(), None, 24);
480
481        for index in 0..8 {
482            session.push_line(InlineMessageKind::Agent, vec![segment(&format!("line {index}"))]);
483        }
484
485        let mut wide_widget = SessionWidget::new(&mut session);
486        (&mut wide_widget).render(wide_area, &mut wide_buf);
487        let wide_transcript = session.transcript_area().expect("wide transcript area");
488
489        let mut standard_widget = SessionWidget::new(&mut session);
490        (&mut standard_widget).render(standard_area, &mut standard_buf);
491        let standard_transcript = session.transcript_area().expect("standard transcript area");
492
493        assert!(wide_transcript.width < standard_transcript.width);
494        assert!(standard_transcript.height > 0);
495    }
496
497    #[test]
498    fn footer_shimmer_respects_reduce_motion_override_and_screen_reader_mode() {
499        let mut session = Session::new(InlineTheme::default(), None, 12);
500        session.handle_command(InlineCommand::SetInputStatus {
501            left: Some("Running tool: edit_file".to_string()),
502            right: None,
503        });
504        session.thinking_spinner.start();
505
506        assert!(footer_shimmer_phase(&session).is_some());
507
508        session.appearance.reduce_motion_mode = true;
509        assert!(session.is_shimmer_active(), "thinking indicator keeps the shared phase active");
510        assert_eq!(footer_shimmer_phase(&session), None);
511
512        session.appearance.reduce_motion_keep_progress_animation = true;
513        assert!(footer_shimmer_phase(&session).is_some());
514
515        session.appearance.screen_reader_mode = true;
516        assert_eq!(footer_shimmer_phase(&session), None);
517    }
518    #[test]
519    fn thinking_surfaces_respect_accessibility_policy_and_preserve_labels() {
520        for (reduce_motion, keep_animation, screen_reader, animated) in [
521            (false, false, false, true),
522            (true, false, false, false),
523            (true, true, false, true),
524            (false, true, true, false),
525            (true, true, true, false),
526        ] {
527            for configured in [None, Some("Custom status")] {
528                let mut session = Session::new(InlineTheme::default(), None, 12);
529                session.input_status_left = configured.map(str::to_string);
530                session.appearance.reduce_motion_mode = reduce_motion;
531                session.appearance.reduce_motion_keep_progress_animation = keep_animation;
532                session.appearance.screen_reader_mode = screen_reader;
533                session.thinking_spinner.start();
534                let area = Rect::new(0, 0, 100, 2);
535                let mut buf = Buffer::empty(area);
536                SessionWidget::new(&mut session).render_footer(area, &mut buf, LayoutMode::Standard);
537                let footer: String = buf.content.iter().map(|cell| cell.symbol()).collect();
538                let composer = session
539                    .render_input_status_line(100)
540                    .unwrap()
541                    .spans
542                    .iter()
543                    .map(|span| span.content.as_ref())
544                    .collect::<String>();
545                for (text, expected_spinner) in [(&footer, animated), (&composer, animated && configured.is_none())] {
546                    assert!(text.contains(configured.unwrap_or("Thinking")), "{text}");
547                    let has_braille = text.chars().any(|ch| ('\u{2800}'..='\u{28ff}').contains(&ch));
548                    assert_eq!(has_braille, expected_spinner);
549                }
550            }
551        }
552    }
553}