Skip to main content

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