返回 DeepSeek-TUI-2026
hooks.rs
根目录 / crates / tui / src / commands / hooks.rs
1 //! `/hooks` slash command — read-only listing of configured
2 //! lifecycle hooks (#460 MVP).
3 //!
4 //! The full picker / persisted enable-disable surface in #460 is
5 //! still M-sized. This MVP gives the user a no-typing view of what's
6 //! actually configured in `~/.deepseek/config.toml`'s `[hooks]`
7 //! table — the most-asked question once hooks start firing.
8
9 use crate::hooks::HookEvent;
10 use crate::tui::app::App;
11
12 use super::CommandResult;
13
14 /// Top-level dispatch for `/hooks`. Subcommands:
15 ///
16 /// * `/hooks` — same as `/hooks list`.
17 /// * `/hooks list` — show every configured hook grouped by event,
18 /// noting whether the global `[hooks].enabled` flag suppresses
19 /// them.
20 /// * `/hooks events` — list every supported `HookEvent` value the
21 /// user can target in `[[hooks.hooks]]` entries. Useful for
22 /// discovery — without this, the only way to learn the event
23 /// names is to read source.
24 pub fn hooks(app: &App, arg: Option<&str>) -> CommandResult {
25 let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase();
26 match sub.as_str() {
27 "" | "list" | "ls" | "show" => list(app),
28 "events" | "event" | "list-events" => events(),
29 other => CommandResult::error(format!(
30 "unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`."
31 )),
32 }
33 }
34
35 fn events() -> CommandResult {
36 let mut out = String::new();
37 out.push_str(
38 "Available hook events (use one of these as `event = \"...\"` in your `[[hooks.hooks]]` entry):\n\n",
39 );
40 // Order matters — group lifecycle events first, then per-tool,
41 // then situational. Stays stable across releases so users can
42 // grep on it.
43 let ordered = [
44 (HookEvent::SessionStart, "fires once when the TUI launches"),
45 (HookEvent::SessionEnd, "fires once on graceful shutdown"),
46 (
47 HookEvent::MessageSubmit,
48 "fires when the user submits a turn (before model dispatch)",
49 ),
50 (
51 HookEvent::ToolCallBefore,
52 "fires before each tool call (read-only observer for now)",
53 ),
54 (
55 HookEvent::ToolCallAfter,
56 "fires after each tool call (read-only observer for now)",
57 ),
58 (
59 HookEvent::ModeChange,
60 "fires on Plan/Agent/Yolo transitions",
61 ),
62 (
63 HookEvent::OnError,
64 "fires on transport / capacity / tool errors",
65 ),
66 ];
67 for (event, desc) in ordered {
68 out.push_str(&format!(" - `{}` — {desc}\n", event_label(event)));
69 }
70 CommandResult::message(out.trim_end().to_string())
71 }
72
73 fn list(app: &App) -> CommandResult {
74 let config = app.hooks.config();
75 if config.hooks.is_empty() {
76 return CommandResult::message(
77 "No hooks configured. Add a `[[hooks.hooks]]` entry to `~/.deepseek/config.toml` to define one.",
78 );
79 }
80
81 let mut out = String::new();
82 out.push_str(&format!(
83 "{} configured hook(s) (global enabled: {}):\n\n",
84 config.hooks.len(),
85 if config.enabled {
86 "yes"
87 } else {
88 "no — all hooks suppressed"
89 }
90 ));
91
92 let mut by_event: std::collections::BTreeMap<&str, Vec<&crate::hooks::Hook>> =
93 std::collections::BTreeMap::new();
94 for hook in &config.hooks {
95 by_event
96 .entry(event_label(hook.event))
97 .or_default()
98 .push(hook);
99 }
100
101 for (event, hooks) in by_event {
102 out.push_str(&format!("### {event}\n"));
103 for hook in hooks {
104 let label = hook
105 .name
106 .as_deref()
107 .filter(|n| !n.trim().is_empty())
108 .map_or_else(|| "(unnamed)".to_string(), str::to_string);
109 let bg = if hook.background { " [bg]" } else { "" };
110 let timeout = format!("{}s", hook.timeout_secs);
111 let condition = match &hook.condition {
112 None | Some(crate::hooks::HookCondition::Always) => String::new(),
113 Some(c) => format!(" if {}", condition_summary(c)),
114 };
115 let cmd_preview = preview_command(&hook.command, 60);
116 out.push_str(&format!(
117 " - {label}{bg} (timeout {timeout}){condition}\n $ {cmd_preview}\n",
118 ));
119 }
120 out.push('\n');
121 }
122
123 if !config.enabled {
124 out.push_str(
125 "Hooks are globally disabled — set `[hooks].enabled = true` in `config.toml` to fire them.\n",
126 );
127 }
128
129 CommandResult::message(out.trim_end().to_string())
130 }
131
132 fn event_label(event: HookEvent) -> &'static str {
133 match event {
134 HookEvent::SessionStart => "session_start",
135 HookEvent::SessionEnd => "session_end",
136 HookEvent::MessageSubmit => "message_submit",
137 HookEvent::ToolCallBefore => "tool_call_before",
138 HookEvent::ToolCallAfter => "tool_call_after",
139 HookEvent::ModeChange => "mode_change",
140 HookEvent::OnError => "on_error",
141 HookEvent::ShellEnv => "shell_env",
142 }
143 }
144
145 fn condition_summary(condition: &crate::hooks::HookCondition) -> String {
146 match condition {
147 crate::hooks::HookCondition::Always => "always".to_string(),
148 crate::hooks::HookCondition::ToolName { name } => format!("tool_name=`{name}`"),
149 crate::hooks::HookCondition::ToolCategory { category } => {
150 format!("tool_category=`{category}`")
151 }
152 crate::hooks::HookCondition::Mode { mode } => format!("mode=`{mode}`"),
153 crate::hooks::HookCondition::ExitCode { code } => format!("exit_code={code}"),
154 crate::hooks::HookCondition::All { conditions } => format!(
155 "all of [{}]",
156 conditions
157 .iter()
158 .map(condition_summary)
159 .collect::<Vec<_>>()
160 .join(", ")
161 ),
162 crate::hooks::HookCondition::Any { conditions } => format!(
163 "any of [{}]",
164 conditions
165 .iter()
166 .map(condition_summary)
167 .collect::<Vec<_>>()
168 .join(", ")
169 ),
170 }
171 }
172
173 /// Single-line preview of the shell command, capped at `max_chars`.
174 fn preview_command(command: &str, max_chars: usize) -> String {
175 let single_line: String = command.chars().filter(|c| *c != '\n').collect();
176 if single_line.chars().count() <= max_chars {
177 return single_line;
178 }
179 let mut out: String = single_line
180 .chars()
181 .take(max_chars.saturating_sub(1))
182 .collect();
183 out.push('…');
184 out
185 }
186
187 #[cfg(test)]
188 mod tests {
189 use super::*;
190 use crate::hooks::{Hook, HookCondition};
191
192 #[test]
193 fn preview_command_truncates_to_cap() {
194 let cmd = "x".repeat(200);
195 assert_eq!(preview_command(&cmd, 10).chars().count(), 10);
196 assert!(preview_command(&cmd, 10).ends_with('…'));
197 }
198
199 #[test]
200 fn preview_command_strips_newlines() {
201 assert_eq!(
202 preview_command("line one\nline two", 50),
203 "line oneline two"
204 );
205 }
206
207 #[test]
208 fn preview_command_keeps_short_input_intact() {
209 assert_eq!(preview_command("echo hi", 50), "echo hi");
210 }
211
212 #[test]
213 fn condition_summary_renders_all_variants() {
214 assert_eq!(condition_summary(&HookCondition::Always), "always");
215 assert_eq!(
216 condition_summary(&HookCondition::ToolName {
217 name: "exec_shell".into()
218 }),
219 "tool_name=`exec_shell`"
220 );
221 assert_eq!(
222 condition_summary(&HookCondition::ToolCategory {
223 category: "shell".into()
224 }),
225 "tool_category=`shell`"
226 );
227 assert_eq!(
228 condition_summary(&HookCondition::Mode {
229 mode: "yolo".into()
230 }),
231 "mode=`yolo`"
232 );
233 assert_eq!(
234 condition_summary(&HookCondition::ExitCode { code: 1 }),
235 "exit_code=1"
236 );
237 assert_eq!(
238 condition_summary(&HookCondition::All {
239 conditions: vec![
240 HookCondition::ToolName {
241 name: "exec_shell".into()
242 },
243 HookCondition::Mode {
244 mode: "yolo".into()
245 }
246 ]
247 }),
248 "all of [tool_name=`exec_shell`, mode=`yolo`]"
249 );
250 }
251
252 #[test]
253 fn events_subcommand_lists_every_event_variant_in_documented_order() {
254 let result = events();
255 let body = result.message.expect("non-empty body");
256 let positions: Vec<(usize, &str)> = [
257 "session_start",
258 "session_end",
259 "message_submit",
260 "tool_call_before",
261 "tool_call_after",
262 "mode_change",
263 "on_error",
264 ]
265 .iter()
266 .map(|name| {
267 (
268 body.find(name).unwrap_or_else(|| {
269 panic!("event `{name}` missing from /hooks events output:\n{body}")
270 }),
271 *name,
272 )
273 })
274 .collect();
275 // Documented order is lifecycle → tool-call → situational.
276 // Each subsequent position must be greater than the previous.
277 for window in positions.windows(2) {
278 let (a_pos, a_name) = window[0];
279 let (b_pos, b_name) = window[1];
280 assert!(
281 a_pos < b_pos,
282 "expected `{a_name}` before `{b_name}` in events listing"
283 );
284 }
285 // Each event line includes the descriptive blurb.
286 assert!(body.contains("fires once when the TUI launches"));
287 assert!(body.contains("read-only observer"));
288 }
289
290 #[test]
291 fn event_label_covers_every_variant() {
292 // Compile-time `match` exhaustiveness; this just sanity-checks
293 // the rendered strings stay stable.
294 assert_eq!(event_label(HookEvent::SessionStart), "session_start");
295 assert_eq!(event_label(HookEvent::SessionEnd), "session_end");
296 assert_eq!(event_label(HookEvent::ToolCallBefore), "tool_call_before");
297 assert_eq!(event_label(HookEvent::ToolCallAfter), "tool_call_after");
298 assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit");
299 assert_eq!(event_label(HookEvent::ModeChange), "mode_change");
300 assert_eq!(event_label(HookEvent::OnError), "on_error");
301 }
302
303 #[test]
304 fn list_renders_hooks_grouped_by_event_and_notes_disabled_state() {
305 // We test the formatter directly via a synthetic HooksConfig
306 // because `App` is heavyweight to spin up here. The actual
307 // `list(&App)` path is exercised once we hand the real
308 // config in via `app.hooks.config()`; the formatter logic is
309 // unit-tested standalone below.
310 let cfg = crate::hooks::HooksConfig {
311 enabled: false,
312 hooks: vec![
313 Hook::new(HookEvent::SessionStart, "echo started").with_name("greet"),
314 Hook::new(HookEvent::ToolCallAfter, "notify-send done")
315 .with_condition(HookCondition::ToolName {
316 name: "exec_shell".into(),
317 })
318 .with_name("notify"),
319 ],
320 ..crate::hooks::HooksConfig::default()
321 };
322
323 // Synthesize the expected sections by re-running the same
324 // formatter logic against the BTreeMap grouping.
325 let mut by_event: std::collections::BTreeMap<&str, Vec<&Hook>> =
326 std::collections::BTreeMap::new();
327 for h in &cfg.hooks {
328 by_event.entry(event_label(h.event)).or_default().push(h);
329 }
330 let events: Vec<&&str> = by_event.keys().collect();
331 // BTreeMap sorts alphabetically — `session_start` before `tool_call_after`.
332 assert_eq!(events, vec![&"session_start", &"tool_call_after"]);
333 }
334 }
335
335 lines RUST