Skip to main content

zeph_commands/
lib.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Slash command registry, handler trait, and channel sink abstraction for Zeph.
5//!
6//! This crate provides the non-generic infrastructure for slash command dispatch:
7//! - [`ChannelSink`] — minimal async I/O trait replacing the `C: Channel` generic in handlers
8//! - [`CommandOutput`] — exhaustive result type for command execution
9//! - [`SlashCategory`] — grouping enum for `/help` output
10//! - [`CommandInfo`] — static metadata for a registered command
11//! - [`CommandHandler`] — object-safe handler trait (no `C` generic)
12//! - [`CommandRegistry`] — registry with longest-word-boundary dispatch
13//! - [`CommandContext`] — non-generic dispatch context with trait-object fields
14//! - [`traits`] — sub-trait definitions for subsystem access
15//! - [`handlers`] — concrete handler implementations (session, debug)
16//!
17//! # Design
18//!
19//! `CommandRegistry` and `CommandHandler` are non-generic: they operate on [`CommandContext`],
20//! a concrete struct whose fields are trait objects (`&mut dyn DebugAccess`, etc.). `zeph-core`
21//! implements these traits on its internal state types and constructs `CommandContext` at dispatch
22//! time from `Agent<C>` fields.
23//!
24//! This crate does NOT depend on `zeph-core`. A change in `zeph-core`'s agent loop does
25//! not recompile `zeph-commands`.
26
27pub mod commands;
28pub mod context;
29pub mod handlers;
30pub mod sink;
31pub mod traits;
32
33pub use commands::COMMANDS;
34
35pub use context::CommandContext;
36pub use sink::{ChannelSink, NullSink};
37pub use traits::agent::{AgentAccess, NullAgent};
38
39/// Status of a long-horizon goal.
40///
41/// Mirrors `zeph_core::goal::GoalStatus`. Defined here to avoid a dependency cycle
42/// (`zeph-commands` cannot depend on `zeph-core`).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum GoalStatusView {
46    /// Goal is being actively tracked.
47    Active,
48    /// Goal is paused; not injected into context.
49    Paused,
50    /// Goal was marked as achieved. Terminal state.
51    Completed,
52    /// Goal was dismissed. Terminal state.
53    Cleared,
54}
55
56impl GoalStatusView {
57    /// Short ASCII symbol used in TUI status badge.
58    #[must_use]
59    pub fn badge_symbol(self) -> &'static str {
60        match self {
61            Self::Active => "▶",
62            Self::Paused => "⏸",
63            Self::Completed => "✓",
64            Self::Cleared => "✗",
65        }
66    }
67}
68
69/// Lightweight cross-crate snapshot of an active goal.
70///
71/// Produced by [`AgentAccess::active_goal_snapshot`] and consumed by the TUI status bar
72/// and metrics bridge. Contains only display-relevant fields.
73#[derive(Debug, Clone, serde::Serialize)]
74pub struct GoalSnapshot {
75    /// UUID string of the goal.
76    pub id: String,
77    /// Goal text, pre-validated to fit within `max_text_chars`.
78    pub text: String,
79    /// Current FSM status.
80    pub status: GoalStatusView,
81    /// Number of turns completed under this goal.
82    pub turns_used: u64,
83    /// Total tokens consumed across all turns.
84    pub tokens_used: u64,
85    /// Optional token budget (`None` = unlimited).
86    pub token_budget: Option<u64>,
87}
88
89use std::future::Future;
90use std::pin::Pin;
91
92/// Result of executing a slash command.
93///
94/// Replaces the heterogeneous return types of earlier command dispatch with a unified,
95/// exhaustive enum.
96#[derive(Debug)]
97pub enum CommandOutput {
98    /// Send a message to the user via the channel.
99    Message(String),
100    /// Command handled silently; no output (e.g., `/clear`).
101    Silent,
102    /// Exit the agent loop immediately.
103    Exit,
104    /// Continue to the next loop iteration.
105    Continue,
106}
107
108/// Category for grouping commands in `/help` output.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum SlashCategory {
111    /// Session management: `/clear`, `/reset`, `/exit`, etc.
112    Session,
113    /// Model and provider configuration: `/model`, `/provider`, `/guardrail`, etc.
114    Configuration,
115    /// Memory and knowledge: `/memory`, `/graph`, `/compact`, etc.
116    Memory,
117    /// Skill management: `/skill`, `/skills`, etc.
118    Skills,
119    /// Planning and focus: `/plan`, `/focus`, `/sidequest`, etc.
120    Planning,
121    /// Debugging and diagnostics: `/debug-dump`, `/log`, `/lsp`, etc.
122    Debugging,
123    /// External integrations: `/mcp`, `/image`, `/agent`, etc.
124    Integration,
125    /// Advanced and experimental: `/experiment`, `/policy`, `/scheduler`, etc.
126    Advanced,
127}
128
129impl SlashCategory {
130    /// Return the display label for this category in `/help` output.
131    #[must_use]
132    pub fn as_str(self) -> &'static str {
133        match self {
134            Self::Session => "Session",
135            Self::Configuration => "Configuration",
136            Self::Memory => "Memory",
137            Self::Skills => "Skills",
138            Self::Planning => "Planning",
139            Self::Debugging => "Debugging",
140            Self::Integration => "Integration",
141            Self::Advanced => "Advanced",
142        }
143    }
144}
145
146/// Static metadata about a registered command, used for `/help` output generation.
147pub struct CommandInfo {
148    /// Command name including the leading slash, e.g. `"/help"`.
149    pub name: &'static str,
150    /// Argument hint shown after the command name in help, e.g. `"[path]"`.
151    pub args: &'static str,
152    /// One-line description shown in `/help` output.
153    pub description: &'static str,
154    /// Category for grouping in `/help`.
155    pub category: SlashCategory,
156    /// Feature gate label, if this command is conditionally compiled.
157    pub feature_gate: Option<&'static str>,
158}
159
160/// Error type returned by command handlers.
161///
162/// Wraps agent-level errors as a string to avoid depending on `zeph-core`'s `AgentError`.
163/// `zeph-core` converts between `AgentError` and `CommandError` at the dispatch boundary.
164#[derive(Debug, thiserror::Error)]
165#[error("{0}")]
166pub struct CommandError(pub String);
167
168impl CommandError {
169    /// Create a `CommandError` from any displayable value.
170    pub fn new(msg: impl std::fmt::Display) -> Self {
171        Self(msg.to_string())
172    }
173}
174
175/// A slash command handler that can be registered with [`CommandRegistry`].
176///
177/// Implementors must be `Send + Sync` because the registry is constructed at agent
178/// initialization time and handlers may be invoked from async contexts.
179///
180/// # Object safety
181///
182/// The `handle` method uses `Pin<Box<dyn Future>>` instead of `async fn` to remain
183/// object-safe, enabling the registry to store `Box<dyn CommandHandler<Ctx>>`. Slash
184/// commands are user-initiated so the box allocation is negligible.
185pub trait CommandHandler<Ctx: ?Sized>: Send + Sync {
186    /// Command name including the leading slash, e.g. `"/help"`.
187    ///
188    /// Must be unique per registry. Used as the dispatch key.
189    fn name(&self) -> &'static str;
190
191    /// One-line description shown in `/help` output.
192    fn description(&self) -> &'static str;
193
194    /// Argument hint shown after the command name in help, e.g. `"[path]"`.
195    ///
196    /// Return an empty string if the command takes no arguments.
197    fn args_hint(&self) -> &'static str {
198        ""
199    }
200
201    /// Category for grouping in `/help`.
202    fn category(&self) -> SlashCategory;
203
204    /// Feature gate label, if this command is conditionally compiled.
205    fn feature_gate(&self) -> Option<&'static str> {
206        None
207    }
208
209    /// Execute the command.
210    ///
211    /// # Arguments
212    ///
213    /// - `ctx`: Typed access to agent subsystems.
214    /// - `args`: Trimmed text after the command name. Empty string when no args given.
215    ///
216    /// # Errors
217    ///
218    /// Returns `Err(CommandError)` when the command fails. The dispatch site logs and
219    /// reports the error to the user.
220    fn handle<'a>(
221        &'a self,
222        ctx: &'a mut Ctx,
223        args: &'a str,
224    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>;
225}
226
227/// Registry of slash command handlers.
228///
229/// Handlers are stored in a `Vec`, not a `HashMap`, because command count is small (< 40)
230/// and registration happens once at agent initialization. Dispatch performs a linear scan
231/// with longest-word-boundary match to support subcommands.
232///
233/// # Dispatch
234///
235/// See [`CommandRegistry::dispatch`] for the full dispatch algorithm.
236///
237/// # Borrow splitting
238///
239/// When stored as an `Agent<C>` field, the dispatch call site uses `std::mem::take` to
240/// temporarily move the registry out of the agent, construct a context, dispatch, and
241/// restore the registry. This avoids borrow-checker conflicts.
242pub struct CommandRegistry<Ctx: ?Sized> {
243    handlers: Vec<Box<dyn CommandHandler<Ctx>>>,
244}
245
246impl<Ctx: ?Sized> CommandRegistry<Ctx> {
247    /// Create an empty registry.
248    #[must_use]
249    pub fn new() -> Self {
250        Self {
251            handlers: Vec::new(),
252        }
253    }
254
255    /// Register a command handler.
256    ///
257    /// # Panics
258    ///
259    /// Panics if a handler with the same name is already registered.
260    pub fn register(&mut self, handler: impl CommandHandler<Ctx> + 'static) {
261        let name = handler.name();
262        assert!(
263            !self.handlers.iter().any(|h| h.name() == name),
264            "duplicate command name: {name}"
265        );
266        self.handlers.push(Box::new(handler));
267    }
268
269    /// Dispatch a command string to the matching handler.
270    ///
271    /// Returns `None` if the input does not start with `/` or no handler matches.
272    ///
273    /// # Algorithm
274    ///
275    /// 1. Return `None` if `input` does not start with `/`.
276    /// 2. Find all handlers where `input == name` or `input.starts_with(name + " ")`.
277    /// 3. Pick the handler with the longest matching name (subcommand resolution).
278    /// 4. Extract `args = input[name.len()..].trim()`.
279    /// 5. Call `handler.handle(ctx, args)` and return the result.
280    ///
281    /// # Errors
282    ///
283    /// Returns `Some(Err(_))` when the matched handler returns an error.
284    pub async fn dispatch(
285        &self,
286        ctx: &mut Ctx,
287        input: &str,
288    ) -> Option<Result<CommandOutput, CommandError>> {
289        let trimmed = input.trim();
290        if !trimmed.starts_with('/') {
291            return None;
292        }
293
294        let mut best_len: usize = 0;
295        let mut best_idx: Option<usize> = None;
296        for (idx, handler) in self.handlers.iter().enumerate() {
297            let name = handler.name();
298            let matched = trimmed == name
299                || trimmed
300                    .strip_prefix(name)
301                    .is_some_and(|rest| rest.starts_with(' '));
302            if matched && name.len() >= best_len {
303                best_len = name.len();
304                best_idx = Some(idx);
305            }
306        }
307
308        let handler = &self.handlers[best_idx?];
309        let name = handler.name();
310        let args = trimmed[name.len()..].trim();
311        Some(handler.handle(ctx, args).await)
312    }
313
314    /// Find the handler that would be selected for the given input, without dispatching.
315    ///
316    /// Returns `Some((idx, name))` or `None` if no handler matches.
317    /// Primarily used in tests to verify routing.
318    #[must_use]
319    pub fn find_handler(&self, input: &str) -> Option<(usize, &'static str)> {
320        let trimmed = input.trim();
321        if !trimmed.starts_with('/') {
322            return None;
323        }
324        let mut best_len: usize = 0;
325        let mut best: Option<(usize, &'static str)> = None;
326        for (idx, handler) in self.handlers.iter().enumerate() {
327            let name = handler.name();
328            let matched = trimmed == name
329                || trimmed
330                    .strip_prefix(name)
331                    .is_some_and(|rest| rest.starts_with(' '));
332            if matched && name.len() >= best_len {
333                best_len = name.len();
334                best = Some((idx, name));
335            }
336        }
337        best
338    }
339
340    /// List all registered commands for `/help` generation.
341    ///
342    /// Returns metadata in registration order.
343    #[must_use]
344    pub fn list(&self) -> Vec<CommandInfo> {
345        self.handlers
346            .iter()
347            .map(|h| CommandInfo {
348                name: h.name(),
349                args: h.args_hint(),
350                description: h.description(),
351                category: h.category(),
352                feature_gate: h.feature_gate(),
353            })
354            .collect()
355    }
356}
357
358impl<Ctx: ?Sized> Default for CommandRegistry<Ctx> {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use std::future::Future;
368    use std::pin::Pin;
369
370    struct MockCtx;
371
372    struct FixedHandler {
373        name: &'static str,
374        category: SlashCategory,
375    }
376
377    impl CommandHandler<MockCtx> for FixedHandler {
378        fn name(&self) -> &'static str {
379            self.name
380        }
381
382        fn description(&self) -> &'static str {
383            "test handler"
384        }
385
386        fn category(&self) -> SlashCategory {
387            self.category
388        }
389
390        fn handle<'a>(
391            &'a self,
392            _ctx: &'a mut MockCtx,
393            args: &'a str,
394        ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>
395        {
396            let name = self.name;
397            Box::pin(async move { Ok(CommandOutput::Message(format!("{name}:{args}"))) })
398        }
399    }
400
401    fn make_handler(name: &'static str) -> FixedHandler {
402        FixedHandler {
403            name,
404            category: SlashCategory::Session,
405        }
406    }
407
408    #[tokio::test]
409    async fn dispatch_routes_longest_match() {
410        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
411        reg.register(make_handler("/plan"));
412        reg.register(make_handler("/plan confirm"));
413
414        let mut ctx = MockCtx;
415        let out = reg
416            .dispatch(&mut ctx, "/plan confirm foo")
417            .await
418            .unwrap()
419            .unwrap();
420        let CommandOutput::Message(msg) = out else {
421            panic!("expected Message");
422        };
423        assert_eq!(msg, "/plan confirm:foo");
424    }
425
426    #[tokio::test]
427    async fn dispatch_returns_none_for_non_slash() {
428        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
429        reg.register(make_handler("/help"));
430        let mut ctx = MockCtx;
431        assert!(reg.dispatch(&mut ctx, "hello").await.is_none());
432    }
433
434    #[tokio::test]
435    async fn dispatch_returns_none_for_unregistered() {
436        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
437        reg.register(make_handler("/help"));
438        let mut ctx = MockCtx;
439        assert!(reg.dispatch(&mut ctx, "/unknown").await.is_none());
440    }
441
442    #[test]
443    #[should_panic(expected = "duplicate command name")]
444    fn register_panics_on_duplicate() {
445        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
446        reg.register(make_handler("/plan"));
447        reg.register(make_handler("/plan"));
448    }
449
450    #[test]
451    fn list_returns_metadata_in_order() {
452        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
453        reg.register(make_handler("/alpha"));
454        reg.register(make_handler("/beta"));
455        let list = reg.list();
456        assert_eq!(list.len(), 2);
457        assert_eq!(list[0].name, "/alpha");
458        assert_eq!(list[1].name, "/beta");
459    }
460
461    #[test]
462    fn slash_category_as_str_all_variants() {
463        let variants = [
464            (SlashCategory::Session, "Session"),
465            (SlashCategory::Configuration, "Configuration"),
466            (SlashCategory::Memory, "Memory"),
467            (SlashCategory::Skills, "Skills"),
468            (SlashCategory::Planning, "Planning"),
469            (SlashCategory::Debugging, "Debugging"),
470            (SlashCategory::Integration, "Integration"),
471            (SlashCategory::Advanced, "Advanced"),
472        ];
473        for (variant, expected) in variants {
474            assert_eq!(variant.as_str(), expected);
475        }
476    }
477}