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
11pub 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 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 #[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 #[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 #[must_use]
77 pub(crate) fn header_area(mut self, area: Rect) -> Self {
78 self.header_area = Some(area);
79 self
80 }
81
82 #[must_use]
84 pub(crate) fn transcript_area(mut self, area: Rect) -> Self {
85 self.transcript_area = Some(area);
86 self
87 }
88
89 #[must_use]
91 pub(crate) fn navigation_area(mut self, area: Rect) -> Self {
92 self.navigation_area = Some(area);
93 self
94 }
95
96 #[must_use]
98 pub fn layout_mode(mut self, mode: LayoutMode) -> Self {
99 self.layout_mode = Some(mode);
100 self
101 }
102
103 #[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 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 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 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 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
168struct 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 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 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 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 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 self.session.poll_log_entries();
244
245 let layout = self.compute_layout(layout_area, mode);
247
248 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 self.session.apply_view_rows(layout.main.height);
256
257 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 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 if let Some(sidebar_area) = layout.sidebar {
281 self.render_sidebar(sidebar_area, buf, mode);
282 }
283
284 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 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}