use super::theme;
use crate::brain::mission_control::{
McActivity, McInboxDetail, McInboxItem, McInboxKind, McScheduleItem, McScheduleKind,
inbox_service,
};
use crate::tui::app::App;
use crate::tui::app::mission_control::McPanel;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::symbols;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let pw = (area.width * 60 / 100).max(40);
let max_ph = (area.height * 70 / 100).max(12);
if app.mc.focused_panel == McPanel::Analytics {
let ph = max_ph;
let px = area.x + area.width.saturating_sub(pw) / 2;
let py = area.y + area.height.saturating_sub(ph) / 2;
let popup = Rect::new(px, py, pw, ph);
frame.render_widget(Clear, popup);
super::analytics_panel::render(frame, &app.mc.analytics, popup, true);
return;
}
let (title, accent, lines) = match app.mc.focused_panel {
McPanel::Inbox => inbox_detail(app),
McPanel::Activity => activity_detail(app),
McPanel::Schedule => schedule_detail(app),
McPanel::Analytics => unreachable!("analytics handled above"),
};
let content_width = pw.saturating_sub(4) as usize; let mut visual_lines: u16 = 0;
for line in &lines {
let line_width: usize = line.spans.iter().map(|s| s.width()).sum();
let wrapped = if content_width > 0 {
line_width.div_ceil(content_width).max(1)
} else {
1
};
visual_lines += wrapped as u16;
}
let content_height = visual_lines.saturating_add(3);
let ph = content_height.min(max_ph).max(12);
let px = area.x + area.width.saturating_sub(pw) / 2;
let py = area.y + area.height.saturating_sub(ph) / 2;
let popup = Rect::new(px, py, pw, ph);
frame.render_widget(Clear, popup);
let block = Block::default()
.title(title)
.title_style(theme::title_style(accent))
.borders(Borders::ALL)
.border_set(symbols::border::ROUNDED)
.border_style(Style::default().fg(accent));
let body = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(block);
frame.render_widget(body, popup);
}
fn inbox_detail(app: &App) -> (String, ratatui::style::Color, Vec<Line<'static>>) {
let items = inbox_service::list();
let item = items.get(app.mc.selected_index);
let lines = match item {
Some(item) => render_inbox_item(item),
None => empty_lines("No proposal selected"),
};
(" Inbox detail ".to_string(), theme::TEAL, lines)
}
fn render_inbox_item(item: &McInboxItem) -> Vec<Line<'static>> {
let kind_label = match item.kind {
McInboxKind::ProposedTool => "tool",
McInboxKind::ProposedCommand => "command",
McInboxKind::ProposedSkill => "skill",
McInboxKind::ProposedBrainDedup => "dedup",
};
let filed = item.created_at.format("%Y-%m-%d %H:%M UTC").to_string();
let mut lines: Vec<Line<'static>> = vec![
blank(),
kv("Kind", kind_label),
kv("Label", &item.label),
kv("Source", &item.source),
kv("Filed", &filed),
kv("ID", &item.id),
blank(),
section_heading("Summary"),
];
for s in wrap_paragraph(&item.summary) {
lines.push(body_line(s));
}
if let Some(detail) = &item.detail {
match detail {
McInboxDetail::BrainDedup {
duplicate_text,
rationale,
duplicate_of,
warnings,
} => {
lines.extend([blank(), section_heading("Rationale")]);
for s in wrap_paragraph(rationale) {
lines.push(body_line(s));
}
lines.extend([
blank(),
section_heading("Duplicates"),
kv("Of", duplicate_of),
]);
lines.extend([blank(), section_heading("Duplicate text (to be removed)")]);
for s in wrap_paragraph(duplicate_text) {
lines.push(body_line(s));
}
if !warnings.is_empty() {
lines.extend([blank(), section_heading("Warnings")]);
for w in warnings {
lines.push(body_line(format!("⚠ {w}")));
}
}
}
}
}
lines.extend([
blank(),
section_heading("Apply / reject"),
body_line(format!(
"Use `rsi_proposals apply {}` to install or `reject {}` to discard.",
item.id, item.id
)),
]);
lines
}
fn activity_detail(app: &App) -> (String, ratatui::style::Color, Vec<Line<'static>>) {
let entry = app.mc.activity.get(app.mc.selected_index);
let lines = match entry {
Some(e) => render_activity(e),
None => empty_lines("No activity selected"),
};
(" Activity detail ".to_string(), theme::ORANGE, lines)
}
fn render_activity(entry: &McActivity) -> Vec<Line<'static>> {
let when = entry.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
let mut lines: Vec<Line<'static>> = vec![
blank(),
kv("When", &when),
kv("Source", &entry.source),
kv("Level", level_label(entry.level)),
blank(),
section_heading("Detail"),
];
for s in wrap_paragraph(&entry.detail) {
lines.push(body_line(s));
}
lines
}
fn level_label(level: crate::brain::mission_control::McActivityLevel) -> &'static str {
use crate::brain::mission_control::McActivityLevel as L;
match level {
L::Info => "info",
L::Success => "success",
L::Warn => "warn",
L::Error => "error",
}
}
fn schedule_detail(app: &App) -> (String, ratatui::style::Color, Vec<Line<'static>>) {
let item = app.mc.schedule.get(app.mc.selected_index);
let lines = match item {
Some(i) => render_schedule(i),
None => empty_lines("No schedule item selected"),
};
(" Schedule detail ".to_string(), theme::WHITE, lines)
}
fn render_schedule(item: &McScheduleItem) -> Vec<Line<'static>> {
let kind = match item.kind {
McScheduleKind::Cron => "cron job",
McScheduleKind::PendingApproval => "pending approval",
};
let state = if item.awaiting_user {
"awaiting user"
} else if !item.enabled {
"paused"
} else {
"active"
};
let mut lines: Vec<Line<'static>> = vec![
blank(),
kv("Kind", kind),
kv("Label", &item.label),
kv("State", state),
];
lines.push(blank());
lines.push(section_heading("Schedule"));
lines.push(kv("Cron", &item.schedule));
if let Some(next) = &item.next_run_at {
lines.push(kv("Next", &next.format("%Y-%m-%d %H:%M UTC").to_string()));
} else {
lines.push(kv("Next", "not scheduled"));
}
lines.push(blank());
lines.push(section_heading("Last run"));
if let Some(last) = &item.last_run_at {
lines.push(kv("At", &last.format("%Y-%m-%d %H:%M UTC").to_string()));
} else {
lines.push(kv("At", "never"));
}
if let Some(status) = &item.last_run_status {
let status_display = match status.as_str() {
"success" => "success",
"error" => "error",
"running" => "running...",
_ => status.as_str(),
};
lines.push(kv("Status", status_display));
}
if let Some(cost) = &item.last_run_cost {
lines.push(kv("Cost", &format!("${:.4}", cost)));
}
if let Some(secs) = &item.last_run_duration_secs {
let dur = if *secs < 60 {
format!("{}s", secs)
} else {
format!("{}m {}s", secs / 60, secs % 60)
};
lines.push(kv("Duration", &dur));
}
if let Some(target) = &item.deliver_to {
lines.push(blank());
lines.push(section_heading("Delivery"));
lines.push(kv("Target", target));
}
if !item.prompt.is_empty() {
lines.push(blank());
lines.push(section_heading("Prompt"));
let preview = if item.prompt.len() > 300 {
format!("{}...", &item.prompt[..300])
} else {
item.prompt.clone()
};
for s in wrap_paragraph(&preview) {
lines.push(body_line(s));
}
}
lines.push(blank());
lines.push(section_heading("Meta"));
lines.push(kv(
"Created",
&item.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
));
if let Some(profile) = &item.profile_name {
lines.push(kv("Profile", profile));
}
lines.push(kv("ID", &item.id));
lines
}
fn empty_lines(message: &str) -> Vec<Line<'static>> {
vec![
blank(),
Line::from(vec![
Span::raw(" "),
Span::styled(message.to_string(), theme::muted()),
]),
]
}
fn blank() -> Line<'static> {
Line::raw("")
}
fn kv(key: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{key:<8} "),
Style::default()
.fg(theme::TEXT_DIM)
.add_modifier(Modifier::BOLD),
),
Span::styled(value.to_string(), Style::default().fg(theme::TEXT_PRIMARY)),
])
}
fn section_heading(text: &str) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(
text.to_string(),
Style::default()
.fg(theme::TEXT_DIM)
.add_modifier(Modifier::BOLD),
),
])
}
fn body_line(text: String) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(text, Style::default().fg(theme::TEXT_PRIMARY)),
])
}
fn wrap_paragraph(text: &str) -> Vec<String> {
text.split('\n').map(|s| s.to_string()).collect()
}