use std::path::PathBuf;
use std::time::SystemTime;
use crate::approval::PendingApproval;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Provider {
Claude,
Codex,
}
impl Provider {
pub fn label(self) -> &'static str {
match self {
Provider::Claude => "cc",
Provider::Codex => "cx",
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Pane {
pub target: String,
pub tmux_session: String,
pub window_name: String,
pub pane_id: String,
pub pid: u32,
pub tty: String,
pub current_command: String,
pub cwd: PathBuf,
pub active: bool,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Session {
pub provider: Provider,
pub pid: u32,
pub session_id: String,
pub alias_session_id: Option<String>,
pub cwd: PathBuf,
pub name: Option<String>,
pub status: String,
pub waiting_for: Option<String>,
pub cli_version: Option<String>,
pub started_at_ms: u64,
pub updated_at_ms: u64,
pub pane: Option<Pane>,
pub transcript_path: Option<PathBuf>,
pub headline: Option<String>,
pub last_prompt: Option<String>,
pub last_prompt_at: Option<SystemTime>,
pub last_turn_duration_ms: Option<u64>,
pub last_turn_msg_count: Option<u64>,
pub last_event_at: Option<SystemTime>,
pub last_stop_at: Option<SystemTime>,
pub user_prompt_count: u64,
pub last_stop_had_errors: bool,
pub last_tool_use: Option<(String, String)>,
pub total_cost_usd: f64,
pub total_tokens_in: u64,
pub total_tokens_out: u64,
pub total_tokens_cache_write: u64,
pub total_tokens_cache_read: u64,
pub latest_context_tokens: u64,
pub context_window: Option<u64>,
pub peak_context_tokens: u64,
pub latest_model: Option<String>,
pub latest_assistant_text: Option<String>,
pub state: AttentionState,
pub approval_prompt_pending: bool,
pub pane_blocked: bool,
pub muted: bool,
pub watched: bool,
pub pinned: bool,
pub pending_approvals: Vec<PendingApproval>,
}
impl Session {
#[allow(clippy::too_many_arguments)]
pub fn new(
provider: Provider,
pid: u32,
session_id: String,
cwd: PathBuf,
name: Option<String>,
status: String,
started_at_ms: u64,
updated_at_ms: u64,
waiting_for: Option<String>,
) -> Self {
Self {
provider,
pid,
session_id,
alias_session_id: None,
cwd,
name,
status,
waiting_for,
cli_version: None,
started_at_ms,
updated_at_ms,
pane: None,
transcript_path: None,
headline: None,
last_prompt: None,
last_prompt_at: None,
last_turn_duration_ms: None,
last_turn_msg_count: None,
last_event_at: None,
last_stop_at: None,
user_prompt_count: 0,
last_stop_had_errors: false,
last_tool_use: None,
total_cost_usd: 0.0,
total_tokens_in: 0,
total_tokens_out: 0,
total_tokens_cache_write: 0,
total_tokens_cache_read: 0,
latest_context_tokens: 0,
context_window: None,
peak_context_tokens: 0,
latest_model: None,
latest_assistant_text: None,
state: AttentionState::Unknown,
approval_prompt_pending: false,
pane_blocked: false,
muted: false,
watched: false,
pinned: false,
pending_approvals: Vec::new(),
}
}
}
pub fn session_display_label(s: &Session) -> String {
s.name
.clone()
.or_else(|| s.pane.as_ref().and_then(useful_window_name))
.or_else(|| {
s.pane
.as_ref()
.map(|p| p.tmux_session.as_str())
.filter(|n| !n.is_empty() && !n.chars().all(|c| c.is_ascii_digit()))
.map(|n| n.to_string())
})
.or_else(|| s.cwd.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| s.cwd.display().to_string())
}
pub fn useful_window_name(pane: &Pane) -> Option<String> {
let name = pane.window_name.trim();
if name.is_empty() {
return None;
}
if name == pane.current_command {
return None;
}
if name.chars().all(|c| c.is_ascii_digit() || c == '.') {
return None;
}
if name.starts_with('[') && name.ends_with(']') {
return None;
}
if is_auto_rename_residue(name) {
return None;
}
Some(name.to_string())
}
fn is_auto_rename_residue(name: &str) -> bool {
matches!(
name,
"fish" | "bash" | "zsh" | "sh" | "dash" | "ksh" | "tcsh"
| "tmux" | "screen"
| "nvim" | "vim" | "vi" | "nano" | "emacs" | "helix" | "hx"
| "claude" | "node" | "python" | "python3" | "ruby" | "go"
| "irb" | "pry" | "ipython" | "psql" | "redis-cli" | "mysql"
| "ssh" | "scp" | "rsync" | "git" | "lazygit" | "gh"
| "top" | "htop" | "btop" | "less" | "more" | "cat" | "tail"
| "make" | "cargo" | "pnpm" | "npm" | "yarn" | "bun"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn session(name: Option<&str>, window_name: &str, tmux_session: &str) -> Session {
let mut s = Session::new(
Provider::Claude,
123,
"sid".to_string(),
PathBuf::from("/tmp/project"),
name.map(str::to_string),
"idle".to_string(),
0,
0,
None,
);
s.pane = Some(Pane {
target: "main:1.0".to_string(),
tmux_session: tmux_session.to_string(),
window_name: window_name.to_string(),
pane_id: "%42".to_string(),
pid: 123,
tty: "/dev/ttys001".to_string(),
current_command: "claude".to_string(),
cwd: PathBuf::from("/tmp/project"),
active: false,
});
s
}
#[test]
fn display_label_prefers_provider_name() {
let s = session(Some("agent-name"), "tmux-name", "tmux-session");
assert_eq!(session_display_label(&s), "agent-name");
}
#[test]
fn display_label_uses_tmux_window_name() {
let s = session(None, "agent-ACDC-21", "acdc-hub");
assert_eq!(session_display_label(&s), "agent-ACDC-21");
}
#[test]
fn display_label_skips_auto_window_name_and_uses_tmux_session() {
let s = session(None, "2.1.158", "acdc-hub");
assert_eq!(session_display_label(&s), "acdc-hub");
}
#[test]
fn display_label_skips_numeric_tmux_session() {
let s = session(None, "fish", "1");
assert_eq!(session_display_label(&s), "project");
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ApprovalMode {
#[default]
Hook,
Tmux,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttentionState {
Error,
Blocked,
JustFinished,
Working,
Fresh,
IdleShort,
IdleLong,
Stale,
Unknown,
}
impl AttentionState {
pub fn priority(self) -> u8 {
match self {
AttentionState::Error => 0,
AttentionState::Blocked => 1,
AttentionState::JustFinished => 2,
AttentionState::Working => 3,
AttentionState::IdleShort => 4,
AttentionState::IdleLong => 5,
AttentionState::Fresh => 6,
AttentionState::Stale => 7,
AttentionState::Unknown => 8,
}
}
pub fn label(self) -> &'static str {
match self {
AttentionState::Error => "error",
AttentionState::Blocked => "block",
AttentionState::JustFinished => "done",
AttentionState::Working => "work",
AttentionState::Fresh => "fresh",
AttentionState::IdleShort => "idle",
AttentionState::IdleLong => "long",
AttentionState::Stale => "stale",
AttentionState::Unknown => "?",
}
}
}