use std::path::Path;
use std::time::{Duration, Instant};
const QUIET_IS_IDLE: Duration = Duration::from_secs(2);
const FIND_AGENT_EVERY: Duration = Duration::from_millis(500);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Attention {
Idle,
NeedsInput,
}
pub struct Pane {
hosted: Option<crate::shim::Hosted>,
pub tmux: Option<String>,
pub resumed: Option<String>,
pub profile: Option<String>,
pub pid: u32,
agent: Option<u32>,
asked_at: Option<Instant>,
pub label: String,
pub view: crate::attach::Attach,
drew_at: Instant,
}
impl Pane {
fn idle(&self) -> bool {
self.drew_at.elapsed() >= QUIET_IS_IDLE
}
pub fn agent(&self) -> u32 {
self.agent.unwrap_or(self.pid)
}
fn find_agent(&mut self) {
let Some(name) = self.tmux.as_deref() else {
return;
};
if self.agent.is_some()
|| self
.asked_at
.is_some_and(|at| at.elapsed() < FIND_AGENT_EVERY)
{
return;
}
self.asked_at = Some(Instant::now());
self.agent = crate::tmux::agent_pid(name);
if self.agent.is_some() {
crate::tmux::quiet(name);
crate::tmux::mouse(name);
crate::tmux::set_label(name, &self.label);
}
}
pub fn outlives_cctop(&self) -> bool {
self.tmux.is_some()
}
pub fn owns_agent(&self) -> bool {
self.hosted.is_some() || self.tmux.is_some()
}
}
#[derive(Debug, Clone)]
pub enum Own {
Tmux(String),
TmuxExisting(String),
Cctop,
}
impl Pane {
pub fn launch(argv: &[String], cwd: Option<&Path>, own: Own) -> anyhow::Result<Pane> {
let tmux = match &own {
Own::Tmux(name) | Own::TmuxExisting(name) => Some(name.clone()),
Own::Cctop => None,
};
let spawn = match &own {
Own::Tmux(name) => {
crate::tmux::prepare(argv, name, cwd);
crate::tmux::attach_or_create(argv, name, cwd)
}
Own::TmuxExisting(name) => crate::tmux::attach(name),
Own::Cctop => argv.to_vec(),
};
let hosted = crate::shim::host(&spawn, cwd)?;
let view = crate::attach::attach(hosted.pid).ok_or_else(|| {
anyhow::anyhow!(
"{} started but its terminal could not be opened",
label_of(argv)
)
})?;
Ok(Pane {
pid: hosted.pid,
label: label_of(argv),
view,
tmux,
resumed: None,
profile: None,
agent: None,
asked_at: None,
hosted: Some(hosted),
drew_at: Instant::now(),
})
}
pub fn view_of(pid: u32, label: String) -> Option<Pane> {
Some(Pane {
hosted: None,
tmux: None,
resumed: None,
profile: None,
pid,
agent: Some(pid),
asked_at: None,
label,
view: crate::attach::attach(pid)?,
drew_at: Instant::now(),
})
}
pub fn kill_agent(&self) -> Result<(), String> {
match &self.tmux {
Some(name) => crate::tmux::kill(name),
None => Ok(()),
}
}
fn finished(&mut self) -> bool {
match self.hosted.as_mut() {
Some(hosted) => hosted.finished().is_some(),
None => self.view.closed(),
}
}
}
#[derive(Debug, Clone)]
pub struct Shared {
pub name: String,
pub label: String,
pub pid: Option<u32>,
pub activity: Option<u64>,
}
impl Shared {
fn idle(&self) -> bool {
let Some(activity) = self.activity else {
return false;
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now.saturating_sub(activity) > QUIET_IS_IDLE.as_secs()
}
}
pub struct Tab {
pub panes: Vec<Pane>,
pub focus: usize,
pub stacked: bool,
pub shared: Option<Shared>,
}
impl Tab {
pub fn new(pane: Pane) -> Tab {
Tab {
panes: vec![pane],
focus: 0,
stacked: false,
shared: None,
}
}
pub fn shared(agent: &crate::tmux::Running) -> Tab {
Tab {
panes: Vec::new(),
focus: 0,
stacked: false,
shared: Some(Shared {
label: agent.label.clone().unwrap_or_else(|| {
agent
.name
.strip_prefix("cctop-")
.unwrap_or(&agent.name)
.to_string()
}),
name: agent.name.clone(),
pid: agent.pid,
activity: agent.activity,
}),
}
}
pub fn detached(&self) -> bool {
self.panes.is_empty() && self.shared.is_some()
}
pub fn sessions(&self) -> impl Iterator<Item = &str> {
self.panes
.iter()
.filter_map(|pane| pane.tmux.as_deref())
.chain(self.shared.iter().map(|s| s.name.as_str()))
}
pub fn attach(&mut self) -> anyhow::Result<()> {
let Some(shared) = self.shared.clone() else {
return Ok(());
};
let argv = [shared.label.clone()];
let mut pane = Pane::launch(&argv, None, Own::TmuxExisting(shared.name.clone()))?;
pane.label = shared.label;
self.panes = vec![pane];
self.focus = 0;
self.shared = None;
Ok(())
}
pub fn detach(&mut self) -> bool {
let [pane] = &self.panes[..] else {
return false;
};
let Some(name) = pane.tmux.clone() else {
return false;
};
self.shared = Some(Shared {
name,
label: pane.label.clone(),
pid: Some(pane.agent()),
activity: None,
});
self.panes.clear();
self.focus = 0;
true
}
pub fn title(&self) -> String {
match self.panes.len() {
0 | 1 => self
.panes
.first()
.map(|p| p.label.clone())
.or_else(|| self.shared.as_ref().map(|s| s.label.clone()))
.unwrap_or_default(),
n => format!("{} +{}", self.panes[0].label, n - 1),
}
}
pub fn focused_mut(&mut self) -> Option<&mut Pane> {
self.panes.get_mut(self.focus)
}
pub fn cycle_focus(&mut self) {
if !self.panes.is_empty() {
self.focus = (self.focus + 1) % self.panes.len();
}
}
pub fn pump(&mut self) -> bool {
self.panes.iter_mut().fold(false, |changed, pane| {
let drew = pane.view.pump();
if drew {
pane.drew_at = Instant::now();
}
pane.find_agent();
drew | changed
})
}
pub fn attention(
&self,
focused: bool,
known: &dyn Fn(u32) -> Option<crate::hook::Signal>,
) -> Option<Attention> {
if let Some(shared) = &self.shared {
return match shared.pid.and_then(&known) {
Some(crate::hook::Signal::NeedsInput) => Some(Attention::NeedsInput),
Some(crate::hook::Signal::Acting) => shared.idle().then_some(Attention::NeedsInput),
Some(signal) if signal.is_working() => None,
Some(_) => Some(Attention::Idle),
None => shared.idle().then_some(Attention::Idle),
};
}
self.panes
.iter()
.enumerate()
.filter(|(i, _)| !(focused && *i == self.focus))
.filter_map(|(_, pane)| match known(pane.agent()) {
Some(crate::hook::Signal::NeedsInput) => Some(Attention::NeedsInput),
Some(crate::hook::Signal::Acting) => pane.idle().then_some(Attention::NeedsInput),
Some(signal) if signal.is_working() => None,
Some(_) => Some(Attention::Idle),
None => pane.idle().then_some(Attention::Idle),
})
.max_by_key(|a| matches!(a, Attention::NeedsInput))
}
pub fn reap(&mut self) -> bool {
self.panes.retain_mut(|pane| !pane.finished());
self.focus = self.focus.min(self.panes.len().saturating_sub(1));
self.panes.is_empty() && self.shared.is_none()
}
}
pub fn harnesses() -> Vec<Vec<String>> {
let mut found: Vec<Vec<String>> = crate::alias::AGENTS
.split_whitespace()
.filter(|agent| crate::shim::is_command(agent))
.map(|agent| vec![agent.to_string()])
.collect();
if let Some(shell) = std::env::var("SHELL").ok().filter(|s| !s.is_empty()) {
found.push(vec![shell]);
}
found
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Choice {
Waiting(crate::tmux::Running),
Start(Vec<String>),
}
impl Choice {
pub fn label(&self) -> String {
match self {
Choice::Waiting(agent) => agent
.name
.strip_prefix("cctop-")
.unwrap_or(&agent.name)
.to_string(),
Choice::Start(argv) => label_of(argv),
}
}
pub fn cwd(&self) -> Option<&Path> {
match self {
Choice::Waiting(agent) => agent.cwd.as_deref(),
Choice::Start(_) => None,
}
}
}
pub fn choices(open: &[String]) -> Vec<Choice> {
crate::tmux::running()
.into_iter()
.filter(|agent| !open.contains(&agent.name))
.map(Choice::Waiting)
.chain(harnesses().into_iter().map(Choice::Start))
.collect()
}
pub fn label_of(argv: &[String]) -> String {
let mut argv = argv;
if argv.first().map(String::as_str) == Some("env") {
let mut rest = &argv[1..];
while rest
.first()
.is_some_and(|a| a.contains('=') && !a.starts_with('-'))
{
rest = &rest[1..];
}
if !rest.is_empty() {
argv = rest;
}
}
argv.iter()
.map(|arg| arg.rsplit('/').next().unwrap_or(arg))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
#[test]
fn a_resume_command_is_not_a_tab_name() {
let argv: Vec<String> = ["claude", "--resume", "4ebf1ab4-2ef8-4fb2-a7d5-d445b5026dc9"]
.iter()
.map(|s| s.to_string())
.collect();
let from_argv = super::label_of(&argv);
assert!(from_argv.contains("4ebf1ab4"), "{from_argv}");
assert!(from_argv.chars().count() > 40, "{from_argv}");
let label = format!(
"{} · {}",
argv[0],
crate::util::truncate("Improve super cctop", super::super::TAB_LABEL_CHARS)
);
assert_eq!(label, "claude · Improve super cctop");
assert!(!label.contains("4ebf1ab4"));
}
#[test]
fn a_profile_prefix_does_not_become_the_tab_name() {
let argv: Vec<String> = ["env", "CLAUDE_CONFIG_DIR=/home/x/.claude-work", "claude"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(label_of(&argv), "claude");
let argv: Vec<String> = ["env", "A=1", "B=2", "claude", "--resume"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(label_of(&argv), "claude --resume");
assert_eq!(label_of(&["env".to_string()]), "env");
assert_eq!(label_of(&["claude".to_string()]), "claude");
}
use super::*;
#[test]
fn the_launcher_only_offers_commands_that_exist() {
for argv in harnesses() {
assert!(
crate::shim::is_command(&argv[0]),
"offered a command that is not installed: {argv:?}"
);
}
}
#[test]
fn the_launcher_offers_what_is_running_before_what_is_new() {
let starts = harnesses().len();
let plain = choices(&[]);
assert_eq!(
plain
.iter()
.filter(|c| matches!(c, Choice::Start(_)))
.count(),
starts
);
let first_start = plain
.iter()
.position(|c| matches!(c, Choice::Start(_)))
.unwrap_or(0);
assert!(
plain[first_start..]
.iter()
.all(|c| matches!(c, Choice::Start(_))),
"a running agent appeared below the new-launch commands"
);
if let Some(Choice::Waiting(agent)) = plain.iter().find(|c| matches!(c, Choice::Waiting(_)))
{
let hidden = choices(std::slice::from_ref(&agent.name));
assert!(!hidden.contains(&Choice::Waiting(agent.clone())));
}
}
fn session(name: &str, label: Option<&str>, ago: u64) -> crate::tmux::Running {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
crate::tmux::Running {
name: name.to_string(),
pid: Some(4321),
cwd: None,
attached: false,
activity: Some(now.saturating_sub(ago)),
label: label.map(str::to_string),
}
}
#[test]
fn a_shared_tab_is_called_what_the_cctop_that_started_it_called_it() {
let tab = Tab::shared(&session(
"cctop-claude-4ebf1ab4-2ef8-4fb2-a7d5-d445b5026dc9",
Some("claude · Improve super cctop"),
0,
));
assert!(tab.detached());
assert_eq!(tab.title(), "claude · Improve super cctop");
let tab = Tab::shared(&session("cctop-claude-32cca860", None, 0));
assert_eq!(tab.title(), "claude-32cca860");
}
#[test]
fn an_unwatched_tab_still_says_when_its_agent_wants_you() {
let quiet = Tab::shared(&session("cctop-claude-a", None, 30));
let busy = Tab::shared(&session("cctop-claude-b", None, 0));
let pid = quiet.shared.as_ref().and_then(|s| s.pid).expect("pid");
assert_eq!(
quiet.attention(false, &|_| Some(crate::hook::Signal::NeedsInput)),
Some(Attention::NeedsInput)
);
assert_eq!(
quiet.attention(false, &|_| Some(crate::hook::Signal::Busy)),
None
);
assert_eq!(
quiet.attention(false, &|asked| (asked == pid)
.then_some(crate::hook::Signal::NeedsInput)),
Some(Attention::NeedsInput)
);
assert_eq!(
quiet.attention(false, &|_| Some(crate::hook::Signal::Acting)),
Some(Attention::NeedsInput)
);
assert_eq!(
busy.attention(false, &|_| Some(crate::hook::Signal::Acting)),
None,
"an agent still repainting mid-tool is working, not asking"
);
let unreported = |_: u32| None;
assert_eq!(quiet.attention(false, &unreported), Some(Attention::Idle));
assert_eq!(busy.attention(false, &unreported), None);
}
#[test]
fn a_detached_tab_is_not_reaped() {
let mut tab = Tab::shared(&session("cctop-claude-a", None, 0));
assert!(!tab.reap(), "a shared tab was reaped for having no pane");
tab.shared = None;
assert!(tab.reap());
}
fn waiting(name: &str) -> Choice {
Choice::Waiting(crate::tmux::Running {
name: name.to_string(),
pid: Some(4321),
cwd: Some(std::path::PathBuf::from("/home/x/proj")),
attached: false,
activity: None,
label: None,
})
}
#[test]
fn a_choice_is_named_for_the_agent_not_the_wrapper() {
assert_eq!(waiting("cctop-claude-32cca860").label(), "claude-32cca860");
assert_eq!(
Choice::Start(vec!["/usr/bin/claude".into()]).label(),
"claude"
);
}
#[test]
fn only_a_running_agent_names_its_own_directory() {
assert_eq!(
waiting("cctop-claude-abc").cwd(),
Some(Path::new("/home/x/proj"))
);
assert_eq!(Choice::Start(vec!["claude".into()]).cwd(), None);
}
#[cfg(target_os = "linux")]
#[test]
fn a_tmux_backed_tab_asks_about_the_agent_and_not_the_client() {
let (_child, client_pid) = crate::shim::test_session(&["sh", "-c", "sleep 30"], (80, 24));
let mut pane = Pane::view_of(client_pid, "claude".into()).expect("attach");
pane.tmux = Some("cctop-claude-abc".into());
let agent_pid = client_pid + 1_000;
pane.agent = Some(agent_pid);
assert_eq!(pane.agent(), agent_pid);
assert!(pane.outlives_cctop());
let mut tab = Tab::new(pane);
tab.focus = 1;
assert_eq!(
tab.attention(true, &|pid| (pid == agent_pid)
.then_some(crate::hook::Signal::NeedsInput)),
Some(Attention::NeedsInput)
);
assert_ne!(
tab.attention(true, &|pid| (pid == client_pid)
.then_some(crate::hook::Signal::NeedsInput)),
Some(Attention::NeedsInput)
);
assert_eq!(
tab.attention(true, &|pid| (pid == agent_pid)
.then_some(crate::hook::Signal::Busy)),
None
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_tab_asks_for_attention_only_when_it_has_something_you_cannot_see() {
let mut kids = Vec::new();
let mut pane = |script: &str| {
let (child, pid) = crate::shim::test_session(&["sh", "-c", script], (80, 24));
kids.push(child);
(pid, Pane::view_of(pid, "agent".into()).expect("attach"))
};
let (busy_pid, busy) = pane("while :; do printf '.'; sleep 0.2; done");
let (quiet_pid, quiet) = pane("printf 'done'; sleep 30");
let mut tab = Tab::new(busy);
tab.panes.push(quiet);
let unreported = |_: u32| None;
assert_eq!(tab.attention(false, &unreported), None);
let deadline = Instant::now() + QUIET_IS_IDLE + Duration::from_secs(2);
while Instant::now() < deadline {
tab.pump();
std::thread::sleep(Duration::from_millis(50));
}
assert_eq!(tab.attention(false, &unreported), Some(Attention::Idle));
assert_eq!(
tab.attention(false, &|pid| (pid == busy_pid)
.then_some(crate::hook::Signal::NeedsInput)),
Some(Attention::NeedsInput)
);
tab.focus = 0;
assert_eq!(
tab.attention(true, &|pid| (pid == busy_pid)
.then_some(crate::hook::Signal::NeedsInput)),
Some(Attention::Idle)
);
tab.focus = 1;
assert_eq!(tab.attention(true, &unreported), None);
drop(tab);
for child in &mut kids {
let _ = child.kill();
let _ = child.wait();
}
for pid in [busy_pid, quiet_pid] {
let _ = crate::shim::socket_path(pid).map(std::fs::remove_file);
}
}
#[cfg(target_os = "linux")]
#[test]
fn leaving_a_tmux_tab_gives_up_its_client_and_nothing_else() {
let (mut child, pid) = crate::shim::test_session(&["sh", "-c", "sleep 30"], (80, 24));
let mut pane = Pane::view_of(pid, "claude · Improve super cctop".into()).expect("attach");
pane.tmux = Some("cctop-claude-abc".into());
let agent_pid = pid + 1_000;
pane.agent = Some(agent_pid);
let mut tab = Tab::new(pane);
assert!(tab.detach());
assert!(tab.detached());
let shared = tab.shared.clone().expect("nothing was kept");
assert_eq!(shared.name, "cctop-claude-abc");
assert_eq!(shared.label, "claude · Improve super cctop");
assert_eq!(shared.pid, Some(agent_pid));
assert_eq!(tab.title(), "claude · Improve super cctop");
let mut owned = Tab::new(Pane::view_of(pid, "claude".into()).expect("attach"));
assert!(!owned.detach());
assert!(owned.shared.is_none());
let mut split = Tab::new(Pane::view_of(pid, "claude".into()).expect("attach"));
split.panes[0].tmux = Some("cctop-claude-abc".into());
split
.panes
.push(Pane::view_of(pid, "shell".into()).expect("attach"));
split.panes[1].tmux = Some("cctop-zsh".into());
assert!(!split.detach());
assert_eq!(split.panes.len(), 2);
let _ = child.kill();
let _ = child.wait();
let _ = crate::shim::socket_path(pid).map(std::fs::remove_file);
}
#[test]
fn a_command_is_labelled_by_its_name_not_its_path() {
assert_eq!(label_of(&["/usr/bin/claude".into()]), "claude");
assert_eq!(
label_of(&["codex".into(), "--full-auto".into()]),
"codex --full-auto"
);
}
}