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 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);
}
}
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,
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,
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(),
}
}
}
pub struct Tab {
pub panes: Vec<Pane>,
pub focus: usize,
pub stacked: bool,
}
impl Tab {
pub fn new(pane: Pane) -> Tab {
Tab {
panes: vec![pane],
focus: 0,
stacked: false,
}
}
pub fn title(&self) -> String {
match self.panes.len() {
0 | 1 => self
.panes
.first()
.map(|p| p.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> {
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(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()
}
}
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 {
argv.iter()
.map(|arg| arg.rsplit('/').next().unwrap_or(arg))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
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 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,
})
}
#[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);
}
}
#[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"
);
}
}