use super::columns::COLUMNS;
use super::render::Layout;
use super::theme;
use super::{AGE_OPTIONS, App, BatchKind, LaunchInto, tabs};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Wrap};
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width.saturating_sub(2));
let h = height.min(area.height.saturating_sub(2));
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
fn modal(
frame: &mut Frame,
area: Rect,
title: &str,
lines: Vec<Line<'static>>,
width: u16,
) -> (Rect, Rect) {
let inner_width = width.saturating_sub(2).max(1);
let height = lines
.iter()
.map(|line| line.width().max(1).div_ceil(inner_width as usize) as u16)
.sum::<u16>()
+ 2;
let rect = centered(area, width, height);
frame.render_widget(Clear, rect);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::colors().border_hi))
.style(theme::canvas())
.title(Span::styled(format!(" {title} "), theme::title()));
let inner = block.inner(rect);
frame.render_widget(block, rect);
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.style(theme::canvas()),
inner,
);
(rect, inner)
}
fn scrollable_modal(
frame: &mut Frame,
area: Rect,
title: &str,
lines: Vec<Line<'static>>,
width: u16,
scroll: u16,
) -> u16 {
let total = lines.len() as u16;
let rect = centered(area, width, total + 2);
frame.render_widget(Clear, rect);
let mut block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::colors().border_hi))
.style(theme::canvas())
.title(Span::styled(format!(" {title} "), theme::title()));
let inner_height = block.inner(rect).height;
let max_scroll = total.saturating_sub(inner_height);
let scroll = scroll.min(max_scroll);
if max_scroll > 0 {
block = block.title_bottom(Span::styled(
format!(
" {}–{} of {total} ↑↓ scroll ",
scroll + 1,
(scroll + inner_height).min(total)
),
theme::dim(),
));
}
let inner = block.inner(rect);
frame.render_widget(block, rect);
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll, 0))
.style(theme::canvas()),
inner,
);
max_scroll
}
pub(super) fn draw_help(frame: &mut Frame, area: Rect, app: &mut App) {
let section = |t: &str| Line::from(Span::styled(t.to_string(), theme::title()));
let item = |k: &str, d: &str| {
Line::from(vec![
Span::styled(
format!(" {k:<16}"),
Style::default().fg(theme::colors().accent),
),
Span::raw(d.to_string()),
])
};
let lines = vec![
section("Start here"),
item("Enter", "Everything you can do to this row, in one menu"),
item("? F1", "This page"),
item("q F10", "Quit"),
Line::default(),
section("Navigation"),
item("↑/k ↓/j", "Move between sessions"),
item("PgUp / PgDn", "Page through the list"),
item("Ctrl+U / Ctrl+D", "Half a page up / down"),
item("g / G", "Jump to first / last"),
item("n / N", "Next / previous search match (wraps)"),
item("b", "Jump to the session that rang last"),
item("f", "Follow mode: keep the selection centered"),
Line::default(),
section("Acting on the selected session"),
item("a", "Open its terminal in a tab"),
item("R", "Resume it in a tab of its own"),
item("s", "Type a line into its terminal"),
item("O", "Hand its context off to a different agent"),
item("y", "Copy resume command or transcript path"),
item("e / E", "Show its subagents / all subagents"),
item("d", "Delete it (only when it is not running)"),
item("Ctrl+K", "Terminate it"),
Line::default(),
section("Several at once"),
item("Space", "Mark / unmark the selected session"),
item("U", "Clear all marks"),
item("D", "Delete all marked sessions"),
item("K", "Terminate all marked live sessions"),
Line::default(),
section("Filter and sort"),
item("/ F3", "Filter by label, project, branch, model, id"),
item(" Tab", "Search inside the transcripts as well"),
item(" ↑ / ↓", "Bring back an earlier search"),
item("S F6 > <", "Sort by any column"),
item("F7", "Filter by age (1d / 1w / 1mo)"),
item("#", "Cost floor: only sessions costing ≥ $X"),
item("`", "Show only running sessions"),
item("Esc", "Clear one filter layer per press"),
Line::from(Span::styled(
" Clicking a column header sorts by it too.",
theme::dim(),
)),
Line::default(),
section("Bottom panels"),
item("← →", "Move between bottom panels"),
item("Tab / Shift+Tab", "Same, either direction"),
item("1 – 9", "Jump to a panel directly"),
item("Shift+↑ / ↓", "Scroll inside the active panel"),
item("Shift+Home / End", "Jump to the top / bottom of it"),
item("[ / ]", "Move through the Tool Activity filter"),
item("L", "Toggle the Tool Activity live filter"),
item("v", "Toggle inline diffs for edits"),
Line::default(),
section("Tabs and splits"),
item(
"t or Alt+n",
"New tab: an agent, a shell, or one still running",
),
item("Alt+v / Alt+s", "Split the tab right / down"),
item("Alt+← / →", "Previous / next tab"),
item("Alt+1 – 9", "Jump to a tab (1 is the dashboard)"),
item(
"Alt+Shift+← / →",
"Move this tab along the bar (or drag it with the mouse)",
),
item("Right-click", "Rename the tab under the pointer"),
item("Alt+o", "Move focus to the next pane"),
item("Alt+w", "Close the pane and stop its agent"),
item("Alt+Shift+W", "The same, by a name that says so"),
item("F9", "Paste the clipboard's image as a file path"),
item("Ctrl+V", "The same, in terminals that send it"),
item("F12", "Back to the dashboard, leaving it running"),
Line::default(),
section("Pasting an image"),
item("", "A terminal carries text, never a picture — so cctop"),
item("", "writes the image to a file and types its path, which"),
item("", "is how every one of these agents reads one."),
item("F9", "Does that with the clipboard of this machine"),
item(
"over ssh",
"That clipboard is on the machine you sshed from,",
),
item("", "and nothing here can reach it. Easiest is the page:"),
item("", "`cctop serve` here, open it there, and paste the"),
item("", "image into the box that answers a session — a"),
item("", "browser can read one where a terminal cannot."),
item("", "Otherwise scp the file and type its path, or paste"),
item("", "the image as base64 text, which cctop also files:"),
item("", " wl-paste -t image/png | base64 -w0 | wl-copy"),
item("", " pngpaste - | base64 | pbcopy"),
item("", " PowerShell: see docs/the-table.md"),
Line::default(),
section("Mouse"),
item("Right-click a row", "Its menu, as Enter opens it"),
item("Click a footer hint", "Presses that key (q asks twice)"),
item("Click [y] / [n]", "Answers a confirmation"),
Line::default(),
section("Elsewhere"),
item("A", "Open the agent this cctop launched"),
item("w", "Bell + desktop alert when a session needs you"),
item("W", "Share the agent's terminal to a browser"),
item(
"B",
"Serve this table to a browser, with or without a tunnel",
),
item("h F8", "Agent integration: what reports to cctop"),
item("r F5", "Refresh now"),
Line::default(),
section("Environment"),
item("CCTOP_THEME", "light / dark / auto (default: auto)"),
item("NO_COLOR", "Drop colour; shape and weight carry the state"),
item(
"CCTOP_COLUMNS_HIDE",
"Column keys to hide, e.g. tok_rate,mem",
),
Line::from(Span::styled(
" Columns also drop by priority on their own as the window narrows.",
theme::dim(),
)),
Line::default(),
Line::from(Span::styled(
" Costs are estimates from published per-token rates. Flat-rate plans",
theme::dim(),
)),
Line::from(Span::styled(
" (Max, Pro, Team) bill differently, so these may not match your invoice.",
theme::dim(),
)),
Line::default(),
Line::from(Span::styled(
" ↑ / ↓ scroll any other key returns",
theme::dim(),
)),
];
app.help_max_scroll = scrollable_modal(frame, area, "Help", lines, 76, app.help_scroll);
}
pub(super) fn draw_search(frame: &mut Frame, area: Rect, app: &App) {
const WIDTH: u16 = 62;
let text_w = WIDTH as usize - 4;
let mut lines = vec![
Line::from(vec![
Span::raw(" > "),
Span::styled(
app.search.clone(),
Style::default()
.fg(theme::colors().value)
.add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(theme::colors().accent)),
]),
Line::from(Span::styled(
format!(
" {} of {} session{}",
app.visible.len(),
app.sessions.len(),
if app.sessions.len() == 1 { "" } else { "s" }
),
theme::dim(),
)),
];
let (marker, style, text) = match (app.search_content, app.scanning) {
(false, _) => (
"○",
theme::dim(),
"Columns only — Tab also searches transcripts".to_string(),
),
(true, true) => (
"◌",
Style::default().fg(theme::colors().accent),
"Searching transcripts…".to_string(),
),
(true, false) => (
"●",
Style::default().fg(theme::colors().cost_low),
match app.scan_hits.len() {
0 if app.search.chars().count() < 3 => {
"Transcripts: type three characters".to_string()
}
0 => "Transcripts: no matches".to_string(),
n => format!("Transcripts: {n} matched"),
},
),
};
lines.push(Line::from(vec![
Span::styled(format!(" {marker} "), style),
Span::styled(crate::util::truncate(&text, text_w), style),
]));
if let Some(snippet) = app.selected_snippet() {
lines.push(Line::from(Span::styled(
format!(" {}", crate::util::truncate(snippet, text_w - 3)),
theme::value(),
)));
}
lines.push(Line::from(Span::styled(
if app.search_history.is_empty() {
" Enter apply Esc cancel"
} else {
" Enter apply ↑/↓ past searches Esc cancel"
},
theme::dim(),
)));
modal(frame, area, "Filter sessions", lines, WIDTH);
}
pub(super) fn draw_resume_confirm(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let Some(session) = app.selected_session() else {
return;
};
let command = session
.resume_argv()
.map(|argv| argv.join(" "))
.unwrap_or_default();
let lines = vec![
Line::from(Span::styled(
format!(
" {} is still running.",
crate::util::truncate(session.display_label(), 40)
),
Style::default().fg(theme::colors().cost_mid),
)),
Line::default(),
Line::from(Span::raw(
" Resuming starts a second agent on the same transcript,",
)),
Line::from(Span::raw(" which neither of them will know about.")),
Line::default(),
Line::from(Span::styled(
format!(" {}", crate::util::truncate(&command, 56)),
theme::value(),
)),
Line::default(),
Line::from(Span::styled(RESUME_KEYS, theme::dim())),
];
let last = lines.len() as u16 - 1;
let (outer, inner) = modal(frame, area, "Resume a running session?", lines, 62);
confirm_chips(
layout,
outer,
inner,
last,
RESUME_KEYS,
&[("[y]", ch('y')), ("[n / Esc]", dismiss())],
);
}
const RESUME_KEYS: &str = " [y] resume anyway [n / Esc] cancel";
pub(super) fn draw_rmux_install(frame: &mut Frame, area: Rect, app: &App) {
let Some(install) = app.rmux_install.as_ref() else {
return;
};
let command = install.shown();
let mut lines = vec![
Line::from(Span::styled(
" rmux is not installed.",
Style::default().fg(theme::colors().cost_mid),
)),
Line::default(),
Line::from(Span::raw(
" With it, agents run inside rmux and survive cctop",
)),
Line::from(Span::raw(
" closing. Without it, quitting takes them with it.",
)),
Line::default(),
Line::from(Span::styled(
format!(" {}", crate::util::truncate(command, 56)),
theme::value(),
)),
];
lines.push(Line::default());
lines.push(Line::from(Span::styled(
" y to install · any other key to start without it",
theme::dim(),
)));
modal(
frame,
area,
&format!("Install rmux with {}?", install.manager),
lines,
62,
);
}
pub(super) fn draw_serve(frame: &mut Frame, area: Rect, app: &App) {
let mut lines: Vec<Line> = Vec::new();
match &app.serving {
None => {
lines.push(Line::from(Span::raw(
" Nothing is being served. The table can be a page,",
)));
lines.push(Line::from(Span::raw(" with cctop still running here.")));
}
Some(serving) => {
fn show(lines: &mut Vec<Line<'static>>, what: &str, url: &str) {
lines.push(Line::from(Span::styled(format!(" {what}"), theme::dim())));
lines.push(Line::from(Span::styled(
format!(" {}", origin_of(url)),
Style::default().fg(theme::colors().accent),
)));
}
show(&mut lines, "This machine", &serving.local);
match &serving.public {
None => {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
" No tunnel — nothing off this machine can reach it.",
theme::dim(),
)));
}
Some(public) => {
lines.push(Line::default());
show(&mut lines, "The internet", public);
lines.push(Line::default());
lines.push(Line::from(Span::styled(
" Anyone holding it reads every session here,",
Style::default().fg(theme::colors().cost_mid),
)));
lines.push(Line::from(Span::styled(
match serving.actions {
true => " and can type at your agents. Cloudflare carries it.",
false => " but cannot act. Cloudflare carries it.",
},
Style::default().fg(theme::colors().cost_mid),
)));
}
}
}
}
if let Some(opening) = &app.share_opening {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
format!(" {} Opening a tunnel to trycloudflare…", opening.frame()),
Style::default().fg(theme::colors().cost_mid),
)));
}
if let Some(error) = &app.serve_error {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
format!(" {}", crate::util::truncate(error, 58)),
Style::default().fg(theme::colors().cost_high),
)));
}
lines.push(Line::default());
lines.push(Line::from(Span::styled(
match app.serving.is_some() {
true => " o open · y copy · l local · t + tunnel · x stop",
false => " l this machine only · t also a public tunnel",
},
theme::dim(),
)));
let widest = lines.iter().map(Line::width).max().unwrap_or(0) as u16;
let width = widest
.saturating_add(3)
.clamp(56, area.width.saturating_sub(4).max(24));
modal(frame, area, "Serve this table to a browser", lines, width);
}
fn origin_of(url: &str) -> String {
let (scheme, rest) = url.split_once("://").unwrap_or(("", url));
let host = rest.split(['/', '?']).next().unwrap_or(rest);
match scheme.is_empty() {
true => host.to_string(),
false => format!("{scheme}://{host}"),
}
}
pub(super) fn draw_sortby(frame: &mut Frame, area: Rect, app: &App) {
let mut lines: Vec<Line> = COLUMNS
.iter()
.enumerate()
.map(|(i, c)| {
let active = c.id == app.sort_col;
let arrow = if active {
if app.sort_asc { " ▲" } else { " ▼" }
} else {
""
};
let text = format!(" {}{}", c.label.trim(), arrow);
let style = if i == app.sortby_cursor {
theme::selected()
} else if active {
Style::default().fg(theme::colors().accent)
} else {
Style::default()
};
Line::from(Span::styled(format!("{text:<26}"), style))
})
.collect();
lines.push(Line::default());
for part in COLUMNS[app.sortby_cursor].desc.lines() {
lines.push(Line::from(Span::styled(format!(" {part}"), theme::dim())));
}
lines.push(Line::default());
lines.push(Line::from(Span::styled(
" Enter select Esc cancel",
theme::dim(),
)));
modal(frame, area, "Sort by", lines, 54);
}
pub(super) fn draw_age_filter(frame: &mut Frame, area: Rect, app: &App) {
let mut lines: Vec<Line> = AGE_OPTIONS
.iter()
.enumerate()
.map(|(i, opt)| {
let active = *opt == app.age_filter;
let marker = if active { "●" } else { "○" };
let text = opt.map(|o| o.label()).unwrap_or("No filter");
let style = if i == app.age_cursor {
theme::selected()
} else {
Style::default()
};
Line::from(vec![
Span::styled(
format!(" {marker} "),
Style::default().fg(if active {
theme::colors().cost_low
} else {
theme::colors().dimmer
}),
),
Span::styled(format!("{text:<22}"), style),
])
})
.collect();
lines.push(Line::from(Span::styled(
" ↑/↓ Enter apply Esc cancel",
theme::dim(),
)));
modal(frame, area, "Show sessions active within", lines, 34);
}
fn tail(s: &str, width: usize) -> String {
let count = s.chars().count();
if count <= width {
return s.to_string();
}
let skip = count - width.saturating_sub(1);
format!("…{}", s.chars().skip(skip).collect::<String>())
}
const MAX_REASON: usize = 34;
fn trailing(item: &super::menu::Item) -> String {
match &item.blocked {
Some(why) => why.clone(),
None => item.key.to_string(),
}
}
pub(super) fn draw_row_menu(
frame: &mut Frame,
area: Rect,
app: &App,
layout: &mut super::render::Layout,
) {
let items = super::menu::items(app);
if items.is_empty() {
return;
}
let label_w = items.iter().map(|i| i.label.len()).max().unwrap_or(0);
let right_w = items
.iter()
.map(|i| trailing(i).chars().count().min(MAX_REASON))
.max()
.unwrap_or(0);
let width = (label_w + right_w + 6).clamp(30, 76) as u16;
let mut lines: Vec<Line<'static>> = Vec::new();
let mut rows: Vec<(usize, usize)> = Vec::new();
for (i, item) in items.iter().enumerate() {
if item.rule {
lines.push(Line::from(Span::styled(
format!(" {} ", "─".repeat(width.saturating_sub(4) as usize)),
theme::dim(),
)));
}
let selected = i == app.menu_cursor && item.enabled();
let style = match (item.enabled(), selected) {
(false, _) => theme::dim(),
(true, true) => theme::selected(),
(true, false) => Style::default(),
};
rows.push((lines.len(), i));
lines.push(Line::from(vec![
Span::styled(format!(" {:<label_w$} ", item.label), style),
Span::styled(
format!(
"{:>right_w$} ",
crate::util::truncate(&trailing(item), MAX_REASON)
),
theme::dim(),
),
]));
}
let subject = app
.selected_session()
.map(|s| crate::util::truncate(s.display_label(), width.saturating_sub(4) as usize))
.unwrap_or_default();
let inner_width = width.saturating_sub(2).max(1) as usize;
let height = lines
.iter()
.map(|line| line.width().max(1).div_ceil(inner_width) as u16)
.sum::<u16>()
+ 2;
let rect = centered(area, width, height);
frame.render_widget(Clear, rect);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::colors().border_hi))
.style(theme::canvas())
.title(Span::styled(format!(" {subject} "), theme::title()))
.title_bottom(Span::styled(" ↑↓ Enter · Esc ", theme::dim()));
let inner = block.inner(rect);
frame.render_widget(block, rect);
frame.render_widget(Paragraph::new(lines).style(theme::canvas()), inner);
layout.modal_rect = Some(rect);
layout.menu_rows = rows
.into_iter()
.map(|(line, i)| (inner.y + line as u16, i))
.collect();
}
pub(super) fn draw_launch(
frame: &mut Frame,
area: Rect,
app: &App,
layout: &mut super::render::Layout,
) {
const WIDTH: u16 = 62;
const NAME: usize = 24;
const WHERE: usize = 20;
const STATE: usize = 12;
const FOOTER: usize = 2;
let choices = app.launch_choices();
let waiting = choices
.iter()
.filter(|c| matches!(c, tabs::Choice::Waiting(_)))
.count();
let dirs: Vec<String> = choices
.iter()
.filter_map(|c| c.cwd())
.map(|d| d.to_string_lossy().into_owned())
.collect();
let mut short = crate::util::abbreviate_paths(&dirs).into_iter();
let mut rows: Vec<Line> = Vec::new();
let mut choice_lines: Vec<usize> = Vec::new();
for (i, choice) in choices.iter().enumerate() {
if i == 0 && waiting > 0 {
rows.push(Line::from(Span::styled(" Still running", theme::label())));
}
if i == waiting && waiting > 0 {
rows.push(Line::from(Span::styled(" Start new", theme::label())));
}
let selected = i == app.launch_cursor;
let style = if selected {
theme::selected()
} else {
Style::default()
};
let reported = match choice {
tabs::Choice::Waiting(agent) => app.waiting_state(agent),
tabs::Choice::Start(_) => None,
};
let dot = match choice {
tabs::Choice::Waiting(agent) if agent.attached => "◉",
tabs::Choice::Waiting(_) => "●",
tabs::Choice::Start(_) => " ",
};
let dot_color = match (choice, reported) {
(tabs::Choice::Waiting(_), Some(signal)) => theme::signal_color(signal),
(tabs::Choice::Waiting(_), None) => theme::colors().dim,
(tabs::Choice::Start(_), _) => theme::colors().dimmer,
};
let state = match reported {
Some(signal) => signal.label().to_string(),
None => String::new(),
};
let state_color = match reported {
Some(signal) => theme::signal_color(signal),
None => theme::colors().dim,
};
let at = match choice.cwd().is_some() {
true => short.next().unwrap_or_default(),
false => String::new(),
};
let name = match choice {
tabs::Choice::Waiting(agent) => app.waiting_label(agent),
tabs::Choice::Start(_) => None,
}
.unwrap_or_else(|| choice.label());
choice_lines.push(rows.len());
rows.push(Line::from(vec![
Span::styled(format!(" {dot} "), Style::default().fg(dot_color)),
Span::styled(
format!("{:<NAME$}", crate::util::truncate(&name, NAME)),
style,
),
Span::styled(
format!("{:<WHERE$}", crate::util::truncate(&at, WHERE - 1)),
theme::dim(),
),
Span::styled(
format!("{:<STATE$}", crate::util::truncate(&state, STATE)),
Style::default().fg(state_color),
),
]));
}
let editing = app.mode == super::Mode::LaunchCwd;
let hits: &[std::path::PathBuf] = match editing {
true => &app.launch_cwd_hits,
false => &[],
};
let room = (area.height as usize)
.saturating_sub(4 + FOOTER + hits.len())
.max(1);
let total = rows.len();
let scrolled = total > room;
let cursor_line = choice_lines.get(app.launch_cursor).copied().unwrap_or(0);
let offset = match scrolled {
true => cursor_line.saturating_sub(room - 1).min(total - room),
false => 0,
};
let shown = room.min(total - offset);
let mut lines: Vec<Line> = rows.into_iter().skip(offset).take(shown).collect();
let picked = choices.get(app.launch_cursor);
let picked_waiting = matches!(picked, Some(tabs::Choice::Waiting(_)));
lines.push(if editing {
Line::from(vec![
Span::styled(" in ", Style::default().fg(theme::colors().label)),
Span::styled(
tail(&app.launch_cwd_input, WIDTH as usize - 8),
match app.launch_cwd_bad {
true => Style::default().fg(theme::colors().cost_high),
false => theme::value(),
},
),
Span::styled("▏", theme::value()),
Span::styled(
match app.launch_cwd_bad {
true => " no such directory",
false => "",
},
Style::default().fg(theme::colors().cost_high),
),
])
} else {
Line::from(Span::styled(
match (picked, &app.launch_cwd) {
(Some(tabs::Choice::Waiting(agent)), _) if agent.attached => {
" ◉ already open elsewhere — both windows share one size".to_string()
}
(Some(tabs::Choice::Waiting(_)), _) => " where it already is".to_string(),
(_, Some(dir)) => format!(
" in {} (c to change)",
crate::util::truncate(
&crate::util::tildify(&dir.to_string_lossy()),
WIDTH as usize - 22
)
),
(_, None) => " in this directory (c to change)".to_string(),
},
Style::default().fg(theme::colors().label),
))
});
let mut hit_lines: Vec<usize> = Vec::new();
for (i, dir) in hits.iter().enumerate() {
let selected = app.launch_cwd_pick == Some(i);
let shown = tail(
&crate::util::tildify(&dir.to_string_lossy()),
WIDTH as usize - 8,
);
hit_lines.push(lines.len());
lines.push(Line::from(vec![
Span::styled(
match selected {
true => " › ",
false => " ",
},
theme::dim(),
),
Span::styled(
shown,
match selected {
true => theme::selected(),
false => theme::dim(),
},
),
]));
}
let profile = match picked {
Some(tabs::Choice::Start(_)) => app.launch_profile(),
_ => None,
};
if let Some(profile) = profile {
lines.push(Line::from(vec![
Span::styled(" as ", Style::default().fg(theme::colors().label)),
Span::styled(profile.name.clone(), theme::value()),
Span::styled(" (p to change)", theme::dim()),
]));
}
let keys = match (editing, picked_waiting) {
(true, _) if !hits.is_empty() => " Enter accept Tab fill in ↑/↓ pick Esc cancel",
(true, _) => " Enter accept Esc keep the old one",
(false, true) => " ↑/↓ Enter reattach Esc cancel",
(false, false) => " ↑/↓ Enter start Esc cancel",
};
lines.push(Line::from(Span::styled(
match scrolled {
true => format!("{keys} {} of {}", app.launch_cursor + 1, choices.len()),
false => keys.to_string(),
},
theme::dim(),
)));
let title = match (app.pending_brief.is_some(), app.launch_into) {
(true, _) => "Hand the context to",
(false, LaunchInto::Tab) => "New tab",
(false, LaunchInto::Split { stacked: false }) => "Split right",
(false, LaunchInto::Split { stacked: true }) => "Split down",
};
let (outer, inner) = modal(frame, area, title, lines, WIDTH);
layout.modal_rect = Some(outer);
layout.launch_cwd_rows = hit_lines
.into_iter()
.enumerate()
.map(|(i, line)| (inner.y + line as u16, i))
.collect();
layout.launch_rows = choice_lines
.into_iter()
.enumerate()
.filter_map(|(i, line)| {
let drawn = line.checked_sub(offset).filter(|d| *d < shown)?;
let row = inner.y + drawn as u16;
(row < inner.y + inner.height).then_some((row, i))
})
.collect();
}
pub(super) fn draw_hooks(frame: &mut Frame, area: Rect, app: &App) {
let Some(report) = &app.hooks else { return };
const WIDTH: u16 = 78;
let text_w = WIDTH as usize - 5;
let mut lines: Vec<Line> = Vec::new();
for (text, problem) in report.lines() {
let (marker, style) = match problem {
true => ("! ", Style::default().fg(theme::colors().cost_mid)),
false => ("· ", theme::value()),
};
lines.push(Line::from(vec![
Span::styled(format!(" {marker}"), style),
Span::styled(crate::util::truncate(&text, text_w), style),
]));
}
lines.push(Line::default());
let reporting = app.reporting();
if reporting.is_empty() {
lines.push(Line::from(Span::styled(
" Nothing has reported in yet",
theme::dim(),
)));
} else {
lines.push(Line::from(Span::styled(" Reporting", theme::label())));
for (project, state) in reporting.iter().take(6) {
lines.push(Line::from(vec![
Span::styled(
format!(" {:<24}", crate::util::truncate(project, 24)),
theme::value(),
),
Span::styled(*state, theme::dim()),
]));
}
if reporting.len() > 6 {
lines.push(Line::from(Span::styled(
format!(" … and {} more", reporting.len() - 6),
theme::dim(),
)));
}
}
lines.push(Line::default());
lines.push(Line::from(Span::styled(
match app.hook_project() {
Some(dir) => format!(
" p / P install / remove in {}",
crate::util::truncate(&dir.display().to_string(), text_w.saturating_sub(30))
),
None => " p / P install / remove for the selected project".into(),
},
theme::dim(),
)));
lines.push(Line::from(Span::styled(
" i / x install / remove for this user (every agent above)",
theme::dim(),
)));
lines.push(Line::from(Span::styled(
" Esc close · sessions already running keep their old hooks",
theme::dim(),
)));
modal(frame, area, "Agent integration", lines, WIDTH);
}
fn ch(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn dismiss() -> KeyEvent {
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
}
fn confirm_chips(
layout: &mut Layout,
outer: Rect,
inner: Rect,
row: u16,
hint: &str,
keys: &[(&str, KeyEvent)],
) {
layout.modal_rect = Some(outer);
for (chip, key) in keys {
let Some(at) = hint.find(chip) else {
continue;
};
let x = inner.x + hint[..at].chars().count() as u16;
let width = chip.chars().count() as u16;
layout.key_hits.push((inner.y + row, x, x + width, *key));
}
}
pub(super) fn draw_delete_confirm(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let Some(s) = app.selected_session() else {
return;
};
let lines = vec![
Line::from(Span::styled(
format!(" {}", s.display_label()),
theme::value(),
)),
Line::from(Span::styled(format!(" {}", s.session_id), theme::dim())),
Line::default(),
Line::from(Span::styled(
" This permanently removes the transcript from disk.",
Style::default().fg(theme::colors().cost_mid),
)),
Line::default(),
Line::from(Span::styled(DELETE_KEYS, theme::dim())),
];
let last = lines.len() as u16 - 1;
let (outer, inner) = modal(frame, area, "Delete session?", lines, 62);
confirm_chips(
layout,
outer,
inner,
last,
DELETE_KEYS,
&[("[y]", ch('y')), ("[n / Esc]", dismiss())],
);
}
const DELETE_KEYS: &str = " [y] delete [n / Esc] cancel";
pub(super) fn draw_delete_blocked(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let Some(s) = app.selected_session() else {
return;
};
let lines = vec![
Line::from(Span::styled(
format!(" {}", s.display_label()),
theme::value(),
)),
Line::default(),
Line::from(Span::raw(" This session is still running.")),
Line::from(Span::raw(" Stop the agent first, then delete it.")),
Line::default(),
Line::from(Span::styled(DISMISS_KEYS, theme::dim())),
];
let last = lines.len() as u16 - 1;
let (outer, inner) = modal(frame, area, "Cannot delete", lines, 62);
confirm_chips(
layout,
outer,
inner,
last,
DISMISS_KEYS,
&[("[any key]", dismiss())],
);
}
const DISMISS_KEYS: &str = " [any key] dismiss";
pub(super) fn draw_kill_confirm(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let Some(s) = app.selected_session() else {
return;
};
let lines = vec![
Line::from(Span::styled(
format!(" {}", s.display_label()),
theme::value(),
)),
Line::from(Span::styled(format!(" {}", s.session_id), theme::dim())),
Line::default(),
Line::from(Span::styled(
" Send a termination signal to this agent process?",
Style::default().fg(theme::colors().cost_mid),
)),
Line::from(Span::raw(" Unsaved work in the agent may be interrupted.")),
Line::default(),
Line::from(Span::styled(KILL_KEYS, theme::dim())),
];
let last = lines.len() as u16 - 1;
let (outer, inner) = modal(frame, area, "Terminate session?", lines, 62);
confirm_chips(
layout,
outer,
inner,
last,
KILL_KEYS,
&[("[y]", ch('y')), ("[n / Esc]", dismiss())],
);
}
const KILL_KEYS: &str = " [y] terminate [n / Esc] cancel";
pub(super) fn draw_quit_confirm(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let label = match &app.hosted {
Some((_, label)) => label.clone(),
None => return,
};
let lines = vec![
Line::from(Span::styled(format!(" {label}"), theme::value())),
Line::default(),
Line::from(Span::styled(
" This agent is running on a terminal cctop owns,",
Style::default().fg(theme::colors().cost_mid),
)),
Line::from(Span::styled(
" and quitting ends it.",
Style::default().fg(theme::colors().cost_mid),
)),
Line::from(Span::raw(" Exit the agent itself to leave it cleanly.")),
Line::default(),
Line::from(Span::styled(QUIT_KEYS, theme::dim())),
];
let last = lines.len() as u16 - 1;
let (outer, inner) = modal(frame, area, "Quit and stop the agent?", lines, 62);
confirm_chips(
layout,
outer,
inner,
last,
QUIT_KEYS,
&[("[y]", ch('y')), ("[n / Esc]", dismiss()), ("[A]", ch('A'))],
);
}
const QUIT_KEYS: &str = " [y] quit anyway [n / Esc] stay [A] back to the agent";
pub(super) fn draw_kill_blocked(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let Some(s) = app.selected_session() else {
return;
};
let lines = vec![
Line::from(Span::styled(
format!(" {}", s.display_label()),
theme::value(),
)),
Line::default(),
Line::from(Span::raw(
" This session has no locally controllable process.",
)),
Line::from(Span::raw(" It may be running in a remote or shared host.")),
Line::default(),
Line::from(Span::styled(DISMISS_KEYS, theme::dim())),
];
let last = lines.len() as u16 - 1;
let (outer, inner) = modal(frame, area, "Cannot terminate", lines, 62);
confirm_chips(
layout,
outer,
inner,
last,
DISMISS_KEYS,
&[("[any key]", dismiss())],
);
}
pub(super) fn draw_batch_confirm(frame: &mut Frame, area: Rect, app: &App, layout: &mut Layout) {
let ms = app.marked_sessions();
let (verb, noun) = match app.batch {
BatchKind::Delete => ("delete", "sessions"),
BatchKind::Kill => ("terminate", "live sessions"),
};
let mut lines = vec![
Line::from(Span::styled(
format!(" {} marked {noun}", ms.len()),
theme::value(),
)),
Line::default(),
];
for s in ms.iter().take(8) {
lines.push(Line::from(Span::styled(
format!(" · {}", s.display_label()),
theme::dim(),
)));
}
if ms.len() > 8 {
lines.push(Line::from(Span::styled(
format!(" … and {} more", ms.len() - 8),
theme::dim(),
)));
}
lines.push(Line::default());
lines.push(Line::from(Span::styled(
match app.batch {
BatchKind::Delete => " This permanently removes their transcripts from disk.",
BatchKind::Kill => " Unsaved work in the agents may be interrupted.",
},
Style::default().fg(theme::colors().cost_mid),
)));
lines.push(Line::default());
let hint = format!(" [y] {verb} all [n / Esc] cancel");
lines.push(Line::from(Span::styled(hint.clone(), theme::dim())));
let last = lines.len() as u16 - 1;
let (outer, inner) = modal(frame, area, &format!("{verb} all?"), lines, 62);
confirm_chips(
layout,
outer,
inner,
last,
&hint,
&[("[y]", ch('y')), ("[n / Esc]", dismiss())],
);
}
pub(super) fn draw_batch_blocked(
frame: &mut Frame,
area: Rect,
app: &App,
deleting: bool,
layout: &mut Layout,
) {
let ms = app.marked_sessions();
let (explain, name) = match app.batch {
BatchKind::Delete => (
"running — stop the agent first",
ms.iter().find(|s| s.is_running()),
),
BatchKind::Kill => (
"has no locally controllable process",
ms.iter().find(|s| s.root_pid().is_none()),
),
};
let lines = vec![
Line::from(Span::styled(
format!(" {} marked sessions", ms.len()),
theme::value(),
)),
Line::default(),
Line::from(Span::raw(format!(
" Not all can be {}.",
if deleting { "deleted" } else { "killed" }
))),
match name {
Some(s) => Line::from(Span::styled(
format!(" · {} {}", s.display_label(), explain),
theme::dim(),
)),
None => Line::from(Span::raw(" At least one is not ready.")),
},
Line::default(),
Line::from(Span::styled(
" e.g. unmark the running / remote sessions first.",
theme::dim(),
)),
Line::default(),
Line::from(Span::styled(DISMISS_KEYS, theme::dim())),
];
let last = lines.len() as u16 - 1;
let title = if deleting {
"Cannot delete all"
} else {
"Cannot terminate all"
};
let (outer, inner) = modal(frame, area, title, lines, 62);
confirm_chips(
layout,
outer,
inner,
last,
DISMISS_KEYS,
&[("[any key]", dismiss())],
);
}
pub(super) fn draw_cost_filter(frame: &mut Frame, area: Rect, app: &App) {
let mut input = app.cost_input.clone();
if input.is_empty() {
input = "0.00".to_string();
}
let lines = vec![
Line::from(Span::styled(
" Only show sessions whose total cost is at least:",
theme::dim(),
)),
Line::from(vec![
Span::raw(" $ "),
Span::styled(
input,
Style::default()
.fg(theme::colors().value)
.add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(theme::colors().accent)),
]),
Line::default(),
Line::from(Span::styled(
" 0 clears the filter Enter apply Esc cancel",
theme::dim(),
)),
];
modal(frame, area, "Cost floor", lines, 50);
}
pub(super) fn draw_send_keys(frame: &mut Frame, area: Rect, app: &App) {
let Some(s) = app.selected_session() else {
return;
};
let lines = vec![
Line::from(Span::styled(
format!(" {}", s.display_label()),
theme::value(),
)),
Line::from(Span::styled(
" Typed into the terminal running this agent, then submitted.",
theme::dim(),
)),
Line::from(Span::styled(
" Needs the agent under `cctop run`, rmux, or cctop as root.",
theme::dim(),
)),
Line::default(),
Line::from(vec![
Span::raw(" > "),
Span::styled(
app.send_input.clone(),
Style::default()
.fg(theme::colors().value)
.add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(theme::colors().accent)),
]),
Line::default(),
Line::from(Span::styled(
" Enter send F9 paste an image Esc cancel",
theme::dim(),
)),
];
modal(frame, area, "Send to session", lines, 64);
}
pub(super) fn draw_rename_tab(
frame: &mut Frame,
area: Rect,
app: &App,
layout: &mut super::render::Layout,
) {
let lines = vec![
Line::from(vec![
Span::styled(" Now called ", theme::dim()),
Span::styled(app.rename_was.clone(), theme::value()),
]),
Line::from(Span::styled(
" The name follows the tab into every cctop on this machine.",
theme::dim(),
)),
Line::default(),
Line::from(vec![
Span::raw(" > "),
Span::styled(
app.rename_input.clone(),
Style::default()
.fg(theme::colors().value)
.add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(theme::colors().accent)),
]),
Line::default(),
Line::from(Span::styled(" Enter rename Esc cancel", theme::dim())),
];
let (outer, _) = modal(frame, area, "Rename tab", lines, 64);
layout.modal_rect = Some(outer);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn centered_rect_never_exceeds_screen() {
let area = Rect {
x: 0,
y: 0,
width: 20,
height: 10,
};
let r = centered(area, 100, 100);
assert!(r.width <= area.width && r.height <= area.height);
assert!(r.x + r.width <= area.width);
}
}