use super::columns::COLUMNS;
use super::render::Layout;
use super::share;
use super::theme;
use super::{AGE_OPTIONS, App, BatchKind, LaunchInto, tabs};
use crate::session::Session;
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(",", "Settings and keybinds, and the file that holds them"),
item("q F10", "Quit"),
item("+", "Add a Claude account (runs claude setup-token)"),
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 Home / End", "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("i", "Read its conversation (works on remote rows)"),
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+t", "Pick a tab from a list, typing to narrow it"),
item("Alt+b", "Jump to the next tab that needs you"),
item(
"Alt+Shift+← / →",
"Move this tab along the bar (or drag it with the mouse)",
),
item("Right-click / Alt+r", "Rename or recolour a tab"),
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(
"Alt+Shift+R",
"Restart its agent on the same session (after an update)",
),
item("F9", "Paste the clipboard's image as a file path"),
item("Ctrl+V", "The same, in terminals that send it"),
item("Home / End", "In a Claude pane: top / bottom of the chat"),
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("", "so F9 asks the terminal instead — kitty answers"),
item("", "when its clipboard_control allows reads. Windows"),
item("", "Terminal never does: tools/clipboard-bridge.ps1"),
item("", "on it plus ssh -R 8377:127.0.0.1:8377 gives F9"),
item("", "and Ctrl+V the clipboard anyway. Otherwise the"),
item("", "page — `cctop serve` here, open it there — takes"),
item("", "a real paste, and base64 text is filed too:"),
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("o", "What was spent and not got back"),
item("c", "How each model did on the work you gave it"),
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_settings(frame: &mut Frame, area: Rect, app: &mut App) {
use crate::settings::{BINDINGS, SETTINGS};
let section = |t: &str| Line::from(Span::styled(t.to_string(), theme::title()));
let path = crate::util::tildify(&crate::config::CONFIG_FILE.to_string_lossy());
let mut lines = vec![
Line::from(vec![Span::styled(" File ", theme::dim()), Span::raw(path)]),
Line::from(Span::styled(
" * set in the file, rather than left at the default",
theme::dim(),
)),
];
for problem in &app.settings.problems {
lines.push(Line::from(Span::styled(
format!(" ! {problem}"),
theme::failed(),
)));
}
let mut cursor_line = 0;
let mut push_row = |lines: &mut Vec<Line<'static>>,
row: usize,
name: &str,
value: String,
set: bool,
what: &str| {
let here = row == app.settings_cursor;
let value = match here {
true if app.settings_capture => "press a key… (Esc cancels)".to_string(),
true if app.settings_input.is_some() => {
format!("{}█", app.settings_input.as_deref().unwrap_or_default())
}
_ => value,
};
let accent = Style::default().fg(theme::colors().accent);
let mut line = Line::from(vec![
Span::raw(format!(" {name:<18}")),
Span::styled(
format!("{value:<14}"),
if set {
accent.add_modifier(Modifier::BOLD)
} else {
accent
},
),
Span::styled(if set { " * " } else { " " }, theme::dim()),
Span::styled(what.to_string(), theme::dim()),
]);
if here {
cursor_line = lines.len();
line = line.style(theme::selected());
}
lines.push(line);
};
lines.push(Line::default());
lines.push(section("[settings]"));
for (row, (name, _, what)) in SETTINGS.iter().enumerate() {
let (value, set) = app.settings.value_of(name);
push_row(&mut lines, row, name, value, set, what);
}
lines.push(Line::default());
lines.push(section("[keys] on the session table"));
for (i, (action, default, what)) in BINDINGS.iter().enumerate() {
let key = app.settings.key_for(action).to_string();
let set = key != *default;
push_row(&mut lines, SETTINGS.len() + i, action, key, set, what);
}
lines.push(Line::default());
lines.push(Line::from(Span::styled(
" ↵ change ⌫ reset e open in your editor Esc close",
theme::dim(),
)));
let inner = (lines.len() as u16 + 2)
.min(area.height.saturating_sub(2))
.saturating_sub(2)
.max(1);
let at = cursor_line as u16;
if app.settings_cursor == 0 {
app.settings_scroll = 0;
} else if at < app.settings_scroll {
app.settings_scroll = at;
} else if at >= app.settings_scroll + inner {
app.settings_scroll = at + 1 - inner;
}
scrollable_modal(frame, area, "Settings", lines, 96, app.settings_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 !serving.readonly.is_empty() {
lines.push(Line::default());
show(
&mut lines,
"Read-only — watches, never acts",
&serving.readonly,
);
}
}
}
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 mut swatches = vec![Span::styled(" Colour ", theme::dim())];
let mut named = "none";
for option in std::iter::once(None).chain(theme::Hue::ALL.into_iter().map(Some)) {
let picked = option == app.rename_color;
if picked {
named = option.map_or("none", theme::Hue::name);
}
let (glyph, style) = match option {
Some(hue) => ("●", Style::default().fg(hue.color())),
None => ("○", theme::dim()),
};
swatches.push(Span::raw(" "));
if picked {
swatches.push(Span::styled("[", theme::dim()));
swatches.push(Span::styled(glyph, style.add_modifier(Modifier::BOLD)));
swatches.push(Span::styled("]", theme::dim()));
} else {
swatches.push(Span::styled(format!(" {glyph} "), style));
}
}
swatches.push(Span::styled(format!(" {named}"), theme::value()));
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 and colour follow 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(swatches),
Line::default(),
Line::from(Span::styled(
" Enter apply ← → colour Esc cancel",
theme::dim(),
)),
];
let (outer, _) = modal(frame, area, "Rename tab", lines, 64);
layout.modal_rect = Some(outer);
}
pub(super) fn draw_switch_tab(
frame: &mut Frame,
area: Rect,
app: &App,
layout: &mut super::render::Layout,
) {
const WIDTH: u16 = 60;
const ROWS: usize = 10;
const TITLE_W: usize = 38;
let matches = app.switch_matches();
let cursor = app.switch_cursor.min(matches.len().saturating_sub(1));
let start = cursor
.saturating_sub(ROWS / 2)
.min(matches.len().saturating_sub(ROWS));
let shown = &matches[start..matches.len().min(start + ROWS)];
let mut lines = vec![
Line::from(vec![
Span::raw(" > "),
Span::styled(
app.switch_filter.clone(),
Style::default()
.fg(theme::colors().value)
.add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(theme::colors().accent)),
]),
Line::default(),
];
if start > 0 {
lines.push(Line::from(Span::styled(
format!(" … {start} above"),
theme::dim(),
)));
}
for (row, &i) in shown.iter().enumerate() {
let tab = i.checked_sub(1).and_then(|t| app.tabs.get(t));
let title = tab.map_or_else(|| "Dashboard".to_string(), tabs::Tab::title);
let number = if i < 9 {
(i + 1).to_string()
} else {
String::new()
};
let state = match app.tab_attention(i) {
Some(tabs::Attention::NeedsInput) => Some(("needs you", theme::colors().cost_mid)),
Some(tabs::Attention::Idle) => Some(("idle", theme::colors().cost_low)),
None if i == app.tab => Some(("current", theme::colors().dim)),
None => None,
};
let mut spans = vec![
Span::styled(
if start + row == cursor {
" › "
} else {
" "
},
Style::default().fg(theme::colors().accent),
),
Span::styled(format!("{number:>2} "), theme::dim()),
];
match tab.and_then(|tab| tab.color) {
Some(hue) => spans.push(Span::styled("● ", Style::default().fg(hue.color()))),
None => spans.push(Span::raw(" ")),
}
spans.push(Span::styled(
format!(
"{:<width$}",
super::render::elide(&title, TITLE_W),
width = TITLE_W
),
theme::value(),
));
if let Some((word, color)) = state {
spans.push(Span::styled(
format!(" {word:>9}"),
Style::default().fg(color),
));
}
let mut line = Line::from(spans);
if start + row == cursor {
line = line.patch_style(theme::selected());
}
lines.push(line);
}
if start + shown.len() < matches.len() {
lines.push(Line::from(Span::styled(
format!(" … {} below", matches.len() - start - shown.len()),
theme::dim(),
)));
}
if matches.is_empty() {
lines.push(Line::from(Span::styled(
" No tab by that name",
theme::dim(),
)));
}
lines.push(Line::default());
lines.push(Line::from(Span::styled(
" Enter go ↑↓ choose Esc cancel",
theme::dim(),
)));
let (outer, _) = modal(frame, area, "Go to tab", lines, WIDTH);
layout.modal_rect = Some(outer);
}
pub(super) fn draw_add_account(frame: &mut Frame, area: Rect, app: &mut App, layout: &mut Layout) {
let esc = KeyEvent::from(KeyCode::Esc);
let flow = &mut app.add_account;
if let Some(outcome) = &flow.outcome
&& flow.pane.is_none()
{
let mut lines = match outcome {
Ok(name) => vec![
Line::from(vec![
Span::styled(" ✓ Saved ", Style::default().fg(theme::colors().cost_low)),
Span::styled(name.clone(), theme::value()),
Span::styled(". Its limits appear in the panel below.", theme::dim()),
]),
Line::from(Span::styled(
" Start an agent on it with p in the launcher, or from a shell:",
theme::dim(),
)),
Line::from(Span::styled(
format!(" cctop as {name} claude"),
theme::value(),
)),
],
Err(why) => vec![Line::from(Span::styled(
format!(" {why}"),
Style::default().fg(theme::colors().cost_mid),
))],
};
let hint = " [Enter] done";
lines.extend([
Line::default(),
Line::from(Span::styled(hint, theme::dim())),
]);
let row = lines.len() as u16 - 1;
let (outer, inner) = modal(frame, area, "Add a Claude account", lines, 72);
confirm_chips(
layout,
outer,
inner,
row,
hint,
&[("[Enter]", KeyEvent::from(KeyCode::Enter))],
);
return;
}
let Some(pane) = flow.pane.as_mut() else {
let hint = " [Enter] run claude setup-token [Esc] cancel";
let lines = vec![
Line::from(Span::styled(" What is it called?", theme::dim())),
Line::from(vec![
Span::raw(" > "),
Span::styled(
flow.name.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(hint, theme::dim())),
];
let row = lines.len() as u16 - 1;
let (outer, inner) = modal(frame, area, "Add a Claude account", lines, 52);
confirm_chips(
layout,
outer,
inner,
row,
hint,
&[("[Enter]", KeyEvent::from(KeyCode::Enter)), ("[Esc]", esc)],
);
return;
};
let rect = centered(area, 116, 30);
frame.render_widget(Clear, rect);
let hint = match flow.link {
Some(_) => " [Ctrl+O] open sign-in link [Esc] cancel ",
None => " [Esc] cancel ",
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::colors().border_hi))
.style(theme::canvas())
.title(Span::styled(
format!(" Add Claude account {} — claude setup-token ", flow.name),
theme::title(),
))
.title_bottom(Span::styled(hint, theme::title()));
let inner = block.inner(rect);
frame.render_widget(block, rect);
let [note, term] = ratatui::layout::Layout::vertical([
ratatui::layout::Constraint::Length(2),
ratatui::layout::Constraint::Min(1),
])
.areas(inner);
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
format!(
" Approve it in the browser signed in as {}. The token is saved",
flow.name
),
theme::dim(),
)),
Line::from(Span::styled(
" the moment it is printed — nothing to copy.",
theme::dim(),
)),
]),
note,
);
pane.view.resize(term.width, term.height);
let (cols, rows) = pane.view.size;
let screen = Rect {
width: cols.min(term.width),
height: rows.min(term.height),
..term
};
frame.render_widget(
tui_term::widget::PseudoTerminal::new(pane.view.parser.screen()),
screen,
);
layout.modal_rect = Some(rect);
let bottom = rect.y + rect.height - 1;
for (chip, key) in [
(
"[Ctrl+O] open sign-in link",
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL),
),
("[Esc] cancel", esc),
] {
if let Some(at) = hint.find(chip) {
let x = rect.x + 1 + hint[..at].chars().count() as u16;
layout
.key_hits
.push((bottom, x, x + chip.chars().count() as u16, key));
}
}
}
pub(super) fn draw_insight(frame: &mut Frame, area: Rect, app: &App) {
let title = match app.insight_kind {
"optimize" => " optimize — what was spent and not got back ",
_ => " compare — models on the work you gave them ",
};
let body: Vec<Line> = match &app.insight {
None => vec![
Line::default(),
Line::from(vec![
Span::styled(format!(" {}", share::spinner_frame()), theme::title()),
Span::styled(
" Reading every transcript. This is the slow one.",
theme::value(),
),
]),
],
Some(text) => text
.lines()
.map(|l| Line::from(Span::styled(l.to_string(), theme::value())))
.collect(),
};
let width = area.width.saturating_sub(4).min(96);
let height = area.height.saturating_sub(4);
let box_area = centered(area, width, height);
frame.render_widget(Clear, box_area);
let footer = match app.insight.is_some() {
true => " ↑↓ scroll · o optimize · c compare · esc close ",
false => " esc close ",
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::colors().border_hi))
.style(theme::canvas())
.title(Span::styled(title, theme::title()))
.title_bottom(Span::styled(footer, theme::dim()));
let visible = box_area.height.saturating_sub(2);
let max_scroll = (body.len() as u16).saturating_sub(visible);
frame.render_widget(
Paragraph::new(body)
.block(block)
.scroll((app.insight_scroll.min(max_scroll), 0)),
box_area,
);
}
pub(super) fn draw_conversation(frame: &mut Frame, area: Rect, app: &mut App) {
let Some(view) = &mut app.chat else {
return;
};
let title = match &view.session.remote {
Some(remote) => format!(
" {} — conversation · on {} ",
view.session.display_label(),
remote.host
),
None => format!(" {} — conversation ", view.session.display_label()),
};
let width = area.width.saturating_sub(4).min(110);
let height = area.height.saturating_sub(4);
let box_area = centered(area, width, height);
frame.render_widget(Clear, box_area);
let text_width = (box_area.width as usize).saturating_sub(4).max(1);
let body: Vec<Line> = match (&view.conversation, &view.error) {
(None, None) => vec![
Line::default(),
Line::from(vec![
Span::styled(format!(" {}", share::spinner_frame()), theme::title()),
match view.host.is_some() {
true => Span::styled(" Reading it over ssh…", theme::value()),
false => Span::styled(" Reading the transcript…", theme::value()),
},
]),
],
(None, Some(why)) => vec![
Line::default(),
Line::from(Span::styled(format!(" {why}"), theme::failed())),
],
(Some(conv), _) => chat_lines(&view.session, conv, text_width),
};
let footer = match &view.conversation {
_ if view.fetching => format!(
" ↑↓ scroll · {} loading earlier… · esc close ",
share::spinner_frame()
),
Some(c) if c.earlier > 0 => {
format!(" ↑↓ scroll · u load {} earlier · esc close ", c.earlier)
}
_ => " ↑↓ scroll · esc close ".to_string(),
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::colors().border_hi))
.style(theme::canvas())
.title(Span::styled(title, theme::title()))
.title_bottom(Span::styled(footer, theme::dim()));
let visible = box_area.height.saturating_sub(2) as usize;
view.max_back = (body.len().saturating_sub(visible)).min(u16::MAX as usize) as u16;
let top = body.len().saturating_sub(visible + view.back as usize);
frame.render_widget(
Paragraph::new(body).block(block).scroll((top as u16, 0)),
box_area,
);
}
fn chat_lines(
session: &Session,
conv: &crate::serve::chat::Conversation,
width: usize,
) -> Vec<Line<'static>> {
let assistant = session.surface.label(session.provider).to_string();
let now = chrono::Utc::now();
let mut out: Vec<Line> = Vec::new();
if let Some(note) = &conv.note {
for line in super::panels::wrap(note, width) {
out.push(Line::styled(line, theme::dim()));
}
out.push(Line::default());
}
for turn in &conv.turns {
if turn.kind.as_ref() == "compaction" {
out.push(Line::styled(" ── compacted ──", theme::dim()));
out.push(Line::default());
continue;
}
let (who, style) = match turn.role.as_ref() {
"user" => ("you".to_string(), theme::title()),
"assistant" => (assistant.clone(), theme::title()),
_ => ("system".to_string(), theme::dim()),
};
let when = crate::util::relative_age(&turn.ts, &now);
let text_style = match turn.kind.as_ref() {
"reasoning" => theme::dim(),
_ => theme::value(),
};
out.push(Line::from(vec![
Span::styled(format!(" {who}"), style),
Span::styled(format!(" {when}"), theme::dim()),
]));
for line in super::panels::wrap(&turn.text, width) {
out.push(Line::styled(format!(" {line}"), text_style));
}
for tool in &turn.tools {
let (mark, style) = match tool.failed {
true => ("✗", theme::failed()),
false => ("⚙", theme::dim()),
};
let counts = match (tool.added, tool.removed) {
(0, 0) => String::new(),
(a, r) => format!(" +{a} −{r}"),
};
out.push(Line::from(vec![
Span::styled(format!(" {mark} {}", tool.name), style),
Span::styled(format!(" {}", tool.detail), theme::dim()),
Span::styled(counts, theme::dim()),
]));
if let Some(full) = &tool.full {
for line in super::panels::wrap(full, width.saturating_sub(6)) {
out.push(Line::styled(format!(" {line}"), theme::dim()));
}
}
if let Some(result) = &tool.result {
for line in super::panels::wrap(result, width.saturating_sub(6)) {
out.push(Line::styled(format!(" {line}"), theme::dim()));
}
}
for line in &tool.diff {
let style = match line.starts_with('+') {
true => theme::value(),
false => theme::dim(),
};
out.push(Line::styled(format!(" {line}"), style));
}
}
out.push(Line::default());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_help_key_longer_than_its_column_keeps_a_gap() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = crate::ui::tests::test_app();
let mut terminal = Terminal::new(TestBackend::new(120, 40)).expect("backend");
terminal
.draw(|frame| draw_help(frame, frame.area(), &mut app))
.expect("draw");
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("Home / End Jump"), "{text}");
}
#[test]
fn the_colour_row_brackets_the_pick_and_names_it() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = crate::ui::tests::test_app();
app.rename_was = "claude-4".to_string();
app.rename_color = Some(theme::Hue::Cyan);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("backend");
let mut layout = crate::ui::render::Layout::default();
terminal
.draw(|frame| draw_rename_tab(frame, frame.area(), &app, &mut layout))
.expect("draw");
let buf = terminal.backend().buffer();
let text = buf
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(
text.contains("cyan"),
"the pick's name is not spelled: {text}"
);
assert!(text.contains("[●]"), "the pick is not bracketed: {text}");
assert!(text.contains('○'), "the none stop is missing: {text}");
let cyan = buf
.content()
.iter()
.find(|cell| cell.symbol() == "●" && cell.fg == theme::Hue::Cyan.color());
assert!(cyan.is_some(), "no swatch wears the picked hue");
}
#[test]
fn a_report_still_loading_turns_a_spinner() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = crate::ui::tests::test_app();
app.mode = crate::ui::Mode::Insight;
app.insight = None;
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("backend");
terminal
.draw(|frame| draw_insight(frame, frame.area(), &app))
.expect("draw");
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(
text.contains("Reading every transcript"),
"the wait is not described: {text}"
);
assert!(
text.chars().any(|c| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(c)),
"no spinner frame on screen: {text}"
);
}
#[test]
fn the_switcher_lists_the_bar_with_its_states() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let shared = |name: &str, signal: Option<crate::hook::Signal>| {
tabs::Tab::shared(&crate::rmux::Running {
name: format!("cctop-{name}"),
pid: None,
cwd: None,
attached: false,
activity: None,
label: Some(name.to_string()),
profile: None,
order: None,
state: signal.map(|signal| crate::rmux::State {
signal,
at: crate::rmux::now_secs(),
}),
color: None,
})
};
let mut app = crate::ui::tests::test_app();
app.tabs = vec![
shared("claude", None),
shared("blocked", Some(crate::hook::Signal::NeedsInput)),
];
app.mode = crate::ui::Mode::SwitchTab;
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("backend");
let mut layout = crate::ui::render::Layout::default();
terminal
.draw(|frame| draw_switch_tab(frame, frame.area(), &app, &mut layout))
.expect("draw");
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("Go to tab"), "no title: {text}");
assert!(
text.contains("Dashboard"),
"the dashboard is missing: {text}"
);
assert!(text.contains("claude"), "a tab is missing: {text}");
assert!(text.contains("needs you"), "the ask is not marked: {text}");
assert!(text.contains("›"), "no cursor on the pick: {text}");
app.switch_filter = "zzz".to_string();
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("backend");
let mut layout = crate::ui::render::Layout::default();
terminal
.draw(|frame| draw_switch_tab(frame, frame.area(), &app, &mut layout))
.expect("draw");
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("No tab by that name"), "silence: {text}");
}
#[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);
}
}