use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
widgets::Widget,
};
use super::{
FooterWidget, HeaderWidget, LayoutMode, Panel, SidebarWidget, TranscriptWidget, footer_hints,
};
use crate::ui::tui::session::Session;
pub struct SessionWidget<'a> {
session: &'a mut Session,
header_lines: Option<Vec<ratatui::text::Line<'static>>>,
header_area: Option<Rect>,
transcript_area: Option<Rect>,
navigation_area: Option<Rect>,
layout_mode: Option<LayoutMode>,
footer_hint_override: Option<&'static str>,
}
impl<'a> SessionWidget<'a> {
pub fn new(session: &'a mut Session) -> Self {
Self {
session,
header_lines: None,
header_area: None,
transcript_area: None,
navigation_area: None,
layout_mode: None,
footer_hint_override: None,
}
}
#[must_use]
pub fn header_lines(mut self, lines: Vec<ratatui::text::Line<'static>>) -> Self {
self.header_lines = Some(lines);
self
}
#[must_use]
pub fn header_area(mut self, area: Rect) -> Self {
self.header_area = Some(area);
self
}
#[must_use]
pub fn transcript_area(mut self, area: Rect) -> Self {
self.transcript_area = Some(area);
self
}
#[must_use]
pub fn navigation_area(mut self, area: Rect) -> Self {
self.navigation_area = Some(area);
self
}
#[must_use]
pub fn layout_mode(mut self, mode: LayoutMode) -> Self {
self.layout_mode = Some(mode);
self
}
#[must_use]
pub fn footer_hint_override(mut self, hint: &'static str) -> Self {
self.footer_hint_override = Some(hint);
self
}
fn compute_layout(&mut self, area: Rect, mode: LayoutMode) -> SessionLayout {
let footer_h = mode.footer_height();
let max_header_pct = mode.max_header_percent();
let header_lines = if let Some(lines) = self.header_lines.as_ref() {
lines.clone()
} else {
self.session.header_lines()
};
let natural_header_h = self
.session
.header_height_from_lines(area.width, &header_lines);
let max_header_h = ((area.height as f32) * max_header_pct) as u16;
let header_h = natural_header_h.min(max_header_h).max(1);
let main_h = area.height.saturating_sub(header_h + footer_h);
let [header_area, main_area, footer_area] = Layout::vertical([
Constraint::Length(header_h),
Constraint::Length(main_h),
Constraint::Length(footer_h),
])
.split(area)[..] else {
return SessionLayout {
header: Rect::ZERO,
main: Rect::ZERO,
sidebar: None,
footer: Rect::ZERO,
mode,
};
};
let show_sidebar = mode.allow_sidebar() && self.session.appearance.should_show_sidebar();
if show_sidebar {
let sidebar_pct = mode.sidebar_width_percent();
let [left, right] = Layout::horizontal([
Constraint::Percentage(100 - sidebar_pct),
Constraint::Percentage(sidebar_pct),
])
.split(main_area)[..] else {
return SessionLayout {
header: header_area,
main: main_area,
sidebar: None,
footer: footer_area,
mode,
};
};
return SessionLayout {
header: header_area,
main: left,
sidebar: Some(right),
footer: footer_area,
mode,
};
}
SessionLayout {
header: header_area,
main: main_area,
sidebar: None,
footer: footer_area,
mode,
}
}
}
struct SessionLayout {
header: Rect,
main: Rect,
sidebar: Option<Rect>,
footer: Rect,
#[allow(dead_code)]
mode: LayoutMode,
}
impl Widget for &mut SessionWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let mode = self
.layout_mode
.unwrap_or_else(|| self.session.resolved_layout_mode(area));
if let (Some(header_area), Some(transcript_area)) = (self.header_area, self.transcript_area)
{
self.session.poll_log_entries();
if header_area.width > 0 && header_area.height > 0 {
let header_lines = if let Some(lines) = self.header_lines.as_ref() {
lines.clone()
} else {
self.session.header_lines()
};
HeaderWidget::new(self.session)
.lines(header_lines)
.render(header_area, buf);
}
if transcript_area.width > 0 && transcript_area.height > 0 {
self.session.apply_view_rows(transcript_area.height);
let has_logs =
self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
if has_logs {
let chunks =
Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(transcript_area);
TranscriptWidget::new(self.session).render(chunks[0], buf);
self.render_logs(chunks[1], buf, mode);
} else {
TranscriptWidget::new(self.session).render(transcript_area, buf);
}
}
if let Some(sidebar_area) = self.navigation_area
&& sidebar_area.width > 0
&& sidebar_area.height > 0
{
self.render_sidebar(sidebar_area, buf, mode);
}
return;
}
let layout_height = area.height.saturating_sub(self.session.input_height);
let layout_area = Rect::new(area.x, area.y, area.width, layout_height);
if layout_area.height == 0 || layout_area.width == 0 {
return;
}
self.session.poll_log_entries();
let layout = self.compute_layout(layout_area, mode);
if layout.header.height != self.session.header_rows {
self.session.header_rows = layout.header.height;
self.session.recalculate_transcript_rows();
}
self.session.apply_view_rows(layout.main.height);
let header_lines = if let Some(lines) = self.header_lines.as_ref() {
lines.clone()
} else {
self.session.header_lines()
};
HeaderWidget::new(self.session)
.lines(header_lines)
.render(layout.header, buf);
let has_logs = self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
if has_logs {
let chunks = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(layout.main);
TranscriptWidget::new(self.session).render(chunks[0], buf);
self.render_logs(chunks[1], buf, mode);
} else {
TranscriptWidget::new(self.session).render(layout.main, buf);
}
if let Some(sidebar_area) = layout.sidebar {
self.render_sidebar(sidebar_area, buf, mode);
}
if mode.show_footer() && layout.footer.height > 0 {
self.render_footer(layout.footer, buf, mode);
}
}
}
impl<'a> SessionWidget<'a> {
fn render_logs(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
use ratatui::widgets::{Paragraph, Wrap};
let inner = Panel::new(&self.session.styles)
.title("Logs")
.active(false)
.mode(mode)
.render_and_get_inner(area, buf);
if inner.height == 0 || inner.width == 0 {
return;
}
let paragraph =
Paragraph::new((*self.session.log_text()).clone()).wrap(Wrap { trim: false });
paragraph.render(inner, buf);
}
fn render_sidebar(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
let queue_items: Vec<String> =
if let Some(cached) = &self.session.queued_inputs_preview_cache {
cached.clone()
} else {
let items: Vec<String> = self
.session
.queued_inputs
.iter()
.take(5)
.map(|input| {
let preview: String = input.chars().take(50).collect();
if input.len() > 50 {
format!("{}...", preview)
} else {
preview
}
})
.collect();
self.session.queued_inputs_preview_cache = Some(items.clone());
items
};
let context_info = self
.session
.input_status_right
.as_deref()
.unwrap_or("Ready");
SidebarWidget::new(&self.session.styles)
.local_agents(self.session.local_agents.clone())
.queue_items(queue_items)
.context_info(context_info)
.mode(mode)
.render(area, buf);
}
fn render_footer(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
let left_status = self.session.status_left_text().unwrap_or("");
let right_status = self.session.status_right_text().unwrap_or("");
let hint = if let Some(hint) = self.footer_hint_override {
hint
} else if self.session.thinking_spinner.is_active
|| self.session.has_status_spinner()
|| self.session.is_running_activity()
{
footer_hints::PROCESSING
} else if self.session.has_active_overlay() {
footer_hints::MODAL
} else if self.session.input_manager.content().is_empty() {
footer_hints::IDLE
} else {
footer_hints::EDITING
};
let shimmer_phase = self
.session
.is_shimmer_active()
.then_some(self.session.shimmer_state.phase());
let mut footer = FooterWidget::new(&self.session.styles)
.left_status(left_status)
.right_status(right_status)
.hint(hint)
.mode(mode);
if self.session.thinking_spinner.is_active {
footer = footer.spinner(self.session.thinking_spinner.current_frame());
}
if let Some(phase) = shimmer_phase {
footer = footer.shimmer_phase(phase);
}
footer.render(area, buf);
}
}
#[allow(dead_code)]
fn has_input_status(session: &Session) -> bool {
let left_present = session
.input_status_left
.as_ref()
.is_some_and(|value| !value.trim().is_empty());
if left_present {
return true;
}
session
.input_status_right
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core_tui::types::{InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme};
use std::sync::Arc;
fn segment(text: &str) -> InlineSegment {
InlineSegment {
text: text.to_string(),
style: Arc::new(InlineTextStyle::default()),
}
}
#[test]
fn auto_layout_resize_recomputes_transcript_area_and_keeps_content_visible() {
let wide_area = Rect::new(0, 0, 120, 24);
let standard_area = Rect::new(0, 0, 100, 24);
let mut wide_buf = Buffer::empty(wide_area);
let mut standard_buf = Buffer::empty(standard_area);
let mut session = Session::new(InlineTheme::default(), None, 24);
for index in 0..8 {
session.push_line(
InlineMessageKind::Agent,
vec![segment(&format!("line {index}"))],
);
}
let mut wide_widget = SessionWidget::new(&mut session);
(&mut wide_widget).render(wide_area, &mut wide_buf);
let wide_transcript = session.transcript_area().expect("wide transcript area");
let mut standard_widget = SessionWidget::new(&mut session);
(&mut standard_widget).render(standard_area, &mut standard_buf);
let standard_transcript = session.transcript_area().expect("standard transcript area");
assert!(wide_transcript.width < standard_transcript.width);
assert!(standard_transcript.height > 0);
}
}