diff --git a/src/channels/cli_chat.rs b/src/channels/cli_chat.rs index 70e25b6..14b9e0f 100644 --- a/src/channels/cli_chat.rs +++ b/src/channels/cli_chat.rs @@ -123,13 +123,14 @@ impl CliChatChannel { let session_id = current_session_guard.clone().unwrap(); // Check for slash command - if let Some((cmd_name, _)) = parse_slash_command(&content) { + if let Some((cmd_name, cmd_args)) = parse_slash_command(&content) { // Send ExecuteSlashCommand via control plane let (reply_tx, mut reply_rx) = mpsc::channel(1); let unified_id = UnifiedSessionId::parse(&session_id); bus.publish_control(ControlMessage { op: SessionCommand::ExecuteSlashCommand { command: cmd_name.to_string(), + args: if cmd_args.is_empty() { None } else { Some(cmd_args.to_string()) }, channel: self.name().to_string(), chat_id: chat_id.clone(), current_session_id: unified_id, diff --git a/src/client/tui/app.rs b/src/client/tui/app.rs index 25e45da..f2f2fe3 100644 --- a/src/client/tui/app.rs +++ b/src/client/tui/app.rs @@ -54,6 +54,10 @@ pub struct App { pub session_scroll_offset: u16, pub should_quit: bool, + // Quit confirmation state (double Ctrl+C to exit) + pub ctrl_c_count: u8, + pub pending_quit: bool, + // Command menu state pub commands: Vec, pub show_command_menu: bool, @@ -76,6 +80,8 @@ impl App { chat_scroll_offset: 0, session_scroll_offset: 0, should_quit: false, + ctrl_c_count: 0, + pending_quit: false, commands: Vec::new(), show_command_menu: false, selected_command_idx: 0, @@ -161,6 +167,28 @@ impl App { self.should_quit = true; } + /// Handle Ctrl+C for quit confirmation (requires double press) + pub fn handle_ctrl_c_for_quit(&mut self) -> bool { + if self.pending_quit { + self.ctrl_c_count += 1; + if self.ctrl_c_count >= 2 { + self.should_quit = true; + return true; + } + false + } else { + self.pending_quit = true; + self.ctrl_c_count = 1; + false + } + } + + /// Cancel pending quit if user presses any other key + pub fn cancel_pending_quit(&mut self) { + self.pending_quit = false; + self.ctrl_c_count = 0; + } + // Command menu methods pub fn set_commands(&mut self, commands: Vec) { self.commands = commands; diff --git a/src/client/tui/components/help_popup.rs b/src/client/tui/components/help_popup.rs index c70fef8..085c1af 100644 --- a/src/client/tui/components/help_popup.rs +++ b/src/client/tui/components/help_popup.rs @@ -10,21 +10,21 @@ pub fn render(f: &mut Frame, area: Rect) { let help_text = vec![ ListItem::new("Commands:"), - ListItem::new(" /new [title] - Create new session"), - ListItem::new(" /sessions - List all sessions"), - ListItem::new(" /use - Load session"), - ListItem::new(" /rename - Rename session"), - ListItem::new(" /archive - Archive session"), - ListItem::new(" /delete - Delete session"), - ListItem::new(" /clear - Clear history"), - ListItem::new(" /help, /? - Show this help"), - ListItem::new(" /quit, /q - Quit"), + ListItem::new(" /new [title] - Archive current, start new"), + ListItem::new(" /sessions - List all conversations"), + ListItem::new(" /switch <id> - Switch to conversation"), + ListItem::new(" /rename <t> - Rename current conversation"), + ListItem::new(" /archive - Archive current conversation"), + ListItem::new(" /delete - Delete current conversation"), + ListItem::new(" /compact - Trigger context compression"), + ListItem::new(" /info - Show session information"), ListItem::new(""), ListItem::new("Keyboard:"), - ListItem::new(" Enter - Send message"), - ListItem::new(" Esc, q - Quit"), - ListItem::new(" ? - Show help"), - ListItem::new(" Arrow keys - Navigate"), + ListItem::new(" Enter - Send message"), + ListItem::new(" Ctrl+C ×2 - Quit"), + ListItem::new(" ? - Show help"), + ListItem::new(" Arrow keys - Navigate"), + ListItem::new(" / - Show command menu"), ]; let list = List::new(help_text).block( diff --git a/src/client/tui/components/title_bar.rs b/src/client/tui/components/title_bar.rs index a0e56e0..9a92d11 100644 --- a/src/client/tui/components/title_bar.rs +++ b/src/client/tui/components/title_bar.rs @@ -7,18 +7,36 @@ use ratatui::{ }; pub fn render(f: &mut Frame, area: Rect, app: &App) { - let title = if let Some(session_id) = &app.current_session_id { - format!("PicoBot | Session: {}", session_id) - } else { - "PicoBot".to_string() - }; - - let paragraph = Paragraph::new(title) - .style( + let (title, style) = if app.pending_quit { + let msg = if let Some(session_id) = &app.current_session_id { + format!("PicoBot | Session: {} | Press Ctrl+C again to quit", session_id) + } else { + "PicoBot | Press Ctrl+C again to quit".to_string() + }; + ( + msg, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + } else if let Some(session_id) = &app.current_session_id { + ( + format!("PicoBot | Session: {}", session_id), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ) + } else { + ( + "PicoBot".to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + }; + + let paragraph = Paragraph::new(title) + .style(style) .block(Block::default().borders(Borders::ALL)); f.render_widget(paragraph, area); diff --git a/src/client/tui/event.rs b/src/client/tui/event.rs index c3e1c7c..9c81110 100644 --- a/src/client/tui/event.rs +++ b/src/client/tui/event.rs @@ -47,10 +47,17 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) { } async fn handle_normal_input(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Esc | KeyCode::Char('q') => { - app.quit(); + // Handle Ctrl+C for quit (double press to exit) + let is_ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL); + if is_ctrl_c { + if app.handle_ctrl_c_for_quit() { + return; } + } else { + app.cancel_pending_quit(); + } + + match key.code { KeyCode::Char('?') => { app.toggle_help(); } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 52d6710..b380494 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -183,8 +183,8 @@ impl GatewayState { let commands = session_manager.get_slash_commands().to_vec(); Ok(SessionEvent::SlashCommandsList { commands }) } - ExecuteSlashCommand { command, channel, chat_id, current_session_id } => { - session_manager.execute_slash_command(&command, &channel, &chat_id, current_session_id.as_ref()) + ExecuteSlashCommand { command, args, channel, chat_id, current_session_id } => { + session_manager.execute_slash_command(&command, args.as_deref(), &channel, &chat_id, current_session_id.as_ref()) .await .map(|(new_id, msg)| SessionEvent::SlashCommandExecuted { new_session_id: new_id, message: msg }) .map_err(|e| ChannelError::Other(e.to_string())) diff --git a/src/session/commands.rs b/src/session/commands.rs index 491bc9a..324f845 100644 --- a/src/session/commands.rs +++ b/src/session/commands.rs @@ -51,6 +51,7 @@ pub enum SessionCommand { /// Execute a slash command ExecuteSlashCommand { command: String, + args: Option<String>, channel: String, chat_id: String, current_session_id: Option<UnifiedSessionId>, diff --git a/src/session/events.rs b/src/session/events.rs index 01d694e..afb3e2a 100644 --- a/src/session/events.rs +++ b/src/session/events.rs @@ -59,6 +59,16 @@ pub enum SessionEvent { new_session_id: Option<UnifiedSessionId>, message: String, }, + /// Context compression completed + ContextCompressed { + original_count: usize, + compressed_count: usize, + }, + /// Session information + SessionInfo { + session_id: String, + message_count: usize, + }, /// Error occurred Error { code: String, diff --git a/src/session/session.rs b/src/session/session.rs index 523607c..0d8bbfd 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -239,9 +239,44 @@ impl SlashCommand { /// Session 支持的斜杠命令列表 pub static SLASH_COMMANDS: &[SlashCommand] = &[ SlashCommand { - name: "reset", - description: "Start a fresh conversation (archives current dialog)", - aliases: &["/reset", "/new"], + name: "new", + description: "Archive current conversation and start a new one", + aliases: &["/new"], + }, + SlashCommand { + name: "sessions", + description: "List all conversations", + aliases: &["/sessions"], + }, + SlashCommand { + name: "switch", + description: "Switch to a specific conversation by ID", + aliases: &["/switch"], + }, + SlashCommand { + name: "rename", + description: "Rename current conversation", + aliases: &["/rename"], + }, + SlashCommand { + name: "archive", + description: "Archive current conversation", + aliases: &["/archive"], + }, + SlashCommand { + name: "delete", + description: "Delete current conversation", + aliases: &["/delete"], + }, + SlashCommand { + name: "compact", + description: "Manually trigger context compression", + aliases: &["/compact"], + }, + SlashCommand { + name: "info", + description: "Print current session information", + aliases: &["/info"], }, ]; @@ -285,6 +320,7 @@ impl SessionManager { pub async fn execute_slash_command( &self, command: &str, + args: Option<&str>, channel: &str, chat_id: &str, current_session_id: Option<&UnifiedSessionId>, @@ -295,16 +331,95 @@ impl SessionManager { .ok_or_else(|| AgentError::Other(format!("Unknown command: {}", command)))?; match cmd.name { - "reset" => { + "new" => { if let Some(sid) = current_session_id { - let unified_str = sid.to_string(); self.store - .archive_session(&unified_str) + .archive_session(&sid.to_string()) .map_err(|e| AgentError::Other(format!("archive session error: {}", e)))?; } - - let (new_id, _title) = self.create_session(channel, chat_id, None).await?; - Ok((Some(new_id), "Starting a fresh conversation...".to_string())) + let title = args.map(|s| s.to_string()); + let (new_id, title) = self.create_session(channel, chat_id, title.as_deref()).await?; + Ok((Some(new_id), format!("New conversation '{}' created.", title))) + } + "sessions" => { + Ok((None, "Fetching sessions list...".to_string())) + } + "switch" => { + let target_id = args + .ok_or_else(|| AgentError::Other("Usage: /switch <session_id>".to_string()))?; + let unified_id = UnifiedSessionId::parse(target_id) + .ok_or_else(|| AgentError::Other("Invalid session ID format".to_string()))?; + Ok((Some(unified_id), format!("Switched to session {}", target_id))) + } + "rename" => { + let title = args + .ok_or_else(|| AgentError::Other("Usage: /rename <title>".to_string()))?; + if let Some(sid) = current_session_id { + self.store + .rename_session(&sid.to_string(), title) + .map_err(|e| AgentError::Other(format!("rename session error: {}", e)))?; + Ok((None, format!("Conversation renamed to '{}'.", title))) + } else { + Ok((None, "No active conversation to rename.".to_string())) + } + } + "archive" => { + if let Some(sid) = current_session_id { + self.store + .archive_session(&sid.to_string()) + .map_err(|e| AgentError::Other(format!("archive session error: {}", e)))?; + Ok((None, "Conversation archived.".to_string())) + } else { + Ok((None, "No active conversation to archive.".to_string())) + } + } + "delete" => { + if let Some(sid) = current_session_id { + self.store + .delete_session(&sid.to_string()) + .map_err(|e| AgentError::Other(format!("delete session error: {}", e)))?; + let (new_id, _title) = self.create_session(channel, chat_id, None).await?; + Ok((Some(new_id), "Conversation deleted. New conversation created.".to_string())) + } else { + Ok((None, "No active conversation to delete.".to_string())) + } + } + "compact" => { + if let Some(sid) = current_session_id { + let session = self.get_or_create_session(sid).await?; + let mut session_guard = session.lock().await; + session_guard.load_history()?; + let original_count = session_guard.get_history().len(); + let history = session_guard.get_history().to_vec(); + let compressed = session_guard.compressor + .compress_if_needed(history) + .await?; + let compressed_count = compressed.len(); + session_guard.clear_history()?; + for msg in compressed { + session_guard.add_message(msg); + } + Ok((None, format!( + "Context compressed: {} → {} messages.", + original_count, compressed_count + ))) + } else { + Ok((None, "No active conversation to compress.".to_string())) + } + } + "info" => { + if let Some(sid) = current_session_id { + let session = self.get_or_create_session(sid).await?; + let session_guard = session.lock().await; + let message_count = session_guard.get_history().len(); + let session_id_str = session_guard.persistent_session_id(); + Ok((None, format!( + "Session ID: {}\nMessage count: {}", + session_id_str, message_count + ))) + } else { + Ok((None, "No active session.".to_string())) + } } _ => Err(AgentError::Other(format!("Command not implemented: {}", cmd.name))), }