Skip to main content

aster_cli/commands/
session.rs

1use 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(&regex_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    // Track if the last message had tool requests to properly handle tool responses
267    let mut skip_next_if_tool_response = false;
268
269    for message in &messages {
270        // Check if this is a User message containing only ToolResponses
271        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 the previous message had tool requests and this one is just tool responses,
280        // don't create a new User section - we'll attach the responses to the tool calls
281        if skip_next_if_tool_response && is_only_tool_response {
282            // Export the tool responses without a User heading
283            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        // Reset the skip flag - we'll update it below if needed
290        skip_next_if_tool_response = false;
291
292        // Output the role prefix except for tool response-only messages
293        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        // Add the message content
302        markdown_output.push_str(&message_to_markdown(message, false));
303        markdown_output.push_str("\n\n---\n\n");
304
305        // Check if this message has any tool requests, to handle the next message differently
306        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
319/// Prompt the user to interactively select a session
320///
321/// Shows a list of available sessions and lets the user select one
322pub 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    // Build the selection prompt
330    let mut selector = select("Select a session to export:");
331
332    // Map to display text
333    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    // Add each session as an option
349    for display_text in display_map.keys() {
350        selector = selector.item(display_text.clone(), display_text.clone(), "");
351    }
352
353    // Add a cancel option
354    let cancel_value = String::from("cancel");
355    selector = selector.item(cancel_value, "Cancel", "Cancel export");
356
357    // Get user selection
358    let selected_display_text: String = selector.interact()?;
359
360    if selected_display_text == "cancel" {
361        return Err(anyhow::anyhow!("Export canceled"));
362    }
363
364    // Retrieve the selected session
365    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}