use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::Serialize;
use tabled::{
Table, Tabled,
settings::{Padding, Style, object::Columns},
};
use crate::git;
use crate::multiplexer::{AgentStatus, create_backend, detect_backend};
use crate::state::StateStore;
use crate::util;
use crate::workflow;
#[derive(Serialize)]
struct StatusEntry {
worktree: String,
branch: String,
status: String,
elapsed_secs: Option<u64>,
title: Option<String>,
pane_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
git: Option<GitInfo>,
}
#[derive(Serialize, Clone)]
struct GitInfo {
has_staged: bool,
has_unstaged: bool,
has_unmerged_commits: bool,
}
#[derive(Tabled)]
struct StatusRow {
#[tabled(rename = "WORKTREE")]
worktree: String,
#[tabled(rename = "STATUS")]
status: String,
#[tabled(rename = "ELAPSED")]
elapsed: String,
#[tabled(rename = "GIT")]
git: String,
#[tabled(rename = "TITLE")]
title: String,
}
fn git_label(git: &Option<GitInfo>) -> String {
let Some(g) = git else {
return "-".to_string();
};
let mut parts = Vec::new();
if g.has_staged {
parts.push("staged");
}
if g.has_unstaged {
parts.push("unstaged");
}
if g.has_unmerged_commits {
parts.push("unmerged");
}
if parts.is_empty() {
"clean".to_string()
} else {
parts.join(",")
}
}
fn status_label(status: Option<AgentStatus>) -> String {
match status {
Some(AgentStatus::Working) => "working".to_string(),
Some(AgentStatus::Waiting) => "waiting".to_string(),
Some(AgentStatus::Done) => "done".to_string(),
None => "-".to_string(),
}
}
fn compute_git_info(wt_path: &std::path::Path, branch: &str) -> GitInfo {
let has_staged = git::has_staged_changes(wt_path).unwrap_or(false);
let has_unstaged = git::has_unstaged_changes(wt_path).unwrap_or(false);
let has_unmerged = (|| -> Option<bool> {
let main = git::get_default_branch_in(Some(wt_path)).ok()?;
let base = git::get_merge_base_in(Some(wt_path), &main).ok()?;
let unmerged = git::get_unmerged_branches_in(Some(wt_path), &base).ok()?;
Some(unmerged.contains(branch))
})()
.unwrap_or(false);
GitInfo {
has_staged,
has_unstaged,
has_unmerged_commits: has_unmerged,
}
}
pub fn run(worktrees: &[String], json: bool, show_git: bool) -> Result<()> {
let mux = create_backend(detect_backend());
let agent_panes =
StateStore::new().and_then(|store| store.load_reconciled_agents(mux.as_ref()))?;
if agent_panes.is_empty() {
if json {
println!("[]");
} else {
println!("No active agents");
}
return Ok(());
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut entries: Vec<StatusEntry> = Vec::new();
if worktrees.is_empty() {
let all_worktrees = git::list_worktrees()?;
let unmerged_branches = if show_git {
git::get_default_branch()
.ok()
.and_then(|main| git::get_merge_base(&main).ok())
.and_then(|base| git::get_unmerged_branches(&base).ok())
.unwrap_or_default()
} else {
std::collections::HashSet::new()
};
for (wt_path, branch) in &all_worktrees {
let matching = workflow::match_agents_to_worktree(&agent_panes, wt_path);
if matching.is_empty() {
continue;
}
let worktree_name = wt_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let git_info = if show_git {
Some(GitInfo {
has_staged: git::has_staged_changes(wt_path).unwrap_or(false),
has_unstaged: git::has_unstaged_changes(wt_path).unwrap_or(false),
has_unmerged_commits: unmerged_branches.contains(branch),
})
} else {
None
};
for agent in matching {
let elapsed_secs = agent.status_ts.map(|ts| now.saturating_sub(ts));
entries.push(StatusEntry {
worktree: worktree_name.clone(),
branch: branch.clone(),
status: status_label(agent.status),
elapsed_secs,
title: agent.pane_title.clone(),
pane_id: agent.pane_id.clone(),
git: git_info.clone(),
});
}
}
} else {
for name in worktrees {
match workflow::resolve_worktree_agents(name, mux.as_ref()) {
Ok((wt_path, matching)) => {
let worktree_name = wt_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let branch = git::get_branch_for_worktree(&wt_path)
.unwrap_or_else(|_| worktree_name.clone());
let git_info = if show_git {
Some(compute_git_info(&wt_path, &branch))
} else {
None
};
for agent in &matching {
let elapsed_secs = agent.status_ts.map(|ts| now.saturating_sub(ts));
entries.push(StatusEntry {
worktree: worktree_name.clone(),
branch: branch.clone(),
status: status_label(agent.status),
elapsed_secs,
title: agent.pane_title.clone(),
pane_id: agent.pane_id.clone(),
git: git_info.clone(),
});
}
}
Err(e) => {
eprintln!("{}: {}", name, e);
}
}
}
}
if json {
println!("{}", serde_json::to_string_pretty(&entries)?);
} else {
if entries.is_empty() {
println!("No active agents");
return Ok(());
}
let rows: Vec<StatusRow> = entries
.iter()
.map(|e| {
let worktree = if e.branch != e.worktree {
format!("{} ({})", e.worktree, e.branch)
} else {
e.worktree.clone()
};
StatusRow {
worktree,
status: e.status.clone(),
elapsed: e
.elapsed_secs
.map(util::format_elapsed_secs)
.unwrap_or("-".to_string()),
git: git_label(&e.git),
title: e.title.clone().unwrap_or("-".to_string()),
}
})
.collect();
let mut table = Table::new(rows);
table
.with(Style::blank())
.modify(Columns::new(..), Padding::new(0, 1, 0, 0));
if !show_git {
table.with(tabled::settings::Remove::column(
tabled::settings::location::ByColumnName::new("GIT"),
));
}
println!("{table}");
}
Ok(())
}