Skip to main content

agm/cmd/
sessions.rs

1use std::fmt::Display;
2use std::process::Command;
3use std::process::Stdio;
4
5use agm_core::agent::Agent;
6use agm_core::agent::session::Session;
7use owo_colors::OwoColorize as _;
8use rootcause::prelude::ResultExt as _;
9use strum::EnumIter;
10use strum::IntoEnumIterator as _;
11
12pub fn run() -> rootcause::Result<()> {
13    let mut sessions = Vec::new();
14
15    sessions.extend(agm_core::agent::session_loader::claude::load_sessions()?);
16    sessions.extend(agm_core::agent::session_loader::codex::load_sessions()?);
17    sessions.extend(agm_core::agent::session_loader::cursor::load_sessions()?);
18
19    sessions.sort_by(|a, b| {
20        b.updated_at
21            .cmp(&a.updated_at)
22            .then_with(|| b.created_at.cmp(&a.created_at))
23            .then_with(|| a.name.cmp(&b.name))
24            .then_with(|| a.id.cmp(&b.id))
25    });
26
27    if sessions.is_empty() {
28        println!("No sessions");
29        return Ok(());
30    }
31
32    let Some(selected) = ytil_tui::minimal_multi_select(sessions.into_iter().map(RenderableSession).collect())? else {
33        println!("No sessions selected");
34        return Ok(());
35    };
36
37    let Some(op) = ytil_tui::minimal_select::<Op>(Op::iter().collect())? else {
38        println!("No action selected");
39        return Ok(());
40    };
41
42    match op {
43        Op::Resume => ytil_tui::require_single(&selected, "sessions").and_then(launch_session),
44        Op::Delete => {
45            for session in &selected {
46                delete_session(session)?;
47            }
48            Ok(())
49        }
50    }
51}
52
53struct RenderableSession(Session);
54
55impl Display for RenderableSession {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        let agent_name = match self.0.agent {
58            Agent::Claude => "CLAUDE".red().bold().to_string(),
59            Agent::Codex => "CODEX".green().bold().to_string(),
60            Agent::Cursor => "CURSOR".bright_black().bold().to_string(),
61            Agent::Gemini | Agent::Opencode => self.0.agent.to_string(),
62        };
63
64        let path_label = agm_core::short_path(
65            &self.0.workspace,
66            std::env::var_os("HOME")
67                .as_deref()
68                .map_or_else(|| std::path::Path::new("/"), std::path::Path::new),
69        );
70        let session_name = ytil_tui::display_fixed_width(&self.0.name, 42);
71        let updated_label = self.0.updated_at.format("%d/%m/%Y-%H:%M").to_string();
72        let created_label = self.0.created_at.format("%d/%m/%Y-%H:%M").to_string();
73
74        write!(
75            f,
76            "{agent_name} {} {} {} {}",
77            path_label.blue(),
78            session_name.white().bold(),
79            updated_label.dimmed(),
80            created_label.dimmed(),
81        )
82    }
83}
84
85#[derive(Debug, EnumIter)]
86enum Op {
87    Resume,
88    Delete,
89}
90
91impl Display for Op {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::Resume => write!(f, "{}", "Resume".green().bold()),
95            Self::Delete => write!(f, "{}", "Delete".red().bold()),
96        }
97    }
98}
99
100fn launch_session(RenderableSession(session): &RenderableSession) -> rootcause::Result<()> {
101    let (program, args) = session.build_resume_command()?;
102
103    let mut cmd = Command::new(program);
104    cmd.args(args);
105    let status = cmd
106        .current_dir(&session.workspace)
107        .stdin(Stdio::inherit())
108        .stdout(Stdio::inherit())
109        .stderr(Stdio::inherit())
110        .status()
111        .context("failed to launch agent CLI")
112        .attach_with(|| format!("agent={}", session.agent.name()))
113        .attach_with(|| format!("workspace={}", session.workspace.display()))
114        .attach_with(|| format!("session_id={}", session.id))?;
115
116    status
117        .exit_ok()
118        .context("agent CLI exited with non-zero status")
119        .attach_with(|| format!("agent={}", session.agent.name()))
120        .attach_with(|| format!("workspace={}", session.workspace.display()))
121        .attach_with(|| format!("session_id={}", session.id))?;
122
123    Ok(())
124}
125
126fn delete_session(session: &RenderableSession) -> rootcause::Result<()> {
127    let delete_path = &session.0.path;
128    if delete_path.is_dir() {
129        std::fs::remove_dir_all(delete_path)
130            .context("failed to delete session directory")
131            .attach_with(|| format!("path={}", delete_path.display()))
132            .attach_with(|| format!("session_id={}", session.0.id))?;
133    } else {
134        std::fs::remove_file(delete_path)
135            .context("failed to delete session file")
136            .attach_with(|| format!("path={}", delete_path.display()))
137            .attach_with(|| format!("session_id={}", session.0.id))?;
138    }
139    println!("{} {session}", "Deleted".red().bold());
140    Ok(())
141}