use super::columns::ColumnId;
use super::modals;
use super::spark;
use super::table;
use super::theme::{self, Gradient};
use super::{App, Mode, panels, tabs};
use crate::pricing::Provider;
use crate::session::Surface;
use crate::util;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout as RLayout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Paragraph};
#[derive(Debug, Default, Clone)]
pub struct Layout {
pub(super) header_row: u16,
pub(super) rows_start: u16,
pub(super) rows_end: u16,
pub(super) tab_row: u16,
pub(super) bottom_start: u16,
pub(super) column_spans: Vec<(u16, u16, ColumnId)>,
pub(super) tab_spans: Vec<(u16, u16, usize)>,
pub(super) workspace_spans: Vec<(u16, u16, usize)>,
pub(super) workspace_new: Option<(u16, u16)>,
pub(super) modal_rect: Option<Rect>,
pub(super) launch_rows: Vec<(u16, usize)>,
pub(super) tool_sidebar: Option<(u16, u16, usize, usize)>,
pub(super) tool_log: Option<(u16, u16, u16)>,
pub(super) pane_rects: Vec<Rect>,
}
impl Layout {
pub fn in_bottom_panel(&self, row: u16) -> bool {
row >= self.bottom_start
}
pub fn row_at(&self, row: u16) -> Option<usize> {
(row >= self.rows_start && row < self.rows_end).then(|| (row - self.rows_start) as usize)
}
pub fn header_column_at(&self, col: u16, row: u16) -> Option<ColumnId> {
if row != self.header_row {
return None;
}
self.column_spans
.iter()
.find(|(a, b, _)| col >= *a && col < *b)
.map(|(_, _, id)| *id)
}
pub fn tool_sidebar_at(&self, col: u16, row: u16) -> Option<usize> {
let (x_end, y_start, first, count) = self.tool_sidebar?;
if col >= x_end || row < y_start {
return None;
}
let offset = (row - y_start) as usize;
(offset < count).then_some(first + offset)
}
pub fn tool_log_row_at(&self, col: u16, row: u16) -> Option<usize> {
let (x_start, y_start, height) = self.tool_log?;
if col < x_start || row < y_start || row >= y_start + height {
return None;
}
Some((row - y_start) as usize)
}
pub fn tab_at(&self, col: u16, row: u16) -> Option<usize> {
if row != self.tab_row {
return None;
}
self.tab_spans
.iter()
.find(|(a, b, _)| col >= *a && col < *b)
.map(|(_, _, i)| *i)
}
pub fn workspace_at(&self, col: u16, row: u16) -> Option<usize> {
if row != 0 {
return None;
}
self.workspace_spans
.iter()
.find(|(a, b, _)| col >= *a && col < *b)
.map(|(_, _, i)| *i)
}
pub fn workspace_new_at(&self, col: u16, row: u16) -> bool {
matches!(self.workspace_new, Some((a, b)) if row == 0 && col >= a && col < b)
}
pub fn in_modal(&self, col: u16, row: u16) -> bool {
self.modal_rect
.is_some_and(|r| r.contains((col, row).into()))
}
pub fn pane_at(&self, col: u16, row: u16) -> Option<(usize, u16, u16)> {
self.pane_rects.iter().enumerate().find_map(|(i, r)| {
(col >= r.x && col < r.right() && row >= r.y && row < r.bottom())
.then(|| (i, col - r.x, row - r.y))
})
}
pub fn launch_row_at(&self, col: u16, row: u16) -> Option<usize> {
self.in_modal(col, row)
.then(|| self.launch_rows.iter().find(|(y, _)| *y == row))
.flatten()
.map(|(_, i)| *i)
}
}
pub(super) fn panel_block(title: &str) -> Block<'static> {
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Span::styled(format!(" {title} "), theme::title()))
}
pub fn draw(frame: &mut Frame, app: &mut App) -> Layout {
let mut area = frame.area();
let mut layout = Layout::default();
{
let bar = Rect { height: 1, ..area };
draw_workspace_bar(frame, bar, app, &mut layout);
area = Rect {
y: area.y + 1,
height: area.height.saturating_sub(1),
..area
};
}
if app.tab > 0 {
let chunks = RLayout::vertical([
Constraint::Length(6),
Constraint::Min(3),
Constraint::Length(1),
])
.split(area);
draw_overview(frame, chunks[0], app);
draw_panes(frame, chunks[1], app, &mut layout);
draw_footer(frame, chunks[2], app);
if app.mode == Mode::Launch {
modals::draw_launch(frame, area, app, &mut layout);
}
return layout;
}
let body_height = area.height.saturating_sub(5 + 3 + 1);
let bottom_height = ((body_height as f32 * 0.45) as u16)
.clamp(8, 24)
.min(body_height.saturating_sub(4));
let chunks = RLayout::vertical([
Constraint::Length(6),
Constraint::Min(4),
Constraint::Length(bottom_height),
Constraint::Length(3),
Constraint::Length(1),
])
.split(area);
draw_overview(frame, chunks[0], app);
table::draw_table(frame, chunks[1], app, &mut layout);
draw_bottom(frame, chunks[2], app, &mut layout);
draw_limits(frame, chunks[3], app);
draw_footer(frame, chunks[4], app);
match app.mode {
Mode::Help => modals::draw_help(frame, area),
Mode::Search => modals::draw_search(frame, area, app),
Mode::SortBy => modals::draw_sortby(frame, area, app),
Mode::AgeFilter => modals::draw_age_filter(frame, area, app),
Mode::DeleteConfirm => modals::draw_delete_confirm(frame, area, app),
Mode::DeleteBlocked => modals::draw_delete_blocked(frame, area, app),
Mode::KillConfirm => modals::draw_kill_confirm(frame, area, app),
Mode::ResumeConfirm => modals::draw_resume_confirm(frame, area, app),
Mode::TmuxInstall => modals::draw_tmux_install(frame, area, app),
Mode::QuitConfirm => modals::draw_quit_confirm(frame, area, app),
Mode::KillBlocked => modals::draw_kill_blocked(frame, area, app),
Mode::BatchConfirm => modals::draw_batch_confirm(frame, area, app),
Mode::BatchDeleteBlocked => modals::draw_batch_blocked(frame, area, app, true),
Mode::BatchKillBlocked => modals::draw_batch_blocked(frame, area, app, false),
Mode::CostFilter => modals::draw_cost_filter(frame, area, app),
Mode::SendKeys => modals::draw_send_keys(frame, area, app),
Mode::Launch => modals::draw_launch(frame, area, app, &mut layout),
Mode::Hooks => modals::draw_hooks(frame, area, app),
Mode::List => {}
}
layout
}
fn draw_workspace_bar(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let titles = std::iter::once("Dashboard".to_string()).chain(app.tabs.iter().map(|t| t.title()));
let on = app.blink_on();
let mut spans = Vec::new();
let mut pos = area.x;
for (i, title) in titles.enumerate() {
let text = format!(" {}:{} ", i + 1, title);
let width = text.chars().count() as u16;
let style = match app.tab_attention(i) {
Some(what) => {
let colour = match what {
tabs::Attention::NeedsInput => theme::COST_MID,
tabs::Attention::Idle => theme::COST_LOW,
};
match on {
true => Style::default()
.bg(colour)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
false => Style::default().fg(colour).add_modifier(Modifier::BOLD),
}
}
None if i == app.tab => Style::default()
.bg(theme::SELECTED_BG)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
None => Style::default().fg(theme::DIM),
};
spans.push(Span::styled(text, style));
layout.workspace_spans.push((pos, pos + width, i));
pos += width;
}
let new_tab = match app.tab {
0 => " + Tab (t) ",
_ => " + Tab (Alt+n) ",
};
let width = new_tab.chars().count() as u16;
if pos + width <= area.x + area.width {
spans.push(Span::styled(
new_tab,
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD),
));
layout.workspace_new = Some((pos, pos + width));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_panes(frame: &mut Frame, area: Rect, app: &mut App, layout: &mut Layout) {
let Some(tab) = app.active_tab() else {
return;
};
if tab.panes.is_empty() {
return;
}
let share = Constraint::Ratio(1, tab.panes.len() as u32);
let slots = match tab.stacked {
true => RLayout::vertical(vec![share; tab.panes.len()]),
false => RLayout::horizontal(vec![share; tab.panes.len()]),
}
.split(area);
let focus = tab.focus;
for (i, pane) in tab.panes.iter_mut().enumerate() {
let mut block = panel_block(&pane.label);
if i == focus {
block = block
.border_style(Style::default().fg(theme::BORDER_HI))
.title_bottom(Span::styled(" F12 back · Alt+w close ", theme::title()));
}
let behind = pane.view.parser.screen().scrollback();
if behind > 0 {
block = block.title_bottom(
Line::from(Span::styled(
format!(" ↑ {behind} — type to catch up "),
theme::title(),
))
.right_aligned(),
);
}
let inner = block.inner(slots[i]);
pane.view.resize(inner.width, inner.height);
let (cols, rows) = pane.view.size;
let screen = Rect {
width: cols.min(inner.width),
height: rows.min(inner.height),
..inner
};
frame.render_widget(block, slots[i]);
frame.render_widget(
tui_term::widget::PseudoTerminal::new(pane.view.parser.screen()),
screen,
);
layout.pane_rects.push(screen);
}
}
fn draw_overview(frame: &mut Frame, area: Rect, app: &App) {
let block = panel_block("Overview");
let inner = block.inner(area);
frame.render_widget(block, area);
let cols =
RLayout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
let (left, right) = (cols[0], cols[1]);
let realtime = app.stats.spend_per_min;
let label_w = 15usize;
let value_w = 12usize;
let chart_w = left.width.saturating_sub((label_w + value_w + 2) as u16) as usize;
let row = |name: &str, amount: f64, series: &[f64], now_idx: Option<usize>| -> Line<'static> {
let mut spans = vec![
Span::styled(format!("{name:<label_w$}"), theme::label()),
Span::styled(
format!("{:>value_w$} ", util::adaptive_usd(amount)),
Style::default()
.fg(Color::Indexed(221))
.add_modifier(Modifier::BOLD),
),
];
spans.extend(spark::sparkline(series, chart_w, 0.0, Gradient::Spend, now_idx).spans);
Line::from(spans)
};
let now = chrono::Local::now();
let hour_idx = Some(chrono::Timelike::hour(&now) as usize);
let day_idx = Some(chrono::Datelike::day(&now) as usize - 1);
let rt_idx = app.global_spend.values().len().checked_sub(1);
let left_lines = vec![
row(
"Live Spend/min",
realtime,
app.global_spend.values(),
rt_idx,
),
row(
"Today Spend",
app.stats.spend_today,
&app.stats.daily_hourly,
hour_idx,
),
row(
"Month-to-date",
app.stats.spend_calendar_month,
&app.stats.monthly_daily,
day_idx,
),
Line::from(vec![
Span::styled(format!("{:<label_w$}", "Total Spend"), theme::label()),
Span::styled(
format!("{:>value_w$}", util::adaptive_usd(app.stats.spend_total)),
Style::default()
.fg(Color::Indexed(221))
.add_modifier(Modifier::BOLD),
),
]),
];
frame.render_widget(Paragraph::new(left_lines), left);
let mem_mb = app.stats.total_memory as f64 / (1024.0 * 1024.0);
let r_label_w = 12usize;
let r_value_w = 9usize;
let r_chart_w = right
.width
.saturating_sub((r_label_w + r_value_w + 2) as u16) as usize;
let mut cpu_spans = vec![
Span::styled(format!("{:<r_label_w$}", "Agents CPU"), theme::label()),
Span::styled(
format!("{:>r_value_w$} ", format!("{:.1}%", app.stats.total_cpu)),
theme::value(),
),
];
cpu_spans.extend(
spark::sparkline(
app.global_cpu.values(),
r_chart_w,
100.0,
Gradient::Cpu,
app.global_cpu.values().len().checked_sub(1),
)
.spans,
);
let right_lines = vec![
Line::from(cpu_spans),
Line::from(vec![
Span::styled(format!("{:<r_label_w$}", "Agents Mem"), theme::label()),
Span::styled(
format!("{:>r_value_w$}", format!("{mem_mb:.0} MB")),
theme::value(),
),
]),
Line::from(vec![
Span::styled(format!("{:<r_label_w$}", "Sessions"), theme::label()),
Span::styled(
format!("{:>r_value_w$}", app.stats.total.to_string()),
theme::value(),
),
Span::raw(" "),
Span::styled(
format!("{} active", app.stats.running),
Style::default().fg(theme::COST_LOW),
),
]),
];
frame.render_widget(Paragraph::new(right_lines), right);
}
fn draw_bottom(frame: &mut Frame, area: Rect, app: &mut App, layout: &mut Layout) {
app.ensure_available_tab();
layout.bottom_start = area.y;
layout.tab_row = area.y;
layout.tool_sidebar = None;
layout.tool_log = None;
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut spans = vec![Span::raw(" ")];
let mut pos = area.x + 2;
layout.tab_spans.clear();
for (i, name) in panels::TABS.iter().enumerate() {
if !app.tab_available(i) {
continue;
}
let style = if i == app.bottom_tab {
theme::title().add_modifier(Modifier::UNDERLINED)
} else {
theme::dim()
};
spans.push(Span::styled((*name).to_string(), style));
spans.push(Span::raw(" "));
let w = name.chars().count() as u16;
layout.tab_spans.push((pos, pos + w, i));
pos += w + 2;
}
if let Some(sub) = app.selected_subagent() {
let what = if sub.description.is_empty() {
sub.agent_type.clone()
} else {
format!("{}: {}", sub.agent_type, sub.description)
};
spans.push(Span::styled(
format!("↳ {}", crate::util::truncate(&what, 48)),
Style::default().fg(theme::ACCENT),
));
}
frame.render_widget(
Paragraph::new(Line::from(spans)),
Rect {
x: area.x + 1,
y: area.y,
width: area.width.saturating_sub(2),
height: 1,
},
);
if app.selected_session().is_none() {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"No session selected",
theme::dim(),
))),
inner,
);
return;
}
if app.bottom_tab == 1 {
if let Some(session) = app.selected_session() {
draw_performance(frame, inner, session, &app.cpu_history, &app.mem_history);
}
return;
}
let mut tool_owners: Vec<Option<String>> = Vec::new();
let (lines, scroll) = {
let width = inner.width as usize;
let Some(session) = app.selected_session() else {
return;
};
let data = app.panel_data.as_ref();
match app.bottom_tab {
0 => (panels::info(session, data, app.plan), app.info_scroll),
2 => (panels::processes(session, width), app.proc_scroll),
3 => {
let live = app.tool_live_only.then_some(app.started_at.as_str());
match data {
Some(d) => {
let (lines, owners) =
draw_tool_sidebar(frame, inner, app, d, live, width, layout);
tool_owners = owners;
(lines, app.tool_scroll)
}
None => (vec![Line::from(Span::styled("Loading…", theme::dim()))], 0),
}
}
4 => (
panels::subagents(data, app.subagent_sort.0, app.subagent_sort.1, width),
app.subagent_scroll,
),
5 => (panels::cost(session, data, app.plan), app.cost_scroll),
6 => (panels::config(session), app.config_scroll),
_ => (panels::context(session, data, width), app.context_scroll),
}
};
let target = if app.bottom_tab == 3 {
Rect {
x: inner.x + TOOL_SIDEBAR_W + 1,
width: inner.width.saturating_sub(TOOL_SIDEBAR_W + 1),
..inner
}
} else {
inner
};
let max_scroll = (lines.len() as u16).saturating_sub(target.height);
let scroll = if app.bottom_tab == 3 {
app.tool_owners = std::mem::take(&mut tool_owners);
layout.tool_log = Some((target.x, target.y, target.height));
app.tool_max_scroll = max_scroll;
if app.tool_follow {
app.tool_scroll = max_scroll;
}
app.tool_scroll.min(max_scroll)
} else {
scroll.min(max_scroll)
};
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), target);
}
const TOOL_SIDEBAR_W: u16 = 18;
#[allow(clippy::too_many_arguments)]
fn draw_tool_sidebar(
frame: &mut Frame,
inner: Rect,
app: &App,
data: &crate::session::SessionData,
live: Option<&str>,
width: usize,
layout: &mut Layout,
) -> (Vec<Line<'static>>, Vec<Option<String>>) {
let tabs = panels::tool_tabs(data);
let first = app.tool_tab.saturating_sub(inner.height as usize / 2);
let visible = tabs.len().saturating_sub(first).min(inner.height as usize);
layout.tool_sidebar = Some((inner.x + TOOL_SIDEBAR_W, inner.y, first, visible));
let lines: Vec<Line> = tabs
.iter()
.enumerate()
.skip(first)
.take(inner.height as usize)
.map(|(i, (name, count))| {
let selected = i == app.tool_tab;
let display = util::pretty_mcp_name(name);
let count_str = count.to_string();
let name_w = (TOOL_SIDEBAR_W as usize).saturating_sub(count_str.len() + 2);
Line::from(vec![
Span::styled(
format!("{:<name_w$}", util::truncate(&display, name_w)),
if selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
theme::dim()
},
),
Span::raw(" "),
Span::styled(count_str, theme::dim()),
])
})
.collect();
frame.render_widget(
Paragraph::new(lines),
Rect {
width: TOOL_SIDEBAR_W,
..inner
},
);
frame.render_widget(
Paragraph::new(
(0..inner.height)
.map(|_| Line::from(Span::styled("│", Style::default().fg(theme::DIMMER))))
.collect::<Vec<_>>(),
),
Rect {
x: inner.x + TOOL_SIDEBAR_W,
width: 1,
..inner
},
);
panels::tool_activity(
data,
app.tool_tab,
live,
app.tool_show_diff,
app.tool_expanded.as_deref(),
width.saturating_sub(TOOL_SIDEBAR_W as usize + 1),
)
}
fn draw_performance(
frame: &mut Frame,
inner: Rect,
session: &crate::session::Session,
cpu_history: &std::collections::HashMap<String, spark::History>,
mem_history: &std::collections::HashMap<String, spark::History>,
) {
if session.surface == Surface::DesktopCowork {
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"Cowork sessions run in a cloud VM.",
theme::dim(),
)),
Line::from(Span::styled(
"No local CPU or memory metrics are available.",
theme::dim(),
)),
]),
inner,
);
return;
}
if session.surface == Surface::Editor && session.provider == Provider::Cursor {
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"Cursor uses a shared editor process.",
theme::dim(),
)),
Line::from(Span::styled(
"No per-session CPU or memory metrics are available.",
theme::dim(),
)),
]),
inner,
);
return;
}
let Some(pm) = &session.process else {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"Performance data is only available for running sessions.",
theme::dim(),
))),
inner,
);
return;
};
let key = session.key();
let empty = spark::History::default();
let cpu = cpu_history.get(&key).unwrap_or(&empty);
let mem = mem_history.get(&key).unwrap_or(&empty);
let mem_mb = pm.memory as f64 / (1024.0 * 1024.0);
let mem_max = util::nice_max(mem.values().iter().cloned().fold(1.0, f64::max));
let cols = RLayout::horizontal([
Constraint::Percentage(50),
Constraint::Length(2),
Constraint::Percentage(50),
])
.split(inner);
let (cpu_area, mem_area) = (cols[0], cols[2]);
let rows = inner.height.saturating_sub(2).max(2) as usize;
let axis_w = format!("{}", mem_max.ceil() as i64).len().max(3) + 2;
let mut left = vec![Line::from(vec![
Span::styled("CPU ", theme::label()),
Span::styled(
format!("{:>6.1}%", pm.cpu),
Style::default().fg(theme::cpu_color(pm.cpu)),
),
Span::raw(" "),
Span::styled("PIDs ", theme::label()),
Span::styled(pm.pids.to_string(), theme::value()),
])];
left.extend(spark::line_chart(
cpu.values(),
cpu_area.width as usize,
rows,
100.0,
Gradient::Cpu,
Some(axis_w),
));
let mut right = vec![Line::from(vec![
Span::styled("Mem ", theme::label()),
Span::styled(format!("{mem_mb:>8.0} MB"), theme::value()),
])];
right.extend(spark::line_chart(
mem.values(),
mem_area.width as usize,
rows,
mem_max,
Gradient::Accent,
Some(axis_w),
));
frame.render_widget(Paragraph::new(left), cpu_area);
frame.render_widget(Paragraph::new(right), mem_area);
}
fn quota_color(window: &crate::quota::Window, now: i64) -> Color {
if let (Some(duration), Some(reset)) = (window.duration, window.resets_at) {
let duration_secs = duration.as_secs() as i64;
let elapsed_secs = (now - (reset - duration_secs)).clamp(1, duration_secs);
let pace_ratio = window.pct as f64 * duration_secs as f64 / (100.0 * elapsed_secs as f64);
if pace_ratio >= 1.5 {
return theme::COST_HIGH;
}
if pace_ratio >= 1.1 {
return theme::COST_MID;
}
return theme::COST_LOW;
}
if window.pct >= 90 {
theme::COST_HIGH
} else if window.pct >= 70 {
theme::COST_MID
} else {
theme::COST_LOW
}
}
fn draw_limits(frame: &mut Frame, area: Rect, app: &App) {
let block = panel_block("Limits");
let inner = block.inner(area);
frame.render_widget(block, area);
let cols =
RLayout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
for (i, (name, status)) in [("Claude", &app.quota.claude), ("Codex", &app.quota.codex)]
.iter()
.enumerate()
{
let mut spans = vec![Span::styled(format!("{name} "), theme::label())];
match status {
crate::quota::ProviderStatus::Pending => {
spans.push(Span::styled("checking…", theme::dim()));
}
crate::quota::ProviderStatus::NotSignedIn => {
spans.push(Span::styled("not signed in", theme::dim()));
}
crate::quota::ProviderStatus::ApiBilling => {
spans.push(Span::styled("API billing, no limits", theme::dim()));
}
crate::quota::ProviderStatus::Expired => {
let cmd = if *name == "Codex" {
"codex login"
} else {
"claude login"
};
spans.push(Span::styled(
"sign-in expired — ",
Style::default().fg(theme::COST_MID),
));
spans.push(Span::styled(cmd.to_string(), theme::value()));
}
crate::quota::ProviderStatus::RateLimited { retry_at } => {
spans.push(Span::styled(
"rate limited",
Style::default().fg(theme::COST_MID),
));
if let Some(at) = retry_at {
let remaining = at - chrono::Utc::now().timestamp();
if remaining > 0 {
spans.push(Span::styled(
format!(" — retry in {}m{:02}s", remaining / 60, remaining % 60),
theme::dim(),
));
}
}
}
crate::quota::ProviderStatus::Unavailable(reason) => {
spans.push(Span::styled(
format!("unavailable ({reason})"),
theme::dim(),
));
}
crate::quota::ProviderStatus::Ok(q) => {
let now = chrono::Utc::now().timestamp();
if let Some(plan) = &q.plan {
spans.push(Span::styled(format!("({plan}) "), theme::dim()));
}
for w in &q.windows {
let color = quota_color(w, now);
spans.push(Span::styled(format!("{} ", w.label), theme::label()));
spans.push(Span::styled(
format!("{:>3}% ", w.pct),
Style::default().fg(color),
));
let filled = (w.pct as usize * 8 / 100).min(8);
spans.push(Span::styled(
"\u{2501}".repeat(filled),
Style::default().fg(color),
));
spans.push(Span::styled(
"\u{2500}".repeat(8 - filled),
Style::default().fg(Color::Indexed(244)),
));
if let Some(reset) = w.resets_at {
let remaining = reset - now;
if remaining > 0 {
spans.push(Span::styled(
format!(" {}h{:02}m", remaining / 3600, (remaining % 3600) / 60),
theme::dim(),
));
}
}
spans.push(Span::raw(" "));
}
if q.limit_reached {
spans.push(Span::styled(
"\u{26a0} limit",
Style::default().fg(theme::COST_HIGH),
));
}
}
}
frame.render_widget(Paragraph::new(Line::from(spans)), cols[i]);
}
}
fn draw_footer(frame: &mut Frame, area: Rect, app: &App) {
if let Some((msg, _)) = &app.status {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
msg.clone(),
Style::default().fg(theme::COST_LOW),
))),
area,
);
return;
}
let key_style = Style::default()
.fg(Color::Black)
.bg(theme::ACCENT)
.add_modifier(Modifier::BOLD);
let label_style = Style::default().fg(theme::DIM);
let mut spans = Vec::new();
for (key, name) in [
("F1", "Help"),
("F3", "Filter"),
("F5", "Refresh"),
("F7", "Age"),
("←→", "Panel"),
("Space", "Mark"),
("D", "Batch"),
("y", "Copy"),
("d", "Delete"),
("k", "Kill"),
("+/-", "Speed"),
("F10", "Quit"),
] {
spans.push(Span::styled(key, key_style));
spans.push(Span::styled(format!("{name} "), label_style));
}
if let Some(age) = app.age_filter {
spans.push(Span::styled(
format!(" Age<{} ", age.short()),
Style::default().fg(theme::PANEL_TITLE),
));
}
if !app.search.is_empty() {
let scope = match (app.search_content, app.scanning) {
(false, _) => String::new(),
(true, true) => " +transcripts…".to_string(),
(true, false) => format!(" +transcripts({})", app.scan_hits.len()),
};
spans.push(Span::styled(
format!(" Filter: {}{scope} ", app.search),
Style::default().fg(Color::Cyan),
));
}
if app.cost_floor > 0.0 {
spans.push(Span::styled(
format!(" ≥${:.2} ", app.cost_floor),
Style::default().fg(theme::COST_HIGH),
));
}
if !app.marked.is_empty() {
spans.push(Span::styled(
format!(" [{} marked] ", app.marked.len()),
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD),
));
}
spans.push(Span::styled(
format!(" {}s ", app.refresh_secs),
Style::default().fg(theme::DIMMER),
));
if app.follow {
spans.push(Span::styled(
" FOLLOW ",
Style::default().fg(theme::COST_MID),
));
}
if let Some(bell) = app
.notify
.footer(app.selected_session().map(|s| s.key()).as_deref())
{
spans.push(Span::styled(
format!(" {bell} "),
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD),
));
}
if let Some(version) = &app.update_available {
spans.push(Span::styled(
format!(" v{version} available — cctop --update "),
Style::default()
.fg(theme::COST_MID)
.add_modifier(Modifier::BOLD),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
pub fn copy_to_clipboard(text: &str) {
use std::io::Write;
use std::process::{Command, Stdio};
const HELPERS: &[(&str, &[&str])] = &[
("wl-copy", &[]),
("xclip", &["-selection", "clipboard"]),
("xsel", &["--clipboard", "--input"]),
("pbcopy", &[]),
("clip.exe", &[]),
];
for (cmd, args) in HELPERS {
let Ok(mut child) = Command::new(cmd)
.args(*args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
continue;
};
if let Some(stdin) = child.stdin.as_mut()
&& stdin.write_all(text.as_bytes()).is_ok()
{
drop(child.stdin.take());
if child.wait().map(|s| s.success()).unwrap_or(false) {
return;
}
}
}
let mut out = std::io::stdout();
let _ = write!(out, "\x1b]52;c;{}\x07", util::b64_encode(text.as_bytes()));
let _ = out.flush();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hit_testing_maps_regions() {
let layout = Layout {
workspace_spans: vec![(0, 12, 0)],
workspace_new: Some((12, 23)),
header_row: 6,
rows_start: 7,
rows_end: 12,
tab_row: 20,
bottom_start: 20,
column_spans: vec![(0, 5, ColumnId::Status), (5, 12, ColumnId::Cost)],
tab_spans: vec![(2, 6, 0), (8, 19, 1)],
tool_sidebar: Some((18, 21, 0, 3)),
tool_log: Some((19, 21, 4)),
modal_rect: Some(Rect::new(10, 8, 20, 6)),
launch_rows: vec![(10, 0), (11, 1)],
pane_rects: vec![Rect::new(1, 7, 40, 10)],
};
assert_eq!(layout.launch_row_at(15, 11), Some(1));
assert_eq!(layout.launch_row_at(15, 12), None);
assert_eq!(layout.launch_row_at(5, 11), None);
assert!(layout.in_modal(10, 8));
assert!(!layout.in_modal(30, 8));
assert_eq!(layout.workspace_at(3, 0), Some(0));
assert_eq!(layout.workspace_at(15, 0), None);
assert!(layout.workspace_new_at(15, 0));
assert!(!layout.workspace_new_at(3, 0));
assert!(!layout.workspace_new_at(15, 1));
assert_eq!(layout.row_at(7), Some(0));
assert_eq!(layout.row_at(11), Some(4));
assert_eq!(layout.row_at(12), None);
assert_eq!(layout.header_column_at(6, 6), Some(ColumnId::Cost));
assert_eq!(layout.header_column_at(6, 7), None);
assert_eq!(layout.tab_at(9, 20), Some(1));
assert_eq!(layout.tab_at(9, 21), None);
assert!(layout.in_bottom_panel(20));
assert!(!layout.in_bottom_panel(19));
assert_eq!(layout.pane_at(1, 7), Some((0, 0, 0)));
assert_eq!(layout.pane_at(10, 9), Some((0, 9, 2)));
assert_eq!(layout.pane_at(41, 9), None);
assert_eq!(layout.pane_at(10, 17), None);
assert_eq!(layout.tool_sidebar_at(4, 22), Some(1));
assert_eq!(layout.tool_sidebar_at(4, 24), None);
assert_eq!(layout.tool_sidebar_at(40, 22), None);
assert_eq!(layout.tool_log_row_at(30, 21), Some(0));
assert_eq!(layout.tool_log_row_at(30, 24), Some(3));
assert_eq!(layout.tool_log_row_at(30, 25), None);
assert_eq!(layout.tool_log_row_at(5, 22), None);
}
#[test]
fn quota_colour_tracks_spending_pace() {
let duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
let reset = 1_000_000;
let window = crate::quota::Window {
label: "7d",
pct: 80,
duration: Some(duration),
resets_at: Some(reset + duration.as_secs() as i64 / 2),
};
assert_eq!(quota_color(&window, reset), theme::COST_HIGH);
let sustainable = crate::quota::Window {
pct: 50,
resets_at: Some(reset + duration.as_secs() as i64 / 2),
..window
};
assert_eq!(quota_color(&sustainable, reset), theme::COST_LOW);
}
#[cfg(target_os = "linux")]
fn test_pane(text: &str) -> (std::process::Child, u32, super::super::tabs::Pane) {
let (child, pid) = crate::shim::test_session(
&["sh", "-c", &format!("printf '{text}'; sleep 30")],
(200, 60),
);
let pane =
super::super::tabs::Pane::view_of(pid, text.into()).expect("no attach connection");
(child, pid, pane)
}
#[cfg(target_os = "linux")]
fn draw_until_sized(
terminal: &mut ratatui::Terminal<ratatui::backend::TestBackend>,
app: &mut App,
want: &[(u16, u16)],
) -> bool {
for _ in 0..50 {
terminal
.draw(|frame| {
draw(frame, app);
})
.expect("draw");
let sized = app.tabs.iter_mut().any(|tab| {
tab.pump();
tab.panes.len() == want.len()
&& tab.panes.iter().zip(want).all(|(p, w)| p.view.size == *w)
});
if sized {
terminal
.draw(|frame| {
draw(frame, app);
})
.expect("draw");
return true;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
false
}
#[test]
fn the_bar_offers_a_new_tab_with_nothing_open() {
use crate::cache::UiPrefs;
use crate::pricing::Plan;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::with_prefs(Plan::Retail, tx, UiPrefs::default());
let (cols, rows) = (80u16, 24u16);
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
let mut layout = Layout::default();
terminal
.draw(|frame| layout = draw(frame, &mut app))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
let top: String = (0..cols).map(|x| buffer[(x, 0)].symbol()).collect();
assert!(
top.starts_with(" 1:Dashboard + Tab (t) "),
"the new-tab button is not on the bar: {top:?}"
);
let (a, _) = layout.workspace_new.expect("no new-tab hit region");
assert!(layout.workspace_new_at(a, 0));
assert!(!layout.workspace_new_at(a, 1));
}
#[cfg(target_os = "linux")]
fn screen(
terminal: &ratatui::Terminal<ratatui::backend::TestBackend>,
cols: u16,
rows: u16,
) -> Vec<String> {
let buffer = terminal.backend().buffer().clone();
(0..rows)
.map(|y| {
(0..cols)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[cfg(target_os = "linux")]
#[test]
fn a_tab_resizes_its_agent_into_the_space_cctop_leaves_it() {
use crate::cache::UiPrefs;
use crate::pricing::Plan;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut child, pid, pane) = test_pane("HELLO-FROM-AGENT");
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::with_prefs(Plan::Retail, tx, UiPrefs::default());
app.tabs.push(super::super::tabs::Tab::new(pane));
app.tab = 1;
let (cols, rows) = (60u16, 21u16);
let want = (cols - 2, rows - 1 - 6 - 1 - 2);
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
let sized = draw_until_sized(&mut terminal, &mut app, &[want]);
let screen = screen(&terminal, cols, rows);
app.tabs.clear();
let _ = child.kill();
let _ = child.wait();
let _ = crate::shim::socket_path(pid).map(std::fs::remove_file);
assert!(
sized,
"the pty was never resized to the pane; wanted {want:?}"
);
assert!(
screen[0].starts_with(" 1:Dashboard 2:HELLO-FROM-AGENT"),
"the tab bar is not the top row: {:?}",
screen[0]
);
assert!(
screen[1].contains("Overview"),
"the Overview is gone: {:?}",
screen[1]
);
assert!(
screen[8].starts_with("│HELLO-FROM-AGENT"),
"the agent's screen is not inside the pane: {:?}",
&screen[7..10]
);
assert!(
screen[rows as usize - 2].contains("F12 back"),
"the focused pane's hint is missing: {:?}",
screen[rows as usize - 2]
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_split_sizes_both_agents_to_their_own_half() {
use crate::cache::UiPrefs;
use crate::pricing::Plan;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut left_child, left_pid, left) = test_pane("LEFT-AGENT");
let (mut right_child, right_pid, right) = test_pane("RIGHT-AGENT");
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::with_prefs(Plan::Retail, tx, UiPrefs::default());
let mut tab = super::super::tabs::Tab::new(left);
tab.panes.push(right);
app.tabs.push(tab);
app.tab = 1;
let (cols, rows) = (80u16, 21u16);
let want = (cols / 2 - 2, rows - 1 - 6 - 1 - 2);
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
let sized = draw_until_sized(&mut terminal, &mut app, &[want, want]);
let screen = screen(&terminal, cols, rows);
app.tabs.clear();
for child in [&mut left_child, &mut right_child] {
let _ = child.kill();
let _ = child.wait();
}
for pid in [left_pid, right_pid] {
let _ = crate::shim::socket_path(pid).map(std::fs::remove_file);
}
assert!(
sized,
"one of the split panes was never resized; wanted {want:?} each"
);
let split_row = &screen[8];
assert!(
split_row.starts_with("│LEFT-AGENT"),
"the left agent is not in the left half: {split_row:?}"
);
let right_half: String = split_row.chars().skip((cols / 2) as usize).collect();
assert!(
right_half.starts_with("│RIGHT-AGENT"),
"the right agent is not in the right half: {split_row:?}"
);
}
}