增强斜杠命令功能,支持参数解析和新命令;实现双重 Ctrl+C 退出确认

This commit is contained in:
xiaoxixi 2026-04-28 00:01:28 +08:00
parent c11eb348f9
commit 61eea62bfc
9 changed files with 216 additions and 36 deletions

View File

@ -123,13 +123,14 @@ impl CliChatChannel {
let session_id = current_session_guard.clone().unwrap(); let session_id = current_session_guard.clone().unwrap();
// Check for slash command // 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 // Send ExecuteSlashCommand via control plane
let (reply_tx, mut reply_rx) = mpsc::channel(1); let (reply_tx, mut reply_rx) = mpsc::channel(1);
let unified_id = UnifiedSessionId::parse(&session_id); let unified_id = UnifiedSessionId::parse(&session_id);
bus.publish_control(ControlMessage { bus.publish_control(ControlMessage {
op: SessionCommand::ExecuteSlashCommand { op: SessionCommand::ExecuteSlashCommand {
command: cmd_name.to_string(), command: cmd_name.to_string(),
args: if cmd_args.is_empty() { None } else { Some(cmd_args.to_string()) },
channel: self.name().to_string(), channel: self.name().to_string(),
chat_id: chat_id.clone(), chat_id: chat_id.clone(),
current_session_id: unified_id, current_session_id: unified_id,

View File

@ -54,6 +54,10 @@ pub struct App {
pub session_scroll_offset: u16, pub session_scroll_offset: u16,
pub should_quit: bool, pub should_quit: bool,
// Quit confirmation state (double Ctrl+C to exit)
pub ctrl_c_count: u8,
pub pending_quit: bool,
// Command menu state // Command menu state
pub commands: Vec<SlashCommandInfo>, pub commands: Vec<SlashCommandInfo>,
pub show_command_menu: bool, pub show_command_menu: bool,
@ -76,6 +80,8 @@ impl App {
chat_scroll_offset: 0, chat_scroll_offset: 0,
session_scroll_offset: 0, session_scroll_offset: 0,
should_quit: false, should_quit: false,
ctrl_c_count: 0,
pending_quit: false,
commands: Vec::new(), commands: Vec::new(),
show_command_menu: false, show_command_menu: false,
selected_command_idx: 0, selected_command_idx: 0,
@ -161,6 +167,28 @@ impl App {
self.should_quit = true; 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 // Command menu methods
pub fn set_commands(&mut self, commands: Vec<SlashCommandInfo>) { pub fn set_commands(&mut self, commands: Vec<SlashCommandInfo>) {
self.commands = commands; self.commands = commands;

View File

@ -10,21 +10,21 @@ pub fn render(f: &mut Frame, area: Rect) {
let help_text = vec![ let help_text = vec![
ListItem::new("Commands:"), ListItem::new("Commands:"),
ListItem::new(" /new [title] - Create new session"), ListItem::new(" /new [title] - Archive current, start new"),
ListItem::new(" /sessions - List all sessions"), ListItem::new(" /sessions - List all conversations"),
ListItem::new(" /use <id> - Load session"), ListItem::new(" /switch <id> - Switch to conversation"),
ListItem::new(" /rename <title> - Rename session"), ListItem::new(" /rename <t> - Rename current conversation"),
ListItem::new(" /archive - Archive session"), ListItem::new(" /archive - Archive current conversation"),
ListItem::new(" /delete - Delete session"), ListItem::new(" /delete - Delete current conversation"),
ListItem::new(" /clear - Clear history"), ListItem::new(" /compact - Trigger context compression"),
ListItem::new(" /help, /? - Show this help"), ListItem::new(" /info - Show session information"),
ListItem::new(" /quit, /q - Quit"),
ListItem::new(""), ListItem::new(""),
ListItem::new("Keyboard:"), ListItem::new("Keyboard:"),
ListItem::new(" Enter - Send message"), ListItem::new(" Enter - Send message"),
ListItem::new(" Esc, q - Quit"), ListItem::new(" Ctrl+C ×2 - Quit"),
ListItem::new(" ? - Show help"), ListItem::new(" ? - Show help"),
ListItem::new(" Arrow keys - Navigate"), ListItem::new(" Arrow keys - Navigate"),
ListItem::new(" / - Show command menu"),
]; ];
let list = List::new(help_text).block( let list = List::new(help_text).block(

View File

@ -7,18 +7,36 @@ use ratatui::{
}; };
pub fn render(f: &mut Frame, area: Rect, app: &App) { pub fn render(f: &mut Frame, area: Rect, app: &App) {
let title = if let Some(session_id) = &app.current_session_id { let (title, style) = if app.pending_quit {
format!("PicoBot | Session: {}", session_id) let msg = if let Some(session_id) = &app.current_session_id {
} else { format!("PicoBot | Session: {} | Press Ctrl+C again to quit", session_id)
"PicoBot".to_string() } else {
}; "PicoBot | Press Ctrl+C again to quit".to_string()
};
let paragraph = Paragraph::new(title) (
.style( 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() Style::default()
.fg(Color::Cyan) .fg(Color::Cyan)
.add_modifier(Modifier::BOLD), .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)); .block(Block::default().borders(Borders::ALL));
f.render_widget(paragraph, area); f.render_widget(paragraph, area);

View File

@ -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) { async fn handle_normal_input(app: &mut App, key: KeyEvent) {
match key.code { // Handle Ctrl+C for quit (double press to exit)
KeyCode::Esc | KeyCode::Char('q') => { let is_ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL);
app.quit(); if is_ctrl_c {
if app.handle_ctrl_c_for_quit() {
return;
} }
} else {
app.cancel_pending_quit();
}
match key.code {
KeyCode::Char('?') => { KeyCode::Char('?') => {
app.toggle_help(); app.toggle_help();
} }

View File

@ -183,8 +183,8 @@ impl GatewayState {
let commands = session_manager.get_slash_commands().to_vec(); let commands = session_manager.get_slash_commands().to_vec();
Ok(SessionEvent::SlashCommandsList { commands }) Ok(SessionEvent::SlashCommandsList { commands })
} }
ExecuteSlashCommand { command, channel, chat_id, current_session_id } => { ExecuteSlashCommand { command, args, channel, chat_id, current_session_id } => {
session_manager.execute_slash_command(&command, &channel, &chat_id, current_session_id.as_ref()) session_manager.execute_slash_command(&command, args.as_deref(), &channel, &chat_id, current_session_id.as_ref())
.await .await
.map(|(new_id, msg)| SessionEvent::SlashCommandExecuted { new_session_id: new_id, message: msg }) .map(|(new_id, msg)| SessionEvent::SlashCommandExecuted { new_session_id: new_id, message: msg })
.map_err(|e| ChannelError::Other(e.to_string())) .map_err(|e| ChannelError::Other(e.to_string()))

View File

@ -51,6 +51,7 @@ pub enum SessionCommand {
/// Execute a slash command /// Execute a slash command
ExecuteSlashCommand { ExecuteSlashCommand {
command: String, command: String,
args: Option<String>,
channel: String, channel: String,
chat_id: String, chat_id: String,
current_session_id: Option<UnifiedSessionId>, current_session_id: Option<UnifiedSessionId>,

View File

@ -59,6 +59,16 @@ pub enum SessionEvent {
new_session_id: Option<UnifiedSessionId>, new_session_id: Option<UnifiedSessionId>,
message: String, message: String,
}, },
/// Context compression completed
ContextCompressed {
original_count: usize,
compressed_count: usize,
},
/// Session information
SessionInfo {
session_id: String,
message_count: usize,
},
/// Error occurred /// Error occurred
Error { Error {
code: String, code: String,

View File

@ -239,9 +239,44 @@ impl SlashCommand {
/// Session 支持的斜杠命令列表 /// Session 支持的斜杠命令列表
pub static SLASH_COMMANDS: &[SlashCommand] = &[ pub static SLASH_COMMANDS: &[SlashCommand] = &[
SlashCommand { SlashCommand {
name: "reset", name: "new",
description: "Start a fresh conversation (archives current dialog)", description: "Archive current conversation and start a new one",
aliases: &["/reset", "/new"], 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( pub async fn execute_slash_command(
&self, &self,
command: &str, command: &str,
args: Option<&str>,
channel: &str, channel: &str,
chat_id: &str, chat_id: &str,
current_session_id: Option<&UnifiedSessionId>, current_session_id: Option<&UnifiedSessionId>,
@ -295,16 +331,95 @@ impl SessionManager {
.ok_or_else(|| AgentError::Other(format!("Unknown command: {}", command)))?; .ok_or_else(|| AgentError::Other(format!("Unknown command: {}", command)))?;
match cmd.name { match cmd.name {
"reset" => { "new" => {
if let Some(sid) = current_session_id { if let Some(sid) = current_session_id {
let unified_str = sid.to_string();
self.store self.store
.archive_session(&unified_str) .archive_session(&sid.to_string())
.map_err(|e| AgentError::Other(format!("archive session error: {}", e)))?; .map_err(|e| AgentError::Other(format!("archive session error: {}", e)))?;
} }
let title = args.map(|s| s.to_string());
let (new_id, _title) = self.create_session(channel, chat_id, None).await?; let (new_id, title) = self.create_session(channel, chat_id, title.as_deref()).await?;
Ok((Some(new_id), "Starting a fresh conversation...".to_string())) 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))), _ => Err(AgentError::Other(format!("Command not implemented: {}", cmd.name))),
} }