Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ Tracks child processes that have open ports. When a parent session dies but the
| Key | Action |
|-----|--------|
| `↑`/`↓` or `k`/`j` | Select session in list |
| `Enter` | Jump to session terminal (tmux only) |
| `Enter` | Jump to session terminal (cmux / tmux / iTerm2) |
| `x` | Kill selected session (SIGKILL) |
| `X` | Kill all orphan ports |
| `q` | Quit |
Expand Down Expand Up @@ -351,13 +351,35 @@ cargo clippy # Lint
- Remote/SSH monitoring
- Notifications/alerts

## tmux Integration

Session jump (`Enter`) only works when abtop runs inside tmux:
1. On startup, detect if `$TMUX` is set. If not, disable Enter key.
2. To map PID → tmux pane: `tmux list-panes -a -F '#{pane_pid} #{session_name}:#{window_index}.#{pane_index}'` then walk process tree to find which pane owns the agent PID.
3. Jump: `tmux select-pane -t {target}`
4. If mapping fails (PID not in any pane), show transient "pane not found" status message.
## Terminal Jump (`Enter`)

`Enter` focuses the terminal running the selected session's agent process.
The logic lives in `src/jump/` as a registry of `TerminalJumper` adapters
(one file per backend). `jumpers()` is the single ordered source of truth;
`resolve()` walks it and the first applicable adapter wins.

Each adapter returns a three-way `JumpAttempt`:
- `NotApplicable` — not this backend's terminal; try the next adapter.
- `Jumped` — focused successfully; stop.
- `Failed(msg)` — this backend owns the process but the focus command errored;
stop and surface `"<backend>: <msg>"` in the status line.

Order (most specific first), mutually exclusive by controlling tty:

1. **cmux** (`jump/cmux.rs`) — reads `CMUX_WORKSPACE_ID` (a UUID cmux exports
into every surface, inherited by the agent) from the process environment via
`ps eww`, then `cmux select-workspace --workspace <uuid>`.
2. **tmux** (`jump/tmux.rs`) — only when abtop itself runs inside tmux (`$TMUX`).
Maps PID → pane via `tmux list-panes -a -F '#{pane_pid} #{session_name}:#{window_index}.#{pane_index}'`
+ process-tree descent, then `switch-client` / `select-window` / `select-pane`.
PID in no pane → `NotApplicable` (lets another backend try).
3. **iTerm2** (`jump/iterm2.rs`) — resolves the PID's controlling tty (`ps -o tty=`),
then AppleScript selects the session whose `tty` matches and brings its
window/app to the front. First call triggers a one-time macOS Automation
permission prompt; until granted, `osascript` exits non-zero → `Failed`.

Parsing/registry logic is unit-tested in `jump/mod.rs`; the thin `ps`/`osascript`/
`tmux` I/O wrappers are verified manually.

## Privacy

Expand Down
103 changes: 6 additions & 97 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ fn sanitize_fallback(prompt: &str, max_len: usize) -> String {
/// Outcome of an Enter-key jump attempt. Distinct from `Option<String>` so
/// callers (notably `--exit-on-jump`) can tell a real tmux jump apart from
/// a no-op (outside tmux, or empty session list).
#[derive(Debug, PartialEq, Eq)]
pub enum JumpOutcome {
/// Actually switched to a tmux pane.
Jumped,
Expand Down Expand Up @@ -789,65 +790,16 @@ impl App {
self.should_quit = true;
}

/// Jump to the terminal running the selected session's Claude process.
/// In tmux: switch to the pane. Otherwise: no-op.
/// Jump to the terminal running the selected session's agent process.
/// Delegates to the terminal-jumper registry (cmux / tmux / iTerm2);
/// see [`crate::jump`]. No-op when nothing is selected or no backend
/// recognizes the process.
pub fn jump_to_session(&mut self) -> JumpOutcome {
if self.sessions.is_empty() {
return JumpOutcome::NoOp;
}
if std::env::var("TMUX").is_err() {
return JumpOutcome::NoOp;
}
let target_pid = self.sessions[self.selected].pid;
match self.jump_via_tmux(target_pid) {
None => JumpOutcome::Jumped,
Some(msg) => JumpOutcome::Failed(msg),
}
}

fn jump_via_tmux(&self, target_pid: u32) -> Option<String> {
let output = std::process::Command::new("tmux")
.args([
"list-panes",
"-a",
"-F",
"#{pane_pid} #{session_name}:#{window_index}.#{pane_index}",
])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);

for line in stdout.lines() {
let mut parts = line.splitn(2, ' ');
let pane_pid: u32 = match parts.next().and_then(|p| p.parse().ok()) {
Some(p) => p,
None => continue,
};
let pane_target = match parts.next() {
Some(t) => t,
None => continue,
};

if is_descendant_of(target_pid, pane_pid) {
// Switch tmux client to the target session (needed for cross-session jumps)
if let Some(session_name) = pane_target.split(':').next() {
let _ = std::process::Command::new("tmux")
.args(["switch-client", "-t", session_name])
.status();
}
if let Some(window) = pane_target.split('.').next() {
let _ = std::process::Command::new("tmux")
.args(["select-window", "-t", window])
.status();
}
let _ = std::process::Command::new("tmux")
.args(["select-pane", "-t", pane_target])
.status();
return None; // success
}
}

Some("pane not found".to_string())
crate::jump::run_jump(target_pid)
}

/// Get the display summary for a session: LLM summary > "..." if pending > raw prompt > "—"
Expand Down Expand Up @@ -1008,49 +960,6 @@ fn load_summary_cache() -> HashMap<String, String> {
}
}

/// Check if `target` PID is a descendant of `ancestor` PID by walking the process tree.
fn is_descendant_of(target: u32, ancestor: u32) -> bool {
if target == ancestor {
return true;
}
// Build a pid->ppid map from ps
let output = match std::process::Command::new("ps")
.args(["-eo", "pid,ppid"])
.output()
{
Ok(o) => o,
Err(_) => return false,
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut ppid_map: HashMap<u32, u32> = HashMap::new();
for line in stdout.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let (Ok(pid), Ok(ppid)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
ppid_map.insert(pid, ppid);
}
}
}
// Walk up from target to see if we reach ancestor
let mut current = target;
let mut depth = 0;
while depth < 50 {
if let Some(&parent) = ppid_map.get(&current) {
if parent == ancestor {
return true;
}
if parent == 0 || parent == 1 || parent == current {
return false;
}
current = parent;
depth += 1;
} else {
return false;
}
}
false
}

fn save_summary_cache(summaries: &HashMap<String, String>) {
let path = cache_path();
let _ = std::fs::create_dir_all(cache_dir());
Expand Down
30 changes: 30 additions & 0 deletions src/jump/cmux.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! cmux (manaflow-ai) backend.
//!
//! Each cmux surface exports `CMUX_WORKSPACE_ID` (a UUID), inherited by the
//! agent process. We read it from the process environment and focus the
//! workspace via the cmux CLI, which accepts the UUID directly as `--workspace`.

use super::{pid_env_var, JumpAttempt, TerminalJumper};
use std::process::Command;

pub struct CmuxJumper;

impl TerminalJumper for CmuxJumper {
fn name(&self) -> &'static str {
"cmux"
}

fn try_jump(&self, pid: u32) -> JumpAttempt {
let Some(workspace) = pid_env_var(pid, "CMUX_WORKSPACE_ID") else {
return JumpAttempt::NotApplicable;
};
match Command::new("cmux")
.args(["select-workspace", "--workspace", &workspace])
.output()
{
Ok(o) if o.status.success() => JumpAttempt::Jumped,
Ok(o) => JumpAttempt::Failed(format!("select-workspace exited {}", o.status)),
Err(e) => JumpAttempt::Failed(format!("cmux CLI not runnable ({e})")),
}
}
}
55 changes: 55 additions & 0 deletions src/jump/iterm2.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! iTerm2 backend.
//!
//! Match the process's controlling tty against an iTerm2 session, then focus
//! its pane/window via AppleScript. The tty discriminates iTerm2 from other
//! terminals (a tmux-hosted process has the tmux pty, not an iTerm2 session
//! tty), so this returns `NotApplicable` for non-iTerm2 hosts.
//!
//! Note: the first AppleScript call triggers a one-time macOS Automation
//! permission prompt; until granted, `osascript` exits non-zero and the
//! attempt surfaces as `Failed`.

use super::{interpret_osascript, pid_tty, JumpAttempt, TerminalJumper};
use std::process::Command;

pub struct ITerm2Jumper;

impl TerminalJumper for ITerm2Jumper {
fn name(&self) -> &'static str {
"iterm2"
}

fn try_jump(&self, pid: u32) -> JumpAttempt {
let Some(tty) = pid_tty(pid) else {
return JumpAttempt::NotApplicable;
};
let script = format!(
r#"if application "iTerm2" is running then
tell application "iTerm2"
repeat with w in windows
repeat with t in tabs of w
repeat with s in sessions of t
if tty of s is "{tty}" then
select s
select t
select w
activate
return "FOUND"
end if
end repeat
end repeat
end repeat
end tell
end if
return "NOTFOUND""#
);
match Command::new("osascript").arg("-e").arg(&script).output() {
Ok(o) if o.status.success() => interpret_osascript(&String::from_utf8_lossy(&o.stdout)),
Ok(o) => JumpAttempt::Failed(format!(
"osascript error: {}",
String::from_utf8_lossy(&o.stderr).trim()
)),
Err(e) => JumpAttempt::Failed(format!("osascript not runnable ({e})")),
}
}
}
Loading