use std::path::Path;
use std::time::{Duration, Instant};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::theme::Hue;
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,
Done,
NeedsInput,
}
pub struct Pane {
hosted: Option<crate::shim::Hosted>,
pub rmux: Option<String>,
asked: Option<std::sync::mpsc::Receiver<Option<u32>>>,
pub resumed: Option<String>,
pub profile: Option<String>,
pub pid: u32,
agent: Option<u32>,
asked_at: Option<Instant>,
pub label: String,
is_agent: bool,
pub view: crate::attach::Attach,
drew_at: Instant,
read: Option<(ScreenPlace, ScreenReading)>,
pub fit: Fit,
}
const FIT_QUIET: std::time::Duration = std::time::Duration::from_millis(1500);
const FIT_NUDGE: std::time::Duration = std::time::Duration::from_millis(150);
const FIT_VERIFY: std::time::Duration = std::time::Duration::from_millis(1000);
#[derive(Default)]
pub struct Fit {
pub window: Option<(u16, u16)>,
nudged_at: Option<Instant>,
typed_at: Option<Instant>,
asking: Option<std::sync::mpsc::Receiver<Option<(u16, u16)>>>,
verify_at: Option<Instant>,
verifying: bool,
gave_up: Option<(u16, u16)>,
}
impl Fit {
fn matches(window: (u16, u16), cols: u16, rows: u16) -> bool {
window.0 == cols && (window.1 == rows || window.1 + 1 == rows)
}
}
type ScreenPlace = (Instant, u16, usize);
type ScreenReading = (Option<crate::hook::Signal>, Option<String>, bool);
impl Pane {
#[cfg(test)]
pub(crate) fn for_test(label: &str) -> Pane {
Pane {
hosted: None,
rmux: None,
resumed: None,
profile: None,
pid: 4321,
agent: None,
asked_at: None,
asked: None,
label: label.into(),
is_agent: true,
view: crate::attach::Attach::for_test(),
drew_at: Instant::now(),
read: None,
fit: Fit::default(),
}
}
pub fn note_input(&mut self, force: bool) {
let back = force || self.fit.typed_at.is_none_or(|t| t.elapsed() >= FIT_QUIET);
self.fit.typed_at = Some(Instant::now());
if !back || self.fitting() || self.fit.gave_up == Some(self.view.size) {
return;
}
self.ask_window(false);
}
fn ask_window(&mut self, verifying: bool) {
let Some(name) = self.rmux.clone() else {
return;
};
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(crate::rmux::window_size(&name));
});
self.fit.asking = Some(rx);
self.fit.verifying = verifying;
}
pub fn fit_request(&mut self, cols: u16, rows: u16) -> (u16, u16) {
let (granted_cols, granted_rows) = self.view.size;
if self.fit.gave_up.is_some_and(|size| size != self.view.size) {
self.fit.gave_up = None;
}
if let Some(rx) = &self.fit.asking {
match rx.try_recv() {
Ok(window) => {
self.fit.asking = None;
let verifying = std::mem::take(&mut self.fit.verifying);
if let Some(window) = window {
self.fit.window = Some(window);
let held = Fit::matches(window, granted_cols, granted_rows);
if verifying {
if !held {
self.fit.gave_up = Some(self.view.size);
}
} else if !held && cols > 1 && self.fit.gave_up.is_none() {
self.fit.nudged_at = Some(Instant::now());
}
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.fit.asking = None,
Err(std::sync::mpsc::TryRecvError::Empty) => {}
}
}
if let Some(at) = self.fit.nudged_at {
if at.elapsed() < FIT_NUDGE {
return (cols - 1, rows);
}
self.fit.nudged_at = None;
self.fit.verify_at = Some(Instant::now() + FIT_VERIFY);
}
if self.fit.verify_at.is_some_and(|at| Instant::now() >= at) && self.fit.asking.is_none() {
self.fit.verify_at = None;
self.ask_window(true);
}
(cols, rows)
}
pub fn fitting(&self) -> bool {
self.fit.asking.is_some() || self.fit.nudged_at.is_some() || self.fit.verify_at.is_some()
}
pub fn read_screen(&mut self) -> Option<crate::peek::Screened> {
if !screenable(self.harness()) {
return None;
}
let screen = self.view.parser.screen();
let (_, cols) = screen.size();
let at = (self.drew_at, cols, screen.scrollback());
let read = match self.read.as_ref() {
Some((where_, found)) if *where_ == at => found.clone(),
_ => {
let rows: Vec<String> = screen.rows(0, cols).collect();
let signal = screen_state(self.harness(), &rows);
let asking = signal == Some(crate::hook::Signal::NeedsInput);
let ask = asking.then(|| screen_ask(self.harness(), &rows)).flatten();
let question = asking && screen_question(self.harness(), &rows);
let found = (signal, ask, question);
self.read = Some((at, found.clone()));
found
}
};
let (signal, ask, question) = read;
let signal = signal.or_else(|| self.idle().then_some(crate::hook::Signal::Idle))?;
Some(crate::peek::Screened {
signal,
ask,
question,
})
}
fn idle(&self) -> bool {
self.drew_at.elapsed() >= QUIET_IS_IDLE
}
fn forget_screen(&mut self) {
self.read = None;
}
fn rang(&self) -> bool {
self.view.rang().is_some()
}
pub fn answer_bell(&mut self) -> Option<String> {
self.view.answer()
}
pub fn agent(&self) -> u32 {
self.agent.unwrap_or(self.pid)
}
fn find_agent(&mut self) {
if self.agent.is_some() || self.rmux.is_none() {
return;
}
if let Some(rx) = &mut self.asked {
match rx.try_recv() {
Ok(found) => {
self.asked = None;
let name = self.rmux.clone().expect("checked above");
self.settle_agent(&name, found);
return;
}
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.asked = None,
}
}
if self
.asked_at
.is_some_and(|at| at.elapsed() < FIND_AGENT_EVERY)
{
return;
}
self.asked_at = Some(Instant::now());
let name = self.rmux.clone().expect("checked above");
let (tx, rx) = std::sync::mpsc::channel();
self.asked = Some(rx);
std::thread::spawn(move || {
let _ = tx.send(crate::rmux::agent_pid(&name));
});
}
fn settle_agent(&mut self, name: &str, found: Option<u32>) {
self.agent = found;
if self.agent.is_some() {
crate::rmux::quiet(name);
crate::rmux::mouse(name);
crate::rmux::set_label(name, &self.label);
if let Some(profile) = &self.profile {
crate::rmux::set_profile(name, profile);
}
}
}
pub fn outlives_cctop(&self) -> bool {
self.rmux.is_some()
}
pub fn owns_agent(&self) -> bool {
self.hosted.is_some() || self.rmux.is_some()
}
pub fn harness(&self) -> &str {
harness_of(self.rmux.as_deref().unwrap_or(self.label.as_str()))
}
pub fn is_agent(&self) -> bool {
self.is_agent
}
pub fn translate_key(&self, key: KeyEvent) -> KeyEvent {
match key.code {
KeyCode::Home | KeyCode::End
if key.modifiers.is_empty() && self.harness() == "claude" =>
{
KeyEvent::new(key.code, KeyModifiers::CONTROL)
}
_ => key,
}
}
}
#[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 rmux = match &own {
Own::Tmux(name) | Own::TmuxExisting(name) => Some(name.clone()),
Own::Cctop => None,
};
let spawn = match &own {
Own::Tmux(name) => {
crate::rmux::prepare(argv, name, cwd);
crate::rmux::attach_or_create(argv, name, cwd)
}
Own::TmuxExisting(name) => crate::rmux::attach(name),
Own::Cctop => argv.to_vec(),
};
let hosted = crate::shim::host(&spawn, cwd, super::render::pane_size())?;
let mut view = crate::attach::attach(hosted.pid).ok_or_else(|| {
anyhow::anyhow!(
"{} started but its terminal could not be opened",
label_of(argv)
)
})?;
if rmux.is_some() {
view.assume_extended_keys();
}
Ok(Pane {
pid: hosted.pid,
label: label_of(argv),
is_agent: starts_an_agent(argv),
view,
rmux,
resumed: None,
profile: None,
agent: None,
asked_at: None,
asked: None,
hosted: Some(hosted),
drew_at: Instant::now(),
read: None,
fit: Fit::default(),
})
}
pub fn view_of(pid: u32, label: String) -> Option<Pane> {
Some(Pane {
hosted: None,
rmux: None,
resumed: None,
profile: None,
pid,
agent: Some(pid),
asked_at: None,
asked: None,
label,
is_agent: true,
view: crate::attach::attach(pid)?,
drew_at: Instant::now(),
read: None,
fit: Fit::default(),
})
}
pub fn kill_agent(&self) -> Result<(), String> {
match &self.rmux {
Some(name) => crate::rmux::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>,
pub profile: Option<String>,
pub state: Option<crate::rmux::State>,
}
impl Shared {
pub fn of(agent: &crate::rmux::Running) -> Shared {
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,
profile: agent.profile.clone(),
state: agent.state,
}
}
pub fn is_agent(&self) -> bool {
let harness = harness_of(&self.name);
crate::alias::AGENTS
.split_whitespace()
.any(|agent| agent == harness)
}
fn recorded(&self) -> Option<crate::hook::Signal> {
let state = self.state?;
state
.is_current(crate::rmux::now_secs())
.then_some(state.signal)
}
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>,
pub extra: Vec<Shared>,
pub color: Option<Hue>,
pub restarted: Option<Instant>,
zoom: bool,
}
impl Tab {
pub fn new(pane: Pane) -> Tab {
Tab {
panes: vec![pane],
focus: 0,
stacked: false,
shared: None,
extra: Vec::new(),
color: None,
restarted: None,
zoom: false,
}
}
#[cfg(test)]
pub fn for_agent(agent: &crate::rmux::Running) -> Tab {
Tab::split_of(agent, &[], agent.axis())
}
pub fn split_of(
leader: &crate::rmux::Running,
others: &[&crate::rmux::Running],
axis: crate::rmux::Axis,
) -> Tab {
Tab {
panes: Vec::new(),
focus: 0,
stacked: axis == crate::rmux::Axis::Stacked,
shared: Some(Shared::of(leader)),
extra: others.iter().map(|agent| Shared::of(agent)).collect(),
color: leader.color.as_deref().and_then(Hue::from_name),
restarted: None,
zoom: false,
}
}
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.rmux.as_deref())
.chain(self.shared.iter().map(|s| s.name.as_str()))
.chain(self.extra.iter().map(|s| s.name.as_str()))
}
pub fn attach(&mut self) -> anyhow::Result<()> {
let Some(shared) = self.shared.clone() else {
return Ok(());
};
let mut panes = vec![Self::attach_one(&shared)?];
for extra in &self.extra {
panes.push(Self::attach_one(extra)?);
}
self.panes = panes;
self.focus = 0;
self.shared = None;
self.extra.clear();
Ok(())
}
fn attach_one(shared: &Shared) -> anyhow::Result<Pane> {
let argv = [shared.label.clone()];
let mut pane = Pane::launch(&argv, None, Own::TmuxExisting(shared.name.clone()))?;
pane.label = shared.label.clone();
pane.profile = shared.profile.clone();
Ok(pane)
}
pub fn detach(&mut self) -> bool {
if self.panes.is_empty() {
return false;
}
if self.panes.iter().any(|pane| pane.view.recording()) {
return false;
}
if self.panes.iter().any(|pane| pane.rmux.is_none()) {
return false;
}
let panes = std::mem::take(&mut self.panes);
let mut shared = Vec::with_capacity(panes.len());
for pane in &panes {
shared.push(Shared {
name: pane.rmux.clone().expect("checked above"),
label: pane.label.clone(),
pid: Some(pane.agent()),
activity: None,
profile: pane.profile.clone(),
state: None,
});
}
self.shared = shared.first().cloned();
self.extra = shared.into_iter().skip(1).collect();
self.focus = 0;
true
}
pub fn rename(&mut self, name: String) {
if let Some(pane) = self.panes.first_mut() {
if let Some(session) = pane.rmux.as_deref() {
crate::rmux::set_label(session, &name);
}
pane.label = name;
pane.forget_screen();
} else if let Some(shared) = self.shared.as_mut() {
crate::rmux::set_label(&shared.name, &name);
shared.label = name;
}
}
pub fn recolor(&mut self, color: Option<Hue>) {
for session in self.sessions() {
crate::rmux::set_color(session, color.map(Hue::name).unwrap_or(""));
}
self.color = color;
}
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 recording(&self) -> bool {
self.panes.iter().any(|pane| pane.view.recording())
}
pub fn focused_mut(&mut self) -> Option<&mut Pane> {
self.panes.get_mut(self.focus)
}
pub fn zoomed(&self) -> bool {
self.zoom && self.panes.len() > 1
}
pub fn toggle_zoom(&mut self) -> Option<bool> {
if self.panes.len() < 2 {
self.zoom = false;
return None;
}
self.zoom = !self.zoom;
Some(self.zoom)
}
pub fn split(&mut self, pane: Pane, stacked: bool) {
self.stacked = stacked;
self.panes.push(pane);
self.focus = self.panes.len() - 1;
self.zoom = false;
self.record_shape();
}
pub fn record_shape(&self) {
let names: Vec<&str> = self.sessions().collect();
let Some(leader) = names.first().copied() else {
return;
};
let axis = match self.stacked {
true => crate::rmux::Axis::Stacked,
false => crate::rmux::Axis::Side,
};
for (index, name) in names.iter().enumerate() {
if names.len() < 2 {
crate::rmux::set_tab(name, "");
} else {
crate::rmux::set_tab(name, leader);
crate::rmux::set_pane(name, index);
}
}
if names.len() > 1 {
crate::rmux::set_axis(&names, axis);
}
}
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 {
let reported = shared.pid.and_then(known).or_else(|| shared.recorded());
return match reported {
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::Idle) if pane.rang() => Some(Attention::Idle),
_ if pane.rang() => Some(Attention::NeedsInput),
_ if !pane.is_agent => None,
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 unwatched_agents(&self, focused: bool) -> Vec<u32> {
if let Some(shared) = &self.shared {
return shared.pid.into_iter().collect();
}
self.panes
.iter()
.enumerate()
.filter(|(i, pane)| pane.is_agent && !(focused && *i == self.focus))
.map(|(_, pane)| pane.agent())
.collect()
}
pub fn reap(&mut self, saved: &mut Vec<(std::path::PathBuf, std::io::Result<()>)>) -> bool {
self.panes.retain_mut(|pane| {
if !pane.finished() {
return true;
}
saved.extend(pane.view.stop_recording());
false
});
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(Box<crate::rmux::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::rmux::running()
.into_iter()
.filter(|agent| !open.contains(&agent.name))
.map(|agent| Choice::Waiting(Box::new(agent)))
.chain(harnesses().into_iter().map(Choice::Start))
.collect()
}
pub fn label_of(argv: &[String]) -> String {
crate::config::without_launch_prefix(argv)
.iter()
.map(|arg| arg.rsplit('/').next().unwrap_or(arg))
.collect::<Vec<_>>()
.join(" ")
}
struct Footer {
harness: &'static str,
lines: usize,
asking: &'static [&'static [&'static str]],
working: &'static [&'static [&'static str]],
}
const FOOTERS: &[Footer] = &[
Footer {
harness: "claude",
lines: 2,
asking: &[&["esc to cancel"]],
working: &[&["esc to interrupt"]],
},
Footer {
harness: "codex",
lines: 10,
asking: &[
&["press enter to confirm or esc to cancel"],
&["would you like to run the following command?"],
&["enter confirm · esc skip"],
&["press enter to confirm or esc to go back"],
&["update now", "skip until next version"],
&["enter to submit answer"],
&["enter to submit all"],
&["allow command?"],
&["do you trust the contents of this directory?"],
&["[y/n]"],
&["yes (y)"],
],
working: &[&[" to interrupt)"]],
},
Footer {
harness: "gemini",
lines: 10,
asking: &[
&["apply this change"],
&["allow execution"],
&["waiting for user confirmation"],
&["do you want to proceed"],
],
working: &[&["esc to cancel"]],
},
Footer {
harness: "opencode",
lines: 10,
asking: &[
&["△ permission required"],
&["esc dismiss", "enter confirm"],
&["esc dismiss", "enter submit"],
],
working: &[
&["esc to interrupt"],
&["ctrl+c to interrupt"],
&["esc interrupt"],
&["esc again to interrupt"],
],
},
Footer {
harness: "cursor",
lines: 8,
asking: &[
&["write to this file?", "proceed (y)"],
&["run this command?"],
&["skip (esc or n)"],
&["(y) (enter)"],
&["keep (n)"],
],
working: &[&["ctrl+c to stop"]],
},
Footer {
harness: "devin",
lines: 8,
asking: &[
&["do you trust the authors of this directory?"],
&["approve once", "esc cancel"],
],
working: &[&["esc to interrupt"], &["guide devin while it works"]],
},
Footer {
harness: "droid",
lines: 8,
asking: &[
&["enter to select", "esc to cancel"],
&["enter select", "esc cancel"],
],
working: &[&["esc to stop"]],
},
Footer {
harness: "pi",
lines: 12,
asking: &[],
working: &[&["working..."]],
},
];
pub(crate) fn screenable(harness: &str) -> bool {
FOOTERS.iter().any(|f| f.harness == harness)
}
pub(crate) fn screen_state(harness: &str, rows: &[String]) -> Option<crate::hook::Signal> {
use crate::hook::Signal;
let footer = FOOTERS.iter().find(|f| f.harness == harness)?;
let bottom: Vec<String> = rows
.iter()
.map(|row| row.trim())
.filter(|row| !row.is_empty())
.rev()
.take(footer.lines)
.map(str::to_lowercase)
.collect();
let text = bottom.join("\n");
let any = |rules: &[&[&str]]| rules.iter().any(|all| all.iter().all(|p| text.contains(p)));
if any(footer.asking) {
Some(Signal::NeedsInput)
} else if any(footer.working) {
Some(Signal::Busy)
} else {
None
}
}
pub(crate) fn screen_question(harness: &str, rows: &[String]) -> bool {
if harness != "claude" {
return false;
}
let text: String = rows
.iter()
.map(|row| row.trim())
.filter(|row| !row.is_empty())
.rev()
.take(30)
.map(str::to_lowercase)
.collect::<Vec<_>>()
.join("\n");
let question = ["chat about this", "type something.", "enter to select"]
.iter()
.any(|p| text.contains(p));
let permission = ["do you want", "tab to amend"]
.iter()
.any(|p| text.contains(p));
question && !permission
}
pub(crate) fn screen_ask(harness: &str, rows: &[String]) -> Option<String> {
const ASK_ROWS: usize = 15;
let bottom: Vec<String> = rows
.iter()
.map(|row| row.trim().to_string())
.filter(|row| !row.is_empty())
.rev()
.take(ASK_ROWS)
.collect();
let found = match harness {
"codex" => bottom.iter().find_map(|row| {
row.strip_prefix('$')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
})?,
"claude" if screen_question(harness, rows) => {
let top = bottom
.iter()
.position(|row| row.starts_with('❯') || row.starts_with('›'))?;
bottom[top + 1..]
.iter()
.take(4)
.map(|row| unbox(row))
.find(|row| row.ends_with('?'))?
}
"claude" => {
let top = bottom
.iter()
.position(|row| row.starts_with('❯') || row.starts_with('›'))?;
let mut detail: Vec<String> = bottom[top + 1..]
.iter()
.map(|row| unbox(row))
.filter(|row| !row.is_empty())
.take(3)
.collect();
detail.reverse();
match detail.is_empty() {
true => return None,
false => detail.join(" — "),
}
}
_ => return None,
};
Some(crate::util::truncate(&found, crate::hook::MAX_ASK))
}
fn unbox(row: &str) -> String {
row.trim_matches(|c: char| "╭╮╰╯─│ ".contains(c))
.trim()
.to_string()
}
pub(crate) fn harness_of(name: &str) -> &str {
name.strip_prefix("cctop-")
.unwrap_or(name)
.split(['-', ' ', '·'])
.next()
.unwrap_or_default()
}
fn starts_an_agent(argv: &[String]) -> bool {
let label = label_of(argv);
let Some(command) = label.split_whitespace().next() else {
return false;
};
crate::alias::AGENTS
.split_whitespace()
.any(|agent| agent == command)
}
#[cfg(test)]
mod tests {
#[test]
fn a_question_with_choices_is_told_from_a_permission_prompt() {
let rows = |text: &str| -> Vec<String> { text.lines().map(String::from).collect() };
let question = rows(
"\
2. What would ship. None of this is committed, and the working tree holds other work.
──────────────────────────────────────
☐ Deploy
How should the deploy step (between migration 1 and the rest) happen?
❯ 1. You deploy, I run SQL (Recommended)
I run step 1 now and tell you; you deploy the code your usual way.
2. I restart the worker
3. Hold everything
4. Type something.
──────────────────────────────────────
5. Chat about this
Enter to select · ↑/↓ to navigate · Esc to cancel",
);
assert_eq!(
super::screen_state("claude", &question),
Some(crate::hook::Signal::NeedsInput)
);
assert!(super::screen_question("claude", &question));
assert_eq!(
super::screen_ask("claude", &question).as_deref(),
Some("How should the deploy step (between migration 1 and the rest) happen?"),
"the question's own words, never the agent's message above it"
);
let permission = rows(
"\
Bash command
rm -rf build
Do you want to proceed?
❯ 1. Yes
2. Yes, and always allow access to this folder
3. No
Esc to cancel · Tab to amend",
);
assert!(!super::screen_question("claude", &permission));
assert!(
!super::screen_question("codex", &question),
"claude's menus only"
);
}
#[test]
fn a_window_counts_as_this_panes_when_only_a_status_line_differs() {
assert!(Fit::matches((120, 40), 120, 40));
assert!(Fit::matches((120, 39), 120, 40));
assert!(!Fit::matches((80, 24), 120, 40));
assert!(!Fit::matches((120, 38), 120, 40));
}
#[test]
fn a_mismatched_window_is_taken_back_with_one_narrow_frame_and_then_the_real_size() {
let mut pane = Pane::for_test("agent");
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Some((60, 15))).expect("send");
pane.fit.asking = Some(rx);
assert_eq!(
pane.fit_request(80, 24),
(79, 24),
"the nudge narrows by one column"
);
assert!(pane.fitting());
assert_eq!(
pane.fit_request(80, 24),
(79, 24),
"and holds it until rmux has seen it"
);
std::thread::sleep(FIT_NUDGE + std::time::Duration::from_millis(20));
assert_eq!(pane.fit_request(80, 24), (80, 24));
assert!(pane.fit.verify_at.is_some());
}
#[test]
fn a_nudge_that_did_not_take_is_not_tried_again_at_the_same_size() {
let mut pane = Pane::for_test("agent");
let size = pane.view.size;
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Some((size.0.saturating_sub(10), size.1)))
.expect("send");
pane.fit.asking = Some(rx);
pane.fit.verifying = true;
pane.fit_request(size.0, size.1);
assert_eq!(pane.fit.gave_up, Some(size));
pane.fit.typed_at = None;
pane.note_input(false);
assert!(!pane.fitting());
}
#[test]
fn a_window_that_is_already_this_panes_is_left_alone() {
let mut pane = Pane::for_test("agent");
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Some((80, 24))).expect("send");
pane.fit.asking = Some(rx);
assert_eq!(pane.fit_request(80, 24), (80, 24));
assert!(!pane.fitting());
}
#[test]
fn each_harness_is_read_by_its_own_words() {
use crate::hook::Signal::{Busy, NeedsInput};
let read = |harness: &str, text: &str| {
let rows: Vec<String> = text.lines().map(String::from).collect();
super::screen_state(harness, &rows)
};
let cases = [
(
"codex",
"Allow command?\n Yes (y) No (n)",
Some(NeedsInput),
),
("gemini", "⠏ Reading files (esc to cancel, 3s)", Some(Busy)),
(
"gemini",
"│ Allow execution of: 'touch x'?\n│ ● 1. Yes",
Some(NeedsInput),
),
("opencode", "■■■■⬝⬝⬝⬝ esc interrupt", Some(Busy)),
(
"opencode",
"△ Permission required\n bash touch x",
Some(NeedsInput),
),
("cursor", "⬢ Generating ctrl+c to stop", Some(Busy)),
(
"cursor",
"Run this command?\n → Run (once) (y)\n Skip (esc or n)",
Some(NeedsInput),
),
("devin", "Running tools · esc to interrupt", Some(Busy)),
(
"devin",
"Approve once\nSelect · Confirm · Esc cancel",
Some(NeedsInput),
),
("devin", "❭ \n Context: 12% used", None),
("droid", "⠋ Thinking… (esc to stop)", Some(Busy)),
(
"droid",
"> Yes, allow\n No, cancel\nEnter to select · ↑↓ to navigate · Esc to cancel",
Some(NeedsInput),
),
("pi", "── ⠋ Working... ──", Some(Busy)),
("pi", "> hello", None),
("aider", "esc to interrupt", None),
];
for (harness, text, want) in cases {
assert_eq!(read(harness, text), want, "{harness}: {text:?}");
}
}
#[test]
fn codex_says_what_it_is_doing_above_its_prompt() {
use crate::hook::Signal;
let screen = |text: &str| {
let rows: Vec<String> = text.lines().map(String::from).collect();
super::screen_state("codex", &rows)
};
let busy = "\
› Run the shell command: touch hello.txt
• I’ll create hello.txt in the current workspace.
◦ Working (5s • esc to interrupt)
› Ask Codex to do anything
GPT-5.6-Terra high · ~/play · No changes
← for agents · ? for shortcuts";
assert_eq!(screen(busy), Some(Signal::Busy));
let between = "\
› Run the shell command: touch hello.txt
• I’ll create hello.txt in the current workspace.
› Ask Codex to do anything
GPT-5.6-Terra high · ~/play · No changes
← for agents · ? for shortcuts";
assert_eq!(screen(between), None, "mid-turn, and no phrase says so");
let approval = "\
• Running touch ../outside.txt
Would you like to run the following command?
$ touch ../outside.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `touch ../outside.txt` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel";
assert_eq!(screen(approval), Some(Signal::NeedsInput));
let hooks = "\
Hooks need review
› 1. Review hooks
2. Trust all and continue
3. Continue without trusting (hooks won't run)
enter confirm · esc skip";
assert_eq!(screen(hooks), Some(Signal::NeedsInput));
}
#[test]
fn claude_says_what_it_is_doing_in_its_footer() {
use crate::hook::Signal;
let screen = |text: &str| {
let rows: Vec<String> = text.lines().map(String::from).collect();
super::screen_state("claude", &rows)
};
let idle = "\
────────────────────────────
❯ Try \"create a util logging.py that...\"
────────────────────────────
⚠ Transcript saving is off
⏸ manual mode on · ? for shortcuts
";
assert_eq!(screen(idle), None, "idle is stillness, not a phrase");
let busy = "\
✢ Quantumizing… (2s · thinking)
────────────────────────────
❯
────────────────────────────
⚠ Transcript saving is off
⏸ manual mode on · esc to interrupt";
assert_eq!(screen(busy), Some(Signal::Busy));
let permission = "\
Do you want to proceed?
❯ 1. Yes
2. Yes, and always allow access to this folder
3. No
Esc to cancel · Tab to amend
";
assert_eq!(screen(permission), Some(Signal::NeedsInput));
let question = "\
❯ 1. Tea
2. Coffee
────────────────────────────
4. Chat about this
Enter to select · ↑/↓ to navigate · Esc to cancel";
assert_eq!(screen(question), Some(Signal::NeedsInput));
let quoted = "\
⏺ The footer reads `Esc to cancel` on a prompt and `esc to interrupt` mid-turn.
That is how the state is told.
❯ fix the parser
⏸ manual mode on";
assert_eq!(screen(quoted), None, "a reply is not the footer");
assert_eq!(screen(""), None);
}
#[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::launch::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");
let argv: Vec<String> = ["env", "CODEX_HOME=/home/x/.codex-work", "codex"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(label_of(&argv), "codex");
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::rmux::Running {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
crate::rmux::Running {
name: name.to_string(),
pid: Some(4321),
cwd: None,
attached: false,
activity: Some(now.saturating_sub(ago)),
label: label.map(str::to_string),
profile: None,
order: None,
state: None,
color: None,
tab: None,
pane: None,
axis: None,
window: None,
}
}
#[test]
fn a_tab_finds_a_question_that_was_asked_before_this_cctop_started() {
let recorded = |signal, ago: u64| {
let mut agent = session("cctop-x", Some("claude"), 0);
agent.state = Some(crate::rmux::State {
signal,
at: crate::rmux::now_secs().saturating_sub(ago),
});
Tab::for_agent(&agent)
};
let unheard = &|_| None;
assert_eq!(
recorded(crate::hook::Signal::NeedsInput, 300).attention(false, unheard),
Some(Attention::NeedsInput),
"the session remembered what this cctop never heard"
);
assert_eq!(
recorded(crate::hook::Signal::Idle, 300).attention(false, unheard),
Some(Attention::Idle),
"and a finished turn is still the quieter of the two"
);
assert_eq!(
recorded(crate::hook::Signal::Busy, 5).attention(false, unheard),
None,
"an agent recorded as working is left alone"
);
assert_eq!(
recorded(crate::hook::Signal::Busy, 3 * 3_600).attention(false, unheard),
None,
);
let stale = recorded(crate::hook::Signal::NeedsInput, 300);
assert_eq!(
stale.attention(false, &|_| Some(crate::hook::Signal::Busy)),
None,
"this cctop heard the agent go back to work"
);
}
fn pane(label: &str, is_agent: bool, ago: u64) -> Pane {
Pane {
hosted: None,
rmux: None,
resumed: None,
profile: None,
pid: 4321,
agent: None,
asked_at: None,
asked: None,
label: label.into(),
is_agent,
view: crate::attach::Attach::for_test(),
drew_at: Instant::now() - Duration::from_secs(ago),
read: None,
fit: Fit::default(),
}
}
fn ringing_tab(bell: &[u8]) -> Tab {
let mut pane = pane("claude", true, 0);
pane.view.parser.process(bell);
Tab::new(pane)
}
#[test]
fn a_tab_that_is_not_an_agent_reports_no_turn() {
let unreported = &|_: u32| None;
assert_eq!(
Tab::new(pane("zsh", false, 5)).attention(false, unreported),
None,
"a quiet shell is a shell, not a finished turn"
);
assert_eq!(
Tab::new(pane("claude", true, 5)).attention(false, unreported),
Some(Attention::Idle),
"and the same silence from an agent still means what it did"
);
assert_eq!(
Tab::new(pane("zsh", false, 5))
.attention(false, &|_| Some(crate::hook::Signal::Acting)),
None
);
let mut rang = pane("zsh", false, 5);
rang.view.parser.process(b"\x07");
assert_eq!(
Tab::new(rang).attention(false, unreported),
Some(Attention::NeedsInput)
);
}
#[test]
fn an_agent_is_recognised_through_the_way_it_was_launched() {
let argv = |s: &str| -> Vec<String> { s.split(' ').map(str::to_string).collect() };
assert!(starts_an_agent(&argv("claude")));
assert!(starts_an_agent(&argv("claude --resume 4ebf1ab4")));
assert!(starts_an_agent(&argv("/usr/local/bin/codex")));
assert!(starts_an_agent(&argv(
"env CLAUDE_CONFIG_DIR=/tmp/x claude"
)));
assert!(!starts_an_agent(&argv("/bin/zsh")));
assert!(!starts_an_agent(&argv("")));
}
#[test]
fn an_agent_that_rang_outranks_looking_busy() {
let ringing = ringing_tab(b"\x07");
assert_eq!(
ringing.attention(false, &|_| Some(crate::hook::Signal::Busy)),
Some(Attention::NeedsInput),
);
assert_eq!(
ringing.attention(false, &|_| Some(crate::hook::Signal::Idle)),
Some(Attention::Idle),
"the idle nudge was read as a question"
);
assert_eq!(
ringing.attention(false, &|_| Some(crate::hook::Signal::Acting)),
Some(Attention::NeedsInput),
);
let quiet = ringing_tab(b"thinking");
assert_eq!(
quiet.attention(false, &|_| Some(crate::hook::Signal::Busy)),
None,
);
assert_eq!(
ringing.attention(true, &|_| Some(crate::hook::Signal::Busy)),
None,
);
}
#[test]
fn a_reading_follows_the_screen_it_was_made_from() {
let busy = " \u{280f} Reading files (esc to interrupt)".as_bytes();
let mut pane = pane("claude", true, 0);
pane.view.parser.process(busy);
assert_eq!(
pane.read_screen().map(|read| read.signal),
Some(crate::hook::Signal::Busy),
"a working hint on the footer"
);
assert_eq!(
pane.read_screen().map(|read| read.signal),
Some(crate::hook::Signal::Busy)
);
pane.view
.parser
.process(" Esc to cancel \u{b7} Tab to amend".as_bytes());
pane.drew_at = Instant::now();
assert_eq!(
pane.read_screen().map(|read| read.signal),
Some(crate::hook::Signal::NeedsInput),
"the footer changed under a pane that drew"
);
}
#[test]
fn a_rename_makes_the_pane_read_again() {
let mut pane = pane("claude", true, 0);
pane.view
.parser
.process(" \u{280f} Reading files (esc to interrupt)".as_bytes());
assert_eq!(
pane.read_screen().map(|read| read.signal),
Some(crate::hook::Signal::Busy)
);
let mut tab = Tab::new(pane);
tab.panes[0].forget_screen();
tab.rename("zsh".into());
assert_eq!(
tab.panes[0].read_screen().map(|read| read.signal),
None,
"read under the harness it is now named for"
);
}
#[test]
fn a_moved_screen_is_read_again() {
let working = " \u{280f} Reading files (esc to interrupt)";
let asking = " Esc to cancel \u{b7} Tab to amend";
let mut screen =
vt100::Parser::new_with_callbacks(4, 80, 5_000, crate::attach::Signals::default());
screen.process(
format!("{working}\r\n{working}\r\n{working}\r\n{working}\r\n{asking}\r\n{asking}")
.as_bytes(),
);
let mut pane = pane("claude", true, 0);
pane.view.parser = screen;
assert_eq!(
pane.read_screen().map(|read| read.signal),
Some(crate::hook::Signal::NeedsInput),
"the prompt is the last line on screen"
);
pane.view.parser.screen_mut().set_scrollback(2);
assert_eq!(
pane.read_screen().map(|read| read.signal),
Some(crate::hook::Signal::Busy),
"a screen scrolled onto other lines says what they say"
);
}
#[test]
fn a_shared_tab_is_called_what_the_cctop_that_started_it_called_it() {
let tab = Tab::for_agent(&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::for_agent(&session("cctop-claude-32cca860", None, 0));
assert_eq!(tab.title(), "claude-32cca860");
}
#[test]
fn the_account_a_pane_runs_as_survives_a_tab_switch() {
let mut running = session("cctop-claude-32cca860", Some("claude"), 0);
running.profile = Some("work".into());
let adopted = Tab::for_agent(&running);
assert_eq!(
adopted.shared.as_ref().and_then(|s| s.profile.as_deref()),
Some("work"),
);
let plain = Tab::for_agent(&session("cctop-claude-4e2b1c90", Some("claude"), 0));
assert_eq!(plain.shared.as_ref().and_then(|s| s.profile.clone()), None);
}
#[test]
fn an_unwatched_tab_still_says_when_its_agent_wants_you() {
let quiet = Tab::for_agent(&session("cctop-claude-a", None, 30));
let busy = Tab::for_agent(&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::for_agent(&session("cctop-claude-a", None, 0));
assert!(
!tab.reap(&mut Vec::new()),
"a shared tab was reaped for having no pane"
);
tab.shared = None;
assert!(tab.reap(&mut Vec::new()));
}
fn waiting(name: &str) -> Choice {
Choice::Waiting(Box::new(crate::rmux::Running {
pid: Some(4321),
cwd: Some(std::path::PathBuf::from("/home/x/proj")),
..crate::rmux::Running::unrecorded(name)
}))
}
#[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);
}
#[test]
fn a_rmux_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.rmux = 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
);
}
#[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 leaving_a_rmux_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.rmux = 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].rmux = Some("cctop-claude-abc".into());
split
.panes
.push(Pane::view_of(pid, "shell".into()).expect("attach"));
split.panes[1].rmux = Some("cctop-zsh".into());
assert!(split.detach());
assert_eq!(split.panes.len(), 0);
assert_eq!(
split.shared.as_ref().map(|s| s.name.as_str()),
Some("cctop-claude-abc")
);
assert_eq!(split.extra.len(), 1);
assert_eq!(
split.extra[0].name, "cctop-zsh",
"the second pane kept its place"
);
assert_eq!(
split.sessions().collect::<Vec<_>>(),
["cctop-claude-abc", "cctop-zsh"]
);
let mut mixed = Tab::new(Pane::view_of(pid, "claude".into()).expect("attach"));
mixed.panes[0].rmux = Some("cctop-claude-abc".into());
mixed
.panes
.push(Pane::view_of(pid, "local shell".into()).expect("attach"));
assert!(!mixed.detach());
assert_eq!(mixed.panes.len(), 2, "nothing was given up");
let _ = child.kill();
let _ = child.wait();
let _ = crate::shim::socket_path(pid).map(std::fs::remove_file);
}
#[test]
fn a_split_survives_being_put_down_and_picked_up() {
let (mut child, pid) = crate::shim::test_session(&["sh", "-c", "sleep 30"], (80, 24));
let mut tab = Tab::new(Pane::view_of(pid, "claude".into()).expect("attach"));
tab.panes[0].rmux = Some("cctop-claude-abc".into());
let mut second = Pane::view_of(pid, "shell".into()).expect("attach");
second.rmux = Some("cctop-zsh".into());
tab.split(second, true);
assert!(tab.detach(), "a split of rmux panes is now detachable");
assert_eq!(
tab.sessions().collect::<Vec<_>>(),
["cctop-claude-abc", "cctop-zsh"]
);
assert!(tab.stacked, "and it comes back divided the same way");
let _ = child.kill();
let _ = child.wait();
let _ = crate::shim::socket_path(pid).map(std::fs::remove_file);
}
#[test]
fn a_split_records_itself_onto_its_sessions() {
if !crate::rmux::available() {
eprintln!("skipping: rmux not installed");
return;
}
let _turn = crate::rmux::test_lock();
let leader = format!("cctop-leader-{}", std::process::id());
let other = format!("cctop-other-{}", std::process::id());
for name in [&leader, &other] {
let _ = crate::rmux::start_detached(&["sleep".into(), "30".into()], name, None);
}
let (mut child, pid) = crate::shim::test_session(&["sh", "-c", "sleep 30"], (80, 24));
let mut tab = Tab::new(Pane::view_of(pid, "claude".into()).expect("attach"));
tab.panes[0].rmux = Some(leader.clone());
let mut second = Pane::view_of(pid, "shell".into()).expect("attach");
second.rmux = Some(other.clone());
tab.split(second, false);
let read = |name: &str| crate::rmux::running().into_iter().find(|s| s.name == name);
assert_eq!(
read(&leader).as_ref().and_then(|s| s.tab.as_deref()),
Some(&leader[..])
);
assert_eq!(
read(&other).as_ref().and_then(|s| s.tab.as_deref()),
Some(&leader[..])
);
assert_eq!(read(&leader).as_ref().and_then(|s| s.pane), Some(0));
assert_eq!(read(&other).as_ref().and_then(|s| s.pane), Some(1));
assert_eq!(
read(&other).map(|s| s.axis()),
Some(crate::rmux::Axis::Side)
);
let mut alone = Tab::new(Pane::view_of(pid, "shell".into()).expect("attach"));
alone.panes[0].rmux = Some(other.clone());
alone.record_shape();
assert_eq!(read(&other).and_then(|s| s.tab), None);
let _ = crate::rmux::kill(&leader);
let _ = crate::rmux::kill(&other);
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"
);
}
#[test]
fn a_sessions_colour_paints_its_tab() {
let mut agent = session("cctop-claude-x", Some("claude"), 0);
agent.color = Some("violet".to_string());
assert_eq!(Tab::for_agent(&agent).color, Some(Hue::Violet));
agent.color = Some("chartreuse".to_string());
assert_eq!(
Tab::for_agent(&agent).color,
None,
"a newer cctop's hue was guessed at"
);
}
#[test]
fn a_tab_with_no_session_still_keeps_its_colour() {
let mut tab = Tab::new(pane("claude", true, 0));
tab.recolor(Some(Hue::Cyan));
assert_eq!(tab.color, Some(Hue::Cyan));
tab.recolor(None);
assert_eq!(tab.color, None);
}
#[test]
fn a_panes_harness_comes_from_its_session_before_its_label() {
let mut pane = pane("renamed work", true, 0);
assert_eq!(pane.harness(), "renamed");
pane.rmux = Some("cctop-claude-32cca860-b503".into());
assert_eq!(pane.harness(), "claude", "a rename hid the harness");
pane.rmux = Some("cctop-zsh".into());
assert_eq!(pane.harness(), "zsh");
}
#[test]
fn home_and_end_are_promoted_only_in_a_claude_pane() {
let claude = pane("claude", true, 0);
for code in [KeyCode::Home, KeyCode::End] {
let sent = claude.translate_key(KeyEvent::new(code, KeyModifiers::NONE));
assert_eq!(sent.code, code);
assert_eq!(sent.modifiers, KeyModifiers::CONTROL);
}
let shifted = claude.translate_key(KeyEvent::new(KeyCode::Home, KeyModifiers::SHIFT));
assert_eq!(shifted.modifiers, KeyModifiers::SHIFT);
let shell = pane("zsh", false, 0);
let sent = shell.translate_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
assert_eq!(sent.modifiers, KeyModifiers::NONE);
}
}