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}