aster_cli/commands/
session.rs1use crate::session::message_to_markdown;
2use anyhow::{Context, Result};
3
4use aster::session::{generate_diagnostics, Session, SessionManager};
5use aster::utils::safe_truncate;
6use cliclack::{confirm, multiselect, select};
7use regex::Regex;
8use std::fs;
9use std::io::Write;
10use std::path::PathBuf;
11
12const TRUNCATED_DESC_LENGTH: usize = 60;
13
14pub async fn remove_sessions(sessions: Vec<Session>) -> Result<()> {
15 println!("The following sessions will be removed:");
16 for session in &sessions {
17 println!("- {} {}", session.id, session.name);
18 }
19
20 let should_delete = confirm("Are you sure you want to delete these sessions?")
21 .initial_value(false)
22 .interact()?;
23
24 if should_delete {
25 for session in sessions {
26 SessionManager::delete_session(&session.id).await?;
27 println!("Session `{}` removed.", session.id);
28 }
29 } else {
30 println!("Skipping deletion of the sessions.");
31 }
32
33 Ok(())
34}
35
36fn prompt_interactive_session_removal(sessions: &[Session]) -> Result<Vec<Session>> {
37 if sessions.is_empty() {
38 println!("No sessions to delete.");
39 return Ok(vec![]);
40 }
41
42 let mut selector = multiselect(
43 "Select sessions to delete (use spacebar, Enter to confirm, Ctrl+C to cancel):",
44 );
45
46 let display_map: std::collections::HashMap<String, Session> = sessions
47 .iter()
48 .map(|s| {
49 let desc = if s.name.is_empty() {
50 "(no name)"
51 } else {
52 &s.name
53 };
54 let truncated_desc = safe_truncate(desc, TRUNCATED_DESC_LENGTH);
55 let display_text = format!("{} - {} ({})", s.updated_at, truncated_desc, s.id);
56 (display_text, s.clone())
57 })
58 .collect();
59
60 for display_text in display_map.keys() {
61 selector = selector.item(display_text.clone(), display_text.clone(), "");
62 }
63
64 let selected_display_texts: Vec<String> = selector.interact()?;
65
66 let selected_sessions: Vec<Session> = selected_display_texts
67 .into_iter()
68 .filter_map(|text| display_map.get(&text).cloned())
69 .collect();
70
71 Ok(selected_sessions)
72}
73
74pub async fn handle_session_remove(
75 session_id: Option<String>,
76 name: Option<String>,
77 regex_string: Option<String>,
78) -> Result<()> {
79 let all_sessions = match SessionManager::list_sessions().await {
80 Ok(sessions) => sessions,
81 Err(e) => {
82 tracing::error!("Failed to retrieve sessions: {:?}", e);
83 return Err(anyhow::anyhow!("Failed to retrieve sessions"));
84 }
85 };
86
87 let matched_sessions: Vec<Session>;
88
89 if let Some(id_val) = session_id {
90 if let Some(session) = all_sessions.iter().find(|s| s.id == id_val) {
91 matched_sessions = vec![session.clone()];
92 } else {
93 return Err(anyhow::anyhow!("Session ID '{}' not found.", id_val));
94 }
95 } else if let Some(name_val) = name {
96 if let Some(session) = all_sessions.iter().find(|s| s.name == name_val) {
97 matched_sessions = vec![session.clone()];
98 } else {
99 return Err(anyhow::anyhow!(
100 "Session with name '{}' not found.",
101 name_val
102 ));
103 }
104 } else if let Some(regex_val) = regex_string {
105 let session_regex = Regex::new(®ex_val)
106 .with_context(|| format!("Invalid regex pattern '{}'", regex_val))?;
107
108 matched_sessions = all_sessions
109 .into_iter()
110 .filter(|session| session_regex.is_match(&session.id))
111 .collect();
112
113 if matched_sessions.is_empty() {
114 println!("Regex string '{}' does not match any sessions", regex_val);
115 return Ok(());
116 }
117 } else {
118 if all_sessions.is_empty() {
119 return Err(anyhow::anyhow!("No sessions found."));
120 }
121 matched_sessions = prompt_interactive_session_removal(&all_sessions)?;
122 }
123
124 if matched_sessions.is_empty() {
125 return Ok(());
126 }
127
128 remove_sessions(matched_sessions).await
129}
130
131pub async fn handle_session_list(
132 format: String,
133 ascending: bool,
134 working_dir: Option<PathBuf>,
135 limit: Option<usize>,
136) -> Result<()> {
137 let mut sessions = SessionManager::list_sessions().await?;
138
139 if let Some(ref pat) = working_dir {
140 let pat_lower = pat.to_string_lossy().to_lowercase();
141 sessions.retain(|s| {
142 s.working_dir
143 .to_string_lossy()
144 .to_lowercase()
145 .contains(&pat_lower)
146 });
147 }
148
149 if ascending {
150 sessions.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
151 } else {
152 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
153 }
154
155 if let Some(n) = limit {
156 sessions.truncate(n);
157 }
158
159 match format.as_str() {
160 "json" => {
161 println!("{}", serde_json::to_string(&sessions)?);
162 }
163 _ => {
164 if sessions.is_empty() {
165 println!("No sessions found");
166 return Ok(());
167 }
168
169 println!("Available sessions:");
170 for session in sessions {
171 let output = format!("{} - {} - {}", session.id, session.name, session.updated_at);
172 println!("{}", output);
173 }
174 }
175 }
176 Ok(())
177}
178
179pub async fn handle_session_export(
180 session_id: String,
181 output_path: Option<PathBuf>,
182 format: String,
183) -> Result<()> {
184 let session = match SessionManager::get_session(&session_id, true).await {
185 Ok(session) => session,
186 Err(e) => {
187 return Err(anyhow::anyhow!(
188 "Session '{}' not found or failed to read: {}",
189 session_id,
190 e
191 ));
192 }
193 };
194
195 let output = match format.as_str() {
196 "json" => serde_json::to_string_pretty(&session)?,
197 "yaml" => serde_yaml::to_string(&session)?,
198 "markdown" => {
199 let conversation = session
200 .conversation
201 .ok_or_else(|| anyhow::anyhow!("Session has no messages"))?;
202 export_session_to_markdown(conversation.messages().to_vec(), &session.name)
203 }
204 _ => return Err(anyhow::anyhow!("Unsupported format: {}", format)),
205 };
206
207 if let Some(output_path) = output_path {
208 fs::write(&output_path, output).with_context(|| {
209 format!("Failed to write to output file: {}", output_path.display())
210 })?;
211 println!("Session exported to {}", output_path.display());
212 } else {
213 println!("{}", output);
214 }
215
216 Ok(())
217}
218
219pub async fn handle_diagnostics(session_id: &str, output_path: Option<PathBuf>) -> Result<()> {
220 println!(
221 "Generating diagnostics bundle for session '{}'...",
222 session_id
223 );
224
225 let diagnostics_data = generate_diagnostics(session_id).await.with_context(|| {
226 format!(
227 "Failed to write to generate diagnostics bundle for session '{}'",
228 session_id
229 )
230 })?;
231
232 let output_file = if let Some(path) = output_path {
233 path.clone()
234 } else {
235 PathBuf::from(format!("diagnostics_{}.zip", session_id))
236 };
237
238 let mut file = fs::File::create(&output_file).context(format!(
239 "Failed to create output file: {}",
240 output_file.display()
241 ))?;
242
243 file.write_all(&diagnostics_data)
244 .context("Failed to write diagnostics data")?;
245
246 println!("Diagnostics bundle saved to: {}", output_file.display());
247
248 Ok(())
249}
250
251fn export_session_to_markdown(
252 messages: Vec<aster::conversation::message::Message>,
253 session_name: &String,
254) -> String {
255 let mut markdown_output = String::new();
256
257 markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name));
258
259 if messages.is_empty() {
260 markdown_output.push_str("*(This session has no messages)*\n");
261 return markdown_output;
262 }
263
264 markdown_output.push_str(&format!("*Total messages: {}*\n\n---\n\n", messages.len()));
265
266 let mut skip_next_if_tool_response = false;
268
269 for message in &messages {
270 let is_only_tool_response = message.role == rmcp::model::Role::User
272 && message.content.iter().all(|content| {
273 matches!(
274 content,
275 aster::conversation::message::MessageContent::ToolResponse(_)
276 )
277 });
278
279 if skip_next_if_tool_response && is_only_tool_response {
282 markdown_output.push_str(&message_to_markdown(message, false));
284 markdown_output.push_str("\n\n---\n\n");
285 skip_next_if_tool_response = false;
286 continue;
287 }
288
289 skip_next_if_tool_response = false;
291
292 if !is_only_tool_response {
294 let role_prefix = match message.role {
295 rmcp::model::Role::User => "### User:\n",
296 rmcp::model::Role::Assistant => "### Assistant:\n",
297 };
298 markdown_output.push_str(role_prefix);
299 }
300
301 markdown_output.push_str(&message_to_markdown(message, false));
303 markdown_output.push_str("\n\n---\n\n");
304
305 if message.content.iter().any(|content| {
307 matches!(
308 content,
309 aster::conversation::message::MessageContent::ToolRequest(_)
310 )
311 }) {
312 skip_next_if_tool_response = true;
313 }
314 }
315
316 markdown_output
317}
318
319pub async fn prompt_interactive_session_selection() -> Result<String> {
323 let sessions = SessionManager::list_sessions().await?;
324
325 if sessions.is_empty() {
326 return Err(anyhow::anyhow!("No sessions found"));
327 }
328
329 let mut selector = select("Select a session to export:");
331
332 let display_map: std::collections::HashMap<String, Session> = sessions
334 .iter()
335 .map(|s| {
336 let desc = if s.name.is_empty() {
337 "(no name)"
338 } else {
339 &s.name
340 };
341 let truncated_desc = safe_truncate(desc, TRUNCATED_DESC_LENGTH);
342
343 let display_text = format!("{} - {} ({})", s.updated_at, truncated_desc, s.id);
344 (display_text, s.clone())
345 })
346 .collect();
347
348 for display_text in display_map.keys() {
350 selector = selector.item(display_text.clone(), display_text.clone(), "");
351 }
352
353 let cancel_value = String::from("cancel");
355 selector = selector.item(cancel_value, "Cancel", "Cancel export");
356
357 let selected_display_text: String = selector.interact()?;
359
360 if selected_display_text == "cancel" {
361 return Err(anyhow::anyhow!("Export canceled"));
362 }
363
364 if let Some(session) = display_map.get(&selected_display_text) {
366 Ok(session.id.clone())
367 } else {
368 Err(anyhow::anyhow!("Invalid selection"))
369 }
370}