mod chat;
mod dialogs;
mod help;
mod input;
pub(crate) mod mission_control;
pub(crate) mod palette;
mod panes;
mod plan_widget;
mod sessions;
pub(crate) mod skills_dialog;
mod tools;
mod utils;
pub(in crate::tui) use utils::char_boundary_at_width;
#[cfg(test)]
pub(crate) use chat::reasoning_to_lines;
#[cfg(test)]
pub(crate) use input::{DropdownFit, dropdown_dimensions, fit_dropdown, truncate_to_chars};
#[cfg(test)]
pub(crate) use tools::collapse_build_output;
use super::app::App;
use super::events::AppMode;
use super::onboarding_render;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Clear, Paragraph},
};
use unicode_width::UnicodeWidthStr;
use chat::render_chat;
use dialogs::{
render_directory_picker, render_file_picker, render_model_selector, render_restart_dialog,
render_update_dialog,
};
use help::{render_help, render_settings};
use input::{render_emoji_picker, render_input, render_slash_autocomplete, render_status_bar};
use plan_widget::render_plan_checklist;
use sessions::render_sessions;
pub fn render(f: &mut Frame, app: &mut App) {
if app.mode == AppMode::Onboarding {
if let Some(ref wizard) = app.onboarding {
onboarding_render::render_onboarding(f, wizard);
}
return;
}
use input::render_queue;
let queue_height: u16 = {
let has_queue = app
.current_session
.as_ref()
.and_then(|s| {
app.queued_messages
.lock()
.ok()
.map(|q| q.get(&s.id).is_some_and(|v| !v.is_empty()))
})
.unwrap_or(false);
if has_queue { 2 } else { 0 }
};
let input_height = if app.input_buffer.is_empty() {
3 } else {
let terminal_width = (f.area().width.saturating_sub(4) as usize).max(1);
const MAX_CONTENT_ROWS: usize = 8; let mut rows = 0usize;
for line in app.input_buffer.lines() {
rows += if line.is_empty() {
1
} else {
(UnicodeWidthStr::width(line) + 2).div_ceil(terminal_width)
};
if rows >= MAX_CONTENT_ROWS {
rows = MAX_CONTENT_ROWS;
break;
}
}
(rows.max(1) as u16 + 2).min(10)
};
let plan_height = app
.plan_document
.as_ref()
.filter(|p| p.status == crate::tui::plan::PlanStatus::InProgress)
.map(|p| (p.tasks.len() + 2).min(8) as u16)
.unwrap_or(0);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10), Constraint::Length(plan_height), Constraint::Length(queue_height), Constraint::Length(input_height), Constraint::Length(1), ])
.split(f.area());
let full_content_area = Rect {
x: chunks[0].x,
y: chunks[0].y,
width: chunks[0].width,
height: chunks[0].height + chunks[1].height + chunks[2].height + chunks[3].height,
};
match app.mode {
AppMode::Onboarding => {
}
AppMode::Chat => {
if app.pane_manager.is_split() {
render_split_panes(f, app, chunks[0]);
} else {
render_chat(f, app, chunks[0]);
}
if plan_height > 0 {
render_plan_checklist(f, app, chunks[1]);
}
if queue_height > 0 {
render_queue(f, app, chunks[2]);
}
app.input_area_x = chunks[3].x;
app.input_area_y = chunks[3].y;
app.input_area_width = chunks[3].width;
app.input_area_height = chunks[3].height;
render_input(f, app, chunks[3]);
render_status_bar(f, app, chunks[4]);
if app.slash_suggestions_active {
render_slash_autocomplete(f, app, chunks[3]);
} else if app.emoji_picker_active {
render_emoji_picker(f, app, chunks[3]);
}
}
AppMode::Sessions => {
f.render_widget(Clear, full_content_area);
let (title_area, content_area) = split_title_area(full_content_area);
render_app_title(f, title_area);
render_sessions(f, app, content_area);
}
AppMode::Help => {
let (title_area, content_area) = split_title_area(full_content_area);
render_app_title(f, title_area);
render_help(f, app, content_area);
}
AppMode::MissionControl => {
f.render_widget(Clear, full_content_area);
let (title_area, content_area) = split_title_area(full_content_area);
render_app_title(f, title_area);
mission_control::draw(f, app, content_area);
}
AppMode::SkillsList => {
f.render_widget(Clear, full_content_area);
let (title_area, content_area) = split_title_area(full_content_area);
render_app_title(f, title_area);
skills_dialog::draw(f, app, content_area);
}
AppMode::Settings => {
let (title_area, content_area) = split_title_area(full_content_area);
render_app_title(f, title_area);
render_settings(f, app, content_area);
}
AppMode::FilePicker => {
render_file_picker(f, app, full_content_area);
}
AppMode::DirectoryPicker => {
render_directory_picker(f, app, full_content_area);
}
AppMode::ModelSelector => {
render_chat(f, app, chunks[0]);
if plan_height > 0 {
render_plan_checklist(f, app, chunks[1]);
}
if queue_height > 0 {
render_queue(f, app, chunks[2]);
}
app.input_area_x = chunks[3].x;
app.input_area_y = chunks[3].y;
app.input_area_width = chunks[3].width;
app.input_area_height = chunks[3].height;
render_input(f, app, chunks[3]);
render_status_bar(f, app, chunks[4]);
render_model_selector(f, app, f.area());
}
AppMode::UsageDashboard => {
render_chat(f, app, chunks[0]);
if plan_height > 0 {
render_plan_checklist(f, app, chunks[1]);
}
if queue_height > 0 {
render_queue(f, app, chunks[2]);
}
app.input_area_x = chunks[3].x;
app.input_area_y = chunks[3].y;
app.input_area_width = chunks[3].width;
app.input_area_height = chunks[3].height;
render_input(f, app, chunks[3]);
render_status_bar(f, app, chunks[4]);
if let Some(ref ds) = app.dashboard_state {
crate::usage::dashboard::render(f, ds, f.area());
}
}
AppMode::RestartPending => {
render_chat(f, app, chunks[0]);
if plan_height > 0 {
render_plan_checklist(f, app, chunks[1]);
}
if queue_height > 0 {
render_queue(f, app, chunks[2]);
}
app.input_area_x = chunks[3].x;
app.input_area_y = chunks[3].y;
app.input_area_width = chunks[3].width;
app.input_area_height = chunks[3].height;
render_input(f, app, chunks[3]);
render_status_bar(f, app, chunks[4]);
render_restart_dialog(f, app, f.area());
}
AppMode::UpdatePrompt => {
if app.pane_manager.is_split() {
render_split_panes(f, app, chunks[0]);
} else {
render_chat(f, app, chunks[0]);
}
if plan_height > 0 {
render_plan_checklist(f, app, chunks[1]);
}
if queue_height > 0 {
render_queue(f, app, chunks[2]);
}
app.input_area_x = chunks[3].x;
app.input_area_y = chunks[3].y;
app.input_area_width = chunks[3].width;
app.input_area_height = chunks[3].height;
render_input(f, app, chunks[3]);
render_status_bar(f, app, chunks[4]);
render_update_dialog(f, app, f.area());
}
}
}
fn render_split_panes(f: &mut Frame, app: &mut App, area: Rect) {
let tree = match &app.pane_manager.root {
Some(t) => t.clone(),
None => return render_chat(f, app, area),
};
let focused_id = app.pane_manager.focused;
let pane_rects = tree.layout(area);
for (pane_id, rect) in pane_rects {
if rect.width < 3 || rect.height < 3 {
continue; }
if pane_id == focused_id {
let inner = panes::focused_pane_border(f, app, rect);
render_chat(f, app, inner);
} else {
panes::render_inactive_pane(f, app, pane_id, rect);
}
}
}
fn split_title_area(area: Rect) -> (Rect, Rect) {
let title_height = 1u16; let used_title = title_height.min(area.height);
let title_area = Rect {
height: used_title,
..area
};
let content_area = Rect {
y: area.y.saturating_add(used_title),
height: area.height.saturating_sub(used_title),
..area
};
(title_area, content_area)
}
fn render_app_title(f: &mut Frame, area: Rect) {
let para = Paragraph::new(vec![Line::from(Span::styled(
" 🦀 OpenCrabs AI Agent",
Style::default()
.fg(Color::Rgb(120, 120, 120))
.add_modifier(Modifier::BOLD),
))]);
f.render_widget(para, area);
}