pub(in crate::tui) mod activity;
mod header;
mod modals;
pub(crate) use header::{header_pin_toggle_area, header_toggle_areas};
mod prompt;
pub(in crate::tui) mod transcript;
mod transcript_modal;
pub(crate) use transcript_modal::TranscriptModal;
pub(crate) use super::layout::{TuiPane, layout_for};
use super::state::{MissionControlState, ModalKind, RightColumnTab};
use super::theme::MissionControlTheme;
pub(crate) use modals::{
connect_provider_choice_list_area, connect_provider_custom_field_visible,
connect_provider_modal_visible, connect_provider_oauth_button_areas,
connect_provider_oauth_controls_visible, connect_provider_oauth_fallback_visible,
header_modal_close_control_area, logout_confirmation_action_row_area, logout_picker_list_area,
mcp_modal_list_area, model_picker_list_area, rewind_modal_text_area, sessions_modal_areas,
sessions_modal_list_area, sessions_modal_preview_area, settings_scope_visible, skill_row_at,
skills_modal_list_area, subagent_viewer_prompt_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,
top_modal_close_control_area, usage_modal_text_area,
};
use ratatui::{
Frame,
layout::{Alignment, Margin, Position, Rect, Size},
style::{Modifier, Style},
symbols::scrollbar::{HORIZONTAL, 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)
}
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,
);
}
#[expect(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,
);
}
#[expect(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_text_scroll_view_with_theme(
frame: &mut Frame<'_>,
area: Rect,
text: &str,
scroll_state: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
theme: MissionControlTheme,
) {
if area.is_empty() {
return;
}
let content_width = scroll_view_content_width(area);
let lines = text
.split('\n')
.map(|line| Line::from(Span::raw(line)))
.collect::<Vec<_>>();
let content_height = usize_to_u16_saturating(
Paragraph::new(lines)
.wrap(ratatui::widgets::Wrap { trim: false })
.line_count(content_width.max(1)),
)
.max(area.height);
let local_scroll_state = *scroll_state.borrow();
let offset = local_scroll_state
.offset()
.y
.min(content_height.saturating_sub(area.height));
if content_width > 0 {
let viewport = Rect {
width: content_width,
..area
};
frame.render_widget(Clear, viewport);
frame.render_widget(
Paragraph::new(text)
.style(theme.pane())
.wrap(ratatui::widgets::Wrap { trim: false })
.scroll((offset, 0)),
viewport,
);
}
render_scroll_view_gutter_with_theme(
frame,
area,
content_width,
content_height,
offset,
None,
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,
);
}
render_scroll_view_gutter_with_theme(
frame,
area,
content_width,
content_height,
scroll_state.offset().y,
scrollbar_metrics,
theme,
);
}
fn render_scroll_view_gutter_with_theme(
frame: &mut Frame<'_>,
area: Rect,
content_width: u16,
content_height: u16,
offset: u16,
scrollbar_metrics: Option<(usize, usize, usize)>,
theme: MissionControlTheme,
) {
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(offset.min(max_scroll)),
usize::from(area.height),
));
render_scrollbar_with_theme(
frame,
area,
content_length,
position,
viewport_length,
theme,
);
}
fn scrollbar_styles(theme: MissionControlTheme) -> (Style, Style) {
let background = theme.surface_background();
(
Style::new().fg(theme.border_active()).bg(background),
Style::new().fg(theme.text_dim()).bg(background),
)
}
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 (thumb_style, track_style) = scrollbar_styles(theme);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.symbols(Set {
track: "│",
thumb: "┃",
begin: VERTICAL.begin,
end: VERTICAL.end,
})
.thumb_style(thumb_style)
.track_style(track_style)
.begin_style(track_style)
.end_style(track_style);
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
pub(super) fn render_horizontal_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 (thumb_style, track_style) = scrollbar_styles(theme);
let scrollbar = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
.symbols(Set {
track: "─",
thumb: "━",
begin: HORIZONTAL.begin,
end: HORIZONTAL.end,
})
.thumb_style(thumb_style)
.track_style(track_style)
.begin_style(track_style)
.end_style(track_style);
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
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) {
draw_in_area(frame, frame.area(), state);
if let Some(modal) = &state.conversation_modal {
state.release_notes_drawn.set(false);
modal.draw(frame);
}
}
pub(super) fn draw_in_area(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
state.release_notes_drawn.set(false);
state.images.borrow_mut().clear_frame();
let layout = layout_for(area, state);
let theme = state.theme;
frame.render_widget(Block::default().style(theme.app()), area);
header::draw_header(frame, layout.header, state);
if state.right_column_tab == RightColumnTab::Diff {
super::diff::draw(frame, layout.transcript, state);
} else {
transcript::draw_transcript(frame, layout.transcript, state);
}
if let Some(area) = layout.right_column_rail {
draw_right_column_rail(frame, area, state);
}
if state.modals.header_modals_pinned && !state.header_modal_visible() {
modals::draw_header_modals(frame, area, state);
}
prompt::draw_prompt(frame, layout.prompt, state);
if !state.header_modal_visible() {
prompt::draw_autocomplete(frame, area, layout.prompt, state);
}
if let Some(help) = layout.help {
header::draw_help(frame, help, state);
}
if state.top_modal().is_none() {
super::diff::draw_dialog(frame, state);
}
modals::draw_top_modal(frame, area, state);
if state.header_modal_visible() && state.prompt_accepts_input() {
prompt::draw_autocomplete(frame, area, layout.prompt, state);
}
draw_toast(frame, area, state);
if state.modal_covers_transcript() || state.autocomplete.is_some() || state.toast.is_some() {
state.images.borrow_mut().clear_frame();
state.release_notes_drawn.set(false);
}
}
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 layout = layout_for(frame.area(), state);
if layout
.right_column_tab_area(RightColumnTab::Activity)
.is_some_and(|tab| tab.height == 1)
{
for tab in [RightColumnTab::Activity, RightColumnTab::Diff] {
if let Some(tab_area) = layout.right_column_tab_area(tab) {
let style = if state.right_column_tab == tab {
theme.right_column_rail_active_tab()
} else {
theme.right_column_rail_inactive_tab()
};
let mut label = Line::from(super::layout::right_column_tab_label(tab));
if tab == RightColumnTab::Diff
&& state.diff.has_unseen_changes
&& state.right_column_tab != tab
{
label = Line::from(
super::layout::right_column_tab_label(tab)
.chars()
.take(usize::from(tab_area.width.saturating_sub(1)))
.collect::<String>(),
);
label.push_span(Span::styled("●", style.fg(theme.text_accent())));
}
frame.render_widget(Paragraph::new(label).style(style), tab_area);
}
}
return;
}
let mut lines = Vec::new();
for tab in [RightColumnTab::Activity, RightColumnTab::Diff] {
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::Diff {
for _ in 0..super::layout::RIGHT_COLUMN_TAB_VERTICAL_PADDING {
lines.push(padded_row(None));
}
}
if tab == RightColumnTab::Diff {
let dot = if state.diff.has_unseen_changes && !active {
"●"
} else {
" "
};
lines.push(Line::from(vec![
Span::styled(format!("{marker} "), style),
Span::styled(dot, style.fg(theme.text_accent())),
Span::styled(" ".repeat(usize::from(area.width.saturating_sub(3))), style),
]));
}
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_section_body(area: Rect, files: bool) -> Rect {
let text = content_text_area_for_pane(TuiPane::Summary, area);
let controls = if files { 1 } else { 2 };
Rect {
y: text.y.saturating_add(controls),
height: text.height.saturating_sub(controls),
..text
}
}
pub(crate) fn activity_detail_modal_area(area: Rect) -> Rect {
super::modal_container::modal_area(area, 60, 60)
}
fn draw_summary(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
let block =
modals::header_modal_block(state, ModalKind::Summary, "Summary", "[Enter] start/stop");
let inner = block.inner(area);
frame.render_widget(Clear, 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}", state.summary_spinner()))
.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 block = modals::header_modal_block(
state,
ModalKind::SessionFiles,
"Session Files",
"[↑/↓] scroll",
);
let inner = pane_text_area(block.inner(area));
frame.render_widget(Clear, area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
"R read ",
super::state::session_files::file_reason_color(theme, 1),
),
Span::styled(
"W written ",
super::state::session_files::file_reason_color(theme, 2),
),
Span::styled(
"E edited",
super::state::session_files::file_reason_color(theme, 4),
),
])),
Rect {
height: inner.height.min(1),
..inner
},
);
let body = Rect {
y: inner.y.saturating_add(1),
height: inner.height.saturating_sub(1),
..inner
};
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,
);
}
pub(super) fn hotkey_style_with_theme(theme: MissionControlTheme) -> Style {
Style::new()
.fg(theme.text_dim())
.bg(theme.surface_background())
.remove_modifier(Modifier::BOLD)
}
pub(super) fn hotkey_span_with_theme(
theme: MissionControlTheme,
text: impl Into<String>,
) -> Span<'static> {
Span::styled(text.into(), hotkey_style_with_theme(theme))
}
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));
}
let mut depth = 0_usize;
let end = after_start
.char_indices()
.find_map(|(index, character)| match character {
'[' => {
depth += 1;
None
}
']' => {
depth = depth.saturating_sub(1);
(depth == 0).then_some(index)
}
_ => None,
});
if let Some(end) = end {
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)
}
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,
})
}
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)
}
pub(crate) fn transcript_text_area_for_pane(area: Rect) -> Rect {
bordered_block().inner(area)
}
pub(crate) fn content_text_area_for_pane(pane: TuiPane, area: Rect) -> Rect {
match pane {
TuiPane::Transcript => transcript_text_area_for_pane(area),
TuiPane::ActivityTree | TuiPane::Detail | TuiPane::Summary => {
pane_text_area(bordered_block().inner(area))
}
TuiPane::Prompt => pane_text_area(inset_when_space_allows(area, 1, 1)),
TuiPane::Help => overlay_text_area(inset_when_space_allows(area, 1, 1)),
TuiPane::RightColumnRail => area,
}
}
#[cfg(test)]
mod profiling;