agent-berth 2.2.1

Monitor and resume coding agents
Documentation
use std::collections::HashSet;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};

use anyhow::{Context, Result, bail};

use crate::db;
use crate::git;
use crate::paths::Context as AppContext;
use crate::process;
use crate::status::Source;
use crate::store::ListedSession;
use crate::tmux::{self, Pane};

pub fn run(
    ctx: &AppContext,
    query: Option<String>,
    preview: bool,
    session: bool,
    dry_run: bool,
) -> Result<()> {
    let sessions = db::query_sessions(ctx, false, None)?;
    let candidates = collect(&sessions, session)?;
    if candidates.is_empty() {
        println!("No active agents in tmux panes.");
        return Ok(());
    }
    if dry_run {
        for candidate in &candidates {
            println!("{}", candidate.line());
        }
        return Ok(());
    }
    if !crate::paths::on_path("fzf") {
        bail!("fzf is required to select a session");
    }
    let lines: Vec<String> = candidates.iter().map(Candidate::line).collect();
    let Some(selected) = select(&lines, query.as_deref(), preview)? else {
        return Ok(());
    };
    let pane_id = selected.split('\t').next().unwrap_or("");
    let candidate = candidates
        .iter()
        .find(|candidate| candidate.pane.id == pane_id)
        .context("selected pane is no longer active")?;
    tmux::attach_pane(&candidate.pane)
}

fn collect(sessions: &[ListedSession], session: bool) -> Result<Vec<Candidate>> {
    let panes = tmux::list_panes(!session)?;
    let mut ordered: Vec<&ListedSession> = sessions.iter().collect();
    ordered.sort_by_key(|session| std::cmp::Reverse(session.last_report_ms));
    let mut seen = HashSet::new();
    let mut candidates = Vec::new();
    for session in ordered {
        if session.source != Source::Cli {
            continue;
        }
        let Some(pid) = session.pid else {
            continue;
        };
        if !process::pid_alive(pid) {
            continue;
        }
        let chain = process::ancestors(pid);
        let Some(pane) = pane_for_pid(&panes, &chain) else {
            continue;
        };
        if !seen.insert(pane.id.clone()) {
            continue;
        }
        candidates.push(Candidate::new(pane.clone(), session.clone()));
    }
    candidates.sort_by(|a, b| a.pane.id.cmp(&b.pane.id));
    Ok(candidates)
}

fn pane_for_pid<'a>(panes: &'a [Pane], chain: &[u32]) -> Option<&'a Pane> {
    panes.iter().find(|pane| chain.contains(&pane.pid))
}

/// Pane running the given session's agent process, if any.
pub(crate) fn pane_for_session<'a>(panes: &'a [Pane], session: &ListedSession) -> Option<&'a Pane> {
    if session.source != Source::Cli {
        return None;
    }
    let pid = session.pid?;
    if !process::pid_alive(pid) {
        return None;
    }
    pane_for_pid(panes, &process::ancestors(pid))
}

fn select(lines: &[String], query: Option<&str>, preview: bool) -> Result<Option<String>> {
    let mut command = Command::new("fzf");
    command.args(fzf_args(query, preview));
    let mut child = command
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .context("run fzf")?;
    if let Some(mut stdin) = child.stdin.take() {
        for line in lines {
            writeln!(stdin, "{line}")?;
        }
    }
    let output = child.wait_with_output().context("wait for fzf")?;
    if !output.status.success() {
        return Ok(None);
    }
    let text = String::from_utf8_lossy(&output.stdout);
    Ok(text.lines().next().map(str::to_string))
}

struct Candidate {
    pane: Pane,
    session: ListedSession,
    cwd: String,
    branch: Option<String>,
}

impl Candidate {
    fn new(pane: Pane, session: ListedSession) -> Self {
        let cwd = session
            .cwd
            .clone()
            .filter(|cwd| !cwd.is_empty())
            .unwrap_or_else(|| pane.path.clone());
        let branch = git::branch(Path::new(&cwd));
        Self {
            pane,
            session,
            cwd,
            branch,
        }
    }

    fn line(&self) -> String {
        format!(
            "{}\t{}\t{}\t{}\t{}\t{}\t{}",
            self.pane.id,
            self.session.provider,
            self.session.status.as_str(),
            self.session.title.as_deref().unwrap_or("-"),
            folder_name(&self.cwd).unwrap_or_else(|| "-".into()),
            self.branch.as_deref().unwrap_or("-"),
            self.session.session_id,
        )
    }
}

fn folder_name(path: &str) -> Option<String> {
    Path::new(path)
        .file_name()
        .map(|name| name.to_string_lossy().into_owned())
}

fn fzf_args(query: Option<&str>, preview: bool) -> Vec<String> {
    let preview_command = tmux::preview_command();
    let preview_window = if preview { "up:80%" } else { "up:80%:hidden" };
    let mut args: Vec<String> = [
        "--delimiter",
        "\t",
        "--with-nth",
        "2..",
        "+m",
        "--ansi",
        "--preview",
        preview_command.as_str(),
        "--preview-window",
        preview_window,
        "--bind",
        "ctrl-t:toggle-preview",
    ]
    .into_iter()
    .map(str::to_string)
    .collect();
    #[cfg(windows)]
    args.extend(["--with-shell".into(), tmux::PREVIEW_SHELL.into()]);
    if let Some(query) = query {
        args.push("-q".into());
        args.push(query.into());
    }
    args
}

#[cfg(test)]
#[path = "attach_tests.rs"]
mod tests;