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}