diff --git a/src/channels/cli_chat.rs b/src/channels/cli_chat.rs index 100336f..70e25b6 100644 --- a/src/channels/cli_chat.rs +++ b/src/channels/cli_chat.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage}; use crate::session::{SessionCommand, SessionEvent, UnifiedSessionId}; -use crate::protocol::{parse_inbound, WsInbound, WsOutbound}; +use crate::protocol::{parse_inbound, WsInbound, WsOutbound, SlashCommandInfo}; use super::base::{Channel, ChannelError}; use super::slash_command::parse_slash_command; @@ -435,6 +435,43 @@ impl CliChatChannel { } } } + WsInbound::GetSlashCommands => { + // Get commands from session manager via control message + let (reply_tx, mut reply_rx) = mpsc::channel(1); + bus.publish_control(ControlMessage { + op: SessionCommand::GetSlashCommands { + channel: "cli_chat".to_string(), + chat_id: "".to_string(), + }, + reply_tx, + }).await?; + + if let Some(result) = reply_rx.recv().await { + match result { + Ok(SessionEvent::SlashCommandsList { commands }) => { + // Convert to SlashCommand to SlashCommandInfo + let command_infos: Vec = commands.into_iter().map(|cmd| { + SlashCommandInfo { + name: cmd.name.to_string(), + description: cmd.description.to_string(), + aliases: cmd.aliases.iter().map(|&a| a.to_string()).collect(), + } + }).collect(); + let _ = client.sender.send(WsOutbound::SlashCommandsList { commands: command_infos }).await; + } + Ok(SessionEvent::Error { code, message }) => { + let _ = client.sender.send(WsOutbound::Error { code, message }).await; + } + Err(e) => { + let _ = client.sender.send(WsOutbound::Error { + code: "GET_COMMANDS_ERROR".to_string(), + message: e.to_string() + }).await; + } + _ => {} + } + } + } WsInbound::Ping => { let _ = client.sender.send(WsOutbound::Pong).await; } diff --git a/src/client/mod.rs b/src/client/mod.rs index 3d770bc..62879d7 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -10,7 +10,7 @@ use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use futures_util::StreamExt; +use futures_util::{SinkExt, StreamExt}; use ratatui::{prelude::CrosstermBackend, Terminal}; use std::io; use tokio_tungstenite::{connect_async, tungstenite::Message}; @@ -25,7 +25,6 @@ pub async fn run(gateway_url: &str) -> Result<(), Box> { app.ws_sender = Some(ws_sender); app.ws_receiver = Some(ws_receiver); - // 初始化终端 enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; @@ -35,7 +34,7 @@ pub async fn run(gateway_url: &str) -> Result<(), Box> { let result = run_app(&mut terminal, app).await; - // 清理终端 - 确保正确的顺序,忽略错误 + // Cleanup terminal, ignore errors let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); let _ = disable_raw_mode(); let _ = terminal.show_cursor(); @@ -50,6 +49,14 @@ async fn run_app( let mut ws_receiver = app.ws_receiver.take().unwrap(); let mut event_reader = event::EventStream::new(); + // Request command list on startup + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::GetSlashCommands; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(Message::Text(text.into())).await; + } + } + loop { terminal.draw(|f| render_ui(f, &app))?; @@ -58,7 +65,7 @@ async fn run_app( match msg { Some(Ok(Message::Text(text))) => { if let Ok(outbound) = serde_json::from_str::(&text) { - handle_ws_message(&mut app, outbound); + handle_ws_message(&mut app, outbound).await; } } Some(Ok(Message::Close(_))) | None => { @@ -83,7 +90,7 @@ async fn run_app( Ok(()) } -fn handle_ws_message(app: &mut App, outbound: WsOutbound) { +async fn handle_ws_message(app: &mut App, outbound: WsOutbound) { match outbound { WsOutbound::AssistantResponse { content, .. } => { app.add_message(MessageRole::Assistant, content); @@ -116,6 +123,12 @@ fn handle_ws_message(app: &mut App, outbound: WsOutbound) { WsOutbound::HistoryCleared { .. } => { app.messages.clear(); } - _ => {} + WsOutbound::SlashCommandsList { commands } => { + app.set_commands(commands); + } + WsOutbound::Pong => {} + WsOutbound::CommandExecuted { message } => { + app.add_message(MessageRole::System, message); + } } } diff --git a/src/client/tui/app.rs b/src/client/tui/app.rs index 3aae7ef..25e45da 100644 --- a/src/client/tui/app.rs +++ b/src/client/tui/app.rs @@ -1,4 +1,4 @@ -use crate::protocol::SessionSummary; +use crate::protocol::{SessionSummary, SlashCommandInfo}; use std::collections::VecDeque; use tokio_tungstenite::tungstenite::Message; @@ -53,6 +53,11 @@ pub struct App { pub chat_scroll_offset: u16, pub session_scroll_offset: u16, pub should_quit: bool, + + // Command menu state + pub commands: Vec, + pub show_command_menu: bool, + pub selected_command_idx: u16, } impl App { @@ -71,6 +76,9 @@ impl App { chat_scroll_offset: 0, session_scroll_offset: 0, should_quit: false, + commands: Vec::new(), + show_command_menu: false, + selected_command_idx: 0, } } @@ -152,4 +160,57 @@ impl App { pub fn quit(&mut self) { self.should_quit = true; } + + // Command menu methods + pub fn set_commands(&mut self, commands: Vec) { + self.commands = commands; + } + + pub fn get_filtered_commands(&self) -> Vec<&SlashCommandInfo> { + let input_lower = self.input.to_lowercase(); + self.commands + .iter() + .filter(|cmd| { + cmd.name.to_lowercase().contains(&input_lower) + || cmd.description.to_lowercase().contains(&input_lower) + || cmd + .aliases + .iter() + .any(|a| a.to_lowercase().contains(&input_lower)) + }) + .collect() + } + + pub fn select_next_command(&mut self) { + let filtered = self.get_filtered_commands(); + if !filtered.is_empty() { + self.selected_command_idx = (self.selected_command_idx + 1) % filtered.len() as u16; + } + } + + pub fn select_prev_command(&mut self) { + let filtered = self.get_filtered_commands(); + if !filtered.is_empty() { + self.selected_command_idx = if self.selected_command_idx == 0 { + filtered.len() as u16 - 1 + } else { + self.selected_command_idx - 1 + }; + } + } + + pub fn get_selected_command(&self) -> Option<&SlashCommandInfo> { + let filtered = self.get_filtered_commands(); + filtered.get(self.selected_command_idx as usize).copied() + } + + pub fn insert_command(&mut self) { + if let Some(cmd) = self.get_selected_command() { + // Use the first alias as the command to insert + if let Some(alias) = cmd.aliases.first() { + self.input = alias.clone(); + self.input_cursor_pos = self.input.len(); + } + } + } } diff --git a/src/client/tui/components/command_menu.rs b/src/client/tui/components/command_menu.rs new file mode 100644 index 0000000..f496cb7 --- /dev/null +++ b/src/client/tui/components/command_menu.rs @@ -0,0 +1,52 @@ +use crate::client::tui::app::App; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem}, + Frame, +}; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let filtered = app.get_filtered_commands(); + + if filtered.is_empty() { + return; + } + + let items: Vec = filtered + .iter() + .enumerate() + .map(|(i, cmd)| { + let is_selected = i == app.selected_command_idx as usize; + let style = if is_selected { + Style::default() + .fg(Color::White) + .bg(Color::Blue) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + // Show first alias as the command + let alias = cmd.aliases.first().map(|a| a.as_str()).unwrap_or(&cmd.name); + + ListItem::new(Line::from(vec![ + Span::styled(alias, style.clone()), + Span::styled(" - ", Style::default().fg(Color::Gray)), + Span::styled(&cmd.description, style), + ])) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title("Commands") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + + f.render_widget(list, area); +} diff --git a/src/client/tui/components/mod.rs b/src/client/tui/components/mod.rs index 5fc9a27..fbc89e9 100644 --- a/src/client/tui/components/mod.rs +++ b/src/client/tui/components/mod.rs @@ -1,4 +1,5 @@ pub mod chat_history; +pub mod command_menu; pub mod help_popup; pub mod input_area; pub mod session_list; diff --git a/src/client/tui/event.rs b/src/client/tui/event.rs index e5148f7..c3e1c7c 100644 --- a/src/client/tui/event.rs +++ b/src/client/tui/event.rs @@ -14,7 +14,39 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) { } return; } - + + if app.show_command_menu { + match key.code { + KeyCode::Esc => { + app.show_command_menu = false; + app.selected_command_idx = 0; + } + KeyCode::Up => { + app.select_prev_command(); + } + KeyCode::Down => { + app.select_next_command(); + } + KeyCode::Enter => { + app.insert_command(); + app.show_command_menu = false; + app.selected_command_idx = 0; + } + KeyCode::Tab => { + app.insert_command(); + } + _ => { + // Handle normal input and check if menu should stay open + handle_normal_input(app, key).await; + } + } + return; + } + + handle_normal_input(app, key).await; +} + +async fn handle_normal_input(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Esc | KeyCode::Char('q') => { app.quit(); @@ -24,9 +56,23 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) { } KeyCode::Char(c) => { app.input_insert_char(c); + + // Show command menu when input starts with / + if !app.show_command_menu && (app.input == "/" || (app.input.len() > 1 && app.input.starts_with('/'))) { + app.show_command_menu = true; + app.selected_command_idx = 0; + } else if app.show_command_menu && !app.input.starts_with('/') { + app.show_command_menu = false; + } } KeyCode::Backspace => { app.input_delete_char(); + + // Hide menu if input no longer starts with / + if app.show_command_menu && !app.input.starts_with('/') { + app.show_command_menu = false; + app.selected_command_idx = 0; + } } KeyCode::Left => { app.input_move_cursor_left(); @@ -48,6 +94,8 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) { } KeyCode::Enter => { let input = app.take_input(); + app.show_command_menu = false; + app.selected_command_idx = 0; if !input.is_empty() { process_input(app, input).await; } @@ -57,140 +105,16 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) { } async fn process_input(app: &mut App, input: String) { - let trimmed = input.trim(); - - if let Some(cmd) = parse_command(trimmed) { - match cmd { - InputCommand::Quit => { - app.quit(); - } - InputCommand::Help => { - app.toggle_help(); - } - InputCommand::Clear => { - if let Some(session_id) = &app.current_session_id { - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::ClearHistory { - chat_id: None, - session_id: Some(session_id.clone()), - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; - } - } - } - } - InputCommand::New(title) => { - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::CreateSession { title }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; - } - } - } - InputCommand::Sessions => { - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::ListSessions { - include_archived: true, - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; - } - } - } - InputCommand::Use(session_id) => { - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::LoadSession { session_id }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; - } - } - } - InputCommand::Rename(title) => { - if let Some(session_id) = &app.current_session_id { - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::RenameSession { - session_id: Some(session_id.clone()), - title, - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; - } - } - } - } - InputCommand::Archive => { - if let Some(session_id) = &app.current_session_id { - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::ArchiveSession { - session_id: Some(session_id.clone()), - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; - } - } - } - } - InputCommand::Delete => { - if let Some(session_id) = &app.current_session_id { - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::DeleteSession { - session_id: Some(session_id.clone()), - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; - } - } - } - } - } - } else { - app.add_message(MessageRole::User, input.clone()); - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::UserInput { - content: input, - chat_id: app.current_session_id.clone(), - channel: None, - sender_id: None, - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; - } + app.add_message(MessageRole::User, input.clone()); + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::UserInput { + content: input, + chat_id: app.current_session_id.clone(), + channel: None, + sender_id: None, + }; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; } } } - -enum InputCommand { - Quit, - Help, - Clear, - New(Option), - Sessions, - Use(String), - Rename(String), - Archive, - Delete, -} - -fn parse_command(input: &str) -> Option { - if !input.starts_with('/') { - return None; - } - - let parts: Vec<&str> = input.splitn(2, char::is_whitespace).collect(); - let cmd = parts[0]; - let arg = parts.get(1).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); - - match cmd { - "/quit" | "/exit" | "/q" => Some(InputCommand::Quit), - "/help" | "/?" => Some(InputCommand::Help), - "/clear" | "/reset" => Some(InputCommand::Clear), - "/new" => Some(InputCommand::New(arg)), - "/sessions" => Some(InputCommand::Sessions), - "/use" => arg.map(InputCommand::Use), - "/rename" => arg.map(InputCommand::Rename), - "/archive" => Some(InputCommand::Archive), - "/delete" => Some(InputCommand::Delete), - _ => None, - } -} diff --git a/src/client/tui/ui.rs b/src/client/tui/ui.rs index 4e89c2a..994e86a 100644 --- a/src/client/tui/ui.rs +++ b/src/client/tui/ui.rs @@ -1,12 +1,10 @@ use crate::client::tui::app::App; +use crate::client::tui::components::*; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, Frame, }; -use super::components::*; - pub fn render_ui(f: &mut Frame, app: &App) { let size = f.size(); let chunks = Layout::default() @@ -30,9 +28,27 @@ pub fn render_ui(f: &mut Frame, app: &App) { input_area::render(f, chunks[2], app); + // Render command menu if needed - position above input area + if app.show_command_menu && !app.get_filtered_commands().is_empty() { + let menu_area = menu_above_input(chunks[2]); + command_menu::render(f, menu_area, app); + } + if app.show_help { - let area = centered_rect(60, 60, size); - help_popup::render(f, area); + let help_area = centered_rect(60, 60, size); + help_popup::render(f, help_area); + } +} + +fn menu_above_input(input_area: Rect) -> Rect { + let max_commands = 6; // Show up to 6 commands + let menu_height = max_commands + 2; // +2 for borders + + Rect { + x: input_area.x + 1, + y: input_area.y.saturating_sub(menu_height), + width: input_area.width.saturating_sub(2), + height: menu_height, } } diff --git a/src/protocol.rs b/src/protocol.rs index a0fc8b9..b4aa2e7 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -12,6 +12,13 @@ pub struct SessionSummary { pub archived_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlashCommandInfo { + pub name: String, + pub description: String, + pub aliases: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum WsInbound { @@ -43,9 +50,7 @@ pub enum WsInbound { include_archived: bool, }, #[serde(rename = "load_session")] - LoadSession { - session_id: String, - }, + LoadSession { session_id: String }, #[serde(rename = "rename_session")] RenameSession { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -62,6 +67,8 @@ pub enum WsInbound { #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, }, + #[serde(rename = "get_slash_commands")] + GetSlashCommands, #[serde(rename = "ping")] Ping, } @@ -70,7 +77,11 @@ pub enum WsInbound { #[serde(tag = "type")] pub enum WsOutbound { #[serde(rename = "assistant_response")] - AssistantResponse { id: String, content: String, role: String }, + AssistantResponse { + id: String, + content: String, + role: String, + }, #[serde(rename = "error")] Error { code: String, message: String }, #[serde(rename = "session_established")] @@ -97,6 +108,8 @@ pub enum WsOutbound { SessionDeleted { session_id: String }, #[serde(rename = "history_cleared")] HistoryCleared { session_id: String }, + #[serde(rename = "slash_commands_list")] + SlashCommandsList { commands: Vec }, #[serde(rename = "pong")] Pong, #[serde(rename = "command_executed")]