pub(in crate::tui) mod activity;
mod header;
mod modals;
mod prompt;
pub(in crate::tui) mod transcript;
pub(crate) use super::layout::{TuiPane, layout_for};
#[cfg(test)]
use super::state::CustomProviderSetupStep;
use super::state::MissionControlState;
use super::theme::MATRIX_GREEN;
#[cfg(test)]
pub(in crate::tui::render) use modals::{PICKER_MODAL, SYSTEM_PROMPT_MODAL, modal_rect};
pub(crate) use modals::{
auth_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, skills_modal_list_area, subagents_modal_list_area,
system_prompt_text_area, tools_modal_list_area, usage_modal_text_area,
};
use ratatui::{
Frame,
layout::{Alignment, Margin, Position, Rect, Size},
style::{Color, 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 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_impl(
frame,
area,
offset,
content_height,
None,
widget,
scroll_state,
);
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_scroll_view_persistent_with_metrics<W: Widget>(
frame: &mut Frame<'_>,
area: Rect,
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_impl(
frame,
area,
offset,
content_height,
Some((
scrollbar_content_length,
scrollbar_position,
scrollbar_viewport_length,
)),
widget,
scroll_state,
);
}
#[allow(clippy::too_many_arguments)]
fn render_scroll_view_persistent_impl<W: Widget>(
frame: &mut Frame<'_>,
area: Rect,
offset: u16,
content_height: u16,
scrollbar_metrics: Option<(usize, usize, usize)>,
widget: W,
scroll_state: &std::cell::RefCell<tui_scrollview::ScrollViewState>,
) {
if area.width == 0 || area.height == 0 {
return;
}
let size = Size::new(
scroll_view_content_width(area),
content_height.max(area.height),
);
let mut view = ScrollView::new(size)
.vertical_scrollbar_visibility(tui_scrollview::ScrollbarVisibility::Always)
.horizontal_scrollbar_visibility(tui_scrollview::ScrollbarVisibility::Never);
view.render_widget(widget, Rect::new(0, 0, size.width, size.height));
let mut scroll_state = scroll_state.borrow_mut();
if scroll_state.offset() == Position::ORIGIN && offset != 0 {
scroll_state.set_offset(Position::new(0, offset));
}
frame.render_stateful_widget(view, area, &mut *scroll_state);
let mut scrollbar_state =
if let Some((content_length, position, viewport_length)) = scrollbar_metrics {
ScrollbarState::new(content_length)
.viewport_content_length(viewport_length)
.position(position.min(content_length.saturating_sub(1)))
} else {
let overflow = content_height.saturating_sub(area.height);
ScrollbarState::new(usize::from(overflow.max(1)))
.position(usize::from(scroll_state.offset().y.min(overflow)))
};
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.symbols(Set {
track: "│",
thumb: "┃",
begin: VERTICAL.begin,
end: VERTICAL.end,
})
.thumb_style(Style::new().fg(MATRIX_GREEN.green_bright).bg(Color::Reset))
.track_style(Style::new().fg(MATRIX_GREEN.green_dim).bg(Color::Reset))
.begin_style(Style::new().fg(MATRIX_GREEN.green_dim).bg(Color::Reset))
.end_style(Style::new().fg(MATRIX_GREEN.green_dim).bg(Color::Reset));
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
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(frame, area, 0, content_height, widget, scroll_state);
}
pub(crate) fn draw(frame: &mut Frame<'_>, state: &MissionControlState) {
let layout = layout_for(frame.area(), state);
frame.render_widget(Block::default().style(MATRIX_GREEN.app()), frame.area());
transcript::draw_transcript(frame, layout.transcript, state);
activity::draw_tree(frame, layout.activity_tree, state);
activity::draw_detail(frame, layout.detail, state);
prompt::draw_prompt(frame, layout.prompt, state);
prompt::draw_autocomplete(frame, frame.area(), layout.prompt, state);
header::draw_status(frame, layout.status, state);
if let Some(help) = layout.help {
header::draw_help(frame, help, state);
}
modals::draw_system_prompt_modal(frame, frame.area(), state);
modals::draw_rewind_modal(frame, frame.area(), state);
modals::draw_sessions_modal(frame, frame.area(), state);
modals::draw_skills_modal(frame, frame.area(), state);
modals::draw_mcp_modal(frame, frame.area(), state);
modals::draw_tools_modal(frame, frame.area(), state);
modals::draw_subagents_modal(frame, frame.area(), state);
modals::draw_models_modal(frame, frame.area(), state);
modals::draw_usage_modal(frame, frame.area(), state);
modals::draw_model_picker(frame, frame.area(), state);
modals::draw_login_picker(frame, frame.area(), state);
modals::draw_logout_confirmation(frame, frame.area(), state);
modals::draw_custom_provider_setup(frame, frame.area(), state);
modals::draw_active_login(frame, frame.area(), state);
modals::draw_custom_provider_replacement_confirmation(frame, frame.area(), state);
draw_toast(frame, frame.area(), state);
}
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 style = match toast.severity {
super::toast::ToastSeverity::Success => MATRIX_GREEN.selection(),
super::toast::ToastSeverity::Error => MATRIX_GREEN.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() -> Style {
Style::new()
.fg(MATRIX_GREEN.green_dim)
.bg(MATRIX_GREEN.background)
.remove_modifier(Modifier::BOLD)
}
pub(super) fn hotkey_span(text: impl Into<String>) -> Span<'static> {
Span::styled(text.into(), hotkey_style())
}
pub(super) fn hotkey_line(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(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 themed_block(title: impl Into<String>, active: bool) -> Block<'static> {
bordered_block()
.border_style(MATRIX_GREEN.border(active))
.title(padded_title_text(title, MATRIX_GREEN.title(active)))
.style(MATRIX_GREEN.app())
}
pub(super) fn overlay_block(title: &'static str) -> Block<'static> {
bordered_block()
.border_style(MATRIX_GREEN.border_alt().fg(MATRIX_GREEN.green))
.title(padded_title_text(title, MATRIX_GREEN.title_alt()))
.style(MATRIX_GREEN.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(frame: &mut Frame<'_>, block_inner: Rect) -> Rect {
frame.render_widget(
Block::default().style(MATRIX_GREEN.panel_alt()),
block_inner,
);
overlay_text_area(block_inner)
}
pub(super) fn render_overlay_fill_horizontal(frame: &mut Frame<'_>, block_inner: Rect) -> Rect {
frame.render_widget(
Block::default().style(MATRIX_GREEN.panel_alt()),
block_inner,
);
pane_text_area(block_inner)
}
pub(crate) fn transcript_text_area_for_pane(area: Rect) -> Rect {
let y = area.y.saturating_add(u16::from(area.height > 0));
let height = area.height.saturating_sub(u16::from(area.height > 0));
pane_text_area(Rect::new(area.x, y, area.width, height))
}
pub(crate) fn content_text_area_for_pane(pane: TuiPane, area: Rect) -> Rect {
if matches!(pane, TuiPane::Status) {
return area;
}
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::Prompt => pane_text_area(block_inner),
TuiPane::Help => overlay_text_area(block_inner),
TuiPane::Transcript | TuiPane::Status => area,
}
}
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod primary_agent_render_tests;