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::buffer::{Buffer, CellDiffOption};
use ratatui::crossterm::event;
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) share_corner: Option<(u16, u16, u16)>,
pub(super) modal_rect: Option<Rect>,
pub(super) launch_rows: Vec<(u16, usize)>,
pub(super) launch_cwd_rows: Vec<(u16, usize)>,
pub(super) menu_rows: Vec<(u16, usize)>,
pub(super) tool_sidebar: Option<(u16, u16, usize, usize)>,
pub(super) tool_log: Option<(u16, u16, u16)>,
pub(super) key_hits: Vec<(u16, u16, u16, event::KeyEvent)>,
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 key_at(&self, col: u16, row: u16) -> Option<event::KeyEvent> {
self.key_hits
.iter()
.find(|(r, a, b, _)| *r == row && col >= *a && col < *b)
.map(|(_, _, _, key)| *key)
}
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 share_corner_at(&self, col: u16, row: u16) -> bool {
matches!(self.share_corner, Some((y, a, b)) if row == y && 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 launch_cwd_row_at(&self, col: u16, row: u16) -> Option<usize> {
self.in_modal(col, row)
.then(|| self.launch_cwd_rows.iter().find(|(y, _)| *y == row))
.flatten()
.map(|(_, i)| *i)
}
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 menu_row_at(&self, col: u16, row: u16) -> Option<usize> {
self.in_modal(col, row)
.then(|| self.menu_rows.iter().find(|(y, _)| *y == row))
.flatten()
.map(|(_, i)| *i)
}
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::colors().border))
.style(theme::canvas())
.title(Span::styled(format!(" {title} "), theme::title()))
}
pub fn draw(frame: &mut Frame, app: &mut App) -> Layout {
let mut area = frame.area();
frame.render_widget(Block::default().style(theme::canvas()), 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, &mut layout);
match app.mode {
Mode::Launch | Mode::LaunchCwd => modals::draw_launch(frame, area, app, &mut layout),
Mode::QuitConfirm => modals::draw_quit_confirm(frame, area, app, &mut layout),
Mode::RenameTab => modals::draw_rename_tab(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, &mut layout);
match app.mode {
Mode::Help => modals::draw_help(frame, area, app),
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, &mut layout),
Mode::DeleteBlocked => modals::draw_delete_blocked(frame, area, app, &mut layout),
Mode::KillConfirm => modals::draw_kill_confirm(frame, area, app, &mut layout),
Mode::ResumeConfirm => modals::draw_resume_confirm(frame, area, app, &mut layout),
Mode::TmuxInstall => modals::draw_rmux_install(frame, area, app),
Mode::Serve => modals::draw_serve(frame, area, app),
Mode::QuitConfirm => modals::draw_quit_confirm(frame, area, app, &mut layout),
Mode::KillBlocked => modals::draw_kill_blocked(frame, area, app, &mut layout),
Mode::BatchConfirm => modals::draw_batch_confirm(frame, area, app, &mut layout),
Mode::BatchDeleteBlocked => modals::draw_batch_blocked(frame, area, app, true, &mut layout),
Mode::BatchKillBlocked => modals::draw_batch_blocked(frame, area, app, false, &mut layout),
Mode::CostFilter => modals::draw_cost_filter(frame, area, app),
Mode::SendKeys => modals::draw_send_keys(frame, area, app),
Mode::RenameTab => modals::draw_rename_tab(frame, area, app, &mut layout),
Mode::Launch | Mode::LaunchCwd => modals::draw_launch(frame, area, app, &mut layout),
Mode::RowMenu => modals::draw_row_menu(frame, area, app, &mut layout),
Mode::Hooks => modals::draw_hooks(frame, area, app),
Mode::Insight => modals::draw_insight(frame, area, app),
Mode::List => {}
}
layout
}
fn draw_workspace_bar(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let titles: Vec<String> = std::iter::once("Dashboard".to_string())
.chain(app.tabs.iter().map(|tab| tab.title()))
.enumerate()
.map(|(i, title)| format!("{}:{}", i + 1, title))
.collect();
let on = app.blink_on();
let mut spans = Vec::new();
let mut pos = area.x;
let new_tab = match app.tab {
0 => " + Tab (t) ",
_ => " + Tab (Alt+n) ",
};
let label_room = area.width.saturating_sub(new_tab.chars().count() as u16) as usize;
let natural: usize = titles.iter().map(|t| t.chars().count() + 2).sum();
let cap = match natural <= label_room {
true => usize::MAX,
false => (label_room / titles.len()).saturating_sub(2).max(3),
};
for (i, title) in titles.iter().enumerate() {
let text = format!(" {} ", elide(title, cap));
let width = text.chars().count() as u16;
let style = match app.tab_attention(i) {
Some(tabs::Attention::NeedsInput) => match on {
true => theme::attention_lit(theme::colors().cost_mid),
false => Style::default()
.fg(theme::colors().cost_mid)
.add_modifier(Modifier::BOLD),
},
Some(tabs::Attention::Idle) => Style::default()
.fg(theme::colors().cost_low)
.add_modifier(Modifier::BOLD),
None if i == app.tab => theme::selected(),
None => Style::default().fg(theme::colors().dim),
};
spans.push(Span::styled(text, style));
layout.workspace_spans.push((pos, pos + width, i));
pos += width;
}
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::colors().accent)
.add_modifier(Modifier::BOLD),
));
layout.workspace_new = Some((pos, pos + width));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn elide(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
text.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
#[derive(Clone, Copy)]
enum QuotaDetail {
Full,
NoResets,
PctOnly,
}
fn pane_quota(
label: &str,
profile: Option<&str>,
quota: &crate::quota::Quota,
now: i64,
budget: u16,
) -> Option<String> {
let command = label
.split_whitespace()
.next()
.unwrap_or_default()
.to_ascii_lowercase();
let status = if command.starts_with("claude") {
quota.claude_for(profile)?
} else if command.starts_with("codex") {
quota.codex_for(profile)?
} else {
return None;
};
[
QuotaDetail::Full,
QuotaDetail::NoResets,
QuotaDetail::PctOnly,
]
.into_iter()
.map(|detail| quota_suffix(status, now, detail))
.take_while(|text| !text.is_empty())
.find(|text| text.chars().count() as u16 + 2 <= budget)
.map(|text| format!(" {text} "))
}
fn quota_suffix(status: &crate::quota::ProviderStatus, now: i64, detail: QuotaDetail) -> String {
let crate::quota::ProviderStatus::Ok(quota) = status else {
return String::new();
};
let windows = quota.windows.iter().map(|window| match detail {
QuotaDetail::PctOnly => format!("{}%", window.pct),
QuotaDetail::NoResets => format!("{} {}%", window.label, window.pct),
QuotaDetail::Full => {
let reset = window
.resets_at
.map(|at| at - now)
.filter(|remaining| *remaining > 0)
.map(|remaining| format!(" {}h{:02}m", remaining / 3600, (remaining % 3600) / 60))
.unwrap_or_default();
format!("{} {}%{}", window.label, window.pct, reset)
}
});
let sep = match detail {
QuotaDetail::PctOnly => "/",
_ => " · ",
};
windows.collect::<Vec<_>>().join(sep)
}
fn draw_panes(frame: &mut Frame, area: Rect, app: &mut App, layout: &mut Layout) {
let quota = app.quota.clone();
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;
let now = chrono::Utc::now().timestamp();
for (i, pane) in tab.panes.iter_mut().enumerate() {
let mut block = panel_block(&pane.label);
let taken = pane.label.chars().count() as u16 + 6;
if let Some(text) = pane_quota(
&pane.label,
pane.profile.as_deref(),
"a,
now,
slots[i].width.saturating_sub(taken).saturating_sub(2),
) {
block = block.title_top(Line::from(Span::styled(text, theme::dim())).right_aligned());
}
if i == focus {
block = block
.border_style(Style::default().fg(theme::colors().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);
}
}
const SPARK_W: usize = 31;
const ROWS: usize = 4;
const MACHINE_W: usize = 42;
const MIN_GRID_W: usize = 30;
const CELL_W: usize = 27;
const RATE_FLOOR: f64 = 0.05; const HOUR_FLOOR: f64 = 1.00; const DAY_FLOOR: f64 = 10.00;
fn spark_spans(
values: &[f64],
width: usize,
floor: f64,
gradient: Gradient,
now: Option<usize>,
) -> Vec<Span<'static>> {
if width == 0 {
return Vec::new();
}
let drawn = width.min(values.len().max(1));
let peak = values.iter().cloned().fold(0.0f64, f64::max);
let mut spans = Vec::with_capacity(drawn + 1);
if width > drawn {
spans.push(Span::raw(" ".repeat(width - drawn)));
}
spans.extend(spark::sparkline(values, drawn, peak.max(floor) * 1.1, gradient, now).spans);
spans
}
fn ranked_row(name: &str, amount: f64, width: usize) -> Vec<Span<'static>> {
let money = util::adaptive_usd(amount);
let name_w = width.saturating_sub(money.chars().count() + 1);
let mut shown: String = match name.chars().count() > name_w && name_w > 0 {
true => name
.chars()
.take(name_w - 1)
.chain(std::iter::once('…'))
.collect(),
false => name.chars().take(name_w).collect(),
};
while shown.chars().count() < name_w {
shown.push(' ');
}
vec![
Span::styled(shown, theme::dim()),
Span::raw(" "),
Span::styled(money, Style::default().fg(theme::colors().cost_mid)),
]
}
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 label_w = 11usize;
let value_w = 12usize;
let gutter = 2usize;
let spark_w = SPARK_W.min(
(inner.width as usize)
.saturating_sub(label_w + value_w + 1 + gutter)
.min(SPARK_W),
);
let left_w = (label_w + value_w + 1 + spark_w + gutter) as u16;
let rest = inner.width.saturating_sub(left_w) as usize;
let (mid_w, right_w) = match rest {
r if r >= MACHINE_W + MIN_GRID_W => (r - MACHINE_W, MACHINE_W),
r if r >= 20 => (0, r),
_ => (0, 0),
};
let cols = RLayout::horizontal([
Constraint::Length(left_w),
Constraint::Length(mid_w as u16),
Constraint::Length(right_w as u16),
])
.split(inner);
let now = chrono::Local::now();
let hour_idx = Some(chrono::Timelike::hour(&now) as usize);
let day_of_month = chrono::Datelike::day(&now) as usize;
let day_idx = Some(day_of_month - 1);
let rt_idx = app.global_spend.values().len().checked_sub(1);
let row = |name: &str, amount: f64, series: &[f64], floor: f64, now_idx: Option<usize>| {
let mut spans = vec![
Span::styled(format!("{name:<label_w$}"), theme::label()),
Span::styled(
format!("{:>value_w$} ", util::adaptive_usd(amount)),
Style::default()
.fg(theme::colors().cost_mid)
.add_modifier(Modifier::BOLD),
),
];
spans.extend(spark_spans(
series,
spark_w,
floor,
Gradient::Spend,
now_idx,
));
Line::from(spans)
};
let per_day = app.stats.spend_calendar_month / day_of_month as f64;
let left_lines = vec![
row(
"Live rate",
app.stats.spend_per_min,
app.global_spend.values(),
RATE_FLOOR,
rt_idx,
),
row(
"Today",
app.stats.spend_today,
&app.stats.daily_hourly,
HOUR_FLOOR,
hour_idx,
),
row(
"This month",
app.stats.spend_calendar_month,
&app.stats.monthly_daily,
DAY_FLOOR,
day_idx,
),
Line::from(vec![
Span::styled(format!("{:<label_w$}", "All time"), theme::label()),
Span::styled(
format!("{:>value_w$} ", util::adaptive_usd(app.stats.spend_total)),
Style::default()
.fg(theme::colors().cost_mid)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{}/day this month", util::adaptive_usd(per_day)),
theme::dim(),
),
]),
];
frame.render_widget(Paragraph::new(left_lines), cols[0]);
if mid_w > 0 {
let head_w = 10usize;
let grid_w = mid_w.saturating_sub(head_w);
let sub_cols = ((grid_w + CELL_W / 2) / CELL_W).max(1);
let cell_w = grid_w / sub_cols;
let capacity = sub_cols * ROWS;
let mut mid_lines: Vec<Line<'static>> = Vec::with_capacity(ROWS);
let top = &app.stats.top_today;
if top.is_empty() {
mid_lines.push(Line::from(vec![
Span::styled(format!("{:<head_w$}", "Top today"), theme::label()),
Span::styled("nothing spent yet today", theme::dim()),
]));
} else {
let shown = capacity.min(top.len());
let hidden = top.len() - shown;
for r in 0..ROWS {
let head = match r {
0 => format!("{:<head_w$}", "Top today"),
_ => " ".repeat(head_w),
};
let mut spans = vec![Span::styled(head, theme::label())];
for c in 0..sub_cols {
let i = c * ROWS + r;
let last = i + 1 == shown;
if i >= shown {
break;
}
if hidden > 0 && last {
let rest: f64 = top[i..].iter().map(|e| e.1).sum();
spans.extend(ranked_row(
&format!("+{} more", hidden + 1),
rest,
cell_w.saturating_sub(2),
));
} else {
spans.extend(ranked_row(&top[i].0, top[i].1, cell_w.saturating_sub(2)));
}
spans.push(Span::raw(" "));
}
mid_lines.push(Line::from(spans));
}
}
frame.render_widget(Paragraph::new(mid_lines), cols[1]);
}
if right_w > 0 {
let r_label_w = 10usize;
let body_w = right_w.saturating_sub(r_label_w);
let stats = &app.stats;
let mut model_spans = vec![Span::styled(
format!("{:<r_label_w$}", "Models"),
theme::label(),
)];
let today: f64 = stats.models_today.iter().map(|m| m.1).sum();
if today <= 0.0 {
model_spans.push(Span::styled("idle today", theme::dim()));
} else {
for (i, (name, cost)) in stats.models_today.iter().take(3).enumerate() {
if i > 0 {
model_spans.push(Span::styled(" · ", theme::dim()));
}
model_spans.push(Span::styled(name.clone(), theme::value()));
model_spans.push(Span::styled(
format!(" {:.0}%", cost / today * 100.0),
theme::dim(),
));
}
}
let mem_mb = stats.total_memory as f64 / (1024.0 * 1024.0);
let cpu_text = format!("{:.1}%", stats.total_cpu);
let cpu_spark_w = body_w.saturating_sub(cpu_text.chars().count() + 2).min(20);
let mut cpu_spans = vec![
Span::styled(format!("{:<r_label_w$}", "Agent CPU"), theme::label()),
Span::styled(format!("{cpu_text:>6} "), theme::value()),
];
cpu_spans.extend(spark_spans(
app.global_cpu.values(),
cpu_spark_w,
100.0,
Gradient::Cpu,
app.global_cpu.values().len().checked_sub(1),
));
let right_lines = vec![
Line::from(model_spans),
Line::from(vec![
Span::styled(format!("{:<r_label_w$}", "Sessions"), theme::label()),
Span::styled(stats.total.to_string(), theme::value()),
Span::styled(" · ", theme::dim()),
Span::styled(
format!("{} live", stats.running),
Style::default().fg(theme::colors().cost_low),
),
Span::styled(format!(" · {} in 24h", stats.active_24h), theme::dim()),
]),
Line::from(cpu_spans),
Line::from(vec![
Span::styled(format!("{:<r_label_w$}", "Agent mem"), theme::label()),
Span::styled(format!("{mem_mb:.0} MB"), theme::value()),
Span::styled(
format!(
" · {} in / {} out",
util::compact_tokens(stats.total_input),
util::compact_tokens(stats.total_output)
),
theme::dim(),
),
]),
];
frame.render_widget(Paragraph::new(right_lines), cols[2]);
}
}
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::colors().border))
.style(theme::canvas());
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::colors().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 != 0
&& let Some(host) = app
.selected_session()
.and_then(|s| s.remote.as_ref())
.map(|r| r.host.clone())
{
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
format!("This session is on {host}."),
theme::dim(),
)),
Line::default(),
Line::from(Span::styled(
"cctop reads that machine's summary over ssh; the transcript this panel \
would break down stays there. Info has everything that crossed.",
theme::dim(),
)),
])
.wrap(ratatui::widgets::Wrap { trim: true }),
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.clash_of(session).as_ref()),
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);
app.panel_max_scroll = max_scroll;
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));
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(theme::colors().value)
.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::colors().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::colors().cost_high;
}
if pace_ratio >= 1.1 {
return theme::colors().cost_mid;
}
return theme::colors().cost_low;
}
if window.pct >= 90 {
theme::colors().cost_high
} else if window.pct >= 70 {
theme::colors().cost_mid
} else {
theme::colors().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 mut accounts: Vec<(String, &'static str, &crate::quota::ProfileQuota)> = Vec::new();
for (harness, qs) in [("Claude", &app.quota.claude), ("Codex", &app.quota.codex)] {
for (i, q) in qs.iter().enumerate() {
let name = match i {
0 => harness.to_string(),
_ => format!("{harness} ({})", q.profile),
};
accounts.push((name, harness, q));
}
}
let share = 100 / accounts.len().max(1) as u16;
let cols = RLayout::horizontal(
accounts
.iter()
.map(|_| Constraint::Percentage(share))
.collect::<Vec<_>>(),
)
.spacing(2)
.split(inner);
for (i, (name, harness, account)) in accounts.iter().enumerate() {
let status = &account.status;
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 (said, cmd) = match (account.source, *harness) {
(crate::config::AccountSource::Token, _) => {
("expired — ", "cctop --add-account")
}
(_, "Codex") => ("sign-in expired — ", "codex login"),
_ => ("sign-in expired — ", "claude login"),
};
spans.push(Span::styled(
said,
Style::default().fg(theme::colors().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::colors().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(theme::gray(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::colors().cost_high),
));
}
if let Some(longest) = q
.windows
.iter()
.max_by_key(|w| w.duration.map(|d| d.as_secs()).unwrap_or(0))
&& let Some(text) = crate::burn::suffix(
&app.burn,
harness.to_ascii_lowercase().as_str(),
&account.profile,
longest.label,
)
{
spans.push(Span::styled(format!(" {text}"), theme::dim()));
}
}
}
frame.render_widget(Paragraph::new(Line::from(spans)), cols[i]);
}
}
struct Hint {
key: &'static str,
name: &'static str,
}
impl Hint {
fn width(&self) -> usize {
self.key.chars().count() + self.name.chars().count() + 2
}
fn event(&self) -> Option<event::KeyEvent> {
use event::{KeyCode, KeyModifiers as Mods};
let (code, mods) = match self.key {
"↑↓" | "Alt+←→" | "Alt+v/s" => return None,
"↵" => (KeyCode::Enter, Mods::NONE),
"Esc" => (KeyCode::Esc, Mods::NONE),
"Tab" => (KeyCode::Tab, Mods::NONE),
"Space" => (KeyCode::Char(' '), Mods::NONE),
"F1" => (KeyCode::F(1), Mods::NONE),
"F10" => (KeyCode::F(10), Mods::NONE),
"F12" => (KeyCode::F(12), Mods::NONE),
alt if alt.starts_with("Alt+") => {
let c = alt.chars().next_back()?;
(KeyCode::Char(c), Mods::ALT)
}
one => match (one.chars().next(), one.chars().count()) {
(Some(c), 1) => (KeyCode::Char(c), Mods::NONE),
_ => return None,
},
};
Some(event::KeyEvent::new(code, mods))
}
}
const fn hint(key: &'static str, name: &'static str) -> Hint {
Hint { key, name }
}
fn fit_hints(hints: &[Hint], room: usize) -> Vec<Span<'static>> {
fit_hints_at(hints, room, None, 0, 0)
}
fn fit_hints_at(
hints: &[Hint],
mut room: usize,
mut hits: Option<&mut Vec<(u16, u16, u16, event::KeyEvent)>>,
x: u16,
row: u16,
) -> Vec<Span<'static>> {
let key_style = theme::key_cap();
let label_style = Style::default().fg(theme::colors().dim);
let mut spans = Vec::new();
let mut at = x;
for h in hints {
let w = h.width();
if w > room {
break;
}
room -= w;
if let Some(hits) = hits.as_deref_mut()
&& let Some(key) = h.event()
{
hits.push((row, at, at + w as u16, key));
}
at += w as u16;
spans.push(Span::styled(h.key, key_style));
spans.push(Span::styled(format!(" {} ", h.name), label_style));
}
spans
}
fn tab_hints() -> Vec<Hint> {
vec![
hint("F12", "Dashboard"),
hint("Alt+←→", "Tabs"),
hint("Alt+n", "New"),
hint("Alt+w", "Close"),
hint("Alt+o", "Focus"),
hint("F1", "Help"),
hint("F9", "Image"),
hint("Alt+v/s", "Split"),
hint("F10", "Quit"),
]
}
fn list_hints(app: &App) -> Vec<Hint> {
let mut hints = vec![hint("↑↓", "Move")];
if app.selected_session().is_some() {
hints.push(hint("↵", "Actions"));
}
hints.push(hint("/", "Filter"));
if app.marked.is_empty() {
hints.push(hint("Space", "Mark"));
} else {
hints.push(hint("D", "Delete marked"));
hints.push(hint("K", "Kill marked"));
hints.push(hint("U", "Unmark"));
}
if app.has_filter() {
hints.push(hint("Esc", "Clear filter"));
}
if app.selected_session().is_some() {
hints.push(hint("a", "Attach"));
hints.push(hint("R", "Resume"));
}
hints.push(hint("t", "New tab"));
hints.push(hint("Tab", "Panel"));
hints.push(hint("S", "Sort"));
hints.push(hint("?", "Help"));
hints.push(hint("q", "Quit"));
hints
}
fn footer_badges(app: &App) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if app.quit_arm {
spans.push(Span::styled(
" click q again to quit ",
Style::default().fg(theme::colors().cost_high),
));
}
if let Some(age) = app.age_filter {
spans.push(Span::styled(
format!(" Age<{} ", age.short()),
Style::default().fg(theme::colors().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(theme::colors().filter_badge),
));
}
if app.cost_floor > 0.0 {
spans.push(Span::styled(
format!(" ≥${:.2} ", app.cost_floor),
Style::default().fg(theme::colors().cost_high),
));
}
if !app.marked.is_empty() {
spans.push(Span::styled(
format!(" [{} marked] ", app.marked.len()),
Style::default()
.fg(theme::colors().accent)
.add_modifier(Modifier::BOLD),
));
}
if app.follow {
spans.push(Span::styled(
" FOLLOW ",
Style::default().fg(theme::colors().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::colors().accent)
.add_modifier(Modifier::BOLD),
));
}
if let Some(down) = app.remote_footer() {
spans.push(Span::styled(
format!(" ⚠ {down} "),
Style::default().fg(theme::colors().cost_mid),
));
}
if let Some(clash) = app.conflict_footer() {
spans.push(Span::styled(
format!(" {clash} "),
Style::default()
.fg(theme::colors().cost_high)
.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::colors().cost_mid)
.add_modifier(Modifier::BOLD),
));
}
spans
}
enum Corner {
Link(String, String),
Button(&'static str),
Working(String),
}
impl Corner {
fn label(&self) -> &str {
match self {
Self::Link(label, _) => label,
Self::Button(label) => label,
Self::Working(label) => label,
}
}
}
fn share_corner(app: &App) -> Corner {
if let Some(opening) = &app.share_opening {
return Corner::Working(format!("{} opening a tunnel…", opening.frame()));
}
match app.serving.as_ref().and_then(|s| s.public.as_deref()) {
Some(url) => Corner::Link(link_label(url), url.to_string()),
None if app.share_arm => Corner::Button("⧉ publish to the internet?"),
None if app.serving.is_some() => Corner::Button("⧉ + tunnel"),
None => Corner::Button("⧉ share"),
}
}
const LINK_MAX: usize = 30;
fn link_label(url: &str) -> String {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let host = rest.split(['/', '?']).next().unwrap_or(rest);
format!("⧉ {}", shorten_host(host))
}
fn shorten_host(host: &str) -> String {
let chars: Vec<char> = host.chars().collect();
if chars.len() <= LINK_MAX {
return host.to_string();
}
let tail = "trycloudflare.com";
let keep = match host.ends_with(tail) {
true => tail.chars().count(),
false => LINK_MAX / 2,
};
let head = LINK_MAX.saturating_sub(keep + 1);
let front: String = chars[..head].iter().collect();
let back: String = chars[chars.len() - keep..].iter().collect();
format!("{front}…{back}")
}
const LINK_MIN_HINTS: usize = 26;
fn draw_hyperlink(buf: &mut Buffer, x: u16, y: u16, label: &str, url: &str, style: Style) {
let url: String = url.chars().filter(|c| !c.is_control()).collect();
let Some(width) = std::num::NonZeroU16::new(label.chars().count() as u16) else {
return;
};
let Some(cell) = buf.cell_mut((x, y)) else {
return;
};
cell.set_symbol(&format!("\x1b]8;;{url}\x1b\\{label}\x1b]8;;\x1b\\"))
.set_style(style)
.set_diff_option(CellDiffOption::ForcedWidth(width));
for (i, ch) in label.chars().enumerate().skip(1) {
let mut utf8 = [0u8; 4];
if let Some(cell) = buf.cell_mut((x + i as u16, y)) {
cell.set_symbol(ch.encode_utf8(&mut utf8)).set_style(style);
}
}
}
fn draw_footer(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let corner = share_corner(app);
let width = corner.label().chars().count();
let reserved = match area.width as usize >= width + 2 + LINK_MIN_HINTS {
true => width as u16 + 2,
false => 0,
};
draw_footer_keys(
frame,
Rect {
width: area.width - reserved,
..area
},
app,
layout,
);
if reserved == 0 {
return;
}
let x = area.right() - width as u16 - 1;
match &corner {
Corner::Link(label, url) => draw_hyperlink(
frame.buffer_mut(),
x,
area.y,
label,
url,
Style::default().fg(theme::colors().accent),
),
Corner::Button(label) => {
let style = match app.share_arm {
true => Style::default().fg(theme::colors().cost_mid),
false => theme::dim(),
};
frame.buffer_mut().set_string(x, area.y, label, style);
}
Corner::Working(label) => {
frame.buffer_mut().set_string(
x,
area.y,
label,
Style::default().fg(theme::colors().cost_mid),
);
}
}
layout.share_corner = Some((area.y, x, x + width as u16));
}
fn draw_footer_keys(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
if let Some((msg, _)) = &app.status {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
msg.clone(),
Style::default().fg(theme::colors().cost_low),
))),
area,
);
return;
}
let total = area.width as usize;
let (hints, badges) = if app.tab > 0 {
(tab_hints(), Vec::new())
} else {
(list_hints(app), footer_badges(app))
};
let badge_w: usize = badges.iter().map(|s| s.content.chars().count()).sum();
let floor = hints.iter().take(3).map(Hint::width).sum::<usize>();
let room = total.saturating_sub(badge_w).max(floor.min(total));
let mut spans = match app.mode {
Mode::List => fit_hints_at(&hints, room, Some(&mut layout.key_hits), area.x, area.y),
_ => fit_hints(&hints, room),
};
spans.extend(badges);
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::*;
fn window(label: &'static str, pct: u32, resets_at: i64) -> crate::quota::Window {
crate::quota::Window {
label,
pct,
duration: None,
resets_at: Some(resets_at),
}
}
#[test]
fn the_share_label_shows_the_host_and_the_link_keeps_the_token() {
let url = "https://few-words-here.trycloudflare.com/?t=secret";
assert_eq!(link_label(url), "⧉ few-words-he…trycloudflare.com");
assert_eq!(link_label("http://127.0.0.1:7777/?t=x"), "⧉ 127.0.0.1:7777");
let long = link_label("https://tribute-resistance-resolved-moscow.trycloudflare.com/?t=x");
assert!(long.starts_with("⧉ tribute"), "{long:?}");
assert!(long.ends_with("trycloudflare.com"), "{long:?}");
assert!(long.chars().count() <= LINK_MAX + 2, "{long:?}");
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
draw_hyperlink(&mut buf, 2, 0, &link_label(url), url, Style::default());
let opening = buf.cell((2, 0)).unwrap();
assert!(opening.symbol().contains(url), "{:?}", opening.symbol());
assert!(opening.symbol().starts_with("\x1b]8;;"));
assert!(opening.symbol().ends_with("\x1b]8;;\x1b\\"));
}
#[test]
fn the_share_link_costs_one_cell_and_still_erases_cleanly() {
let url = "https://few-words-here.trycloudflare.com/?t=secret";
let label = link_label(url);
let mut linked = Buffer::empty(Rect::new(0, 0, 40, 1));
draw_hyperlink(&mut linked, 2, 0, &label, url, Style::default());
assert_eq!(linked.cell((3, 0)).unwrap().symbol(), " ");
assert_eq!(linked.cell((4, 0)).unwrap().symbol(), "f");
let mut again = Buffer::empty(Rect::new(0, 0, 40, 1));
draw_hyperlink(&mut again, 2, 0, &label, url, Style::default());
assert!(linked.diff(&again).is_empty());
let blank = Buffer::empty(Rect::new(0, 0, 40, 1));
let erased: Vec<u16> = linked.diff(&blank).iter().map(|(x, _, _)| *x).collect();
for (i, ch) in label.chars().enumerate().filter(|(_, c)| *c != ' ') {
let x = 2 + i as u16;
assert!(
erased.contains(&x),
"{ch} at column {x} survived: {erased:?}"
);
}
}
#[test]
fn the_share_button_sits_in_the_bottom_right_corner() {
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 footer: String = (0..cols).map(|x| buffer[(x, rows - 1)].symbol()).collect();
assert!(
footer.ends_with("⧉ share "),
"the share button is not in the corner: {footer:?}"
);
assert!(footer.contains("Quit"), "{footer:?}");
let (row, a, b) = layout.share_corner.expect("no share hit region");
assert_eq!((row, b), (rows - 1, cols - 1));
assert!(layout.share_corner_at(a, row));
assert!(layout.share_corner_at(b - 1, row));
assert!(!layout.share_corner_at(b, row));
assert!(!layout.share_corner_at(a - 1, row));
}
#[test]
fn the_share_corner_asks_before_it_publishes() {
use crate::cache::UiPrefs;
use crate::pricing::Plan;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::with_prefs(Plan::Retail, tx, UiPrefs::default());
assert!(app.serving.is_none());
assert_eq!(share_corner(&app).label(), "⧉ share");
app.share_arm = true;
let armed = share_corner(&app);
assert_eq!(armed.label(), "⧉ publish to the internet?");
assert!(matches!(armed, Corner::Button(_)));
}
#[test]
fn the_share_corner_spins_while_the_tunnel_is_being_opened() {
use crate::cache::UiPrefs;
use crate::pricing::Plan;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::with_prefs(Plan::Retail, tx, UiPrefs::default());
let (_done_tx, done_rx) = std::sync::mpsc::channel();
app.share_opening = Some(super::super::Opening {
rx: done_rx,
since: std::time::Instant::now(),
});
let label = share_corner(&app).label().to_string();
assert!(label.ends_with(" opening a tunnel…"), "{label:?}");
assert_eq!(
label.chars().count(),
" opening a tunnel…".chars().count() + 1
);
app.on_share_corner(true);
assert!(app.share_opening.is_some());
assert!(app.serving.is_none());
}
#[test]
fn a_narrow_footer_drops_whole_hints_from_the_tail() {
let hints = [hint("↑↓", "Move"), hint("↵", "Actions"), hint("q", "Quit")];
assert_eq!(fit_hints(&hints, 100).len(), 6);
assert_eq!(fit_hints(&hints, 17).len(), 2);
assert_eq!(fit_hints(&hints, 18).len(), 4);
assert!(fit_hints(&hints, 3).is_empty());
}
#[test]
fn the_footer_hints_follow_what_the_dashboard_is_doing() {
use crate::cache::UiPrefs;
use crate::pricing::Plan;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::with_prefs(Plan::Retail, tx, UiPrefs::default());
let names =
|app: &App| -> Vec<&'static str> { list_hints(app).iter().map(|h| h.name).collect() };
assert!(app.selected_session().is_none());
let empty = names(&app);
assert!(!empty.contains(&"Actions"), "{empty:?}");
assert!(!empty.contains(&"Attach"), "{empty:?}");
assert!(!empty.contains(&"Clear filter"), "{empty:?}");
assert!(
empty.contains(&"Help") && empty.contains(&"Quit"),
"{empty:?}"
);
app.marked.insert("session-key".into());
let marked = names(&app);
assert!(!marked.contains(&"Mark"), "{marked:?}");
assert!(marked.contains(&"Delete marked"), "{marked:?}");
assert!(marked.contains(&"Unmark"), "{marked:?}");
app.search = "web".into();
assert!(names(&app).contains(&"Clear filter"));
}
#[test]
fn pane_quota_narrows_to_fit_and_only_for_a_provider() {
let quota = crate::quota::Quota {
claude: vec![crate::quota::ProfileQuota {
profile: "default".into(),
source: crate::config::AccountSource::Directory,
status: crate::quota::ProviderStatus::Ok(crate::quota::ProviderQuota {
plan: None,
windows: vec![window("5h", 37, 10_000), window("7d", 21, 500_000)],
limit_reached: false,
}),
}],
..Default::default()
};
assert_eq!(
pane_quota("claude-6", None, "a, 4_500, 40).as_deref(),
Some(" 5h 37% 1h31m · 7d 21% 137h38m ")
);
assert_eq!(
pane_quota("claude-6", None, "a, 4_500, 20).as_deref(),
Some(" 5h 37% · 7d 21% ")
);
assert_eq!(
pane_quota("claude-6", None, "a, 4_500, 10).as_deref(),
Some(" 37%/21% ")
);
assert!(pane_quota("claude-6", None, "a, 4_500, 5).is_none());
assert!(pane_quota("zsh", None, "a, 4_500, 40).is_none());
}
#[test]
fn a_crowded_tab_bar_elides_instead_of_overflowing() {
let titles: Vec<String> = (1..=6).map(|i| format!("{i}:claude-{i}")).collect();
let label_room = 40usize;
let cap = (label_room / titles.len()).saturating_sub(2).max(3);
let drawn: usize = titles
.iter()
.map(|t| elide(t, cap).chars().count() + 2)
.sum();
assert!(drawn <= label_room, "{drawn} columns in {label_room}");
assert_eq!(elide("1:claude-1", cap), "1:c…");
assert_eq!(elide("1:cc", cap), "1:cc");
}
#[test]
fn quota_suffix_drops_detail_step_by_step() {
let status = crate::quota::ProviderStatus::Ok(crate::quota::ProviderQuota {
plan: None,
windows: vec![window("5h", 37, 10_000), window("7d", 21, 500_000)],
limit_reached: false,
});
assert_eq!(
quota_suffix(&status, 4_500, QuotaDetail::Full),
"5h 37% 1h31m · 7d 21% 137h38m"
);
assert_eq!(
quota_suffix(&status, 4_500, QuotaDetail::NoResets),
"5h 37% · 7d 21%"
);
assert_eq!(
quota_suffix(&status, 4_500, QuotaDetail::PctOnly),
"37%/21%"
);
assert!(
quota_suffix(
&crate::quota::ProviderStatus::Pending,
4_500,
QuotaDetail::Full
)
.is_empty()
);
}
#[test]
fn hit_testing_maps_regions() {
let layout = Layout {
workspace_spans: vec![(0, 12, 0)],
workspace_new: Some((12, 23)),
share_corner: Some((24, 50, 78)),
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)],
launch_cwd_rows: vec![(12, 0)],
menu_rows: vec![(9, 0), (10, 1)],
key_hits: vec![(
24,
0,
10,
event::KeyEvent::new(event::KeyCode::Enter, event::KeyModifiers::NONE),
)],
pane_rects: vec![Rect::new(1, 7, 40, 10)],
};
assert_eq!(
layout.key_at(3, 24).map(|k| k.code),
Some(event::KeyCode::Enter)
);
assert!(layout.key_at(10, 24).is_none());
assert!(layout.key_at(3, 23).is_none());
assert!(layout.share_corner_at(50, 24));
assert!(layout.share_corner_at(77, 24));
assert!(!layout.share_corner_at(78, 24));
assert!(!layout.share_corner_at(50, 23));
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_eq!(layout.launch_cwd_row_at(15, 12), Some(0));
assert_eq!(layout.launch_cwd_row_at(15, 11), None);
assert_eq!(layout.launch_cwd_row_at(5, 12), None);
assert_eq!(layout.menu_row_at(15, 9), Some(0));
assert_eq!(layout.menu_row_at(15, 10), Some(1));
assert_eq!(layout.menu_row_at(15, 13), None);
assert_eq!(layout.menu_row_at(5, 9), 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::colors().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::colors().cost_low);
}
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)
}
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));
}
#[test]
fn an_expired_account_is_told_to_log_into_its_own_harness() {
use crate::cache::UiPrefs;
use crate::pricing::Plan;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let expired = |profile: &str, source| crate::quota::ProfileQuota {
profile: profile.to_string(),
status: crate::quota::ProviderStatus::Expired,
source,
};
use crate::config::AccountSource::{Directory, Token};
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::with_prefs(Plan::Retail, tx, UiPrefs::default());
app.quota.claude = vec![expired("default", Directory), expired("side", Token)];
app.quota.codex = vec![expired("default", Directory), expired("work", Directory)];
let (cols, rows) = (200u16, 50u16);
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
terminal
.draw(|frame| {
draw(frame, &mut app);
})
.expect("draw");
let buffer = terminal.backend().buffer().clone();
let screen: String = (0..rows)
.map(|y| {
(0..cols)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let line = screen
.lines()
.find(|l| l.contains("Codex (work)"))
.expect("the named account was not drawn");
let mine = &line[line.find("Codex (work)").expect("found above")..];
assert!(
mine.contains("codex login") && !mine.contains("claude login"),
"a Codex account was pointed at another harness's login: {mine:?}"
);
let line = screen
.lines()
.find(|l| l.contains("Claude (side)"))
.expect("the token account was not drawn");
let mine = &line[line.find("Claude (side)").expect("found above")..];
assert!(
mine.contains("cctop --add-account") && !mine.contains("claude login"),
"a token account was told to log in: {mine:?}"
);
}
#[test]
fn the_overview_spends_its_width_on_a_breakdown_and_gives_it_back_when_narrow() {
use crate::cache::UiPrefs;
use crate::pricing::Plan;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let overview = |cols: u16| -> Vec<String> {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::with_prefs(Plan::Retail, tx, UiPrefs::default());
app.stats.spend_today = 60.0;
app.stats.spend_calendar_month = 300.0;
app.stats.spend_total = 900.0;
app.stats.top_today = vec![
("orchard".into(), 30.0),
("beehive".into(), 20.0),
("cellar".into(), 6.0),
("attic".into(), 3.0),
("shed".into(), 1.0),
];
app.stats.models_today = vec![("opus-5".into(), 45.0), ("haiku-4-5".into(), 15.0)];
let rows = 30u16;
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
terminal
.draw(|frame| {
draw(frame, &mut app);
})
.expect("draw");
let buffer = terminal.backend().buffer().clone();
(0..6)
.map(|y| (0..cols).map(|x| buffer[(x, y)].symbol()).collect())
.collect()
};
let wide = overview(190).join("\n");
assert!(
wide.contains("Top today") && wide.contains("orchard") && wide.contains("shed"),
"the breakdown is missing from a wide Overview: {wide}"
);
assert!(
wide.contains("opus-5") && wide.contains("75%"),
"today's model mix is not shown as a share of today: {wide}"
);
assert!(
wide.contains("/day this month"),
"the daily average is missing: {wide}"
);
let narrow = overview(72).join("\n");
assert!(
narrow.contains("Live rate") && narrow.contains("All time"),
"a narrow Overview lost the spend rows it exists for: {narrow}"
);
assert!(
!narrow.contains("Top today"),
"the breakdown was squeezed into a narrow Overview: {narrow}"
);
}
#[test]
fn the_row_menu_gives_every_entry_exactly_one_line() {
use crate::cache::UiPrefs;
use crate::pricing::Plan;
use crate::session::Session;
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 mut session = Session::new(crate::pricing::Provider::Claude, "abc".into());
session.started_at = "2026-01-01T00:00:00Z".into();
session.last_active = session.started_at.clone();
session.label_source = "/repo".into();
app.sessions = vec![session];
app.refilter();
app.selected = 0;
app.open_row_menu();
assert_eq!(app.mode, super::super::Mode::RowMenu);
let (cols, rows) = (120u16, 40u16);
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 items = super::super::menu::items(&app);
let blocked: Vec<&str> = items.iter().filter_map(|i| i.blocked.as_deref()).collect();
assert!(!blocked.is_empty(), "a stopped session refuses something");
assert_eq!(layout.menu_rows.len(), items.len());
let mut lines: Vec<u16> = layout.menu_rows.iter().map(|(y, _)| *y).collect();
lines.sort_unstable();
lines.dedup();
assert_eq!(lines.len(), items.len(), "entries share or straddle lines");
let buffer = terminal.backend().buffer().clone();
let text_at = |y: u16| -> String { (0..cols).map(|x| buffer[(x, y)].symbol()).collect() };
for (y, i) in &layout.menu_rows {
let line = text_at(*y);
assert!(
line.contains(items[*i].label),
"entry {i} is not on its own line: {line:?}"
);
if let Some(why) = &items[*i].blocked {
let head: String = why.chars().take(12).collect();
assert!(
line.contains(&head),
"the refusal for {:?} is not beside it: {line:?}",
items[*i].label
);
}
}
}
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()
}
#[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]
);
assert!(
screen[rows as usize - 1].contains("Dashboard"),
"the agent footer is not showing workspace controls: {:?}",
screen[rows as usize - 1]
);
assert!(
!screen[rows as usize - 1].contains("Filter"),
"the dashboard footer leaked into an agent tab: {:?}",
screen[rows as usize - 1]
);
}
#[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:?}"
);
}
}