diff --git a/crates/zeph-tui/src/app.rs b/crates/zeph-tui/src/app.rs index a20466f8..079b0fa7 100644 --- a/crates/zeph-tui/src/app.rs +++ b/crates/zeph-tui/src/app.rs @@ -6,12 +6,14 @@ use ratatui::text::Line; use tokio::sync::{Notify, mpsc, oneshot, watch}; use tracing::debug; +use crate::command::TuiCommand; use crate::event::{AgentEvent, AppEvent}; use crate::hyperlink::HyperlinkSpan; use crate::layout::AppLayout; use crate::metrics::MetricsSnapshot; use crate::theme::Theme; use crate::widgets; +use crate::widgets::command_palette::CommandPaletteState; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RenderCacheKey { @@ -124,6 +126,8 @@ pub struct App { status_label: Option, throbber_state: throbber_widgets_tui::ThrobberState, confirm_state: Option, + command_palette: Option, + command_tx: Option>, pub should_quit: bool, user_input_tx: mpsc::Sender, agent_event_rx: mpsc::Receiver, @@ -162,6 +166,8 @@ impl App { status_label: None, throbber_state: throbber_widgets_tui::ThrobberState::default(), confirm_state: None, + command_palette: None, + command_tx: None, should_quit: false, user_input_tx, agent_event_rx, @@ -236,6 +242,12 @@ impl App { self } + #[must_use] + pub fn with_command_tx(mut self, tx: mpsc::Sender) -> Self { + self.command_tx = Some(tx); + self + } + pub fn poll_metrics(&mut self) { if let Some(ref mut rx) = self.metrics_rx && rx.has_changed().unwrap_or(false) @@ -520,6 +532,18 @@ impl App { msg.diff_data = Some(diff); } } + AgentEvent::CommandResult { output, .. } => { + self.command_palette = None; + self.messages.push(ChatMessage { + role: MessageRole::System, + content: output, + streaming: false, + tool_name: None, + diff_data: None, + filter_stats: None, + }); + self.scroll_offset = 0; + } } } @@ -549,6 +573,10 @@ impl App { widgets::confirm::render(&state.prompt, frame, frame.area()); } + if let Some(palette) = &self.command_palette { + widgets::command_palette::render(palette, frame, frame.area()); + } + if self.show_help { widgets::help::render(frame, frame.area()); } @@ -606,6 +634,11 @@ impl App { return; } + if self.command_palette.is_some() { + self.handle_palette_key(key); + return; + } + match self.input_mode { InputMode::Normal => self.handle_normal_key(key), InputMode::Insert => self.handle_insert_key(key), @@ -626,6 +659,141 @@ impl App { } } + fn handle_palette_key(&mut self, key: KeyEvent) { + let Some(palette) = self.command_palette.as_mut() else { + return; + }; + match key.code { + KeyCode::Esc => { + self.command_palette = None; + } + KeyCode::Enter => { + if let Some(entry) = palette.selected_entry() { + let cmd = entry.command.clone(); + self.execute_command(cmd); + } + self.command_palette = None; + } + KeyCode::Up => { + palette.move_up(); + } + KeyCode::Down => { + palette.move_down(); + } + KeyCode::Backspace => { + palette.pop_char(); + } + KeyCode::Char(c) => { + palette.push_char(c); + } + _ => {} + } + } + + fn execute_command(&mut self, cmd: TuiCommand) { + match cmd { + TuiCommand::SkillList => { + let skills = if self.metrics.active_skills.is_empty() { + "No skills loaded.".to_owned() + } else { + let lines: Vec = self + .metrics + .active_skills + .iter() + .map(|s| format!(" - {s}")) + .collect(); + format!( + "Loaded skills ({}):\n{}", + self.metrics.active_skills.len(), + lines.join("\n") + ) + }; + self.push_system_message(skills); + } + TuiCommand::McpList => { + let tools = if self.metrics.active_mcp_tools.is_empty() { + "No MCP tools available.".to_owned() + } else { + let lines: Vec = self + .metrics + .active_mcp_tools + .iter() + .map(|t| format!(" - {t}")) + .collect(); + format!( + "MCP servers: {} Tools ({}):\n{}", + self.metrics.mcp_server_count, + self.metrics.active_mcp_tools.len(), + lines.join("\n") + ) + }; + self.push_system_message(tools); + } + TuiCommand::MemoryStats => { + let msg = format!( + "Memory stats:\n SQLite messages: {}\n Qdrant available: {}\n Embeddings generated: {}", + self.metrics.sqlite_message_count, + self.metrics.qdrant_available, + self.metrics.embeddings_generated, + ); + self.push_system_message(msg); + } + TuiCommand::ViewCost => { + let msg = format!( + "Cost:\n Spent: ${:.4}\n Prompt tokens: {}\n Completion tokens: {}\n Total tokens: {}\n Cache read: {}\n Cache creation: {}", + self.metrics.cost_spent_cents / 100.0, + self.metrics.prompt_tokens, + self.metrics.completion_tokens, + self.metrics.total_tokens, + self.metrics.cache_read_tokens, + self.metrics.cache_creation_tokens, + ); + self.push_system_message(msg); + } + TuiCommand::ViewTools => { + let tools = if self.metrics.active_mcp_tools.is_empty() { + "No tools available.".to_owned() + } else { + let lines: Vec = self + .metrics + .active_mcp_tools + .iter() + .map(|t| format!(" - {t}")) + .collect(); + format!( + "Available tools ({}):\n{}", + self.metrics.active_mcp_tools.len(), + lines.join("\n") + ) + }; + self.push_system_message(tools); + } + TuiCommand::ViewConfig | TuiCommand::ViewAutonomy => { + if let Some(ref tx) = self.command_tx { + // try_send: capacity 16, user-triggered one at a time — overflow not possible in practice + let _ = tx.try_send(cmd); + } else { + self.push_system_message( + "Config not available (no command channel).".to_owned(), + ); + } + } + } + } + + fn push_system_message(&mut self, content: String) { + self.show_splash = false; + self.messages.push(ChatMessage { + role: MessageRole::System, + content, + streaming: false, + tool_name: None, + diff_data: None, + filter_stats: None, + }); + self.scroll_offset = 0; + } + fn handle_normal_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc if self.is_agent_busy() => { @@ -635,6 +803,9 @@ impl App { } KeyCode::Char('q') => self.should_quit = true, KeyCode::Char('i') => self.input_mode = InputMode::Insert, + KeyCode::Char(':') => { + self.command_palette = Some(CommandPaletteState::new()); + } KeyCode::Up | KeyCode::Char('k') => { self.scroll_offset = self.scroll_offset.saturating_add(1); } @@ -861,7 +1032,8 @@ impl App { self.editing_queued = false; self.pending_count += 1; - // Non-blocking send; if channel full, message is dropped + // Non-blocking send; capacity 32 — silent drop if agent loop is saturated. + // Message is visible in chat but not processed; acceptable for interactive TUI. let _ = self.user_input_tx.try_send(text); } } @@ -2014,4 +2186,188 @@ mod tests { )); } } + + mod command_palette_tests { + use super::*; + + #[test] + fn colon_in_normal_mode_opens_palette() { + let (mut app, _rx, _tx) = make_app(); + app.input_mode = InputMode::Normal; + assert!(app.command_palette.is_none()); + + let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + assert!(app.command_palette.is_some()); + } + + #[test] + fn esc_closes_palette() { + let (mut app, _rx, _tx) = make_app(); + app.input_mode = InputMode::Normal; + app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new()); + + let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + assert!(app.command_palette.is_none()); + } + + #[test] + fn palette_intercepts_all_keys_except_ctrl_c() { + let (mut app, _rx, _tx) = make_app(); + app.input_mode = InputMode::Insert; + app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new()); + + // Typing a char goes to palette, not to input field + let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + assert!(app.input().is_empty()); + let palette = app.command_palette.as_ref().unwrap(); + assert_eq!(palette.query, "s"); + } + + #[test] + fn enter_on_selected_dispatches_command_locally() { + let (mut app, _rx, _tx) = make_app(); + app.input_mode = InputMode::Normal; + // Open palette + let colon = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE); + app.handle_event(AppEvent::Key(colon)).unwrap(); + assert!(app.command_palette.is_some()); + + // Enter on first command (skill:list) + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(enter)).unwrap(); + assert!(app.command_palette.is_none()); + // Should have added a system message + assert!(!app.messages().is_empty()); + assert_eq!(app.messages().last().unwrap().role, MessageRole::System); + } + + #[test] + fn typing_in_palette_filters_commands() { + let (mut app, _rx, _tx) = make_app(); + app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new()); + + let m = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); + let c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE); + let p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE); + app.handle_event(AppEvent::Key(m)).unwrap(); + app.handle_event(AppEvent::Key(c)).unwrap(); + app.handle_event(AppEvent::Key(p)).unwrap(); + + let palette = app.command_palette.as_ref().unwrap(); + assert_eq!(palette.query, "mcp"); + assert_eq!(palette.filtered.len(), 1); + assert_eq!(palette.filtered[0].id, "mcp:list"); + } + + #[test] + fn backspace_in_palette_removes_char() { + let (mut app, _rx, _tx) = make_app(); + app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new()); + + let s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE); + app.handle_event(AppEvent::Key(s)).unwrap(); + assert_eq!(app.command_palette.as_ref().unwrap().query, "s"); + + let bs = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(bs)).unwrap(); + assert!(app.command_palette.as_ref().unwrap().query.is_empty()); + } + + #[test] + fn command_result_event_adds_system_message() { + let (mut app, _rx, _tx) = make_app(); + app.handle_agent_event(AgentEvent::CommandResult { + command_id: "skill:list".to_owned(), + output: "No skills loaded.".to_owned(), + }); + assert_eq!(app.messages().len(), 1); + assert_eq!(app.messages()[0].role, MessageRole::System); + assert_eq!(app.messages()[0].content, "No skills loaded."); + assert!(app.command_palette.is_none()); + } + + #[test] + fn command_result_closes_palette_if_open() { + let (mut app, _rx, _tx) = make_app(); + app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new()); + app.handle_agent_event(AgentEvent::CommandResult { + command_id: "view:config".to_owned(), + output: "config output".to_owned(), + }); + assert!(app.command_palette.is_none()); + } + + #[test] + fn colon_in_insert_mode_types_colon() { + let (mut app, _rx, _tx) = make_app(); + app.input_mode = InputMode::Insert; + let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + assert!(app.command_palette.is_none()); + assert_eq!(app.input(), ":"); + } + + #[test] + fn enter_with_empty_filter_does_not_panic() { + let (mut app, _rx, _tx) = make_app(); + let mut palette = crate::widgets::command_palette::CommandPaletteState::new(); + // type something that matches nothing + for c in "xxxxxxxxxx".chars() { + palette.push_char(c); + } + assert!(palette.filtered.is_empty()); + app.command_palette = Some(palette); + + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(enter)).unwrap(); + // palette should close without crashing, no message added + assert!(app.command_palette.is_none()); + } + + #[test] + fn execute_view_config_with_command_tx_sends_command() { + let (mut app, _rx, _tx) = make_app(); + let (cmd_tx, mut cmd_rx) = mpsc::channel::(16); + app.command_tx = Some(cmd_tx); + + app.execute_command(TuiCommand::ViewConfig); + + let received = cmd_rx.try_recv().expect("command should be sent"); + assert_eq!(received, TuiCommand::ViewConfig); + assert!( + app.messages().is_empty(), + "no system message when channel present" + ); + } + + #[test] + fn execute_view_autonomy_with_command_tx_sends_command() { + let (mut app, _rx, _tx) = make_app(); + let (cmd_tx, mut cmd_rx) = mpsc::channel::(16); + app.command_tx = Some(cmd_tx); + + app.execute_command(TuiCommand::ViewAutonomy); + + let received = cmd_rx.try_recv().expect("command should be sent"); + assert_eq!(received, TuiCommand::ViewAutonomy); + assert!( + app.messages().is_empty(), + "no system message when channel present" + ); + } + + #[test] + fn execute_view_config_without_command_tx_adds_fallback_message() { + let (mut app, _rx, _tx) = make_app(); + assert!(app.command_tx.is_none()); + + app.execute_command(TuiCommand::ViewConfig); + + assert_eq!(app.messages().len(), 1); + assert!(app.messages()[0].content.contains("no command channel")); + } + } } diff --git a/crates/zeph-tui/src/channel.rs b/crates/zeph-tui/src/channel.rs index 2f82fb4d..5d62cf11 100644 --- a/crates/zeph-tui/src/channel.rs +++ b/crates/zeph-tui/src/channel.rs @@ -1,6 +1,7 @@ use tokio::sync::mpsc; use zeph_core::channel::{Channel, ChannelError, ChannelMessage}; +use crate::command::TuiCommand; use crate::event::AgentEvent; #[derive(Debug)] @@ -8,6 +9,7 @@ pub struct TuiChannel { user_input_rx: mpsc::Receiver, agent_event_tx: mpsc::Sender, accumulated: String, + command_rx: Option>, } impl TuiChannel { @@ -20,8 +22,19 @@ impl TuiChannel { user_input_rx, agent_event_tx, accumulated: String::new(), + command_rx: None, } } + + #[must_use] + pub fn with_command_rx(mut self, rx: mpsc::Receiver) -> Self { + self.command_rx = Some(rx); + self + } + + pub fn try_recv_command(&mut self) -> Option { + self.command_rx.as_mut()?.try_recv().ok() + } } impl Channel for TuiChannel { @@ -306,6 +319,31 @@ mod tests { assert!(debug.contains("TuiChannel")); } + #[test] + fn try_recv_command_returns_none_without_receiver() { + let (mut ch, _user_tx, _agent_rx) = make_channel(); + assert!(ch.try_recv_command().is_none()); + } + + #[test] + fn try_recv_command_returns_none_when_empty() { + let (ch, _user_tx, _agent_rx) = make_channel(); + let (_cmd_tx, cmd_rx) = mpsc::channel(16); + let mut ch = ch.with_command_rx(cmd_rx); + assert!(ch.try_recv_command().is_none()); + } + + #[test] + fn try_recv_command_returns_sent_command() { + let (ch, _user_tx, _agent_rx) = make_channel(); + let (cmd_tx, cmd_rx) = mpsc::channel(16); + cmd_tx.try_send(TuiCommand::SkillList).unwrap(); + let mut ch = ch.with_command_rx(cmd_rx); + let cmd = ch.try_recv_command().expect("should receive command"); + assert_eq!(cmd, TuiCommand::SkillList); + assert!(ch.try_recv_command().is_none(), "second call returns None"); + } + #[tokio::test] async fn send_tool_output_bundles_diff_atomically() { let (mut ch, _user_tx, mut agent_rx) = make_channel(); diff --git a/crates/zeph-tui/src/command.rs b/crates/zeph-tui/src/command.rs new file mode 100644 index 00000000..0e94e951 --- /dev/null +++ b/crates/zeph-tui/src/command.rs @@ -0,0 +1,137 @@ +/// Commands that can be sent from TUI to Agent loop. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TuiCommand { + SkillList, + McpList, + MemoryStats, + ViewCost, + ViewTools, + ViewConfig, + ViewAutonomy, +} + +/// Metadata for command palette display and fuzzy matching. +pub struct CommandEntry { + pub id: &'static str, + pub label: &'static str, + pub category: &'static str, + pub command: TuiCommand, +} + +/// Static registry of all available commands. +#[must_use] +pub fn command_registry() -> &'static [CommandEntry] { + static COMMANDS: &[CommandEntry] = &[ + CommandEntry { + id: "skill:list", + label: "List loaded skills", + category: "skill", + command: TuiCommand::SkillList, + }, + CommandEntry { + id: "mcp:list", + label: "List MCP servers and tools", + category: "mcp", + command: TuiCommand::McpList, + }, + CommandEntry { + id: "memory:stats", + label: "Show memory statistics", + category: "memory", + command: TuiCommand::MemoryStats, + }, + CommandEntry { + id: "view:cost", + label: "Show cost breakdown", + category: "view", + command: TuiCommand::ViewCost, + }, + CommandEntry { + id: "view:tools", + label: "List available tools", + category: "view", + command: TuiCommand::ViewTools, + }, + CommandEntry { + id: "view:config", + label: "Show active configuration", + category: "view", + command: TuiCommand::ViewConfig, + }, + CommandEntry { + id: "view:autonomy", + label: "Show autonomy/trust level", + category: "view", + command: TuiCommand::ViewAutonomy, + }, + ]; + COMMANDS +} + +/// Filters commands by case-insensitive substring match on id or label. +#[must_use] +pub fn filter_commands(query: &str) -> Vec<&'static CommandEntry> { + if query.is_empty() { + return command_registry().iter().collect(); + } + let q = query.to_lowercase(); + command_registry() + .iter() + .filter(|e| e.id.to_lowercase().contains(&q) || e.label.to_lowercase().contains(&q)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_seven_commands() { + assert_eq!(command_registry().len(), 7); + } + + #[test] + fn filter_empty_query_returns_all() { + let results = filter_commands(""); + assert_eq!(results.len(), 7); + } + + #[test] + fn filter_by_id_prefix() { + let results = filter_commands("skill"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "skill:list"); + } + + #[test] + fn filter_by_label_substring() { + let results = filter_commands("memory"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "memory:stats"); + } + + #[test] + fn filter_case_insensitive() { + let results = filter_commands("VIEW"); + assert_eq!(results.len(), 4); + } + + #[test] + fn filter_no_match_returns_empty() { + let results = filter_commands("xxxxxx"); + assert!(results.is_empty()); + } + + #[test] + fn filter_partial_label_match() { + let results = filter_commands("cost"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "view:cost"); + } + + #[test] + fn filter_mcp_matches_id_and_label() { + let results = filter_commands("mcp"); + assert!(results.iter().any(|e| e.id == "mcp:list")); + } +} diff --git a/crates/zeph-tui/src/event.rs b/crates/zeph-tui/src/event.rs index 98434210..2dfaf42b 100644 --- a/crates/zeph-tui/src/event.rs +++ b/crates/zeph-tui/src/event.rs @@ -76,6 +76,10 @@ pub enum AgentEvent { }, QueueCount(usize), DiffReady(zeph_core::DiffData), + CommandResult { + command_id: String, + output: String, + }, } pub struct EventReader { diff --git a/crates/zeph-tui/src/lib.rs b/crates/zeph-tui/src/lib.rs index be6ac17c..38eb6320 100644 --- a/crates/zeph-tui/src/lib.rs +++ b/crates/zeph-tui/src/lib.rs @@ -1,5 +1,6 @@ pub mod app; pub mod channel; +pub mod command; pub mod event; pub mod highlight; pub mod hyperlink; @@ -19,6 +20,7 @@ use zeph_core::channel::ChannelError; pub use app::App; pub use channel::TuiChannel; +pub use command::TuiCommand; pub use event::{AgentEvent, AppEvent, CrosstermEventSource, EventReader, EventSource}; pub use metrics::{MetricsCollector, MetricsSnapshot}; diff --git a/crates/zeph-tui/src/widgets/command_palette.rs b/crates/zeph-tui/src/widgets/command_palette.rs new file mode 100644 index 00000000..2fc21c17 --- /dev/null +++ b/crates/zeph-tui/src/widgets/command_palette.rs @@ -0,0 +1,284 @@ +use ratatui::Frame; +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; + +use crate::command::{CommandEntry, filter_commands}; +use crate::layout::centered_rect; +use crate::theme::Theme; + +pub struct CommandPaletteState { + pub query: String, + pub cursor: usize, + pub selected: usize, + pub filtered: Vec<&'static CommandEntry>, +} + +impl CommandPaletteState { + #[must_use] + pub fn new() -> Self { + Self { + query: String::new(), + cursor: 0, + selected: 0, + filtered: filter_commands(""), + } + } + + pub fn push_char(&mut self, c: char) { + let byte_offset = self + .query + .char_indices() + .nth(self.cursor) + .map_or(self.query.len(), |(i, _)| i); + self.query.insert(byte_offset, c); + self.cursor += 1; + self.refilter(); + } + + pub fn pop_char(&mut self) { + if self.cursor > 0 { + let byte_offset = self + .query + .char_indices() + .nth(self.cursor - 1) + .map_or(self.query.len(), |(i, _)| i); + self.query.remove(byte_offset); + self.cursor -= 1; + self.refilter(); + } + } + + pub fn move_up(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + pub fn move_down(&mut self) { + if !self.filtered.is_empty() { + self.selected = (self.selected + 1).min(self.filtered.len() - 1); + } + } + + #[must_use] + pub fn selected_entry(&self) -> Option<&'static CommandEntry> { + self.filtered.get(self.selected).copied() + } + + fn refilter(&mut self) { + self.filtered = filter_commands(&self.query); + if self.filtered.is_empty() { + self.selected = 0; + } else { + self.selected = self.selected.min(self.filtered.len() - 1); + } + } +} + +impl Default for CommandPaletteState { + fn default() -> Self { + Self::new() + } +} + +pub fn render(state: &CommandPaletteState, frame: &mut Frame, area: Rect) { + let theme = Theme::default(); + + #[allow(clippy::cast_possible_truncation)] + let height = (state.filtered.len() as u16 + 4).clamp(6, 20); + let popup = centered_rect(60, height, area); + + frame.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.panel_border) + .title(" Command Palette ") + .title_alignment(Alignment::Center); + + frame.render_widget(block, popup); + + let inner = popup.inner(ratatui::layout::Margin { + horizontal: 1, + vertical: 1, + }); + + if inner.height < 2 { + return; + } + + let query_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 1, + }; + + let query_line = Line::from(vec![ + Span::styled(": ", theme.highlight), + Span::raw(&state.query), + ]); + frame.render_widget(Paragraph::new(query_line), query_area); + + if inner.height < 3 { + return; + } + + let list_area = Rect { + x: inner.x, + y: inner.y + 2, + width: inner.width, + height: inner.height - 2, + }; + + let items: Vec = state + .filtered + .iter() + .enumerate() + .map(|(i, entry)| { + let style = if i == state.selected { + Style::default().bg(theme.highlight.fg.unwrap_or(ratatui::style::Color::Blue)) + } else { + Style::default() + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("{:<20}", entry.id), style.patch(theme.panel_title)), + Span::styled(format!(" {}", entry.label), style), + ])) + }) + .collect(); + + let mut list_state = ListState::default(); + list_state.select(Some(state.selected)); + + frame.render_stateful_widget(List::new(items), list_area, &mut list_state); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::render_to_string; + + #[test] + fn new_state_has_all_commands() { + let state = CommandPaletteState::new(); + assert_eq!(state.filtered.len(), 7); + assert_eq!(state.selected, 0); + assert!(state.query.is_empty()); + assert_eq!(state.cursor, 0); + } + + #[test] + fn push_char_updates_query_and_filters() { + let mut state = CommandPaletteState::new(); + state.push_char('s'); + state.push_char('k'); + assert_eq!(state.query, "sk"); + assert_eq!(state.cursor, 2); + assert_eq!(state.filtered.len(), 1); + assert_eq!(state.filtered[0].id, "skill:list"); + } + + #[test] + fn pop_char_removes_last_char() { + let mut state = CommandPaletteState::new(); + state.push_char('s'); + state.push_char('k'); + state.pop_char(); + assert_eq!(state.query, "s"); + assert_eq!(state.cursor, 1); + } + + #[test] + fn pop_char_on_empty_is_noop() { + let mut state = CommandPaletteState::new(); + state.pop_char(); + assert!(state.query.is_empty()); + assert_eq!(state.cursor, 0); + } + + #[test] + fn move_down_increments_selection() { + let mut state = CommandPaletteState::new(); + assert_eq!(state.selected, 0); + state.move_down(); + assert_eq!(state.selected, 1); + } + + #[test] + fn move_down_clamps_at_last() { + let mut state = CommandPaletteState::new(); + state.selected = 6; + state.move_down(); + assert_eq!(state.selected, 6); + } + + #[test] + fn move_up_decrements_selection() { + let mut state = CommandPaletteState::new(); + state.selected = 3; + state.move_up(); + assert_eq!(state.selected, 2); + } + + #[test] + fn move_up_clamps_at_zero() { + let mut state = CommandPaletteState::new(); + state.selected = 0; + state.move_up(); + assert_eq!(state.selected, 0); + } + + #[test] + fn selected_entry_returns_correct_command() { + let state = CommandPaletteState::new(); + let entry = state.selected_entry().unwrap(); + assert_eq!(entry.id, "skill:list"); + } + + #[test] + fn selected_entry_returns_none_when_empty_filter() { + let mut state = CommandPaletteState::new(); + for c in "xxxxxxxxxx".chars() { + state.push_char(c); + } + assert!(state.selected_entry().is_none()); + } + + #[test] + fn refilter_clamps_selection_to_new_len() { + let mut state = CommandPaletteState::new(); + state.selected = 5; + state.push_char('s'); + state.push_char('k'); + assert!(state.selected < state.filtered.len().max(1)); + } + + #[test] + fn render_command_palette_snapshot() { + let state = CommandPaletteState::new(); + let output = render_to_string(80, 24, |frame, area| { + render(&state, frame, area); + }); + assert!(output.contains("Command Palette")); + assert!(output.contains("skill:list")); + assert!(output.contains("mcp:list")); + } + + #[test] + fn render_with_query() { + let mut state = CommandPaletteState::new(); + state.push_char('v'); + state.push_char('i'); + state.push_char('e'); + state.push_char('w'); + let output = render_to_string(80, 24, |frame, area| { + render(&state, frame, area); + }); + assert!( + output.contains("view:cost") + || output.contains("view:config") + || output.contains("view:tools") + ); + } +} diff --git a/crates/zeph-tui/src/widgets/mod.rs b/crates/zeph-tui/src/widgets/mod.rs index e58f0fe0..2f6414da 100644 --- a/crates/zeph-tui/src/widgets/mod.rs +++ b/crates/zeph-tui/src/widgets/mod.rs @@ -1,4 +1,5 @@ pub mod chat; +pub mod command_palette; pub mod confirm; pub mod diff; pub mod help; diff --git a/src/main.rs b/src/main.rs index 365c69d5..8231a52d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -548,7 +548,8 @@ async fn main() -> anyhow::Result<()> { std::thread::spawn(move || reader.run()); let mut tui_app = App::new(tui_handle.user_tx, tui_handle.agent_rx) - .with_cancel_signal(agent.cancel_signal()); + .with_cancel_signal(agent.cancel_signal()) + .with_command_tx(tui_handle.command_tx); tui_app.set_show_source_labels(config.tui.show_source_labels); let history: Vec<(&str, &str)> = agent @@ -571,6 +572,18 @@ async fn main() -> anyhow::Result<()> { let agent_tx = tui_handle.agent_tx; tokio::spawn(forward_status_to_tui(status_rx, agent_tx.clone())); + tokio::spawn(forward_tui_commands( + tui_handle.command_rx, + agent_tx.clone(), + TuiCommandContext { + provider: format!("{:?}", config.llm.provider), + model: config.llm.model.clone(), + agent_name: config.agent.name.clone(), + semantic_enabled: config.memory.semantic.enabled, + autonomy_level: format!("{:?}", config.security.autonomy_level), + max_tool_iterations: config.agent.max_tool_iterations, + }, + )); if let Some(tool_rx) = shell_executor_for_tui { tokio::spawn(forward_tool_events_to_tui(tool_rx, agent_tx.clone())); @@ -623,6 +636,51 @@ async fn forward_status_to_stderr(mut rx: tokio::sync::mpsc::UnboundedReceiver, + tx: tokio::sync::mpsc::Sender, + ctx: TuiCommandContext, +) { + while let Some(cmd) = rx.recv().await { + let (command_id, output) = match cmd { + zeph_tui::TuiCommand::ViewConfig => { + let text = format!( + "Active configuration:\n Provider: {}\n Model: {}\n Agent name: {}\n Semantic enabled: {}", + ctx.provider, ctx.model, ctx.agent_name, ctx.semantic_enabled, + ); + ("view:config".to_owned(), text) + } + zeph_tui::TuiCommand::ViewAutonomy => { + let text = format!( + "Autonomy level: {}\n Max tool iterations: {}", + ctx.autonomy_level, ctx.max_tool_iterations, + ); + ("view:autonomy".to_owned(), text) + } + _ => continue, + }; + if tx + .send(zeph_tui::AgentEvent::CommandResult { command_id, output }) + .await + .is_err() + { + break; + } + } +} + #[cfg(feature = "tui")] async fn forward_status_to_tui( mut rx: tokio::sync::mpsc::UnboundedReceiver, @@ -841,6 +899,8 @@ struct TuiHandle { user_tx: tokio::sync::mpsc::Sender, agent_tx: tokio::sync::mpsc::Sender, agent_rx: tokio::sync::mpsc::Receiver, + command_tx: tokio::sync::mpsc::Sender, + command_rx: tokio::sync::mpsc::Receiver, } #[cfg(feature = "tui")] @@ -852,11 +912,15 @@ async fn create_channel_with_tui( let (user_tx, user_rx) = tokio::sync::mpsc::channel(32); let (agent_tx, agent_rx) = tokio::sync::mpsc::channel(256); let agent_tx_clone = agent_tx.clone(); + // command_tx goes to App; command_rx is handled by forward_tui_commands task. + let (command_tx, command_rx) = tokio::sync::mpsc::channel::(16); let channel = TuiChannel::new(user_rx, agent_tx); let handle = TuiHandle { user_tx, agent_tx: agent_tx_clone, agent_rx, + command_tx, + command_rx, }; return Ok((AppChannel::Tui(channel), Some(handle))); }