Skip to main content

ags/
sessions.rs

1use std::cell::RefCell;
2use std::fmt::Display;
3use std::fmt::Formatter;
4#[cfg(unix)]
5use std::os::unix::process::CommandExt;
6use std::path::Path;
7use std::process::Command;
8use std::process::Stdio;
9
10use owo_colors::OwoColorize;
11use rootcause::prelude::ResultExt;
12use rootcause::report;
13use serde::Serialize;
14use strum::EnumIter;
15use strum::IntoEnumIterator;
16use ytil_agents::agent::Agent;
17use ytil_agents::agent::session::Session;
18use ytil_agents::agent::session::SessionKey;
19
20pub fn list_json(args: &[String]) -> rootcause::Result<()> {
21    let session_keys = parse_json_session_keys(args)?;
22    let sessions = load_sorted_sessions_by_key(&session_keys)?;
23    let home_dir = std::env::var_os("HOME").map_or_else(|| std::path::PathBuf::from("/"), std::path::PathBuf::from);
24    let rows = sessions
25        .into_iter()
26        .map(RenderableSession::from)
27        .map(|session| JsonSession::new(&session, &home_dir))
28        .collect::<rootcause::Result<Vec<_>>>()?;
29
30    println!(
31        "{}",
32        serde_json::to_string(&rows).context("failed to serialize sessions")?
33    );
34    Ok(())
35}
36
37fn parse_json_session_keys(args: &[String]) -> rootcause::Result<Vec<SessionKey>> {
38    let mut session_keys = Vec::new();
39    let mut args = args.iter();
40    while let Some(arg) = args.next() {
41        match arg.as_str() {
42            "--session" => {
43                let Some(key) = args.next() else {
44                    return Err(report!("missing --session value"));
45                };
46                session_keys.push(key.parse()?);
47            }
48            unexpected => {
49                return Err(report!("unknown ags list --json arg").attach(format!("arg={unexpected}")));
50            }
51        }
52    }
53    if session_keys.is_empty() {
54        return Err(report!("ags list --json requires at least one --session"));
55    }
56    session_keys.sort();
57    session_keys.dedup();
58    Ok(session_keys)
59}
60
61pub fn run() -> rootcause::Result<()> {
62    let sessions = load_sorted_sessions()?;
63
64    if sessions.is_empty() {
65        println!("No sessions");
66        return Ok(());
67    }
68
69    let renderable_sessions: Vec<RenderableSession> = sessions.into_iter().map(RenderableSession::from).collect();
70    let Some(selected) = ytil_tui::minimal_multi_select(renderable_sessions, ToString::to_string, |session| {
71        session.session.search_text.clone()
72    })?
73    else {
74        println!("No sessions selected");
75        return Ok(());
76    };
77
78    let Some(op) = ytil_tui::minimal_select::<Op>(Op::iter().collect())? else {
79        println!("No action selected");
80        return Ok(());
81    };
82
83    match op {
84        Op::Resume => ytil_tui::require_single(&selected, "sessions").and_then(launch_session),
85        Op::Delete => {
86            for session in &selected {
87                delete_session(session)?;
88            }
89            Ok(())
90        }
91    }
92}
93
94fn load_sorted_sessions() -> rootcause::Result<Vec<Session>> {
95    let mut sessions = Vec::new();
96    sessions.extend(ytil_agents::agent::session_loader::load_sessions()?);
97    sort_sessions(&mut sessions);
98    Ok(sessions)
99}
100
101fn load_sorted_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
102    let mut sessions = ytil_agents::agent::session_loader::load_sessions_by_key(keys)?;
103    sort_sessions(&mut sessions);
104    Ok(sessions)
105}
106
107fn sort_sessions(sessions: &mut [Session]) {
108    sessions.sort_by(|a, b| {
109        b.updated_at
110            .cmp(&a.updated_at)
111            .then_with(|| b.created_at.cmp(&a.created_at))
112            .then_with(|| a.name.cmp(&b.name))
113            .then_with(|| a.id.cmp(&b.id))
114    });
115}
116
117struct RenderableSession {
118    session: Session,
119    branch: RefCell<Option<String>>,
120}
121
122impl From<Session> for RenderableSession {
123    fn from(session: Session) -> Self {
124        Self {
125            session,
126            branch: RefCell::default(),
127        }
128    }
129}
130
131impl RenderableSession {
132    fn branch(&self) -> Option<String> {
133        if let Some(branch) = self.branch.borrow().as_ref() {
134            return Some(branch.to_owned());
135        }
136
137        let branch = ytil_git::branch::get_at(&self.session.workspace, self.session.created_at)?;
138        *self.branch.borrow_mut() = Some(branch.clone());
139        Some(branch)
140    }
141
142    fn plain_summary(&self, home_dir: &Path) -> String {
143        let path_label = ytil_tui::short_path(&self.session.workspace, home_dir);
144        let session_name = ytil_tui::display_fixed_width(&self.session.name, 42);
145        let updated_label = self.session.updated_at.format("%d/%m/%Y-%H:%M").to_string();
146        let created_label = self.session.created_at.format("%d/%m/%Y-%H:%M").to_string();
147        let agent = self.session.agent.short_name();
148
149        self.branch().map_or_else(
150            || format!("{agent} {path_label} {session_name} {updated_label} {created_label}"),
151            |branch| format!("{agent} {path_label} {branch} {session_name} {updated_label} {created_label}"),
152        )
153    }
154}
155
156impl Display for RenderableSession {
157    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
158        let agent_name = match self.session.agent {
159            Agent::Claude => self.session.agent.short_name().red().bold().to_string(),
160            Agent::Codex => self.session.agent.short_name().green().bold().to_string(),
161            Agent::Cursor => self.session.agent.short_name().bright_black().bold().to_string(),
162            Agent::Gemini | Agent::Opencode => self.session.agent.short_name().bold().to_string(),
163        };
164
165        let path_label = ytil_tui::short_path(
166            &self.session.workspace,
167            std::env::var_os("HOME")
168                .as_deref()
169                .map_or_else(|| std::path::Path::new("/"), std::path::Path::new),
170        );
171        let session_name = ytil_tui::display_fixed_width(&self.session.name, 42);
172        let updated_label = self.session.updated_at.format("%d/%m/%Y-%H:%M").to_string();
173        let created_label = self.session.created_at.format("%d/%m/%Y-%H:%M").to_string();
174
175        if let Some(branch) = self.branch() {
176            write!(
177                f,
178                "{agent_name} {} {} {} {} {}",
179                path_label.cyan().bold(),
180                branch.white(),
181                session_name.dimmed().bold(),
182                updated_label.blue(),
183                created_label.blue(),
184            )
185        } else {
186            write!(
187                f,
188                "{agent_name} {} {} {} {}",
189                path_label.cyan().bold(),
190                session_name.dimmed().bold(),
191                updated_label.blue(),
192                created_label.blue(),
193            )
194        }
195    }
196}
197
198#[derive(Serialize)]
199struct JsonSession {
200    agent: &'static str,
201    workspace: std::path::PathBuf,
202    session_id: String,
203    summary: String,
204    display: String,
205    search: String,
206    updated_at: chrono::DateTime<chrono::Utc>,
207    resume_program: String,
208    resume_args: Vec<String>,
209}
210
211impl JsonSession {
212    fn new(session: &RenderableSession, home_dir: &Path) -> rootcause::Result<Self> {
213        let display = session.plain_summary(home_dir);
214        let search = search_corpus(&display, &session.session.search_text);
215        let (resume_program, resume_args) = session.session.build_resume_command()?;
216        Ok(Self {
217            agent: session.session.agent.name(),
218            workspace: session.session.workspace.clone(),
219            session_id: session.session.id.clone(),
220            summary: session.session.name.clone(),
221            display,
222            search,
223            updated_at: session.session.updated_at,
224            resume_program: resume_program.to_string(),
225            resume_args,
226        })
227    }
228}
229
230fn search_corpus(display_text: &str, hidden_search: &str) -> String {
231    let visible_match_text = normalize_search(display_text);
232    let hidden_search = normalize_search(hidden_search);
233    if hidden_search.is_empty() || hidden_search == visible_match_text {
234        visible_match_text
235    } else {
236        format!("{visible_match_text} {hidden_search}")
237    }
238}
239
240fn normalize_search(value: &str) -> String {
241    value.split_whitespace().collect::<Vec<_>>().join(" ")
242}
243
244#[derive(Debug, EnumIter)]
245enum Op {
246    Resume,
247    Delete,
248}
249
250impl Display for Op {
251    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
252        match self {
253            Self::Resume => write!(f, "{}", "Resume".green().bold()),
254            Self::Delete => write!(f, "{}", "Delete".red().bold()),
255        }
256    }
257}
258
259fn launch_session(session: &RenderableSession) -> rootcause::Result<()> {
260    let session = &session.session;
261    let (program, args) = session.build_resume_command()?;
262
263    let mut cmd = Command::new(program);
264    cmd.args(args)
265        .current_dir(&session.workspace)
266        .stdin(Stdio::inherit())
267        .stdout(Stdio::inherit())
268        .stderr(Stdio::inherit());
269
270    #[cfg(unix)]
271    {
272        Err::<(), std::io::Error>(cmd.exec())
273            .context("failed to exec agent CLI")
274            .attach_with(|| format!("agent={}", session.agent.name()))
275            .attach_with(|| format!("workspace={}", session.workspace.display()))
276            .attach_with(|| format!("session_id={}", session.id))?;
277
278        Ok(())
279    }
280
281    #[cfg(not(unix))]
282    {
283        let status = cmd
284            .status()
285            .context("failed to launch agent CLI")
286            .attach_with(|| format!("agent={}", session.agent.name()))
287            .attach_with(|| format!("workspace={}", session.workspace.display()))
288            .attach_with(|| format!("session_id={}", session.id))?;
289
290        if !status.success() {
291            return Err(report!("agent CLI exited with non-zero status")
292                .attach_with(|| format!("agent={}", session.agent.name()))
293                .attach_with(|| format!("workspace={}", session.workspace.display()))
294                .attach_with(|| format!("session_id={}", session.id))
295                .attach_with(|| format!("status={status}")));
296        }
297
298        Ok(())
299    }
300}
301
302fn delete_session(session: &RenderableSession) -> rootcause::Result<()> {
303    let delete_path = &session.session.path;
304    if delete_path.is_dir() {
305        std::fs::remove_dir_all(delete_path)
306            .context("failed to delete session directory")
307            .attach_with(|| format!("path={}", delete_path.display()))
308            .attach_with(|| format!("session_id={}", session.session.id))?;
309    } else {
310        std::fs::remove_file(delete_path)
311            .context("failed to delete session file")
312            .attach_with(|| format!("path={}", delete_path.display()))
313            .attach_with(|| format!("session_id={}", session.session.id))?;
314    }
315    println!("{} {session}", "Deleted".red().bold());
316    Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321    use chrono::DateTime;
322    use tempfile::tempdir;
323
324    use super::*;
325
326    #[test]
327    fn test_search_corpus_matches_ags_visible_plus_hidden_filtering() {
328        let display = "cx  ~/repo   branch   session name  09/05/2026-10:00";
329        let hidden = "first user prompt\nassistant reply";
330
331        let search = search_corpus(display, hidden);
332
333        pretty_assertions::assert_eq!(
334            search,
335            "cx ~/repo branch session name 09/05/2026-10:00 first user prompt assistant reply"
336        );
337    }
338
339    #[test]
340    fn test_json_session_renders_plain_ags_summary_and_resume_command() {
341        let dir = tempdir().expect("tempdir should be created");
342        let workspace = dir.path().join("repo");
343        std::fs::create_dir_all(&workspace).expect("workspace should be created");
344        let created_at = DateTime::from_timestamp(1_700_000_000, 0).expect("test timestamp should be valid");
345        let updated_at = DateTime::from_timestamp(1_700_000_100, 0).expect("test timestamp should be valid");
346        let session = Session {
347            id: "session-id".to_string(),
348            agent: Agent::Codex,
349            name: "fix issue".to_string(),
350            search_text: "hidden prompt".to_string(),
351            workspace: workspace.clone(),
352            path: dir.path().join("session.jsonl"),
353            created_at: created_at.to_utc(),
354            updated_at: updated_at.to_utc(),
355        };
356        let renderable = RenderableSession::from(session);
357
358        assert2::assert!(let Ok(row) = JsonSession::new(&renderable, dir.path()));
359
360        assert2::assert!(row.display.starts_with("cx ~/repo fix issue"));
361        assert2::assert!(row.search.contains("hidden prompt"));
362        pretty_assertions::assert_eq!(row.agent, "codex");
363        pretty_assertions::assert_eq!(row.workspace, workspace);
364        pretty_assertions::assert_eq!(row.session_id, "session-id");
365        pretty_assertions::assert_eq!(row.summary, "fix issue");
366        pretty_assertions::assert_eq!(row.updated_at, updated_at.to_utc());
367        pretty_assertions::assert_eq!(row.resume_program, "codex");
368        pretty_assertions::assert_eq!(row.resume_args.first().map(String::as_str), Some("resume"));
369    }
370
371    #[test]
372    fn test_parse_json_session_keys_requires_at_least_one_session_key() {
373        assert2::assert!(let Err(err) = parse_json_session_keys(&[]));
374        assert!(err.to_string().contains("requires at least one --session"));
375    }
376
377    #[test]
378    fn test_parse_json_session_keys_parses_and_dedupes_requested_session_keys() {
379        assert2::assert!(let Ok(session_keys) = parse_json_session_keys(&[
380            String::from("--session"),
381            String::from("codex:target"),
382            String::from("--session"),
383            String::from("codex:target"),
384        ]));
385
386        pretty_assertions::assert_eq!(session_keys, [SessionKey::new(Agent::Codex, "target")]);
387    }
388}