Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
358 changes: 357 additions & 1 deletion crates/zeph-tui/src/app.rs

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions crates/zeph-tui/src/channel.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
use tokio::sync::mpsc;
use zeph_core::channel::{Channel, ChannelError, ChannelMessage};

use crate::command::TuiCommand;
use crate::event::AgentEvent;

#[derive(Debug)]
pub struct TuiChannel {
user_input_rx: mpsc::Receiver<String>,
agent_event_tx: mpsc::Sender<AgentEvent>,
accumulated: String,
command_rx: Option<mpsc::Receiver<TuiCommand>>,
}

impl TuiChannel {
Expand All @@ -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<TuiCommand>) -> Self {
self.command_rx = Some(rx);
self
}

pub fn try_recv_command(&mut self) -> Option<TuiCommand> {
self.command_rx.as_mut()?.try_recv().ok()
}
}

impl Channel for TuiChannel {
Expand Down Expand Up @@ -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();
Expand Down
137 changes: 137 additions & 0 deletions crates/zeph-tui/src/command.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
}
4 changes: 4 additions & 0 deletions crates/zeph-tui/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ pub enum AgentEvent {
},
QueueCount(usize),
DiffReady(zeph_core::DiffData),
CommandResult {
command_id: String,
output: String,
},
}

pub struct EventReader {
Expand Down
2 changes: 2 additions & 0 deletions crates/zeph-tui/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod app;
pub mod channel;
pub mod command;
pub mod event;
pub mod highlight;
pub mod hyperlink;
Expand All @@ -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};

Expand Down
Loading
Loading