use super::columns::COLUMNS;
use super::theme;
use super::{AGE_OPTIONS, App, BatchKind, LaunchInto, session_root_pid, tabs};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, 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 height = lines.len() as 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::BORDER_HI))
.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 }), inner);
(rect, inner)
}
pub(super) fn draw_help(frame: &mut Frame, area: Rect) {
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::ACCENT)),
Span::raw(d.to_string()),
])
};
let lines = vec![
section("Navigation"),
item("↑ ↓/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("Home / End", "Jump to first / last"),
item("n / N", "Next / previous search match (wraps)"),
item("b", "Jump to the session that rang last"),
Line::default(),
section("Panels"),
item("← →", "Move between bottom panels"),
item("Tab / Shift+Tab", "Same, either direction"),
item("1 – 7", "Jump to a panel directly"),
item("Shift+↑ / ↓", "Scroll inside the active panel"),
item("f", "Follow mode: keep the selection centered"),
item("L", "Toggle the Tool Activity live filter"),
item("v", "Toggle inline diffs for edits"),
Line::default(),
section("Filter and sort"),
item("/ or F3", "Filter by label, project, branch, model, id"),
item(" Tab", "Search inside the transcripts as well"),
item(" ↑ / ↓", "Bring back an earlier search"),
item("F6 > <", "Open the sort-by panel"),
item("F7", "Filter by age (1d / 1w / 1mo)"),
item("#", "Cost floor: only sessions costing ≥ $X"),
item("`", "Show only running sessions"),
item("P / M / T", "Sort by status / memory / cost"),
item("H / X / S", "Sort by harness / context / tools"),
item("+ / - / =", "Speed up / slow down / reset refresh"),
item("Esc", "Clear the active filter"),
Line::default(),
section("Batch actions"),
item("Space", "Mark / unmark the selected session"),
item("D", "Delete all marked sessions"),
item("K", "Terminate all marked live sessions"),
item("U", "Clear all marks"),
Line::default(),
section("Other"),
item("w", "Bell + desktop alert when a session needs you"),
item("y", "Copy resume command or transcript path"),
item("d", "Delete the selected session (not running)"),
item("k", "Terminate the selected live session"),
item("s", "Type a line into the session's terminal"),
item("R", "Resume the session in a tab of its own"),
item("O", "Hand the session's context off to a different agent"),
item("a", "Open the session's terminal in a tab"),
item("A", "Open the agent this cctop launched"),
item("h or F8", "Agent integration: what reports to cctop"),
item("r or F5", "Refresh now"),
item("q or F10", "Quit"),
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+o", "Move focus to the next pane"),
item("Alt+w", "Close the pane; a tmux-backed agent keeps running"),
item("Alt+W", "Stop the focused pane's agent for good"),
item("F12", "Back to the dashboard, leaving it running"),
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(" Press any key to return", theme::dim())),
];
modal(frame, area, "Help", lines, 76);
}
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(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(theme::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::ACCENT),
"Searching transcripts…".to_string(),
),
(true, false) => (
"●",
Style::default().fg(theme::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) {
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::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(
" y to resume anyway · any other key to cancel",
theme::dim(),
)),
];
modal(frame, area, "Resume a running session?", lines, 62);
}
pub(super) fn draw_tmux_install(frame: &mut Frame, area: Rect, app: &App) {
let Some(install) = app.tmux_install.as_ref() else {
return;
};
let command = install.shown();
let sudo = command.starts_with("sudo ");
let mut lines = vec![
Line::from(Span::styled(
" tmux is not installed.",
Style::default().fg(theme::COST_MID),
)),
Line::default(),
Line::from(Span::raw(
" With it, agents run inside tmux 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(),
)),
];
if sudo {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
" Runs in a tab, so sudo can ask you for a password.",
theme::dim(),
)));
}
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 tmux with {}?", install.manager),
lines,
62,
);
}
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 {
Style::default()
.bg(theme::SELECTED_BG)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else if active {
Style::default().fg(theme::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 {
Style::default()
.bg(theme::SELECTED_BG)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Line::from(vec![
Span::styled(
format!(" {marker} "),
Style::default().fg(if active {
theme::COST_LOW
} else {
theme::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);
}
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 {
Style::default()
.bg(theme::SELECTED_BG)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} 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::DIM,
(tabs::Choice::Start(_), _) => theme::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::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 room = (area.height as usize).saturating_sub(4 + FOOTER).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(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 {}",
crate::util::truncate(
&crate::util::tildify(&dir.to_string_lossy()),
WIDTH as usize - 6
)
),
(_, None) => " in this directory".to_string(),
},
Style::default().fg(theme::LABEL),
)));
let keys = match picked_waiting {
true => " ↑/↓ Enter reattach Esc cancel",
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_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::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);
}
pub(super) fn draw_delete_confirm(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(format!(" {}", s.session_id), theme::dim())),
Line::default(),
Line::from(Span::styled(
" This permanently removes the transcript from disk.",
Style::default().fg(theme::COST_MID),
)),
Line::default(),
Line::from(Span::styled(
" [y] delete [n / Esc] cancel",
theme::dim(),
)),
];
modal(frame, area, "Delete session?", lines, 62);
}
pub(super) fn draw_delete_blocked(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::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(" [any key] dismiss", theme::dim())),
];
modal(frame, area, "Cannot delete", lines, 62);
}
pub(super) fn draw_kill_confirm(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(format!(" {}", s.session_id), theme::dim())),
Line::default(),
Line::from(Span::styled(
" Send a termination signal to this agent process?",
Style::default().fg(theme::COST_MID),
)),
Line::from(Span::raw(" Unsaved work in the agent may be interrupted.")),
Line::default(),
Line::from(Span::styled(
" [y] terminate [n / Esc] cancel",
theme::dim(),
)),
];
modal(frame, area, "Terminate session?", lines, 62);
}
pub(super) fn draw_quit_confirm(frame: &mut Frame, area: Rect, app: &App) {
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::COST_MID),
)),
Line::from(Span::styled(
" and quitting ends it.",
Style::default().fg(theme::COST_MID),
)),
Line::from(Span::raw(" Exit the agent itself to leave it cleanly.")),
Line::default(),
Line::from(Span::styled(
" [y] quit anyway [n / Esc] stay [A] back to the agent",
theme::dim(),
)),
];
modal(frame, area, "Quit and stop the agent?", lines, 62);
}
pub(super) fn draw_kill_blocked(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::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(" [any key] dismiss", theme::dim())),
];
modal(frame, area, "Cannot terminate", lines, 62);
}
pub(super) fn draw_batch_confirm(frame: &mut Frame, area: Rect, app: &App) {
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::COST_MID),
)));
lines.push(Line::default());
lines.push(Line::from(Span::styled(
format!(" [y] {verb} all [n / Esc] cancel"),
theme::dim(),
)));
modal(frame, area, &format!("{verb} all?"), lines, 62);
}
pub(super) fn draw_batch_blocked(frame: &mut Frame, area: Rect, app: &App, deleting: bool) {
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| session_root_pid(s).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(" [any key] dismiss", theme::dim())),
];
let title = if deleting {
"Cannot delete all"
} else {
"Cannot terminate all"
};
modal(frame, area, title, lines, 62);
}
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(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(theme::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`, tmux, or cctop as root.",
theme::dim(),
)),
Line::default(),
Line::from(vec![
Span::raw(" > "),
Span::styled(
app.send_input.clone(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(theme::ACCENT)),
]),
Line::default(),
Line::from(Span::styled(" Enter send Esc cancel", theme::dim())),
];
modal(frame, area, "Send to session", lines, 64);
}
#[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);
}
}