pub mod columns;
mod input;
mod modals;
pub mod panels;
pub mod render;
pub mod spark;
mod table;
pub mod tabs;
pub mod theme;
use crate::cache::UiPrefs;
use crate::cli::Args;
use crate::loader::{Loader, Stats};
use crate::pricing::{Plan, Provider};
use crate::quota::Quota;
use crate::session::{Session, SessionData};
use columns::ColumnId;
use ratatui::crossterm::cursor::Show;
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event};
use ratatui::crossterm::execute;
use spark::History;
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::time::{Duration, Instant};
const QUOTA_INTERVAL_SECS: u64 = 300;
const QUOTA_TICK: Duration = Duration::from_secs(10);
const FULL_WALK_INTERVAL: Duration = Duration::from_secs(60);
const PENDING_WALK_INTERVAL: Duration = Duration::from_secs(3);
const SCAN_DEBOUNCE: Duration = Duration::from_millis(300);
const HANDOFF_SETTLE: Duration = Duration::from_secs(3);
const MIN_SCAN_CHARS: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
List,
Help,
Search,
SortBy,
AgeFilter,
DeleteConfirm,
DeleteBlocked,
KillConfirm,
ResumeConfirm,
QuitConfirm,
KillBlocked,
BatchConfirm,
BatchDeleteBlocked,
BatchKillBlocked,
CostFilter,
SendKeys,
Launch,
Hooks,
TmuxInstall,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Deferred {
Resume,
Launch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchInto {
Tab,
Split { stacked: bool },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchKind {
Delete,
Kill,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgeFilter {
Day,
Week,
Month,
}
impl AgeFilter {
pub fn max_age_ms(&self) -> i64 {
match self {
AgeFilter::Day => 86_400_000,
AgeFilter::Week => 604_800_000,
AgeFilter::Month => 2_592_000_000,
}
}
pub fn label(&self) -> &'static str {
match self {
AgeFilter::Day => "Last 24 hours",
AgeFilter::Week => "Last 7 days",
AgeFilter::Month => "Last 30 days",
}
}
pub fn short(&self) -> &'static str {
match self {
AgeFilter::Day => "1 day",
AgeFilter::Week => "1 week",
AgeFilter::Month => "1 month",
}
}
pub fn key(&self) -> &'static str {
match self {
AgeFilter::Day => "1d",
AgeFilter::Week => "1w",
AgeFilter::Month => "1mo",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"1d" => Some(AgeFilter::Day),
"1w" => Some(AgeFilter::Week),
"1mo" => Some(AgeFilter::Month),
_ => None,
}
}
}
pub const AGE_OPTIONS: [Option<AgeFilter>; 4] = [
Some(AgeFilter::Day),
Some(AgeFilter::Week),
Some(AgeFilter::Month),
None,
];
enum Request {
Refresh,
RefreshLive,
Data(Box<Session>),
Delete(Box<Session>),
Terminate {
session_key: String,
pid: u32,
},
SendKeys {
pid: u32,
text: String,
},
Scan {
query: String,
targets: Vec<crate::session::search::Target>,
},
Shutdown,
}
enum Response {
Discovered(Vec<Session>),
Annotated(Box<Session>),
Sessions(Box<(Vec<Session>, Stats)>),
LiveRows(Box<(Vec<Session>, Stats)>),
Data(String, Box<SessionData>),
Quota(Box<Quota>),
PricingReady,
UpdateAvailable(String),
Terminated {
session_key: String,
result: Result<(), String>,
},
Deleted {
session_key: String,
result: Result<(), String>,
},
KeysSent {
result: Result<(), String>,
},
Scanned {
query: String,
hits: HashMap<String, String>,
},
}
fn search_haystack(s: &Session) -> String {
format!(
"{} {} {} {} {} {} {}",
s.display_label(),
s.model,
s.harness,
s.provider.as_str(),
s.session_id,
s.label_source,
columns::branch_of(s).unwrap_or_default(),
)
.to_ascii_lowercase()
}
type ScanCache = HashMap<(String, String), Option<String>>;
const MAX_SCAN_CACHE: usize = 20_000;
fn scan(
cache: &mut ScanCache,
targets: &[crate::session::search::Target],
needle: &str,
) -> HashMap<String, String> {
use rayon::prelude::*;
let found: Vec<(&crate::session::search::Target, Option<String>)> = targets
.par_iter()
.map(|target| {
let memo = (!target.running)
.then(|| cache.get(&(target.key.clone(), needle.to_string())))
.flatten();
match memo {
Some(remembered) => (target, remembered.clone()),
None => (
target,
crate::session::search::find(target, needle).map(|hit| hit.snippet),
),
}
})
.collect();
if cache.len() + found.len() > MAX_SCAN_CACHE {
cache.clear();
}
let mut hits = HashMap::new();
for (target, snippet) in found {
if !target.running {
cache.insert((target.key.clone(), needle.to_string()), snippet.clone());
}
if let Some(snippet) = snippet {
hits.insert(target.key.clone(), snippet);
}
}
hits
}
fn spawn_worker(
plan: Plan,
rx: Receiver<Request>,
tx: Sender<Response>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut loader = Loader::new();
let mut sent_initial_discovery = false;
let mut live_rows: Vec<Session> = Vec::new();
let mut scans: ScanCache = HashMap::new();
while let Ok(req) = rx.recv() {
match req {
Request::Refresh => {
let first_load = !sent_initial_discovery;
sent_initial_discovery = true;
let sessions = loader.load_progressive(
plan,
first_load,
|sessions| {
if first_load {
let _ = tx.send(Response::Discovered(sessions.to_vec()));
}
},
|session| {
if first_load {
let _ = tx.send(Response::Annotated(Box::new(session.clone())));
}
},
);
let stats = crate::loader::compute_stats(&sessions);
live_rows = sessions.clone();
if tx
.send(Response::Sessions(Box::new((sessions, stats))))
.is_err()
{
break;
}
}
Request::RefreshLive => {
if live_rows.is_empty() {
continue;
}
let moved = loader.refresh_live(plan, &mut live_rows);
let stats = crate::loader::compute_stats(&live_rows);
if tx
.send(Response::LiveRows(Box::new((moved, stats))))
.is_err()
{
break;
}
}
Request::Data(session) => {
let data = loader.store().session_data_fresh(&session);
if tx
.send(Response::Data(session.key(), Box::new(data)))
.is_err()
{
break;
}
}
Request::Delete(session) => {
let result = match session.provider {
Provider::Claude => crate::session::claude::delete(&session)
.map_err(|error| error.to_string()),
Provider::Codex => crate::session::codex::delete(&session)
.map_err(|error| error.to_string()),
Provider::Cursor => crate::session::cursor::delete(&session)
.map_err(|error| error.to_string()),
Provider::Gemini => crate::session::gemini::delete(&session)
.map_err(|error| error.to_string()),
Provider::OpenCode => crate::session::opencode::delete(&session)
.map_err(|error| error.to_string()),
Provider::Pi => {
crate::session::pi::delete(&session).map_err(|error| error.to_string())
}
Provider::Windsurf => crate::session::windsurf::delete(&session)
.map_err(|error| error.to_string()),
};
if result.is_ok() {
loader.store().evict(&session);
}
if tx
.send(Response::Deleted {
session_key: session.key(),
result,
})
.is_err()
{
break;
}
}
Request::Terminate { session_key, pid } => {
let result = crate::proc::terminate(pid);
if tx
.send(Response::Terminated {
session_key,
result,
})
.is_err()
{
break;
}
}
Request::SendKeys { pid, text } => {
let result = crate::inject::send_line(pid, &text);
if tx.send(Response::KeysSent { result }).is_err() {
break;
}
}
Request::Scan { query, targets } => {
let needle = query.to_ascii_lowercase();
let hits = loader.gently(|| scan(&mut scans, &targets, &needle));
if tx.send(Response::Scanned { query, hits }).is_err() {
break;
}
}
Request::Shutdown => break,
}
}
loader.store().save();
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Row {
Session(usize),
Subagent {
parent: usize,
index: usize,
},
}
impl Row {
pub fn session(self) -> usize {
match self {
Row::Session(i) => i,
Row::Subagent { parent, .. } => parent,
}
}
pub fn is_subagent(self) -> bool {
matches!(self, Row::Subagent { .. })
}
}
pub struct App {
pub sessions: Vec<Session>,
pub visible: Vec<Row>,
pub finished_agents: std::collections::HashSet<String>,
pub expanded: std::collections::HashSet<String>,
pub stats: Stats,
pub selected: usize,
pub scroll: usize,
pub plan: Plan,
pub mode: Mode,
pub sort_col: ColumnId,
pub sort_asc: bool,
pub sortby_cursor: usize,
pub search: String,
pub search_content: bool,
pub scan_query: String,
pub scan_hits: HashMap<String, String>,
pub scanning: bool,
scan_typed_at: Option<Instant>,
pub search_history: Vec<String>,
history_cursor: Option<(usize, String)>,
pub age_filter: Option<AgeFilter>,
pub age_cursor: usize,
pub live_only: bool,
pub marked: HashSet<String>,
pub deleting: HashSet<String>,
pub batch: BatchKind,
pub follow: bool,
pub refresh_secs: f64,
pub cost_floor: f64,
pub cost_input: String,
pub send_input: String,
pub list_height: u16,
pub bottom_tab: usize,
pub panel_data: Option<SessionData>,
panel_key: String,
panel_stamp: String,
pub info_scroll: u16,
pub cost_scroll: u16,
pub config_scroll: u16,
pub proc_scroll: u16,
pub context_scroll: u16,
pub subagent_scroll: u16,
pub tool_scroll: u16,
pub tool_follow: bool,
pub tool_max_scroll: u16,
pub tool_tab: usize,
pub tool_live_only: bool,
pub tool_show_diff: bool,
pub tool_expanded: Option<String>,
pub tool_owners: Vec<Option<String>>,
pub subagent_sort: (panels::SubagentSort, bool),
pub cpu_history: HashMap<String, History>,
pub mem_history: HashMap<String, History>,
pub global_cpu: History,
pub global_spend: History,
pub quota: Quota,
pub update_available: Option<String>,
pub status: Option<(String, Instant)>,
pub started_at: String,
started: Instant,
pub notify: crate::notify::Notifier,
pub tabs: Vec<tabs::Tab>,
pub tab: usize,
pub hooked: HashMap<String, crate::hook::Reported>,
pub hooks: Option<crate::hook::Report>,
pub listener: Option<crate::hook::Listener>,
pub launch_cursor: usize,
pub launch_offer: Vec<tabs::Choice>,
pub launch_into: LaunchInto,
pub launch_cwd: Option<std::path::PathBuf>,
pub tmux_install: Option<crate::tmux::Install>,
pub tmux_deferred: Option<Deferred>,
pub tmux_declined: bool,
pub tmux_installing: Option<u32>,
pub pending_brief: Option<std::path::PathBuf>,
pub handoff_send: Option<(u32, String, Instant)>,
pub hosted: Option<(u32, String)>,
prefs: UiPrefs,
tx: Sender<Request>,
pub needs_redraw: bool,
pub should_quit: bool,
}
impl App {
fn new(plan: Plan, tx: Sender<Request>) -> Self {
Self::with_prefs(plan, tx, UiPrefs::load())
}
fn with_prefs(plan: Plan, tx: Sender<Request>, prefs: UiPrefs) -> Self {
let age_filter = prefs
.inactivity_filter
.as_deref()
.and_then(AgeFilter::parse);
let age_cursor = AGE_OPTIONS
.iter()
.position(|o| *o == age_filter)
.unwrap_or(AGE_OPTIONS.len() - 1);
App {
sessions: Vec::new(),
visible: Vec::new(),
finished_agents: std::collections::HashSet::new(),
expanded: prefs
.expanded
.iter()
.cloned()
.collect::<std::collections::HashSet<_>>(),
stats: Stats::default(),
selected: 0,
scroll: 0,
plan,
mode: Mode::List,
sort_col: ColumnId::Last,
sort_asc: true,
sortby_cursor: 0,
search: String::new(),
search_content: false,
scan_query: String::new(),
scan_hits: HashMap::new(),
scanning: false,
scan_typed_at: None,
search_history: prefs.search_history.clone(),
history_cursor: None,
age_filter,
age_cursor,
live_only: prefs.live_only,
marked: HashSet::new(),
deleting: HashSet::new(),
batch: BatchKind::Delete,
follow: false,
refresh_secs: 2.0,
cost_floor: prefs.cost_floor,
cost_input: String::new(),
send_input: String::new(),
list_height: 0,
bottom_tab: prefs.bottom_tab.min(panels::TABS.len() - 1),
panel_data: None,
panel_key: String::new(),
panel_stamp: String::new(),
info_scroll: 0,
cost_scroll: 0,
config_scroll: 0,
proc_scroll: 0,
context_scroll: 0,
subagent_scroll: 0,
tool_scroll: 0,
tool_follow: true,
tool_max_scroll: 0,
tool_tab: 0,
tool_live_only: prefs.agent_live_filter,
tool_show_diff: prefs.tool_show_diff,
tool_expanded: None,
tool_owners: Vec::new(),
subagent_sort: (
panels::SubagentSort::parse(&prefs.subagent_sort_col),
prefs.subagent_sort_asc,
),
cpu_history: HashMap::new(),
mem_history: HashMap::new(),
global_cpu: History::default(),
global_spend: History::default(),
quota: Quota::default(),
notify: crate::notify::Notifier::new(prefs.notify),
update_available: None,
status: None,
started_at: chrono::Utc::now().to_rfc3339(),
started: Instant::now(),
prefs,
tx,
tabs: Vec::new(),
tab: 0,
hooked: HashMap::new(),
hooks: None,
listener: None,
launch_cursor: 0,
launch_offer: Vec::new(),
launch_into: LaunchInto::Tab,
launch_cwd: None,
tmux_install: None,
tmux_deferred: None,
tmux_declined: false,
tmux_installing: None,
pending_brief: None,
handoff_send: None,
hosted: None,
needs_redraw: true,
should_quit: false,
}
}
pub fn selected_session(&self) -> Option<&Session> {
self.visible
.get(self.selected)
.and_then(|row| self.sessions.get(row.session()))
}
pub fn selected_row(&self) -> Option<Row> {
self.visible.get(self.selected).copied()
}
pub fn selected_subagent(&self) -> Option<&crate::session::Subagent> {
match self.selected_row()? {
Row::Session(_) => None,
Row::Subagent { parent, index } => self.sessions.get(parent)?.subagents.get(index),
}
}
pub fn on_subagent(&self) -> bool {
self.selected_row().is_some_and(Row::is_subagent)
}
pub fn is_expanded(&self, session: &Session) -> bool {
self.expanded.contains(&session.key())
}
fn toggle_expanded(&mut self) {
let Some(row) = self.selected_row() else {
return;
};
let Some(session) = self.sessions.get(row.session()) else {
return;
};
if session.subagents.is_empty() {
self.set_status("No subagents to show");
return;
}
let key = session.key();
if !self.expanded.remove(&key) {
self.expanded.insert(key);
}
if row.is_subagent() {
self.selected = self.selected.saturating_sub(1);
}
self.refilter();
self.save_prefs();
}
fn toggle_expanded_all(&mut self) {
if self.expanded.is_empty() {
self.expanded = self
.sessions
.iter()
.filter(|s| !s.subagents.is_empty())
.map(Session::key)
.collect();
} else {
self.expanded.clear();
}
self.refilter();
self.save_prefs();
}
fn save_prefs(&mut self) {
self.prefs.bottom_tab = self.bottom_tab;
self.prefs.live_only = self.live_only;
self.prefs.inactivity_filter = self.age_filter.map(|a| a.key().to_string());
self.prefs.agent_live_filter = self.tool_live_only;
self.prefs.tool_show_diff = self.tool_show_diff;
let mut expanded: Vec<String> = self.expanded.iter().cloned().collect();
expanded.sort();
self.prefs.expanded = expanded;
self.prefs.subagent_sort_col = self.subagent_sort.0.key().to_string();
self.prefs.subagent_sort_asc = self.subagent_sort.1;
self.prefs.cost_floor = self.cost_floor;
self.prefs.notify = self.notify.enabled;
self.prefs.search_history = self.search_history.clone();
self.prefs.save();
}
fn set_status(&mut self, msg: impl Into<String>) {
self.status = Some((msg.into(), Instant::now()));
self.needs_redraw = true;
}
pub fn refilter(&mut self) {
let anchor = self.selected_row().map(|r| self.row_key(r));
let now = chrono::Utc::now();
let now_ms = now.timestamp_millis();
let query = self.search.to_ascii_lowercase();
let mut visible: Vec<usize> = (0..self.sessions.len())
.filter(|&i| {
let s = &self.sessions[i];
if self.live_only && !s.is_running() {
return false;
}
if let Some(age) = self.age_filter {
let ts = if s.last_active.is_empty() {
&s.started_at
} else {
&s.last_active
};
let within = crate::util::parse_ts(ts)
.map(|d| now_ms - d.timestamp_millis() <= age.max_age_ms())
.unwrap_or(false);
if !within {
return false;
}
}
if !self.matches_query(s, &query) {
return false;
}
if self.cost_floor > 0.0 {
let cost = if s.cost_available {
s.total_cost.unwrap_or(0.0)
} else {
return true;
};
if cost < self.cost_floor {
return false;
}
}
true
})
.collect();
let col = self.sort_col;
let asc = self.sort_asc;
visible.sort_by(|&a, &b| {
let ord = columns::compare(col, &self.sessions[a], &self.sessions[b], &now);
if asc { ord } else { ord.reverse() }
});
self.visible = visible
.into_iter()
.flat_map(|i| {
let session = &self.sessions[i];
let children = if self.expanded.contains(&session.key()) {
session.subagents.len()
} else {
0
};
std::iter::once(Row::Session(i))
.chain((0..children).map(move |index| Row::Subagent { parent: i, index }))
})
.collect();
self.selected = anchor
.and_then(|key| self.visible.iter().position(|&r| self.row_key(r) == key))
.unwrap_or(self.selected)
.min(self.visible.len().saturating_sub(1));
self.ensure_available_tab();
self.needs_redraw = true;
}
fn row_key(&self, row: Row) -> String {
let Some(session) = self.sessions.get(row.session()) else {
return String::new();
};
match row {
Row::Session(_) => session.key(),
Row::Subagent { index, .. } => match session.subagents.get(index) {
Some(sub) => format!("{}/{}", session.key(), sub.agent_id),
None => session.key(),
},
}
}
fn push_history(&mut self) {
self.global_spend.push(self.stats.spend_per_min);
self.global_cpu.push(self.stats.total_cpu as f64);
for s in &self.sessions {
let Some(p) = &s.process else { continue };
let key = s.key();
self.cpu_history
.entry(key.clone())
.or_default()
.push(p.cpu as f64);
self.mem_history
.entry(key)
.or_default()
.push(p.memory as f64 / (1024.0 * 1024.0));
}
}
fn panel_subject(&self, row: Row) -> Option<Session> {
let session = self.sessions.get(row.session())?;
let Row::Subagent { index, .. } = row else {
return Some(session.clone());
};
let sub = session.subagents.get(index)?;
if sub.ghost {
return None;
}
let mut stand_in = Session::new(session.provider, sub.agent_id.clone());
stand_in.surface = session.surface;
stand_in.model = sub.model.clone();
stand_in.label_source = session.label_source.clone();
stand_in.harness = session.harness.clone();
stand_in.title = Some(sub.description.clone()).filter(|d| !d.is_empty());
stand_in.started_at = sub.started_at.clone().unwrap_or_default();
stand_in.data_file = session
.data_file
.as_ref()
.map(|f| f.with_extension("").join("subagents"))
.map(|dir| dir.join(format!("{}.jsonl", sub.agent_id)));
stand_in.last_active = stand_in
.data_file
.as_ref()
.map(|f| crate::util::ms_to_rfc3339(crate::config::file_mtime_ms(f) as i64))
.unwrap_or_default();
Some(stand_in)
}
fn sync_panel_data(&mut self) {
let Some(row) = self.selected_row() else {
self.panel_data = None;
self.panel_key.clear();
return;
};
let Some(session) = self.panel_subject(row) else {
self.panel_data = None;
self.panel_key.clear();
return;
};
let key = session.key();
let stamp = session.last_active.clone();
let switched = key != self.panel_key;
let grew = !switched && stamp != self.panel_stamp;
if !switched && !grew {
return;
}
self.panel_key = key;
self.panel_stamp = stamp;
if switched {
self.panel_data = None;
self.info_scroll = 0;
self.cost_scroll = 0;
self.config_scroll = 0;
self.proc_scroll = 0;
self.subagent_scroll = 0;
self.tool_scroll = 0;
self.tool_follow = true;
self.tool_tab = 0;
self.tool_expanded = None;
}
let _ = self.tx.send(Request::Data(Box::new(session)));
}
fn move_selection(&mut self, delta: isize) {
if self.visible.is_empty() {
return;
}
let last = self.visible.len() - 1;
self.selected = (self.selected as isize + delta).clamp(0, last as isize) as usize;
self.ensure_available_tab();
self.needs_redraw = true;
}
fn tab_available(&self, tab: usize) -> bool {
match tab {
1 | 2 => self.selected_session().is_some_and(Session::is_running),
7 => self
.selected_session()
.is_some_and(|s| s.provider == Provider::Claude),
_ => true,
}
}
fn ensure_available_tab(&mut self) {
if !self.tab_available(self.bottom_tab) {
self.bottom_tab = 0;
}
}
fn set_sort(&mut self, col: ColumnId) {
if self.sort_col == col {
self.sort_asc = !self.sort_asc;
} else {
self.sort_col = col;
self.sort_asc = true;
}
self.refilter();
self.save_prefs();
}
fn toggle_tool_expansion(&mut self, row_offset: usize) {
let line = self.tool_scroll as usize + row_offset;
let Some(Some(key)) = self.tool_owners.get(line) else {
return;
};
let key = key.clone();
self.tool_expanded = (self.tool_expanded.as_deref() != Some(key.as_str())).then_some(key);
self.tool_follow = false;
self.needs_redraw = true;
}
fn cycle_tool_filter(&mut self, delta: isize) {
let n = self
.panel_data
.as_ref()
.map(|d| panels::tool_tabs(d).len())
.unwrap_or(0);
if n == 0 {
return;
}
let n = n as isize;
self.tool_tab = (((self.tool_tab as isize + delta) % n + n) % n) as usize;
self.tool_follow = true;
self.bottom_tab = 3;
self.needs_redraw = true;
}
fn cycle_tab(&mut self, delta: isize) {
let n = panels::TABS.len() as isize;
let mut next = self.bottom_tab;
for _ in 0..panels::TABS.len() {
next = (((next as isize + delta) % n + n) % n) as usize;
if self.tab_available(next) {
self.bottom_tab = next;
break;
}
}
self.save_prefs();
self.needs_redraw = true;
}
fn scroll_active_panel(&mut self, delta: i32) {
let bump = |v: &mut u16| *v = (*v as i32 + delta).max(0) as u16;
match self.bottom_tab {
0 => bump(&mut self.info_scroll),
1 => {} 2 => bump(&mut self.proc_scroll),
3 => {
let next = (self.tool_scroll as i32 + delta).clamp(0, self.tool_max_scroll as i32);
self.tool_scroll = next as u16;
self.tool_follow = self.tool_scroll >= self.tool_max_scroll;
}
4 => bump(&mut self.subagent_scroll),
5 => bump(&mut self.cost_scroll),
6 => bump(&mut self.config_scroll),
_ => bump(&mut self.context_scroll),
}
self.needs_redraw = true;
}
fn copy_selection(&mut self) {
let Some(s) = self.selected_session() else {
return;
};
let text = match self.bottom_tab {
0 => match s.resume_argv() {
Some(argv) => argv.join(" "),
None => s
.data_file
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| s.session_id.clone()),
},
_ => s
.data_file
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| s.session_id.clone()),
};
render::copy_to_clipboard(&text);
self.set_status(format!("Copied: {}", crate::util::truncate(&text, 60)));
}
fn matches_search(&self, s: &Session) -> bool {
self.matches_query(s, &self.search.to_ascii_lowercase())
}
fn matches_query(&self, s: &Session, query: &str) -> bool {
if query.is_empty() {
return true;
}
if search_haystack(s).contains(query) {
return true;
}
self.search_content && self.scan_query == query && self.scan_hits.contains_key(&s.key())
}
pub fn selected_snippet(&self) -> Option<&str> {
let s = self.selected_session()?;
(self.search_content && self.scan_query == self.search.to_ascii_lowercase())
.then(|| self.scan_hits.get(&s.key()))
.flatten()
.map(String::as_str)
}
pub(super) fn search_edited(&mut self) {
self.history_cursor = None;
self.scan_typed_at = Some(Instant::now());
self.refilter();
}
pub(super) fn toggle_content_search(&mut self) {
self.search_content = !self.search_content;
if !self.search_content {
self.scan_hits.clear();
self.scan_query.clear();
}
self.scan_typed_at = Some(Instant::now());
self.refilter();
}
pub(super) fn tick_scan(&mut self) {
if !self.search_content || self.scanning {
return;
}
let query = self.search.to_ascii_lowercase();
if query == self.scan_query {
self.scan_typed_at = None;
return;
}
if query.chars().count() < MIN_SCAN_CHARS {
if !self.scan_query.is_empty() {
self.scan_query.clear();
self.scan_hits.clear();
self.refilter();
self.needs_redraw = true;
}
return;
}
match self.scan_typed_at {
Some(at) if at.elapsed() < SCAN_DEBOUNCE => return,
_ => {}
}
self.scan_typed_at = None;
let targets: Vec<crate::session::search::Target> = self
.sessions
.iter()
.map(crate::session::search::Target::of)
.collect();
if self.tx.send(Request::Scan { query, targets }).is_ok() {
self.scanning = true;
self.needs_redraw = true;
}
}
fn scanned(&mut self, query: String, hits: HashMap<String, String>) {
self.scanning = false;
self.scan_query = query;
self.scan_hits = hits;
self.refilter();
self.needs_redraw = true;
}
pub(super) fn remember_query(&mut self) {
let query = self.search.trim().to_string();
if query.is_empty() {
return;
}
self.search_history.retain(|q| q != &query);
self.search_history.insert(0, query);
self.search_history
.truncate(crate::cache::MAX_SEARCH_HISTORY);
self.save_prefs();
}
pub(super) fn history_step(&mut self, delta: isize) {
if self.search_history.is_empty() {
return;
}
let (at, typed) = match self.history_cursor.take() {
Some((at, typed)) => (at as isize + delta, typed),
None if delta < 0 => return,
None => (0, self.search.clone()),
};
if at < 0 {
self.search = typed;
} else {
let at = (at as usize).min(self.search_history.len() - 1);
self.search = self.search_history[at].clone();
self.history_cursor = Some((at, typed));
}
self.scan_typed_at = Some(Instant::now());
self.refilter();
self.needs_redraw = true;
}
fn cycle_matches(&mut self, delta: isize) {
if self.visible.is_empty() {
return;
}
let matches: Vec<usize> = self
.visible
.iter()
.enumerate()
.filter(|(_, row)| self.matches_search(&self.sessions[row.session()]))
.map(|(at, _)| at)
.collect();
if matches.is_empty() {
return;
}
let pos = matches.iter().position(|&at| at == self.selected);
let n = matches.len() as isize;
let next = ((pos.unwrap_or(0) as isize + delta).rem_euclid(n)) as usize;
self.selected = matches[next];
self.ensure_available_tab();
self.needs_redraw = true;
}
fn toggle_mark(&mut self) {
let Some(s) = self.selected_session() else {
return;
};
let key = s.key();
if !self.marked.remove(&key) {
self.marked.insert(key);
}
self.needs_redraw = true;
}
fn marked_sessions(&self) -> Vec<&Session> {
self.visible
.iter()
.filter_map(|row| match row {
Row::Session(i) => self.sessions.get(*i),
Row::Subagent { .. } => None,
})
.filter(|s| self.marked.contains(&s.key()))
.collect()
}
fn batch_ok(&self, kind: BatchKind) -> bool {
self.marked_sessions().iter().all(|s| match kind {
BatchKind::Delete => !s.is_running(),
BatchKind::Kill => session_root_pid(s).is_some(),
})
}
fn unmark_all(&mut self) {
if self.marked.is_empty() {
return;
}
self.marked.clear();
self.needs_redraw = true;
}
fn batch(&mut self, kind: BatchKind) {
if self.marked_sessions().is_empty() {
self.set_status("No sessions marked — press Space to mark");
return;
}
self.batch = kind;
self.mode = if self.batch_ok(kind) {
Mode::BatchConfirm
} else {
match kind {
BatchKind::Delete => Mode::BatchDeleteBlocked,
BatchKind::Kill => Mode::BatchKillBlocked,
}
};
self.needs_redraw = true;
}
fn batch_execute(&mut self) {
let kind = self.batch;
let marked: Vec<Session> = self.marked_sessions().into_iter().cloned().collect();
let mut requested = 0;
let mut acted_on: Vec<String> = Vec::new();
let mut failed = 0;
for s in &marked {
let key = s.key();
match kind {
BatchKind::Delete => {
if self.tx.send(Request::Delete(Box::new(s.clone()))).is_ok() {
self.deleting.insert(key.clone());
requested += 1;
acted_on.push(key);
} else {
failed += 1;
}
}
BatchKind::Kill => match session_root_pid(s) {
Some(pid) => {
self.tx
.send(Request::Terminate {
session_key: key.clone(),
pid,
})
.ok();
acted_on.push(key);
}
None => failed += 1,
},
}
}
for key in &acted_on {
self.marked.remove(key);
}
self.set_status(match kind {
BatchKind::Delete => {
if failed == 0 {
format!("Deleting {requested} session(s)…")
} else {
format!("Deleting {requested} session(s), {failed} failed to start")
}
}
BatchKind::Kill => format!(
"Kill sent to {} session(s){}",
acted_on.len(),
if failed > 0 {
format!(" ({} skipped)", failed)
} else {
String::new()
}
),
});
}
fn check_bells(&mut self) {
self.notify.observe(&self.sessions);
}
fn toggle_notifications(&mut self) {
self.notify.enabled = !self.notify.enabled;
self.save_prefs();
self.set_status(if self.notify.enabled {
"Notifications on — bell and desktop alert when a session needs you"
} else {
"Notifications off"
});
}
fn jump_to_bell(&mut self) {
let Some(key) = self.notify.last.as_ref().map(|r| r.key.clone()) else {
self.set_status("Nothing has rung yet");
return;
};
match self
.visible
.iter()
.position(|&r| !r.is_subagent() && self.sessions[r.session()].key() == key)
{
Some(row) => {
self.selected = row;
self.ensure_available_tab();
self.needs_redraw = true;
}
None => self.set_status("The session that rang is hidden by the current filter"),
}
}
fn adjust_refresh(&mut self, delta: f64) {
self.refresh_secs = (self.refresh_secs + delta).clamp(0.5, 60.0);
self.needs_redraw = true;
}
fn half_page(&self) -> usize {
((self.list_height as usize / 2).max(1)).min(PAGE as usize)
}
}
const PAGE: isize = 10;
const BLINK_MS: u128 = 600;
impl App {
pub fn active_tab(&mut self) -> Option<&mut tabs::Tab> {
self.tabs.get_mut(self.tab.checked_sub(1)?)
}
pub fn focused_pane(&mut self) -> Option<&mut tabs::Pane> {
self.active_tab()?.focused_mut()
}
pub fn tab_attention(&self, index: usize) -> Option<tabs::Attention> {
let tab = self.tabs.get(index.checked_sub(1)?)?;
tab.attention(index == self.tab, &|pid| self.pane_signal(pid))
}
fn apply_hooks(&mut self, events: Vec<crate::hook::Event>) -> (bool, bool) {
let changed = !events.is_empty();
let mut lifecycle = false;
for event in events {
lifecycle |= event.reported.signal.is_lifecycle();
if let Some(agent) = event.finished_agent {
self.finished_agents.insert(agent);
}
match event.reported.signal {
crate::hook::Signal::Ended => {
self.hooked.remove(&event.session_id);
}
_ => {
self.hooked.insert(event.session_id, event.reported);
}
}
}
self.apply_finished_agents();
(changed, lifecycle)
}
fn apply_finished_agents(&mut self) {
if self.finished_agents.is_empty() {
return;
}
for session in &mut self.sessions {
for sub in &mut session.subagents {
let id = sub.agent_id.strip_prefix("agent-").unwrap_or(&sub.agent_id);
if self.finished_agents.contains(id) {
sub.status = crate::session::SubagentStatus::Done;
}
}
}
}
pub fn open_hooks(&mut self) {
self.hooks = Some(self.hook_status());
self.mode = Mode::Hooks;
self.needs_redraw = true;
}
fn hook_status(&self) -> crate::hook::Report {
crate::hook::status(self.hook_project().as_deref(), self.listener.as_ref())
}
pub fn hook_project(&self) -> Option<std::path::PathBuf> {
self.selected_session()
.map(|s| std::path::PathBuf::from(&s.label_source))
.filter(|dir| dir.is_dir())
}
pub fn set_hooks(&mut self, scope: crate::hook::Scope, install: bool) {
let done = match install {
true => crate::hook::install(&scope),
false => crate::hook::remove(&scope),
};
self.set_status(format!(
"{} {} agents ({})",
match install {
true => "Asked",
false => "Stopped",
},
done.len(),
scope.label()
));
self.hooks = Some(self.hook_status());
}
pub fn reporting(&self) -> Vec<(String, &'static str)> {
let mut rows: Vec<(String, &'static str)> = self
.hooked
.values()
.map(|r| {
let name = std::path::Path::new(&r.cwd)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "—".into());
(name, r.signal.label())
})
.collect();
rows.sort();
rows
}
fn hooked_signal(&self, session_id: &str) -> Option<crate::hook::Signal> {
if let Some(reported) = self.hooked.get(session_id) {
return Some(reported.signal);
}
let tail = gemini_id_tail(session_id)?;
self.hooked
.iter()
.find(|(id, _)| id.starts_with(tail))
.map(|(_, reported)| reported.signal)
}
fn pane_signal(&self, pid: u32) -> Option<crate::hook::Signal> {
self.sessions
.iter()
.filter(|session| session_root_pid(session) == Some(pid))
.find_map(|session| {
self.hooked_signal(&session.session_id).or({
match session.activity_state {
crate::session::ActivityState::WaitingForInput => {
Some(crate::hook::Signal::NeedsInput)
}
_ => None,
}
})
})
}
fn mark_answered(&mut self, pid: u32) {
let answered: Vec<String> = self
.sessions
.iter()
.filter(|session| session_root_pid(session) == Some(pid))
.map(|session| session.session_id.clone())
.collect();
for id in answered {
if let Some(reported) = self.hooked.get_mut(&id)
&& !reported.signal.is_working()
{
reported.signal = crate::hook::Signal::Busy;
}
}
}
pub fn any_attention(&self) -> bool {
(1..=self.tabs.len()).any(|i| self.tab_attention(i).is_some())
}
pub fn blink_on(&self) -> bool {
(self.started.elapsed().as_millis() / BLINK_MS).is_multiple_of(2)
}
pub fn show_tab(&mut self, tab: usize) {
self.tab = tab.min(self.tabs.len());
self.needs_redraw = true;
}
pub fn cycle_workspace(&mut self, delta: isize) {
let count = self.tabs.len() as isize + 1;
self.tab = (self.tab as isize + delta).rem_euclid(count) as usize;
self.needs_redraw = true;
}
pub fn launch_prompt(&mut self, into: LaunchInto) {
if matches!(into, LaunchInto::Split { .. }) && self.active_tab().is_none() {
self.set_status("Nothing to split — open a tab first");
return;
}
let offer = tabs::choices(&self.open_tmux());
if offer.is_empty() {
self.set_status("No agent found in PATH, and $SHELL is not set");
return;
}
self.launch_offer = offer;
self.launch_into = into;
self.launch_cursor = 0;
self.launch_cwd = match into {
LaunchInto::Split { .. } => self.launch_cwd.clone(),
LaunchInto::Tab => self
.selected_session()
.map(|s| std::path::PathBuf::from(&s.label_source))
.filter(|dir| dir.is_dir()),
};
self.mode = Mode::Launch;
}
pub(super) fn handoff_selected(&mut self) {
let Some(session) = self.selected_session().cloned() else {
return;
};
let data = match self.panel_key == session.key() {
true => self.panel_data.as_ref(),
false => None,
};
let brief = crate::handoff::build(&session, data);
let path = match crate::handoff::write(&brief) {
Ok(path) => path,
Err(error) => {
self.set_status(format!("Could not write the handoff brief: {error}"));
return;
}
};
self.pending_brief = Some(path);
self.launch_prompt(LaunchInto::Tab);
if self.mode != Mode::Launch {
self.pending_brief = None;
return;
}
self.set_status(format!(
"Handing off {} — pick who takes it",
brief.summary()
));
}
pub(super) fn tick_handoff(&mut self) {
let Some((pid, line, due)) = self.handoff_send.clone() else {
return;
};
if Instant::now() < due {
return;
}
self.handoff_send = None;
match crate::inject::send_line(pid, &line) {
Ok(()) => self.set_status("Handed the brief over"),
Err(error) => self.set_status(format!("Could not hand the brief over: {error}")),
}
}
pub(super) fn resume_selected(&mut self) {
let Some(session) = self.selected_session() else {
return;
};
let Some(argv) = session.resume_argv() else {
self.set_status(format!(
"{} sessions cannot be resumed from a shell",
session.provider.as_str()
));
return;
};
if !crate::shim::is_command(&argv[0]) {
self.set_status(format!("{} is not installed on this machine", argv[0]));
return;
}
if session.is_running() {
self.mode = Mode::ResumeConfirm;
return;
}
self.resume_now();
}
pub(super) fn resume_now(&mut self) {
let Some(session) = self.selected_session() else {
return;
};
let Some(argv) = session.resume_argv() else {
return;
};
let cwd = session.work_dir();
let what = format!("{} · {}", session.display_label(), argv[0]);
let tmux = crate::tmux::name_for_session(session.provider.as_str(), &session.session_id);
if let Some(at) = self.tabs.iter().position(|tab| {
tab.panes
.iter()
.any(|p| p.tmux.as_deref() == Some(&tmux) || p.resumed.as_deref() == Some(&tmux))
}) {
self.tab = at + 1;
self.needs_redraw = true;
self.set_status(format!("Already open: {what}"));
return;
}
let Some(own) = self.own_preferring_tmux(Deferred::Resume, || tmux.clone()) else {
return;
};
let verb = match &own {
tabs::Own::Tmux(name) if crate::tmux::exists(name) => "Reattached to",
_ => "Resumed",
};
self.open_tab(&argv, cwd, &what, own, verb, Some(tmux));
}
fn own_preferring_tmux(
&mut self,
deferred: Deferred,
name: impl FnOnce() -> String,
) -> Option<tabs::Own> {
if crate::tmux::available() {
return Some(tabs::Own::Tmux(name()));
}
if self.tmux_declined || self.tmux_installing.is_some() {
return Some(tabs::Own::Cctop);
}
let Some(install) = crate::tmux::installer() else {
return Some(tabs::Own::Cctop);
};
self.tmux_install = Some(install);
self.tmux_deferred = Some(deferred);
self.mode = Mode::TmuxInstall;
self.needs_redraw = true;
None
}
pub(super) fn tmux_install_answer(&mut self, install: bool) {
self.mode = Mode::List;
let Some(offer) = self.tmux_install.take() else {
return;
};
if !install {
self.tmux_declined = true;
self.run_deferred_launch();
return;
}
match tabs::Pane::launch(&offer.argv, None, tabs::Own::Cctop) {
Ok(pane) => {
self.tmux_installing = Some(pane.pid);
self.tabs.push(tabs::Tab::new(pane));
self.tab = self.tabs.len();
self.set_status(format!("Installing tmux with {}", offer.manager));
}
Err(error) => {
self.set_status(format!("Could not run the install: {error}"));
self.tmux_declined = true;
self.run_deferred_launch();
}
}
}
pub(super) fn poll_tmux_install(&mut self) {
let Some(pid) = self.tmux_installing else {
return;
};
if crate::tmux::available() {
self.tmux_installing = None;
self.set_status("tmux installed");
self.run_deferred_launch();
return;
}
let open = self
.tabs
.iter()
.flat_map(|tab| tab.panes.iter())
.any(|pane| pane.pid == pid);
if !open {
self.tmux_installing = None;
self.tmux_declined = true;
if self.tmux_deferred.is_some() {
self.set_status("tmux was not installed — starting without it");
}
self.run_deferred_launch();
}
}
fn run_deferred_launch(&mut self) {
match self.tmux_deferred.take() {
Some(Deferred::Resume) => self.resume_now(),
Some(Deferred::Launch) => self.launch_selected(),
None => {}
}
}
fn open_tab(
&mut self,
argv: &[String],
cwd: Option<std::path::PathBuf>,
what: &str,
own: tabs::Own,
verb: &str,
resumed: Option<String>,
) {
let mut pane = match tabs::Pane::launch(argv, cwd.as_deref(), own) {
Ok(pane) => pane,
Err(error) => {
self.set_status(format!("Could not start {what}: {error}"));
return;
}
};
pane.resumed = resumed;
let kept = match pane.outlives_cctop() {
true => " — it will outlive cctop",
false => "",
};
self.tabs.push(tabs::Tab::new(pane));
self.tab = self.tabs.len();
let where_ = cwd
.map(|dir| format!(" in {}", crate::util::tildify(&dir.to_string_lossy())))
.unwrap_or_default();
self.set_status(format!("{verb} {what}{where_}{kept}"));
}
pub fn open_tmux(&self) -> Vec<String> {
self.tabs
.iter()
.flat_map(|tab| tab.panes.iter())
.filter_map(|pane| pane.tmux.clone())
.collect()
}
pub fn launch_choices(&self) -> &[tabs::Choice] {
&self.launch_offer
}
pub fn waiting_state(&self, agent: &crate::tmux::Running) -> Option<crate::hook::Signal> {
self.pane_signal(agent.pid?)
}
pub fn waiting_label(&self, agent: &crate::tmux::Running) -> Option<String> {
let pid = agent.pid?;
self.sessions
.iter()
.find(|session| session_root_pid(session) == Some(pid))
.map(|session| session.display_label().to_string())
}
pub fn launch_selected(&mut self) {
let Some(choice) = self.launch_offer.get(self.launch_cursor).cloned() else {
return;
};
let cwd = self.launch_cwd.clone();
let (argv, own) = match &choice {
tabs::Choice::Waiting(agent) => (
vec![choice.label()],
tabs::Own::TmuxExisting(agent.name.clone()),
),
tabs::Choice::Start(argv) => {
let own = self.own_preferring_tmux(Deferred::Launch, || {
crate::tmux::free_name(&tabs::label_of(argv))
});
let Some(own) = own else { return };
(argv.clone(), own)
}
};
if let tabs::Choice::Waiting(agent) = &choice
&& !crate::tmux::exists(&agent.name)
{
self.set_status(format!("{} has ended", choice.label()));
return;
}
let argv = &argv;
let pane = match tabs::Pane::launch(argv, cwd.as_deref(), own) {
Ok(pane) => pane,
Err(error) => {
self.set_status(format!("Could not start {}: {error}", tabs::label_of(argv)));
return;
}
};
let label = pane.label.clone();
if let Some(path) = self.pending_brief.take()
&& matches!(choice, tabs::Choice::Start(_))
{
self.handoff_send = Some((
pane.pid,
crate::handoff::prompt_for(&path),
Instant::now() + HANDOFF_SETTLE,
));
}
let kept = match pane.outlives_cctop() {
true => " — it will outlive cctop",
false => "",
};
match self.launch_into {
LaunchInto::Split { stacked } => {
let Some(tab) = self.active_tab() else { return };
tab.stacked = stacked;
tab.panes.push(pane);
tab.focus = tab.panes.len() - 1;
}
LaunchInto::Tab => {
self.tabs.push(tabs::Tab::new(pane));
self.tab = self.tabs.len();
}
}
self.set_status(match &choice {
tabs::Choice::Waiting(agent) => {
let at = agent
.cwd
.as_ref()
.map(|dir| format!(" in {}", crate::util::tildify(&dir.to_string_lossy())))
.unwrap_or_default();
format!("Reattached to {label}{at} — it was never gone")
}
tabs::Choice::Start(_) => {
let where_ = cwd
.map(|dir| format!(" in {}", crate::util::tildify(&dir.to_string_lossy())))
.unwrap_or_default();
format!("Started {label}{where_}{kept}")
}
});
}
pub fn close_pane(&mut self) {
let Some(tab) = self.active_tab() else {
return;
};
let closed = (tab.focus < tab.panes.len()).then(|| {
let pane = tab.panes.remove(tab.focus);
(pane.label.clone(), pane.outlives_cctop())
});
tab.focus = tab.focus.min(tab.panes.len().saturating_sub(1));
self.drop_empty_tabs();
if let Some((label, detached)) = closed {
self.set_status(match detached {
true => format!("Detached from {label} — it is still running"),
false => format!("Closed {label}"),
});
}
}
pub fn kill_pane(&mut self) {
let Some(pane) = self.focused_pane() else {
return;
};
if !pane.owns_agent() {
let label = pane.label.clone();
self.set_status(format!(
"{label} is not cctop's to stop — Alt+w closes this view of it"
));
return;
}
let (label, owned) = (pane.label.clone(), pane.outlives_cctop());
if let Err(error) = pane.kill_agent() {
self.set_status(format!("Could not stop {label}: {error}"));
return;
}
self.close_pane();
if owned {
self.set_status(format!("Stopped {label}"));
}
}
pub fn drop_empty_tabs(&mut self) {
let mut index = 0;
self.tabs.retain(|tab| {
index += 1;
if !tab.panes.is_empty() {
return true;
}
if self.tab >= index {
self.tab -= 1;
}
false
});
self.tab = self.tab.min(self.tabs.len());
}
pub(super) fn attach_selected(&mut self) {
let Some(session) = self.selected_session() else {
return;
};
let label = format!("{} · {}", session.abbrev_label, session.model);
let title = session.display_label().to_string();
let resumed = crate::tmux::name_for_session(session.provider.as_str(), &session.session_id);
let Some(pid) = session_root_pid(session) else {
self.set_status("Selected session has no local process");
return;
};
if self.open_view(pid, label.clone()) {
return;
}
if let Some(name) = crate::tmux::holding(pid) {
if let Some(at) = self
.tabs
.iter()
.position(|tab| tab.panes.iter().any(|p| p.tmux.as_deref() == Some(&name)))
{
self.tab = at + 1;
self.needs_redraw = true;
self.set_status(format!("Already open: {label}"));
return;
}
self.open_tab(
&[title],
None,
&label,
tabs::Own::TmuxExisting(name),
"Attached to",
Some(resumed),
);
return;
}
self.set_status(
"Only sessions started by cctop can be attached — start them as `cctop claude`",
);
}
pub(super) fn attach_hosted(&mut self) {
let Some((pid, label)) = self.hosted.clone() else {
self.set_status("No agent was launched by this cctop — start one as `cctop claude`");
return;
};
if !self.open_view(pid, label) {
self.set_status("The agent's terminal is gone");
}
}
fn open_view(&mut self, pid: u32, label: String) -> bool {
let shows = |pane: &tabs::Pane| pane.pid == pid || pane.agent() == pid;
if let Some((index, tab)) = self
.tabs
.iter_mut()
.enumerate()
.find(|(_, tab)| tab.panes.iter().any(shows))
{
tab.focus = tab.panes.iter().position(shows).unwrap_or(0);
self.tab = index + 1;
self.needs_redraw = true;
return true;
}
let Some(pane) = tabs::Pane::view_of(pid, label) else {
return false;
};
self.tabs.push(tabs::Tab::new(pane));
self.tab = self.tabs.len();
self.needs_redraw = true;
true
}
}
fn session_root_pid(session: &Session) -> Option<u32> {
session
.process
.as_ref()?
.process_list
.iter()
.find_map(|process| (process.is_root && !process.ghost).then_some(process.pid))
}
fn gemini_id_tail(session_id: &str) -> Option<&str> {
let tail = session_id.strip_prefix("session-")?.rsplit_once('-')?.1;
(tail.len() == 8 && tail.chars().all(|c| c.is_ascii_alphanumeric())).then_some(tail)
}
pub fn run(args: &Args, hosted: Option<crate::shim::Hosted>) -> anyhow::Result<i32> {
let (req_tx, req_rx) = channel::<Request>();
let (res_tx, res_rx) = channel::<Response>();
let worker = spawn_worker(args.plan, req_rx, res_tx.clone());
{
let tx = res_tx.clone();
std::thread::spawn(move || {
crate::pricing::refresh_pricing_blocking();
let _ = tx.send(Response::PricingReady);
});
}
spawn_quota_poller(res_tx.clone());
std::thread::spawn(move || {
if let Some(version) = crate::update::available_update() {
let _ = res_tx.send(Response::UpdateAvailable(version));
}
});
let mut app = App::new(args.plan, req_tx.clone());
app.refresh_secs = args.delay;
let _ = req_tx.send(Request::Refresh);
let mut hosted = hosted;
if let Some(hosted) = hosted.as_ref() {
app.hosted = Some((hosted.pid, hosted.label.clone()));
app.attach_hosted();
}
let mut terminal = ratatui::init();
let previous_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
previous_hook(info);
}));
let _ = execute!(std::io::stdout(), EnableMouseCapture);
let watch = crate::watch::Watch::start();
app.listener = crate::hook::Listener::start();
for fixed in crate::hook::repair(app.hook_project().as_deref()) {
app.set_status(&fixed);
}
if app
.hook_status()
.entries
.iter()
.any(|s| s.health.is_problem())
{
app.set_status("Agent hooks need attention — press h");
}
let result = event_loop(
&mut app,
&mut terminal,
&res_rx,
&req_tx,
watch.as_ref(),
hosted.as_mut(),
);
let had_tabs = !app.open_tmux().is_empty();
app.tabs.clear();
drop(hosted);
let left_running = match had_tabs {
true => crate::tmux::sessions(),
false => Vec::new(),
};
restore_terminal();
if !left_running.is_empty() {
println!(
"{} agent{} still running in tmux; `cctop` then `t` to get back to {}.",
left_running.len(),
if left_running.len() == 1 { "" } else { "s" },
if left_running.len() == 1 {
"it"
} else {
"them"
},
);
}
let _ = req_tx.send(Request::Shutdown);
let _ = worker.join();
app.save_prefs();
result
}
fn restore_terminal() {
let _ = execute!(std::io::stdout(), DisableMouseCapture);
let _ = execute!(std::io::stdout(), Show);
ratatui::restore();
let _ = execute!(std::io::stdout(), Show);
}
fn spawn_quota_poller(tx: Sender<Response>) {
std::thread::spawn(move || {
let mut quota = Quota::default();
let (mut claude_due, mut codex_due) = (Instant::now(), Instant::now());
loop {
let now = Instant::now();
let mut changed = false;
if now >= claude_due {
let status = crate::quota::fetch_claude();
claude_due =
now + Duration::from_secs(status.retry_delay_secs(QUOTA_INTERVAL_SECS));
quota.claude = status;
changed = true;
}
if now >= codex_due {
let status = crate::quota::fetch_codex();
codex_due = now + Duration::from_secs(status.retry_delay_secs(QUOTA_INTERVAL_SECS));
quota.codex = status;
changed = true;
}
if changed {
quota.fetched = true;
if tx.send(Response::Quota(Box::new(quota.clone()))).is_err() {
break;
}
}
std::thread::sleep(QUOTA_TICK);
}
});
}
fn event_loop(
app: &mut App,
terminal: &mut ratatui::DefaultTerminal,
res_rx: &Receiver<Response>,
req_tx: &Sender<Request>,
watch: Option<&crate::watch::Watch>,
mut hosted: Option<&mut crate::shim::Hosted>,
) -> anyhow::Result<i32> {
let mut last_refresh = Instant::now();
let mut last_full_walk = Instant::now();
let mut layout = render::Layout::default();
let mut refresh_in_flight = true;
let mut last_blink = true;
loop {
let mut annotated_rows_changed = false;
let mut rows_changed = false;
loop {
match res_rx.try_recv() {
Ok(Response::Discovered(sessions)) => {
app.sessions = sessions;
app.stats = crate::loader::compute_stats(&app.sessions);
app.refilter();
rows_changed = true;
}
Ok(Response::Annotated(session)) => {
let found = app.sessions.iter_mut().find(|s| {
s.provider == session.provider && s.session_id == session.session_id
});
if let Some(existing) = found {
*existing = *session;
} else {
app.sessions.push(*session);
}
annotated_rows_changed = true;
}
Ok(Response::Sessions(payload)) => {
let (sessions, stats) = *payload;
app.sessions = sessions;
app.stats = stats;
app.push_history();
app.refilter();
refresh_in_flight = false;
annotated_rows_changed = false;
rows_changed = true;
}
Ok(Response::LiveRows(payload)) => {
let (rows, stats) = *payload;
for row in rows {
let found = app
.sessions
.iter_mut()
.find(|s| s.provider == row.provider && s.session_id == row.session_id);
match found {
Some(existing) => *existing = row,
None => app.sessions.push(row),
}
}
app.stats = stats;
app.push_history();
app.refilter();
refresh_in_flight = false;
rows_changed = true;
}
Ok(Response::Data(key, data)) => {
if key == app.panel_key {
app.panel_data = Some(*data);
app.needs_redraw = true;
}
}
Ok(Response::Quota(q)) => {
app.quota = *q;
app.needs_redraw = true;
}
Ok(Response::UpdateAvailable(version)) => {
app.update_available = Some(version);
app.needs_redraw = true;
}
Ok(Response::PricingReady) => {
let _ = req_tx.send(Request::Refresh);
refresh_in_flight = true;
}
Ok(Response::Terminated {
session_key,
result,
}) => match result {
Ok(()) => {
app.set_status("Termination signal sent");
let _ = req_tx.send(Request::Refresh);
refresh_in_flight = true;
}
Err(error) => {
app.set_status(format!("Could not stop {session_key}: {error}"));
}
},
Ok(Response::Deleted {
session_key,
result,
}) => {
app.deleting.remove(&session_key);
match result {
Ok(()) => {
app.sessions.retain(|session| session.key() != session_key);
app.marked.remove(&session_key);
app.stats = crate::loader::compute_stats(&app.sessions);
app.refilter();
app.set_status("Deleted session");
}
Err(error) => {
app.set_status(format!("Could not delete {session_key}: {error}"))
}
}
}
Ok(Response::KeysSent { result }) => match result {
Ok(()) => app.set_status("Sent to the session's terminal"),
Err(error) => app.set_status(error),
},
Ok(Response::Scanned { query, hits }) => app.scanned(query, hits),
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
}
}
if annotated_rows_changed || rows_changed {
app.apply_finished_agents();
}
if annotated_rows_changed {
app.stats = crate::loader::compute_stats(&app.sessions);
app.refilter();
}
if rows_changed {
app.check_bells();
}
app.sync_panel_data();
app.tick_scan();
let mut drawn = false;
for tab in &mut app.tabs {
drawn |= tab.pump();
}
let closed = app.tabs.iter_mut().fold(false, |any, tab| tab.reap() | any);
if closed {
app.drop_empty_tabs();
}
app.poll_tmux_install();
if drawn || closed {
app.needs_redraw = true;
}
if let Some(events) = app.listener.as_ref().map(crate::hook::Listener::drain) {
let (changed, lifecycle) = app.apply_hooks(events);
app.needs_redraw |= changed;
if lifecycle && !refresh_in_flight {
let _ = req_tx.send(Request::Refresh);
refresh_in_flight = true;
last_refresh = Instant::now();
}
}
app.tick_handoff();
let phase = app.blink_on();
if phase != last_blink && app.any_attention() {
app.needs_redraw = true;
}
last_blink = phase;
if let Some((_, at)) = &app.status
&& at.elapsed() > Duration::from_secs(3)
{
app.status = None;
app.needs_redraw = true;
}
if app.needs_redraw {
terminal.draw(|frame| layout = render::draw(frame, app))?;
app.needs_redraw = false;
}
let refresh_every = Duration::from_secs_f64(app.refresh_secs);
let idle_wait = match app.tab {
0 => Duration::from_millis(200),
_ => Duration::from_millis(16),
};
let wait = refresh_every
.checked_sub(last_refresh.elapsed())
.unwrap_or(Duration::ZERO)
.min(idle_wait);
if event::poll(wait)? {
match event::read()? {
Event::Key(key) => app.on_key(key),
Event::Mouse(m) => app.on_mouse(m, &layout),
Event::Resize(_, _) => app.needs_redraw = true,
_ => {}
}
}
if let Some(hosted) = hosted.as_mut()
&& let Some(code) = hosted.finished()
{
return Ok(code);
}
if app.should_quit {
break;
}
let refresh_every = Duration::from_secs_f64(app.refresh_secs);
if last_refresh.elapsed() >= refresh_every && !refresh_in_flight {
last_refresh = Instant::now();
refresh_in_flight = true;
let watched_change = watch.is_some_and(crate::watch::Watch::took_structural_change);
let awaiting = !watched_change
&& last_full_walk.elapsed() >= PENDING_WALK_INTERVAL
&& watch.is_some_and(|w| {
w.awaiting_discovery(|path| {
app.sessions
.iter()
.any(|s| s.data_file.as_deref() == Some(path))
})
});
let full_due =
watched_change || awaiting || last_full_walk.elapsed() >= FULL_WALK_INTERVAL;
if full_due {
last_full_walk = Instant::now();
}
let _ = req_tx.send(if full_due {
Request::Refresh
} else {
Request::RefreshLive
});
}
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_app() -> App {
let (tx, rx) = channel();
std::mem::forget(rx);
App::with_prefs(Plan::Retail, tx, UiPrefs::default())
}
#[test]
fn a_click_on_a_modal_does_not_reach_what_it_covers() {
use ratatui::layout::Rect;
let mut app = test_app();
app.mode = Mode::Launch;
let layout = render::Layout {
modal_rect: Some(Rect::new(10, 8, 20, 6)),
launch_rows: vec![(9, 0), (10, 1)],
tab_row: 10,
tab_spans: vec![(10, 20, 3)],
..Default::default()
};
let click = |col, row| crossterm::event::MouseEvent {
kind: event::MouseEventKind::Down(event::MouseButton::Left),
column: col,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.on_mouse(click(15, 10), &layout);
assert_eq!(app.bottom_tab, 0, "the click went through to the panels");
assert_eq!(app.launch_cursor, 1, "the click did not pick a choice");
assert_eq!(app.mode, Mode::Launch, "the launcher closed on a pick");
app.needs_redraw = false;
app.on_mouse(click(40, 10), &layout);
assert_eq!(app.mode, Mode::List);
assert_eq!(app.bottom_tab, 0);
assert!(app.needs_redraw, "the dismissal never repainted");
}
#[test]
fn the_search_box_leaves_the_table_its_mouse() {
let mut app = test_app();
for id in ["a", "b", "c"] {
app.sessions
.push(crate::session::Session::new(Provider::Claude, id.into()));
}
app.visible = vec![Row::Session(0), Row::Session(1), Row::Session(2)];
let layout = render::Layout {
rows_start: 7,
rows_end: 12,
bottom_start: 14,
modal_rect: None,
..Default::default()
};
let at = |kind, row| crossterm::event::MouseEvent {
kind,
column: 5,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.mode = Mode::Search;
app.on_mouse(at(event::MouseEventKind::ScrollDown, 9), &layout);
assert_eq!(app.selected, 1, "the wheel is dead while searching");
app.on_mouse(
at(event::MouseEventKind::Down(event::MouseButton::Left), 9),
&layout,
);
assert_eq!(app.selected, 2, "a click cannot reach the row it landed on");
assert_eq!(app.mode, Mode::Search, "the click closed the search box");
}
#[test]
fn clicking_the_new_tab_button_paints_the_launcher() {
let mut app = test_app();
let layout = render::Layout {
workspace_new: Some((12, 23)),
..Default::default()
};
app.needs_redraw = false;
app.on_mouse(
crossterm::event::MouseEvent {
kind: event::MouseEventKind::Down(event::MouseButton::Left),
column: 15,
row: 0,
modifiers: crossterm::event::KeyModifiers::NONE,
},
&layout,
);
assert!(app.needs_redraw, "the click asked for no frame");
assert!(matches!(app.mode, Mode::Launch | Mode::List));
}
#[cfg(target_os = "linux")]
#[test]
fn stopping_an_agent_cctop_does_not_own_declines_instead_of_pretending() {
let (mut child, pid) = crate::shim::test_session(&["sh", "-c", "sleep 30"], (80, 24));
let pane = tabs::Pane::view_of(pid, "agent".into()).expect("attach");
assert!(
!pane.owns_agent(),
"a borrowed pane claimed the agent as cctop's"
);
let mut app = test_app();
app.tabs.push(tabs::Tab::new(pane));
app.tab = 1;
app.kill_pane();
assert_eq!(
app.tabs.len(),
1,
"the view was closed as if it were a kill"
);
let (status, _) = app.status.clone().expect("nothing was said");
assert!(status.contains("not cctop's to stop"), "{status}");
let _ = child.kill();
let _ = child.wait();
let _ = crate::shim::socket_path(pid).map(std::fs::remove_file);
}
#[cfg(target_os = "linux")]
#[test]
fn resuming_a_session_already_in_a_tab_goes_to_that_tab() {
let (mut child, pid) = crate::shim::test_session(&["sh", "-c", "sleep 30"], (80, 24));
let mut pane = tabs::Pane::view_of(pid, "claude".into()).expect("attach");
pane.resumed = Some(crate::tmux::name_for_session("claude", "abc"));
assert!(pane.tmux.is_none());
let mut app = test_app();
app.sessions
.push(crate::session::Session::new(Provider::Claude, "abc".into()));
app.visible = vec![Row::Session(0)];
app.selected = 0;
app.tabs.push(tabs::Tab::new(pane));
app.tab = 0;
app.resume_now();
assert_eq!(
app.tabs.len(),
1,
"a second agent was put on one transcript"
);
assert_eq!(app.tab, 1, "the tab already holding it was not shown");
let (status, _) = app.status.clone().expect("nothing was said");
assert!(status.contains("Already open"), "{status}");
let _ = child.kill();
let _ = child.wait();
let _ = crate::shim::socket_path(pid).map(std::fs::remove_file);
}
#[test]
fn the_launcher_keeps_its_cursor_on_screen() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = test_app();
app.launch_offer = (0..20)
.map(|i| tabs::Choice::Start(vec![format!("agent-{i}")]))
.collect();
app.mode = Mode::Launch;
let (cols, rows) = (80u16, 14u16);
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
for cursor in 0..app.launch_offer.len() {
app.launch_cursor = cursor;
let mut layout = render::Layout::default();
terminal
.draw(|frame| layout = render::draw(frame, &mut app))
.expect("draw");
let row = layout
.launch_rows
.iter()
.find(|(_, i)| *i == cursor)
.map(|(row, _)| *row);
let row = row.unwrap_or_else(|| panic!("choice {cursor} was not drawn"));
assert!(row < rows, "choice {cursor} drawn off screen at row {row}");
let buffer = terminal.backend().buffer().clone();
let label = format!("agent-{cursor}");
let line: String = (0..cols).map(|x| buffer[(x, row)].symbol()).collect();
assert!(
line.contains(&label),
"row {row} is not choice {cursor}: {line:?}"
);
let text: String = (0..rows)
.map(|y| {
(0..cols)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect();
assert!(text.contains("Enter start"), "the keys scrolled off");
assert!(
text.contains("this directory") || text.contains(" in "),
"where it would run scrolled off"
);
}
}
#[test]
fn ownership_asks_only_when_tmux_could_actually_be_installed() {
let mut app = test_app();
let own = app.own_preferring_tmux(Deferred::Launch, || "cctop-x".into());
match (crate::tmux::available(), crate::tmux::installer()) {
(true, _) => assert!(matches!(own, Some(tabs::Own::Tmux(_)))),
(false, Some(_)) => {
assert!(own.is_none(), "the launch waits for the answer");
assert_eq!(app.mode, Mode::TmuxInstall);
}
(false, None) => assert!(matches!(own, Some(tabs::Own::Cctop))),
}
}
#[test]
fn a_declined_offer_is_not_made_again() {
let mut app = test_app();
app.tmux_declined = true;
let own = app.own_preferring_tmux(Deferred::Launch, || "cctop-x".into());
assert!(own.is_some(), "the launch goes ahead without asking");
assert_ne!(app.mode, Mode::TmuxInstall);
}
#[test]
fn declining_the_offer_releases_the_launch() {
let mut app = test_app();
app.mode = Mode::TmuxInstall;
app.tmux_install = Some(crate::tmux::Install {
manager: "apt",
argv: vec!["sh".into(), "-c".into(), "apt-get install -y tmux".into()],
});
app.tmux_deferred = Some(Deferred::Launch);
app.tmux_install_answer(false);
assert!(app.tmux_declined);
assert!(app.tmux_install.is_none());
assert!(
app.tmux_deferred.is_none(),
"the launch was run, not dropped"
);
assert_eq!(app.mode, Mode::List);
}
#[test]
fn an_install_that_ends_without_tmux_releases_the_launch() {
let mut app = test_app();
app.tmux_installing = Some(u32::MAX);
app.tmux_deferred = Some(Deferred::Launch);
app.poll_tmux_install();
assert!(app.tmux_installing.is_none());
assert!(app.tmux_deferred.is_none());
}
#[test]
fn test_app_starts_from_default_prefs() {
let app = test_app();
assert!(!app.live_only);
assert!(app.age_filter.is_none());
assert!(app.search.is_empty());
assert_eq!(app.sort_col, ColumnId::Last);
}
#[test]
fn a_reported_state_is_kept_until_the_session_ends() {
let event = |id: &str, signal: crate::hook::Signal| crate::hook::Event {
session_id: id.into(),
reported: crate::hook::Reported {
signal,
cwd: "/w/proj".into(),
},
finished_agent: None,
};
let mut app = test_app();
assert_eq!(app.apply_hooks(Vec::new()), (false, false));
assert!(app.hooked_signal("a").is_none());
assert_eq!(
app.apply_hooks(vec![event("a", crate::hook::Signal::Started)]),
(true, true)
);
assert_eq!(
app.apply_hooks(vec![
event("a", crate::hook::Signal::Busy),
event("a", crate::hook::Signal::Idle),
]),
(true, false)
);
assert_eq!(app.hooked_signal("a"), Some(crate::hook::Signal::Idle));
assert_eq!(app.reporting(), vec![("proj".to_string(), "idle")]);
assert_eq!(
app.apply_hooks(vec![event("a", crate::hook::Signal::Ended)]),
(true, true)
);
assert!(app.hooked_signal("a").is_none());
assert!(app.reporting().is_empty());
}
#[test]
fn a_gemini_event_finds_the_chat_file_it_belongs_to() {
let mut app = test_app();
app.apply_hooks(vec![crate::hook::Event {
session_id: "79709c93-1111-4111-8111-111111111111".into(),
reported: crate::hook::Reported {
signal: crate::hook::Signal::Idle,
cwd: "/w/proj".into(),
},
finished_agent: None,
}]);
assert_eq!(
app.hooked_signal("session-2026-05-14T17-34-79709c93"),
Some(crate::hook::Signal::Idle)
);
assert!(
app.hooked_signal("session-2026-05-14T17-34-deadbeef")
.is_none()
);
assert!(app.hooked_signal("79709c93").is_none());
assert_eq!(
gemini_id_tail("session-2026-05-14T17-34-79709c93"),
Some("79709c93")
);
assert_eq!(gemini_id_tail("019fda22-5315-7580-84de-033e4f6835b5"), None);
}
#[test]
fn typing_into_a_pane_settles_the_question_it_answers() {
let mut app = test_app();
let mut session = session("a", true, "proj");
session.process.as_mut().unwrap().process_list = vec![crate::proc::ProcEntry {
pid: 7,
is_root: true,
ghost: false,
cpu: 0.0,
memory: 0,
args: String::new(),
}];
app.sessions = vec![session];
app.mark_answered(7);
assert!(app.hooked_signal("a").is_none());
app.apply_hooks(vec![crate::hook::Event {
session_id: "a".into(),
reported: crate::hook::Reported {
signal: crate::hook::Signal::NeedsInput,
cwd: "/w/proj".into(),
},
finished_agent: None,
}]);
assert_eq!(
app.hooked_signal("a"),
Some(crate::hook::Signal::NeedsInput)
);
app.mark_answered(7);
assert_eq!(app.hooked_signal("a"), Some(crate::hook::Signal::Busy));
app.apply_hooks(vec![crate::hook::Event {
session_id: "a".into(),
reported: crate::hook::Reported {
signal: crate::hook::Signal::NeedsInput,
cwd: "/w/proj".into(),
},
finished_agent: None,
}]);
app.mark_answered(8);
assert_eq!(
app.hooked_signal("a"),
Some(crate::hook::Signal::NeedsInput)
);
}
fn session(id: &str, running: bool, label: &str) -> Session {
let mut s = Session::new(Provider::Claude, id.into());
s.label_source = label.into();
s.last_active = chrono::Utc::now().to_rfc3339();
s.started_at = s.last_active.clone();
if running {
s.process = Some(crate::proc::ProcInfo::default());
}
s
}
fn with_subagents(id: &str, names: &[&str]) -> Session {
let mut s = session(id, true, id);
s.subagents = names
.iter()
.map(|n| crate::session::Subagent {
agent_id: format!("agent-{n}"),
agent_type: "general-purpose".into(),
description: (*n).into(),
model: "claude-opus-5".into(),
started_at: None,
last_active: None,
duration_ms: 0,
status: crate::session::SubagentStatus::Running,
cost: 0.0,
tool_count: 0,
tool_use_id: None,
context: None,
ghost: false,
})
.collect();
s
}
#[test]
fn expanded_subagents_sit_directly_under_their_parent() {
let mut app = test_app();
app.sessions = vec![
with_subagents("a", &["one", "two"]),
session("b", true, "b"),
];
app.refilter();
assert_eq!(app.visible.len(), 2, "collapsed: one row per session");
app.expanded.insert(app.sessions[0].key());
app.refilter();
let at = app
.visible
.iter()
.position(|&r| r == Row::Session(0))
.expect("parent row");
assert_eq!(app.visible.len(), 4);
assert_eq!(
&app.visible[at..at + 3],
&[
Row::Session(0),
Row::Subagent {
parent: 0,
index: 0
},
Row::Subagent {
parent: 0,
index: 1
},
]
);
}
#[test]
fn the_cursor_stays_on_a_child_row_across_a_refresh() {
let mut app = test_app();
app.sessions = vec![with_subagents("a", &["one", "two"])];
app.expanded.insert(app.sessions[0].key());
app.refilter();
app.selected = 2;
app.refilter();
assert_eq!(
app.visible[app.selected],
Row::Subagent {
parent: 0,
index: 1
}
);
assert_eq!(
app.selected_subagent().map(|s| s.description.clone()),
Some("two".into())
);
}
#[test]
fn a_child_row_owns_its_parent_but_is_not_it() {
let mut app = test_app();
app.sessions = vec![with_subagents("a", &["one"])];
app.expanded.insert(app.sessions[0].key());
app.refilter();
app.selected = 0;
assert!(!app.on_subagent());
assert!(app.selected_subagent().is_none());
app.selected = 1;
assert!(app.on_subagent());
assert_eq!(
app.selected_session().map(|s| s.session_id.clone()),
Some("a".into()),
"a child still resolves to the session that owns it"
);
assert!(app.selected_subagent().is_some());
}
#[test]
fn collapsing_from_a_child_lands_the_cursor_on_its_parent() {
let mut app = test_app();
app.sessions = vec![with_subagents("a", &["one", "two"])];
app.expanded.insert(app.sessions[0].key());
app.refilter();
app.selected = 1;
app.toggle_expanded();
assert_eq!(app.visible.len(), 1);
assert_eq!(app.visible[app.selected], Row::Session(0));
assert!(app.expanded.is_empty());
}
#[test]
fn a_hooks_word_retires_a_subagent_the_transcript_still_calls_running() {
let mut app = test_app();
app.sessions = vec![with_subagents("a", &["one", "two"])];
assert!(
app.sessions[0]
.subagents
.iter()
.all(|s| s.status == crate::session::SubagentStatus::Running)
);
app.finished_agents.insert("one".into());
app.apply_finished_agents();
let status = |i: usize| app.sessions[0].subagents[i].status;
assert_eq!(status(0), crate::session::SubagentStatus::Done);
assert_eq!(
status(1),
crate::session::SubagentStatus::Running,
"only the subagent named may be retired"
);
}
#[test]
fn expanding_a_session_without_subagents_says_so() {
let mut app = test_app();
app.sessions = vec![session("a", true, "a")];
app.refilter();
app.toggle_expanded();
assert!(app.expanded.is_empty());
assert!(app.status.is_some(), "the refusal has to be visible");
}
#[test]
fn live_filter_hides_stopped_sessions() {
let mut app = test_app();
app.sessions = vec![session("a", true, "x"), session("b", false, "y")];
app.live_only = true;
app.refilter();
assert_eq!(app.visible.len(), 1);
assert_eq!(app.sessions[app.visible[0].session()].session_id, "a");
}
#[test]
fn live_filter_includes_transcript_inferred_cursor_session() {
let mut app = test_app();
let mut cursor = Session::new(Provider::Cursor, "cursor".into());
cursor.started_at = chrono::Utc::now().to_rfc3339();
cursor.last_active = cursor.started_at.clone();
cursor.inferred_running = true;
app.sessions = vec![cursor];
app.live_only = true;
app.refilter();
assert_eq!(app.visible.len(), 1);
assert!(app.sessions[app.visible[0].session()].is_running());
assert!(app.sessions[app.visible[0].session()].process.is_none());
}
#[test]
fn session_root_pid_excludes_ghost_and_child_processes() {
let mut session = session("a", true, "x");
session.process.as_mut().unwrap().process_list = vec![
crate::proc::ProcEntry {
pid: 1,
is_root: true,
ghost: true,
cpu: 0.0,
memory: 0,
args: String::new(),
},
crate::proc::ProcEntry {
pid: 2,
is_root: false,
ghost: false,
cpu: 0.0,
memory: 0,
args: String::new(),
},
crate::proc::ProcEntry {
pid: 3,
is_root: true,
ghost: false,
cpu: 0.0,
memory: 0,
args: String::new(),
},
];
assert_eq!(session_root_pid(&session), Some(3));
}
#[test]
fn runtime_tabs_are_unavailable_for_stopped_sessions() {
let mut app = test_app();
app.sessions = vec![session("stopped", false, "x")];
app.refilter();
assert!(!app.tab_available(1));
assert!(!app.tab_available(2));
app.bottom_tab = 0;
app.cycle_tab(1);
assert_eq!(app.bottom_tab, 3);
}
#[test]
fn selecting_stopped_session_leaves_runtime_tab() {
let mut app = test_app();
app.sessions = vec![
session("running", true, "x"),
session("stopped", false, "y"),
];
app.visible = vec![Row::Session(0), Row::Session(1)];
app.bottom_tab = 1;
app.move_selection(1);
assert_eq!(app.bottom_tab, 0);
}
#[test]
fn search_matches_label_and_id_case_insensitively() {
let mut app = test_app();
app.sessions = vec![
session("aaa", false, "/home/x/alpha"),
session("bbb", false, "/home/x/beta"),
];
app.search = "alpha".into();
app.refilter();
assert_eq!(app.visible.len(), 1);
app.search = "BBB".into();
app.refilter();
assert_eq!(app.visible.len(), 1);
assert_eq!(app.sessions[app.visible[0].session()].session_id, "bbb");
}
#[test]
fn search_matches_the_full_working_directory() {
let mut app = test_app();
let mut deep = session("aaa", false, "/home/x/work/api/services/billing");
deep.abbrev_label = "…/billing".into();
app.sessions = vec![deep, session("bbb", false, "/home/x/other")];
app.search = "work/api".into();
app.refilter();
assert_eq!(app.visible.len(), 1);
assert_eq!(app.sessions[app.visible[0].session()].session_id, "aaa");
}
#[test]
fn transcript_hits_widen_the_filter_for_their_own_query_only() {
let mut app = test_app();
app.sessions = vec![
session("aaa", false, "/home/x/alpha"),
session("bbb", false, "/home/x/beta"),
];
let hit = |key: &str| HashMap::from([(key.to_string(), "…flywheel…".to_string())]);
app.search = "flywheel".into();
app.refilter();
assert!(app.visible.is_empty());
app.search_content = true;
app.scanned("flywheel".into(), hit("claude:bbb"));
assert_eq!(app.visible.len(), 1);
assert_eq!(app.sessions[app.visible[0].session()].session_id, "bbb");
app.search = "alpha".into();
app.scanned("alpha".into(), hit("claude:bbb"));
assert_eq!(
app.visible.len(),
2,
"metadata and content matches, not one"
);
app.search = "alphabet".into();
app.refilter();
assert!(app.visible.is_empty());
assert!(app.selected_snippet().is_none());
}
#[test]
fn leaving_content_search_drops_its_rows() {
let mut app = test_app();
app.sessions = vec![session("aaa", false, "/home/x/alpha")];
app.search = "flywheel".into();
app.search_content = true;
app.scanned(
"flywheel".into(),
HashMap::from([("claude:aaa".into(), "…flywheel…".into())]),
);
assert_eq!(app.visible.len(), 1);
assert_eq!(app.selected_snippet(), Some("…flywheel…"));
app.toggle_content_search();
assert!(app.visible.is_empty());
assert!(app.selected_snippet().is_none());
}
#[test]
fn a_scan_waits_for_a_word_and_for_the_typing_to_settle() {
let mut app = test_app();
app.sessions = vec![session("aaa", false, "/home/x/alpha")];
app.search_content = true;
app.search = "fl".into();
app.search_edited();
app.scan_typed_at = None;
app.tick_scan();
assert!(!app.scanning);
app.search = "flywheel".into();
app.search_edited();
app.tick_scan();
assert!(!app.scanning, "fired before the debounce elapsed");
app.scan_typed_at = Some(Instant::now() - SCAN_DEBOUNCE);
app.tick_scan();
assert!(app.scanning);
app.scanned("flywheel".into(), HashMap::new());
app.tick_scan();
assert!(!app.scanning);
}
#[test]
fn the_query_history_walks_both_ways() {
let mut app = test_app();
app.search_history = vec!["newest".into(), "older".into()];
app.search = "half-typ".into();
app.history_step(1);
assert_eq!(app.search, "newest");
app.history_step(1);
assert_eq!(app.search, "older");
app.history_step(1);
assert_eq!(app.search, "older");
app.history_step(-1);
assert_eq!(app.search, "newest");
app.history_step(-1);
assert_eq!(app.search, "half-typ");
app.history_step(-1);
assert_eq!(app.search, "half-typ");
}
#[test]
fn a_repeated_query_is_remembered_once() {
let mut app = test_app();
app.search = "alpha".into();
app.remember_query();
app.search = "beta".into();
app.remember_query();
app.search = "alpha".into();
app.remember_query();
assert_eq!(app.search_history, vec!["alpha", "beta"]);
app.search = " ".into();
app.remember_query();
assert_eq!(app.search_history, vec!["alpha", "beta"]);
}
#[test]
fn selection_follows_the_session_across_a_resort() {
let mut app = test_app();
app.sessions = vec![session("a", false, "/x/a"), session("b", false, "/x/b")];
app.sort_col = ColumnId::Project;
app.sort_asc = true;
app.refilter();
app.selected = 1;
let before = app.selected_session().unwrap().key();
app.sort_asc = false;
app.refilter();
assert_eq!(app.selected_session().unwrap().key(), before);
assert_eq!(app.selected, 0);
}
#[test]
fn selection_stays_in_bounds_when_sessions_disappear() {
let mut app = test_app();
app.sessions = (0..5)
.map(|i| session(&i.to_string(), false, "/x"))
.collect();
app.refilter();
app.selected = 4;
app.sessions.truncate(2);
app.refilter();
assert!(app.selected < app.visible.len());
}
#[test]
fn empty_list_does_not_panic_on_navigation() {
let mut app = test_app();
app.refilter();
app.move_selection(1);
app.move_selection(-1);
assert_eq!(app.selected, 0);
assert!(app.selected_session().is_none());
}
#[test]
fn sort_toggles_on_repeat_and_resets_on_change() {
let mut app = test_app();
app.sort_col = ColumnId::Cost;
app.sort_asc = true;
app.set_sort(ColumnId::Cost);
assert!(!app.sort_asc, "same column must flip direction");
app.set_sort(ColumnId::Cpu);
assert!(app.sort_asc, "new column starts ascending");
assert_eq!(app.sort_col, ColumnId::Cpu);
}
#[test]
fn age_filter_roundtrips_through_prefs_keys() {
for f in [AgeFilter::Day, AgeFilter::Week, AgeFilter::Month] {
assert_eq!(AgeFilter::parse(f.key()), Some(f));
}
assert_eq!(AgeFilter::parse("nope"), None);
}
#[test]
fn age_filter_excludes_old_sessions() {
let mut app = test_app();
let mut old = session("old", false, "/x");
old.last_active = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
app.sessions = vec![session("new", false, "/x"), old];
app.age_filter = Some(AgeFilter::Day);
app.refilter();
assert_eq!(app.visible.len(), 1);
assert_eq!(app.sessions[app.visible[0].session()].session_id, "new");
}
#[test]
fn cost_floor_filters_by_total_cost() {
let mut app = test_app();
let mut cheap = session("cheap", false, "/x");
cheap.total_cost = Some(0.50);
let mut pricey = session("pricey", false, "/x");
pricey.total_cost = Some(5.00);
app.sessions = vec![cheap, pricey];
app.cost_floor = 1.0;
app.refilter();
assert_eq!(app.visible.len(), 1);
assert_eq!(app.sessions[app.visible[0].session()].session_id, "pricey");
}
#[test]
fn cost_floor_zero_shows_everything() {
let mut app = test_app();
app.sessions = vec![session("a", false, "/x"), session("b", false, "/x")];
app.cost_floor = 0.0;
app.refilter();
assert_eq!(app.visible.len(), 2);
}
#[test]
fn cycle_matches_wraps_around_search_matches() {
let mut app = test_app();
let now = chrono::Utc::now().to_rfc3339();
let mk = |id: &str, label: &str| {
let mut s = session(id, false, label);
s.last_active = now.clone();
s.started_at = now.clone();
s
};
app.sessions = vec![mk("a", "/x/match"), mk("b", "/y"), mk("c", "/z/match")];
app.search = "match".into();
app.refilter();
assert_eq!(app.visible.len(), 2);
app.selected = 0;
app.cycle_matches(1);
assert_eq!(app.selected_session().unwrap().session_id, "c");
app.cycle_matches(1);
assert_eq!(app.selected_session().unwrap().session_id, "a");
app.cycle_matches(-1);
assert_eq!(app.selected_session().unwrap().session_id, "c");
}
#[test]
fn batch_delete_keeps_marked_sessions_visible_until_worker_confirms() {
let mut app = test_app();
app.sessions = vec![
session("a", false, "/x"),
session("b", false, "/x"),
session("c", false, "/x"),
];
app.refilter();
let row = |app: &App, id: &str| {
app.visible
.iter()
.position(|&r| app.sessions[r.session()].session_id == id)
.expect("fixture is visible")
};
app.selected = row(&app, "a");
app.toggle_mark();
app.selected = row(&app, "c");
app.toggle_mark();
assert_eq!(app.marked.len(), 2);
assert_eq!(app.marked_sessions().len(), 2);
app.batch(BatchKind::Delete);
assert_eq!(app.mode, Mode::BatchConfirm);
app.batch_execute();
assert_eq!(app.sessions.len(), 3);
assert_eq!(app.deleting.len(), 2);
assert!(app.marked.is_empty());
}
#[test]
fn batch_delete_refuses_when_marked_session_is_running() {
let mut app = test_app();
app.sessions = vec![session("a", false, "/x"), session("b", true, "/x")];
app.refilter();
app.selected = 0;
app.toggle_mark();
app.selected = 1;
app.toggle_mark();
app.batch(BatchKind::Delete);
assert_eq!(app.mode, Mode::BatchDeleteBlocked);
}
#[test]
fn b_jumps_the_selection_to_the_session_that_rang() {
let mut app = test_app();
app.sessions = vec![session("a", true, "/x/a"), session("b", true, "/x/b")];
app.refilter();
let target = app.sessions[1].key();
app.notify.last = Some(crate::notify::Rang {
key: target.clone(),
label: "b".into(),
reason: crate::notify::Reason::NeedsInput,
at: Instant::now(),
});
app.selected = 0;
app.jump_to_bell();
assert_eq!(app.selected_session().map(Session::key), Some(target));
app.search = "x/a".into();
app.refilter();
app.selected = 0;
app.jump_to_bell();
assert_eq!(app.selected, 0);
assert!(app.status.is_some());
}
#[test]
fn refresh_interval_adjusts_and_clamps() {
let mut app = test_app();
app.refresh_secs = 2.0;
app.adjust_refresh(0.5);
assert_eq!(app.refresh_secs, 2.5);
app.adjust_refresh(-10.0);
assert_eq!(app.refresh_secs, 0.5);
app.adjust_refresh(100.0);
assert_eq!(app.refresh_secs, 60.0);
}
}