diff --git a/CHANGELOG.md b/CHANGELOG.md index aa073e4c..b4459e64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 count. Mirrored under `storage.{spillover,stash}` in the JSON output so `deepseek doctor --json` keeps a stable schema. +- **`/hooks events` subcommand** (#460 polish) — lists every + supported `HookEvent` value with a short blurb so users can + discover which events to target in `[[hooks.hooks]]` entries + without reading source. Ordered lifecycle → per-tool → + situational, stable across releases. - **RLM tool family** (#512) — `rlm` tool cards map to `ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm" wording cleaned out of docs / comments / tests. diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index 417fae27..837faa6a 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -13,18 +13,63 @@ use super::CommandResult; /// Top-level dispatch for `/hooks`. Subcommands: /// -/// * `/hooks` — same as `/hooks list`. -/// * `/hooks list` — show every configured hook grouped by event, +/// * `/hooks` — same as `/hooks list`. +/// * `/hooks list` — show every configured hook grouped by event, /// noting whether the global `[hooks].enabled` flag suppresses /// them. +/// * `/hooks events` — list every supported `HookEvent` value the +/// user can target in `[[hooks.hooks]]` entries. Useful for +/// discovery — without this, the only way to learn the event +/// names is to read source. pub fn hooks(app: &App, arg: Option<&str>) -> CommandResult { let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase(); match sub.as_str() { "" | "list" | "ls" | "show" => list(app), - other => CommandResult::error(format!("unknown subcommand `{other}`. Try `/hooks list`.")), + "events" | "event" | "list-events" => events(), + other => CommandResult::error(format!( + "unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`." + )), } } +fn events() -> CommandResult { + let mut out = String::new(); + out.push_str( + "Available hook events (use one of these as `event = \"...\"` in your `[[hooks.hooks]]` entry):\n\n", + ); + // Order matters — group lifecycle events first, then per-tool, + // then situational. Stays stable across releases so users can + // grep on it. + let ordered = [ + (HookEvent::SessionStart, "fires once when the TUI launches"), + (HookEvent::SessionEnd, "fires once on graceful shutdown"), + ( + HookEvent::MessageSubmit, + "fires when the user submits a turn (before model dispatch)", + ), + ( + HookEvent::ToolCallBefore, + "fires before each tool call (read-only observer for now)", + ), + ( + HookEvent::ToolCallAfter, + "fires after each tool call (read-only observer for now)", + ), + ( + HookEvent::ModeChange, + "fires on Plan/Agent/Yolo transitions", + ), + ( + HookEvent::OnError, + "fires on transport / capacity / tool errors", + ), + ]; + for (event, desc) in ordered { + out.push_str(&format!(" - `{}` — {desc}\n", event_label(event))); + } + CommandResult::message(out.trim_end().to_string()) +} + fn list(app: &App) -> CommandResult { let config = app.hooks.config(); if config.hooks.is_empty() { @@ -203,6 +248,44 @@ mod tests { ); } + #[test] + fn events_subcommand_lists_every_event_variant_in_documented_order() { + let result = events(); + let body = result.message.expect("non-empty body"); + let positions: Vec<(usize, &str)> = [ + "session_start", + "session_end", + "message_submit", + "tool_call_before", + "tool_call_after", + "mode_change", + "on_error", + ] + .iter() + .map(|name| { + ( + body.find(name).unwrap_or_else(|| { + panic!("event `{name}` missing from /hooks events output:\n{body}") + }), + *name, + ) + }) + .collect(); + // Documented order is lifecycle → tool-call → situational. + // Each subsequent position must be greater than the previous. + for window in positions.windows(2) { + let (a_pos, a_name) = window[0]; + let (b_pos, b_name) = window[1]; + assert!( + a_pos < b_pos, + "expected `{a_name}` before `{b_name}` in events listing" + ); + } + // Each event line includes the descriptive blurb. + assert!(body.contains("fires once when the TUI launches")); + assert!(body.contains("read-only observer")); + } + #[test] fn event_label_covers_every_variant() { // Compile-time `match` exhaustiveness; this just sanity-checks diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 4e97acfa..207d0edf 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -182,7 +182,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "hooks", aliases: &["hook"], - usage: "/hooks [list]", + usage: "/hooks [list|events]", description_id: MessageId::CmdHooksDescription, }, CommandInfo {