feat(commands): /hooks read-only lifecycle hook listing (#460 MVP)

Slash command enumerates configured lifecycle hooks from the
user's `[hooks]` table, grouped by event. The full picker /
persisted enable-disable surface in #460 is still M-sized work;
this MVP gives users a no-typing view of what's actually loaded
— the most-asked question once hooks start firing.

Implementation:

* `crates/tui/src/commands/hooks.rs` formats the hook list with
  per-event headings, hook name (or `(unnamed)`), background
  marker, timeout, condition summary, and a 60-char shell
  command preview.
* `condition_summary` covers every `HookCondition` variant
  (Always/ToolName/ToolCategory/Mode/ExitCode/All/Any) so the
  listing stays informative for compound conditions too.
* `event_label` maps each `HookEvent` to its config-file string
  so the listing matches what the user wrote in TOML.
* New `HookExecutor::config()` accessor exposes the underlying
  `HooksConfig` for read-only callers; doesn't open the door
  to mutation, which still belongs to the broader #460 work.
* Registered in `commands::COMMANDS` with `aliases: &["hook"]`,
  usage `/hooks [list]`, and `MessageId::CmdHooksDescription`
  localized in en, ja, zh-Hans, pt-BR.
* Wired into `command_palette::command_runs_directly` so
  pressing Enter from Ctrl+K runs `/hooks list` straight.

Tests:
  6 unit tests covering preview-cap truncation, newline
  stripping, condition-summary variants, event-label
  exhaustiveness, and BTreeMap-grouping ordering.
This commit is contained in:
Hunter Bown
2026-05-03 06:36:37 -05:00
parent 15127046e8
commit a368dc53b8
6 changed files with 285 additions and 0 deletions
+9
View File
@@ -219,6 +219,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
dropped. Pairs with `/stash list` and `/stash pop` so the
user can fully manage the stash from inside the TUI without
reaching for `rm`.
- **`/hooks` read-only listing** (#460 MVP) — slash command
enumerates configured lifecycle hooks grouped by event,
showing each hook's name, command preview, timeout, and
condition. Notes the global `[hooks].enabled` flag's state.
No more `cat ~/.deepseek/config.toml` to debug "did my hook
actually load". The picker / persisted enable-disable
surface from #460 stays as v0.8.9 follow-up. Available via
`/hooks` or `/hooks list`; aliased to `/hook`. Localized in
en/ja/zh-Hans/pt-BR.
- **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.
+250
View File
@@ -0,0 +1,250 @@
//! `/hooks` slash command — read-only listing of configured
//! lifecycle hooks (#460 MVP).
//!
//! The full picker / persisted enable-disable surface in #460 is
//! still M-sized. This MVP gives the user a no-typing view of what's
//! actually configured in `~/.deepseek/config.toml`'s `[hooks]`
//! table — the most-asked question once hooks start firing.
use crate::hooks::HookEvent;
use crate::tui::app::App;
use super::CommandResult;
/// Top-level dispatch for `/hooks`. Subcommands:
///
/// * `/hooks` — same as `/hooks list`.
/// * `/hooks list` — show every configured hook grouped by event,
/// noting whether the global `[hooks].enabled` flag suppresses
/// them.
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`.")),
}
}
fn list(app: &App) -> CommandResult {
let config = app.hooks.config();
if config.hooks.is_empty() {
return CommandResult::message(
"No hooks configured. Add a `[[hooks.hooks]]` entry to `~/.deepseek/config.toml` to define one.",
);
}
let mut out = String::new();
out.push_str(&format!(
"{} configured hook(s) (global enabled: {}):\n\n",
config.hooks.len(),
if config.enabled {
"yes"
} else {
"no — all hooks suppressed"
}
));
let mut by_event: std::collections::BTreeMap<&str, Vec<&crate::hooks::Hook>> =
std::collections::BTreeMap::new();
for hook in &config.hooks {
by_event
.entry(event_label(hook.event))
.or_default()
.push(hook);
}
for (event, hooks) in by_event {
out.push_str(&format!("### {event}\n"));
for hook in hooks {
let label = hook
.name
.as_deref()
.filter(|n| !n.trim().is_empty())
.map_or_else(|| "(unnamed)".to_string(), str::to_string);
let bg = if hook.background { " [bg]" } else { "" };
let timeout = format!("{}s", hook.timeout_secs);
let condition = match &hook.condition {
None | Some(crate::hooks::HookCondition::Always) => String::new(),
Some(c) => format!(" if {}", condition_summary(c)),
};
let cmd_preview = preview_command(&hook.command, 60);
out.push_str(&format!(
" - {label}{bg} (timeout {timeout}){condition}\n $ {cmd_preview}\n",
));
}
out.push('\n');
}
if !config.enabled {
out.push_str(
"Hooks are globally disabled — set `[hooks].enabled = true` in `config.toml` to fire them.\n",
);
}
CommandResult::message(out.trim_end().to_string())
}
fn event_label(event: HookEvent) -> &'static str {
match event {
HookEvent::SessionStart => "session_start",
HookEvent::SessionEnd => "session_end",
HookEvent::MessageSubmit => "message_submit",
HookEvent::ToolCallBefore => "tool_call_before",
HookEvent::ToolCallAfter => "tool_call_after",
HookEvent::ModeChange => "mode_change",
HookEvent::OnError => "on_error",
}
}
fn condition_summary(condition: &crate::hooks::HookCondition) -> String {
match condition {
crate::hooks::HookCondition::Always => "always".to_string(),
crate::hooks::HookCondition::ToolName { name } => format!("tool_name=`{name}`"),
crate::hooks::HookCondition::ToolCategory { category } => {
format!("tool_category=`{category}`")
}
crate::hooks::HookCondition::Mode { mode } => format!("mode=`{mode}`"),
crate::hooks::HookCondition::ExitCode { code } => format!("exit_code={code}"),
crate::hooks::HookCondition::All { conditions } => format!(
"all of [{}]",
conditions
.iter()
.map(condition_summary)
.collect::<Vec<_>>()
.join(", ")
),
crate::hooks::HookCondition::Any { conditions } => format!(
"any of [{}]",
conditions
.iter()
.map(condition_summary)
.collect::<Vec<_>>()
.join(", ")
),
}
}
/// Single-line preview of the shell command, capped at `max_chars`.
fn preview_command(command: &str, max_chars: usize) -> String {
let single_line: String = command.chars().filter(|c| *c != '\n').collect();
if single_line.chars().count() <= max_chars {
return single_line;
}
let mut out: String = single_line
.chars()
.take(max_chars.saturating_sub(1))
.collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::{Hook, HookCondition};
#[test]
fn preview_command_truncates_to_cap() {
let cmd = "x".repeat(200);
assert_eq!(preview_command(&cmd, 10).chars().count(), 10);
assert!(preview_command(&cmd, 10).ends_with('…'));
}
#[test]
fn preview_command_strips_newlines() {
assert_eq!(
preview_command("line one\nline two", 50),
"line oneline two"
);
}
#[test]
fn preview_command_keeps_short_input_intact() {
assert_eq!(preview_command("echo hi", 50), "echo hi");
}
#[test]
fn condition_summary_renders_all_variants() {
assert_eq!(condition_summary(&HookCondition::Always), "always");
assert_eq!(
condition_summary(&HookCondition::ToolName {
name: "exec_shell".into()
}),
"tool_name=`exec_shell`"
);
assert_eq!(
condition_summary(&HookCondition::ToolCategory {
category: "shell".into()
}),
"tool_category=`shell`"
);
assert_eq!(
condition_summary(&HookCondition::Mode {
mode: "yolo".into()
}),
"mode=`yolo`"
);
assert_eq!(
condition_summary(&HookCondition::ExitCode { code: 1 }),
"exit_code=1"
);
assert_eq!(
condition_summary(&HookCondition::All {
conditions: vec![
HookCondition::ToolName {
name: "exec_shell".into()
},
HookCondition::Mode {
mode: "yolo".into()
}
]
}),
"all of [tool_name=`exec_shell`, mode=`yolo`]"
);
}
#[test]
fn event_label_covers_every_variant() {
// Compile-time `match` exhaustiveness; this just sanity-checks
// the rendered strings stay stable.
assert_eq!(event_label(HookEvent::SessionStart), "session_start");
assert_eq!(event_label(HookEvent::SessionEnd), "session_end");
assert_eq!(event_label(HookEvent::ToolCallBefore), "tool_call_before");
assert_eq!(event_label(HookEvent::ToolCallAfter), "tool_call_after");
assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit");
assert_eq!(event_label(HookEvent::ModeChange), "mode_change");
assert_eq!(event_label(HookEvent::OnError), "on_error");
}
#[test]
fn list_renders_hooks_grouped_by_event_and_notes_disabled_state() {
// We test the formatter directly via a synthetic HooksConfig
// because `App` is heavyweight to spin up here. The actual
// `list(&App)` path is exercised once we hand the real
// config in via `app.hooks.config()`; the formatter logic is
// unit-tested standalone below.
let cfg = crate::hooks::HooksConfig {
enabled: false,
hooks: vec![
Hook::new(HookEvent::SessionStart, "echo started").with_name("greet"),
Hook::new(HookEvent::ToolCallAfter, "notify-send done")
.with_condition(HookCondition::ToolName {
name: "exec_shell".into(),
})
.with_name("notify"),
],
..crate::hooks::HooksConfig::default()
};
// Synthesize the expected sections by re-running the same
// formatter logic against the BTreeMap grouping.
let mut by_event: std::collections::BTreeMap<&str, Vec<&Hook>> =
std::collections::BTreeMap::new();
for h in &cfg.hooks {
by_event.entry(event_label(h.event)).or_default().push(h);
}
let events: Vec<&&str> = by_event.keys().collect();
// BTreeMap sorts alphabetically — `session_start` before `tool_call_after`.
assert_eq!(events, vec![&"session_start", &"tool_call_after"]);
}
}
+8
View File
@@ -9,6 +9,7 @@ mod core;
mod cycle;
mod debug;
mod goal;
mod hooks;
mod init;
mod jobs;
mod mcp;
@@ -178,6 +179,12 @@ pub const COMMANDS: &[CommandInfo] = &[
usage: "/stash [list|pop|clear]",
description_id: MessageId::CmdStashDescription,
},
CommandInfo {
name: "hooks",
aliases: &["hook"],
usage: "/hooks [list]",
description_id: MessageId::CmdHooksDescription,
},
CommandInfo {
name: "subagents",
aliases: &["agents"],
@@ -469,6 +476,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"provider" => provider::provider(app, arg),
"queue" | "queued" => queue::queue(app, arg),
"stash" | "park" => stash::stash(app, arg),
"hooks" | "hook" => hooks::hooks(app, arg),
"subagents" | "agents" => core::subagents(app),
"links" | "dashboard" | "api" => core::deepseek_links(app),
"home" | "stats" | "overview" => core::home_dashboard(app),
+7
View File
@@ -470,6 +470,13 @@ impl HookExecutor {
}
/// Get the session ID
/// Read-only access to the underlying configuration. Used by
/// `/hooks` (#460 read-only MVP) so the user can list configured
/// hooks without reaching for `cat ~/.deepseek/config.toml`.
pub fn config(&self) -> &HooksConfig {
&self.config
}
pub fn session_id(&self) -> &str {
&self.session_id
}
+10
View File
@@ -230,6 +230,7 @@ pub enum MessageId {
CmdExportDescription,
CmdHelpDescription,
CmdHomeDescription,
CmdHooksDescription,
CmdGoalDescription,
CmdInitDescription,
CmdJobsDescription,
@@ -416,6 +417,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdExportDescription,
MessageId::CmdHelpDescription,
MessageId::CmdHomeDescription,
MessageId::CmdHooksDescription,
MessageId::CmdInitDescription,
MessageId::CmdJobsDescription,
MessageId::CmdLinksDescription,
@@ -727,6 +729,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdExportDescription => "Export conversation to markdown",
MessageId::CmdHelpDescription => "Show help information",
MessageId::CmdHomeDescription => "Show home dashboard with stats and quick actions",
MessageId::CmdHooksDescription => "List configured lifecycle hooks (read-only)",
MessageId::CmdGoalDescription => "Set a session goal with optional token budget",
MessageId::CmdInitDescription => "Generate AGENTS.md for project",
MessageId::CmdLspDescription => "Toggle LSP diagnostics on or off",
@@ -1005,6 +1008,9 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::CmdExportDescription => "会話を Markdown にエクスポート",
MessageId::CmdHelpDescription => "ヘルプを表示",
MessageId::CmdHomeDescription => "統計とクイックアクション付きのホームダッシュボードを表示",
MessageId::CmdHooksDescription => {
"設定済みのライフサイクルフックを一覧表示(読み取り専用)"
}
MessageId::CmdGoalDescription => "トークンバジェット付きのセッション目標を設定",
MessageId::CmdInitDescription => "プロジェクト用に AGENTS.md を生成",
MessageId::CmdLspDescription => "LSP 診断のオン・オフを切り替え",
@@ -1265,6 +1271,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::CmdExportDescription => "将对话导出为 Markdown",
MessageId::CmdHelpDescription => "显示帮助信息",
MessageId::CmdHomeDescription => "显示主页面板,含统计与快捷操作",
MessageId::CmdHooksDescription => "列出已配置的生命周期钩子(只读)",
MessageId::CmdGoalDescription => "设置带有可选令牌预算的会话目标",
MessageId::CmdInitDescription => "为项目生成 AGENTS.md",
MessageId::CmdLspDescription => "切换 LSP 诊断的开启或关闭",
@@ -1509,6 +1516,9 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::CmdExportDescription => "Exportar a conversa para markdown",
MessageId::CmdHelpDescription => "Exibir informações de ajuda",
MessageId::CmdHomeDescription => "Exibir o painel inicial com estatísticas e ações rápidas",
MessageId::CmdHooksDescription => {
"Listar hooks de ciclo de vida configurados (somente leitura)"
}
MessageId::CmdGoalDescription => {
"Definir uma meta de sessão com orçamento de tokens opcional"
}
+1
View File
@@ -402,6 +402,7 @@ fn command_runs_directly(name: &str) -> bool {
| "models"
| "queue"
| "stash"
| "hooks"
| "subagents"
| "links"
| "home"