pub(in crate::tui) mod activity;
mod header;
mod modals;
mod prompt;
pub(in crate::tui) mod transcript;
#[cfg(test)]
pub(crate) use super::layout::RIGHT_COLUMN_RAIL_WIDTH;
pub(crate) use super::layout::{TuiPane, layout_for};
use super::state::{MissionControlState, RightColumnTab};
use super::theme::MissionControlTheme;
#[cfg(test)]
pub(in crate::tui::render) use modals::{
CONNECT_PROVIDER_MODAL, PICKER_MODAL, SYSTEM_PROMPT_MODAL, modal_rect,
};
pub(crate) use modals::{
connect_provider_choice_list_area, connect_provider_custom_field_visible,
connect_provider_modal_visible, connect_provider_oauth_controls_visible,
connect_provider_oauth_fallback_visible, logout_confirmation_action_row_area,
logout_picker_list_area, mcp_modal_list_area, model_picker_list_area, models_modal_list_area,
rewind_modal_text_area, sessions_modal_areas, sessions_modal_list_area,
sessions_modal_preview_area, settings_scope_visible, skills_modal_list_area,
subagent_viewer_tab_at, subagent_viewer_timeline_area, subagents_modal_list_area,
system_prompt_text_area, theme_picker_list_area, theme_picker_query_visible,
tools_modal_list_area, usage_modal_text_area,
};
use ratatui::{
Frame,
layout::{Alignment, Margin, Position, Rect, Size},
style::{Modifier, Style},
symbols::scrollbar::{Set, VERTICAL},
text::{Line, Span},
widgets::{
Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Widget,
},
};
use tui_scrollview::ScrollView;
pub(super) fn usize_to_u16_saturating(value: usize) -> u16 {
value.min(usize::from(u16::MAX)) as u16
}
pub(super) fn scroll_view_content_width(area: Rect) -> u16 {
area.width.saturating_sub(1)
}
pub(super) fn transcript_scroll_view_content_width(area: Rect) -> u16 {
area.width.saturating_sub(2)
}
#[cfg(test)]
pub(super) fn render_scroll_view_persistent<W: Widget>(
frame: &mut Frame<'_>,
area: Rect,
offset: u16,
content_height: u16,
widget: W,
scroll_state: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
) {
render_scroll_view_persistent_with_theme(
frame,
area,
offset,
content_height,
widget,
scroll_state,
MissionControlTheme::default(),
);
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_scroll_view_persistent_with_theme<W: Widget>(
frame: &mut Frame<'_>,
area: Rect,
offset: u16,
content_height: u16,
widget: W,
scroll_state: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
theme: MissionControlTheme,
) {
render_scroll_view_persistent_impl(
frame,
area,
scroll_view_content_width(area),
offset,
content_height,
None,
widget,
scroll_state,
theme,
);
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
#[expect(dead_code)]
pub(super) fn render_scroll_view_persistent_with_metrics<W: Widget>(
frame: &mut Frame<'_>,
area: Rect,
content_width: u16,
offset: u16,
content_height: u16,
scrollbar_content_length: usize,
scrollbar_position: usize,
scrollbar_viewport_length: usize,
widget: W,
scroll_state: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
) {
render_scroll_view_persistent_with_metrics_and_theme(
frame,
area,
content_width,
offset,
content_height,
scrollbar_content_length,
scrollbar_position,
scrollbar_viewport_length,
widget,
scroll_state,
MissionControlTheme::default(),
);
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_scroll_view_persistent_with_metrics_and_theme<W: Widget>(
frame: &mut Frame<'_>,
area: Rect,
content_width: u16,
offset: u16,
content_height: u16,
scrollbar_content_length: usize,
scrollbar_position: usize,
scrollbar_viewport_length: usize,
widget: W,
scroll_state: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
theme: MissionControlTheme,
) {
render_scroll_view_persistent_impl(
frame,
area,
content_width,
offset,
content_height,
Some((
scrollbar_content_length,
scrollbar_position,
scrollbar_viewport_length,
)),
widget,
scroll_state,
theme,
);
}
#[allow(clippy::too_many_arguments)]
fn render_scroll_view_persistent_impl<W: Widget>(
frame: &mut Frame<'_>,
area: Rect,
content_width: u16,
offset: u16,
content_height: u16,
scrollbar_metrics: Option<(usize, usize, usize)>,
widget: W,
scroll_state: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
theme: MissionControlTheme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let size = Size::new(
content_width.min(area.width),
content_height.max(area.height),
);
let mut view = ScrollView::new(size);
view.render_widget(widget, Rect::new(0, 0, size.width, size.height));
let mut local_scroll_state = *scroll_state.borrow();
if local_scroll_state.offset() == Position::ORIGIN && offset != 0 {
local_scroll_state.set_offset(Position::new(0, offset));
}
render_prepared_scroll_view_with_theme(
frame,
area,
view,
&mut local_scroll_state,
scrollbar_metrics,
theme,
);
}
fn render_prepared_scroll_view_with_theme(
frame: &mut Frame<'_>,
area: Rect,
view: ScrollView,
scroll_state: &mut tui_scrollview::ScrollViewState,
scrollbar_metrics: Option<(usize, usize, usize)>,
theme: MissionControlTheme,
) {
if area.is_empty() {
return;
}
let content_height = view.size().height;
let content_width = view.size().width.min(area.width.saturating_sub(1));
let view = view.scrollbars_visibility(tui_scrollview::ScrollbarVisibility::Never);
if content_width > 0 {
frame.render_stateful_widget(
view,
Rect {
width: content_width,
..area
},
scroll_state,
);
}
let gutter = Rect::new(
area.x.saturating_add(content_width),
area.y,
area.width.saturating_sub(content_width),
area.height,
);
frame.render_widget(Clear, gutter);
frame.render_widget(Block::default().style(theme.pane()), gutter);
let max_scroll = content_height.saturating_sub(area.height);
let (content_length, position, viewport_length) = scrollbar_metrics.unwrap_or((
usize::from(max_scroll) + 1,
usize::from(scroll_state.offset().y.min(max_scroll)),
usize::from(area.height),
));
render_scrollbar_with_theme(
frame,
area,
content_length,
position,
viewport_length,
theme,
);
}
#[cfg(test)]
#[expect(dead_code)]
pub(super) fn render_scrollbar(
frame: &mut Frame<'_>,
area: Rect,
content_length: usize,
position: usize,
viewport_length: usize,
) {
render_scrollbar_with_theme(
frame,
area,
content_length,
position,
viewport_length,
MissionControlTheme::default(),
);
}
pub(super) fn render_scrollbar_with_theme(
frame: &mut Frame<'_>,
area: Rect,
content_length: usize,
position: usize,
viewport_length: usize,
theme: MissionControlTheme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let mut scrollbar_state = ScrollbarState::new(content_length.max(1))
.viewport_content_length(viewport_length)
.position(position.min(content_length.saturating_sub(1)));
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.symbols(Set {
track: "│",
thumb: "┃",
begin: VERTICAL.begin,
end: VERTICAL.end,
})
.thumb_style(
Style::new()
.fg(theme.border_active())
.bg(theme.surface_background()),
)
.track_style(
Style::new()
.fg(theme.text_dim())
.bg(theme.surface_background()),
)
.begin_style(
Style::new()
.fg(theme.text_dim())
.bg(theme.surface_background()),
)
.end_style(
Style::new()
.fg(theme.text_dim())
.bg(theme.surface_background()),
);
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
#[cfg(test)]
#[expect(dead_code)]
pub(super) fn render_scroll_view_persistent_state<W: Widget>(
frame: &mut Frame<'_>,
area: Rect,
content_height: u16,
widget: W,
scroll_state: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
) {
render_scroll_view_persistent_state_with_theme(
frame,
area,
content_height,
widget,
scroll_state,
MissionControlTheme::default(),
);
}
pub(super) fn render_scroll_view_persistent_state_with_theme<W: Widget>(
frame: &mut Frame<'_>,
area: Rect,
content_height: u16,
widget: W,
scroll_state: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
theme: MissionControlTheme,
) {
render_scroll_view_persistent_with_theme(
frame,
area,
0,
content_height,
widget,
scroll_state,
theme,
);
}
pub(crate) fn draw(frame: &mut Frame<'_>, state: &MissionControlState) {
let layout = layout_for(frame.area(), state);
let theme = state.theme;
frame.render_widget(Block::default().style(theme.app()), frame.area());
transcript::draw_transcript(frame, layout.transcript, state);
if let Some(area) = layout.right_column_rail {
draw_right_column_rail(frame, area, state);
}
match state.right_column_tab {
RightColumnTab::Activity => {
if let Some(area) = layout.activity_tree {
activity::draw_tree(frame, area, state);
}
if let Some(area) = layout.detail {
activity::draw_detail(frame, area, state);
}
}
RightColumnTab::Summary => {
if let Some(area) = layout.summary {
draw_summary(frame, area, state);
}
}
}
prompt::draw_prompt(frame, layout.prompt, state);
prompt::draw_autocomplete(frame, frame.area(), layout.prompt, state);
if let Some(help) = layout.help {
header::draw_help(frame, help, state);
}
modals::draw_top_modal(frame, frame.area(), state);
draw_toast(frame, frame.area(), state);
}
fn draw_right_column_rail(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
if area.width == 0 || area.height == 0 {
return;
}
let theme = state.theme;
frame.render_widget(
Block::default().style(theme.right_column_rail_background()),
area,
);
let mut lines = Vec::new();
for tab in [RightColumnTab::Activity, RightColumnTab::Summary] {
let active = state.right_column_tab == tab;
let style = if active {
theme.right_column_rail_active_tab()
} else {
theme.right_column_rail_inactive_tab()
};
let marker = if active { "▌" } else { " " };
let padded_row = |character: Option<char>| {
let mut row = String::with_capacity(usize::from(area.width));
row.push_str(marker);
row.push(' ');
row.push(character.unwrap_or(' '));
row.extend(std::iter::repeat_n(
' ',
usize::from(area.width.saturating_sub(3)),
));
Line::from(Span::styled(row, style))
};
if tab == RightColumnTab::Summary {
lines.push(padded_row(None));
}
for padding in 0..super::layout::RIGHT_COLUMN_TAB_VERTICAL_PADDING {
if tab == RightColumnTab::Summary
&& padding + 1 == super::layout::RIGHT_COLUMN_TAB_VERTICAL_PADDING
{
let spinner_color = if state.summary.running {
theme.text_accent()
} else {
theme.text_muted()
};
lines.push(Line::from(vec![
Span::styled(format!("{marker} "), style),
Span::styled(state.summary_spinner(), style.fg(spinner_color)),
Span::styled(" ".repeat(usize::from(area.width.saturating_sub(3))), style),
]));
} else {
lines.push(padded_row(None));
}
}
lines.extend(
super::layout::right_column_tab_label(tab)
.chars()
.map(|character| padded_row(Some(character))),
);
for _ in 0..super::layout::RIGHT_COLUMN_TAB_VERTICAL_PADDING {
lines.push(padded_row(None));
}
if tab == RightColumnTab::Activity {
lines.push(Line::from(Span::styled(
"─".repeat(usize::from(area.width)),
theme.right_column_rail_separator(),
)));
}
}
frame.render_widget(
Paragraph::new(lines).style(theme.right_column_rail_background()),
area,
);
}
pub(crate) fn summary_sections(area: Rect) -> [Rect; 2] {
let sections = ratatui::layout::Layout::vertical([
ratatui::layout::Constraint::Percentage(40),
ratatui::layout::Constraint::Percentage(60),
])
.split(area);
[sections[0], sections[1]]
}
pub(crate) fn summary_section_body(area: Rect, files: bool) -> Rect {
let section = summary_sections(area)[usize::from(files)];
let text = content_text_area_for_pane(TuiPane::Summary, section);
let controls = if files { 1 } else { 2 };
Rect {
y: text.y.saturating_add(controls),
height: text.height.saturating_sub(controls),
..text
}
}
fn draw_summary(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let [summary_area, files_area] = summary_sections(area);
draw_session_files(frame, files_area, state);
let area = summary_area;
let theme = state.theme;
let active = state.is_summary_focused() && !state.session_files.focused;
let block = themed_block_with_theme(theme, "Summary", active);
let inner = block.inner(area);
frame.render_widget(block, area);
let text_area = pane_text_area(inner);
if text_area.width == 0 || text_area.height == 0 {
return;
}
let summary = &state.summary;
let button = if summary.enabled {
"[ Stop ]"
} else {
"[ Start ]"
};
let status = if summary.running {
"Running"
} else if summary.enabled {
"Waiting"
} else {
"Stopped"
};
frame.render_widget(
Paragraph::new(format!("{button} {status} · Enter")).style(theme.pane()),
Rect {
height: 1,
..text_area
},
);
let body = Rect {
y: text_area.y.saturating_add(2),
height: text_area.height.saturating_sub(2),
..text_area
};
if body.width < 2 || body.height == 0 {
return;
}
let content = Rect {
width: body.width.saturating_sub(1),
..body
};
let max_scroll = summary.max_scroll(content.width, content.height);
let offset = summary.scroll.min(max_scroll);
frame.render_widget(
Paragraph::new(summary.text())
.style(theme.pane())
.wrap(ratatui::widgets::Wrap { trim: false })
.scroll((offset, 0)),
content,
);
render_scrollbar_with_theme(
frame,
body,
usize::from(max_scroll) + 1,
usize::from(offset),
usize::from(body.height),
theme,
);
}
fn draw_session_files(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
let active = state.is_summary_focused() && state.session_files.focused;
let reason_color = |reason| super::state::session_files::file_reason_color(theme, reason);
let title = Line::from(vec![
Span::raw(" Session files · "),
Span::styled("R read", reason_color(1)),
Span::raw(" "),
Span::styled("W written", reason_color(2)),
Span::raw(" "),
Span::styled("E edited", reason_color(4)),
Span::raw(" "),
])
.style(theme.title(active));
let block = bordered_block()
.border_style(theme.border(active))
.title(title)
.style(theme.app());
let body = pane_text_area(block.inner(area));
let body = Rect {
y: body.y.saturating_add(1),
height: body.height.saturating_sub(1),
..body
};
frame.render_widget(block, area);
let max_scroll = state.session_files.max_scroll(body.height);
let offset = state.session_files.scroll.min(max_scroll);
let content = Rect {
width: body.width.saturating_sub(1),
..body
};
frame.render_widget(
Paragraph::new(
state
.session_files
.visible_text(offset, body.height, state.theme),
)
.style(state.theme.pane()),
content,
);
render_scrollbar_with_theme(
frame,
body,
usize::from(max_scroll) + 1,
usize::from(offset),
usize::from(body.height),
state.theme,
);
}
fn draw_toast(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let Some(toast) = &state.toast else {
return;
};
if area.width < 8 || area.height < 3 {
return;
}
let width = (toast.message.chars().count() as u16)
.saturating_add(4)
.min(area.width);
let height = 3.min(area.height);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height + 1);
let toast_area = Rect::new(x, y, width, height);
let theme = state.theme;
let style = match toast.severity {
super::toast::ToastSeverity::Success => theme.selection(),
super::toast::ToastSeverity::Error => theme.transcript_error_body(),
};
frame.render_widget(Clear, toast_area);
let block = bordered_block().border_style(style).style(style);
let inner = block.inner(toast_area);
frame.render_widget(block, toast_area);
frame.render_widget(
Paragraph::new(toast.message.clone())
.style(style)
.alignment(Alignment::Center),
inner,
);
}
#[cfg(test)]
pub(super) fn hotkey_style() -> Style {
hotkey_style_with_theme(MissionControlTheme::default())
}
pub(super) fn hotkey_style_with_theme(theme: MissionControlTheme) -> Style {
Style::new()
.fg(theme.text_dim())
.bg(theme.surface_background())
.remove_modifier(Modifier::BOLD)
}
#[cfg(test)]
#[expect(dead_code)]
pub(super) fn hotkey_span(text: impl Into<String>) -> Span<'static> {
hotkey_span_with_theme(MissionControlTheme::default(), text)
}
pub(super) fn hotkey_span_with_theme(
theme: MissionControlTheme,
text: impl Into<String>,
) -> Span<'static> {
Span::styled(text.into(), hotkey_style_with_theme(theme))
}
#[cfg(test)]
#[expect(dead_code)]
pub(super) fn hotkey_line(text: impl Into<String>, base_style: Style) -> Line<'static> {
hotkey_line_with_theme(MissionControlTheme::default(), text, base_style)
}
pub(super) fn hotkey_line_with_theme(
theme: MissionControlTheme,
text: impl Into<String>,
base_style: Style,
) -> Line<'static> {
let text = text.into();
let mut spans = Vec::new();
let mut rest = text.as_str();
while let Some(start) = rest.find('[') {
let (before, after_start) = rest.split_at(start);
if !before.is_empty() {
spans.push(Span::styled(before.to_string(), base_style));
}
if let Some(end) = after_start.find(']') {
let (hotkey, after_end) = after_start.split_at(end + 1);
spans.push(hotkey_span_with_theme(theme, hotkey.to_string()));
rest = after_end;
} else {
spans.push(Span::styled(after_start.to_string(), base_style));
rest = "";
}
}
if !rest.is_empty() {
spans.push(Span::styled(rest.to_string(), base_style));
}
Line::from(spans)
}
const PANE_TEXT_HORIZONTAL_PADDING: u16 = 1;
pub(super) fn bordered_block() -> Block<'static> {
Block::default().borders(Borders::ALL)
}
pub(super) fn padded_title_text(text: impl Into<String>, style: Style) -> Line<'static> {
let text = text.into();
let padded = if text.is_empty() {
text
} else {
format!(" {text} ")
};
Line::styled(padded, style)
}
#[cfg(test)]
pub(super) fn themed_block(title: impl Into<String>, active: bool) -> Block<'static> {
themed_block_with_theme(MissionControlTheme::default(), title, active)
}
pub(super) fn themed_block_with_theme(
theme: MissionControlTheme,
title: impl Into<String>,
active: bool,
) -> Block<'static> {
bordered_block()
.border_style(theme.border(active))
.title(padded_title_text(title, theme.title(active)))
.style(theme.app())
}
#[cfg(test)]
#[expect(dead_code)]
pub(super) fn overlay_block(title: &'static str) -> Block<'static> {
overlay_block_with_theme(MissionControlTheme::default(), title)
}
pub(super) fn overlay_block_with_theme(
theme: MissionControlTheme,
title: &'static str,
) -> Block<'static> {
bordered_block()
.border_style(theme.border_alt().fg(theme.border_alternate()))
.title(padded_title_text(title, theme.title_alt()))
.style(theme.overlay_panel())
}
pub(super) fn pane_text_area(block_inner: Rect) -> Rect {
inset_when_space_allows(block_inner, PANE_TEXT_HORIZONTAL_PADDING, 0)
}
pub(super) fn overlay_text_area(background: Rect) -> Rect {
inset_when_space_allows(
background,
PANE_TEXT_HORIZONTAL_PADDING,
PANE_TEXT_HORIZONTAL_PADDING,
)
}
fn inset_when_space_allows(area: Rect, horizontal: u16, vertical: u16) -> Rect {
let horizontal = if area.width > horizontal.saturating_mul(2) {
horizontal
} else {
0
};
let vertical = if area.height > vertical.saturating_mul(2) {
vertical
} else {
0
};
area.inner(Margin {
horizontal,
vertical,
})
}
#[cfg(test)]
#[expect(dead_code)]
pub(super) fn render_overlay_fill(frame: &mut Frame<'_>, block_inner: Rect) -> Rect {
render_overlay_fill_with_theme(frame, block_inner, MissionControlTheme::default())
}
pub(super) fn render_overlay_fill_with_theme(
frame: &mut Frame<'_>,
block_inner: Rect,
theme: MissionControlTheme,
) -> Rect {
frame.render_widget(Block::default().style(theme.panel_alt()), block_inner);
overlay_text_area(block_inner)
}
#[cfg(test)]
#[expect(dead_code)]
pub(super) fn render_overlay_fill_horizontal(frame: &mut Frame<'_>, block_inner: Rect) -> Rect {
render_overlay_fill_horizontal_with_theme(frame, block_inner, MissionControlTheme::default())
}
pub(super) fn render_overlay_fill_horizontal_with_theme(
frame: &mut Frame<'_>,
block_inner: Rect,
theme: MissionControlTheme,
) -> Rect {
frame.render_widget(Block::default().style(theme.panel_alt()), block_inner);
pane_text_area(block_inner)
}
fn transcript_surface(area: Rect) -> Rect {
let horizontal_inset = u16::from(area.width > 2);
Rect::new(
area.x.saturating_add(horizontal_inset),
area.y,
area.width
.saturating_sub(horizontal_inset.saturating_mul(2)),
area.height,
)
}
pub(crate) fn transcript_title_area_for_pane(area: Rect) -> Rect {
let surface = transcript_surface(area);
let title_surface = inset_when_space_allows(surface, 0, 1);
Rect::new(
title_surface.x,
title_surface.y,
title_surface.width,
u16::from(surface.height > 0),
)
}
pub(crate) fn transcript_text_area_for_pane(area: Rect) -> Rect {
let surface = transcript_surface(area);
let y_offset = surface.height.min(4);
let height = surface.height.saturating_sub(y_offset);
Rect::new(
surface.x,
surface.y.saturating_add(y_offset),
surface.width,
height,
)
}
pub(crate) fn content_text_area_for_pane(pane: TuiPane, area: Rect) -> Rect {
if matches!(pane, TuiPane::Transcript) {
return transcript_text_area_for_pane(area);
}
let block_inner = inset_when_space_allows(area, 1, 1);
match pane {
TuiPane::ActivityTree | TuiPane::Detail | TuiPane::Summary | TuiPane::Prompt => {
pane_text_area(block_inner)
}
TuiPane::Help => overlay_text_area(block_inner),
TuiPane::Transcript | TuiPane::RightColumnRail => area,
}
}
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod primary_agent_render_tests;
#[cfg(test)]
mod scrollbar_alignment_tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
use std::cell::RefCell;
#[test]
fn shared_scroll_view_matches_scrollbar_metrics_without_changing_saved_offset() {
let theme = MissionControlTheme::default();
for (height, offset) in [(5, 0), (6, 0), (6, 1), (30, 12), (30, 25), (30, 99)] {
let state = RefCell::new(tui_scrollview::ScrollViewState::default());
state.borrow_mut().set_offset(Position::new(0, offset));
let mut terminal = Terminal::new(TestBackend::new(10, 5)).unwrap();
terminal
.draw(|frame| {
render_scroll_view_persistent_with_theme(
frame,
Rect::new(0, 0, 5, 5),
0,
height,
Paragraph::new("content"),
&state,
theme,
);
render_scrollbar_with_theme(
frame,
Rect::new(5, 0, 5, 5),
usize::from(height.saturating_sub(5)) + 1,
usize::from(offset),
5,
theme,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
for y in 0..5 {
assert_eq!(
buffer[(4, y)],
buffer[(9, y)],
"height={height}, offset={offset}"
);
}
assert_eq!(state.borrow().offset().y, offset);
}
}
}