Skip to main content

fresh_core/
hooks.rs

1//! Hook System: Event subscription and notification for plugins
2//!
3//! Hooks allow plugins to subscribe to editor events and react to them.
4
5use anyhow::Result;
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9use crate::action::Action;
10use crate::api::ViewTokenWire;
11use crate::{BufferId, CursorId, SplitId};
12
13/// Arguments passed to hook callbacks
14#[derive(Debug, Clone, serde::Serialize)]
15#[serde(untagged)]
16pub enum HookArgs {
17    /// Before a file is opened
18    BeforeFileOpen { path: PathBuf },
19
20    /// After a file is successfully opened
21    AfterFileOpen { buffer_id: BufferId, path: PathBuf },
22
23    /// Before a buffer is saved to disk
24    BeforeFileSave { buffer_id: BufferId, path: PathBuf },
25
26    /// After a buffer is successfully saved
27    AfterFileSave { buffer_id: BufferId, path: PathBuf },
28
29    /// The file explorer mutated the filesystem (paste, duplicate, ...)
30    /// without going through a buffer save. Plugins that surface
31    /// filesystem-derived state (git status decorations, etc.) use this
32    /// to re-scan after explorer-driven changes that wouldn't otherwise
33    /// fire `BeforeFileSave`/`AfterFileSave`. `path` is one of the
34    /// affected paths; for batch operations (multi-paste) the hook
35    /// fires once per refresh, not once per file.
36    AfterFileExplorerChange { path: PathBuf },
37
38    /// A buffer was closed
39    BufferClosed { buffer_id: BufferId },
40
41    /// Before text is inserted
42    BeforeInsert {
43        buffer_id: BufferId,
44        position: usize,
45        text: String,
46    },
47
48    /// After text was inserted
49    AfterInsert {
50        buffer_id: BufferId,
51        position: usize,
52        text: String,
53        /// Byte position where the affected range starts
54        affected_start: usize,
55        /// Byte position where the affected range ends (after the inserted text)
56        affected_end: usize,
57        /// Line number where insertion occurred (0-indexed)
58        start_line: usize,
59        /// Line number where insertion ended (0-indexed)
60        end_line: usize,
61        /// Number of lines added by this insertion
62        lines_added: usize,
63    },
64
65    /// Before text is deleted
66    BeforeDelete {
67        buffer_id: BufferId,
68        start: usize,
69        end: usize,
70    },
71
72    /// After text was deleted
73    AfterDelete {
74        buffer_id: BufferId,
75        start: usize,
76        end: usize,
77        deleted_text: String,
78        /// Byte position where the deletion occurred
79        affected_start: usize,
80        /// Length of the deleted content in bytes
81        deleted_len: usize,
82        /// Line number where deletion started (0-indexed)
83        start_line: usize,
84        /// Line number where deletion ended (0-indexed, in original buffer)
85        end_line: usize,
86        /// Number of lines removed by this deletion
87        lines_removed: usize,
88    },
89
90    /// Cursor moved to a new position
91    CursorMoved {
92        buffer_id: BufferId,
93        cursor_id: CursorId,
94        old_position: usize,
95        new_position: usize,
96        /// Line number at new position (1-indexed)
97        line: usize,
98        /// Text properties at the new cursor position
99        text_properties: Vec<std::collections::HashMap<String, serde_json::Value>>,
100    },
101
102    /// Buffer became active
103    BufferActivated { buffer_id: BufferId },
104
105    /// Buffer was deactivated
106    BufferDeactivated { buffer_id: BufferId },
107
108    /// LSP diagnostics were updated for a file
109    DiagnosticsUpdated {
110        /// The URI of the file that was updated
111        uri: String,
112        /// Number of diagnostics in the update
113        count: usize,
114    },
115
116    /// Before a command/action is executed
117    PreCommand { action: Action },
118
119    /// After a command/action was executed
120    PostCommand { action: Action },
121
122    /// Editor has been idle for N milliseconds (no input)
123    Idle { milliseconds: u64 },
124
125    /// Editor is initializing
126    EditorInitialized {},
127
128    /// All plugin packages + init.ts have been loaded. Fires after the
129    /// plugin discovery loop and before session restore — the lifecycle
130    /// hook for code that wants to configure a plugin via its
131    /// getPluginApi(...) surface. See design §3.3 (phase 2).
132    PluginsLoaded {},
133
134    /// Editor has completed startup: plugins are loaded, session is
135    /// restored, and the active buffer exists. Design §3.3 (phase 3).
136    Ready {},
137
138    /// The editor's active authority changed (e.g. local → container,
139    /// container → local). Fires after the new authority is in place
140    /// and the plugin state snapshot has been refreshed, so handlers
141    /// can read the new label via `editor.getAuthorityLabel()`.
142    /// Plugins use this to re-register state-dependent commands
143    /// that should only appear in one authority mode (e.g. dev
144    /// container `Detach` only when attached). In production a
145    /// transition triggers a full editor restart that re-runs plugin
146    /// init from scratch; this hook lets plugins react inline
147    /// without that, which keeps the harness in sync too.
148    AuthorityChanged { label: String },
149
150    /// The workspace trust level changed (e.g. Restricted → Trusted) via the
151    /// trust modal, the status-bar trust pill, or a plugin-dispatched trust
152    /// action. Fires after the new level is live and persisted *and* the
153    /// plugin state snapshot has been refreshed, so handlers can read it via
154    /// `editor.workspaceTrustLevel()` (or the `level` payload). Plugins use
155    /// this to re-trigger trust-gated work inline — env-manager re-runs its
156    /// activation flow the moment a folder is trusted, instead of waiting for
157    /// the next window switch or editor restart. Deliberately a lightweight
158    /// hook, *not* an editor rebuild (which would reset other sessions).
159    TrustChanged { level: String },
160
161    /// Rendering is starting for a buffer (called once per buffer before render_line hooks)
162    RenderStart { buffer_id: BufferId },
163
164    /// A line is being rendered (called during the rendering pass)
165    RenderLine {
166        buffer_id: BufferId,
167        line_number: usize,
168        byte_start: usize,
169        byte_end: usize,
170        content: String,
171    },
172
173    /// Lines have changed and need processing (batched for efficiency)
174    LinesChanged {
175        buffer_id: BufferId,
176        lines: Vec<LineInfo>,
177    },
178
179    /// Prompt input changed (user typed/edited)
180    PromptChanged { prompt_type: String, input: String },
181
182    /// Prompt was confirmed (user pressed Enter)
183    PromptConfirmed {
184        prompt_type: String,
185        input: String,
186        selected_index: Option<usize>,
187    },
188
189    /// Prompt was cancelled (user pressed Escape/Ctrl+G)
190    PromptCancelled { prompt_type: String, input: String },
191
192    /// Prompt suggestion selection changed (user navigated with Up/Down)
193    PromptSelectionChanged {
194        prompt_type: String,
195        selected_index: usize,
196    },
197
198    /// Request keyboard shortcuts data (key, action) for the help buffer
199    KeyboardShortcuts { bindings: Vec<(String, String)> },
200
201    /// LSP find references response received
202    LspReferences {
203        /// The symbol name being queried
204        symbol: String,
205        /// The locations where the symbol is referenced
206        locations: Vec<LspLocation>,
207    },
208
209    /// View transform request
210    ViewTransformRequest {
211        buffer_id: BufferId,
212        split_id: SplitId,
213        /// Byte offset of the viewport start
214        viewport_start: usize,
215        /// Byte offset of the viewport end
216        viewport_end: usize,
217        /// Base tokens (Text, Newline, Space) from the source
218        tokens: Vec<ViewTokenWire>,
219        /// Byte positions of all cursors in this buffer
220        cursor_positions: Vec<usize>,
221    },
222
223    /// Mouse click event
224    MouseClick {
225        /// Column (x coordinate) in screen cells
226        column: u16,
227        /// Row (y coordinate) in screen cells
228        row: u16,
229        /// Mouse button: "left", "right", "middle"
230        button: String,
231        /// Modifier keys
232        modifiers: String,
233        /// Content area X offset
234        content_x: u16,
235        /// Content area Y offset
236        content_y: u16,
237        /// Buffer under the click (None when the click is outside any
238        /// buffer panel).
239        buffer_id: Option<u64>,
240        /// 0-indexed buffer row (line number) of the click, accounting
241        /// for scroll. None when the click is outside any buffer.
242        buffer_row: Option<u32>,
243        /// 0-indexed byte column inside the buffer row. None when the
244        /// click is outside any buffer.
245        buffer_col: Option<u32>,
246    },
247
248    /// Mouse move/hover event
249    MouseMove {
250        /// Column (x coordinate) in screen cells
251        column: u16,
252        /// Row (y coordinate) in screen cells
253        row: u16,
254        /// Content area X offset
255        content_x: u16,
256        /// Content area Y offset
257        content_y: u16,
258    },
259
260    /// LSP server request (server -> client)
261    LspServerRequest {
262        /// The language/server that sent the request
263        language: String,
264        /// The JSON-RPC method name
265        method: String,
266        /// The server command used to spawn this LSP
267        server_command: String,
268        /// The request parameters as a JSON string
269        params: Option<String>,
270    },
271
272    /// Viewport changed (scrolled or resized)
273    ViewportChanged {
274        split_id: SplitId,
275        buffer_id: BufferId,
276        top_byte: usize,
277        top_line: Option<usize>,
278        width: u16,
279        height: u16,
280    },
281
282    /// LSP server failed to start or crashed
283    LspServerError {
284        /// The language that failed
285        language: String,
286        /// The server command that failed
287        server_command: String,
288        /// Error type: "not_found", "spawn_failed", "timeout", "crash"
289        error_type: String,
290        /// Human-readable error message
291        message: String,
292    },
293
294    /// User clicked the LSP status indicator
295    LspStatusClicked {
296        /// The language of the current buffer
297        language: String,
298        /// Whether there's an active error
299        has_error: bool,
300        /// Commands of configured servers whose binaries are not on `$PATH`
301        /// (or absolute-path equivalents). Empty when every configured
302        /// server is installed. Plugins can inspect this to show tailored
303        /// install hints without waiting for a failed spawn.
304        missing_servers: Vec<String>,
305        /// Whether the user previously dismissed the LSP pill for this
306        /// language (via the popup's "Disable" action). Plugins seeing
307        /// this as `true` should offer "Enable" / "Install" rather than
308        /// "Start".
309        user_dismissed: bool,
310    },
311
312    /// User selected an action from an action popup
313    ActionPopupResult {
314        /// The popup ID
315        popup_id: String,
316        /// The action ID selected, or "dismissed"
317        action_id: String,
318    },
319
320    /// User clicked a plugin-registered status-bar token. Fires
321    /// regardless of which plugin registered it; subscribers filter
322    /// by `plugin_name` + `token_name`. Plugins typically use this
323    /// to re-open a deferred prompt or surface the relevant
324    /// settings UI for what the token represents (e.g., trust chip
325    /// click → trust elevation popup, env pill click → env activate
326    /// popup).
327    StatusBarTokenClicked {
328        /// Plugin that originally called `RegisterStatusBarElement`.
329        plugin_name: String,
330        /// Token name within that plugin's namespace.
331        token_name: String,
332    },
333
334    /// Background process output (streaming)
335    ProcessOutput {
336        /// The process ID
337        process_id: u64,
338        /// The output data
339        data: String,
340    },
341
342    /// A new editor session was created. Fires after the session is
343    /// added to `Editor.sessions`, before any UI retarget. Plugins
344    /// (like Orchestrator) use this to reconcile their per-session
345    /// bookkeeping with the editor.
346    WindowCreated {
347        /// The new session's stable id.
348        id: u64,
349        /// Resolved label (basename fallback applied).
350        label: String,
351        /// Absolute project root.
352        root: String,
353    },
354
355    /// An editor session was closed and its state dropped. The id
356    /// is still valid in the payload but is no longer present in
357    /// `editor.listWindows()`.
358    WindowClosed { id: u64 },
359
360    /// The active session changed. Fires after the editor's UI has
361    /// retargeted (file tree, working_dir, snapshot). Plugins
362    /// observing for "the editor's project root just changed" use
363    /// this rather than polling.
364    ActiveWindowChanged {
365        /// The previously active session id, or `None` only on
366        /// first switch from the initial base session — currently
367        /// always `Some` since the base session always exists.
368        previous_id: Option<u64>,
369        /// The newly active session id. Always present in the
370        /// `sessions` list.
371        active_id: u64,
372    },
373
374    /// PTY terminal received output bytes from the spawned process.
375    /// Fires for every async batch the editor reads off the PTY, so it
376    /// is hot — consumers should be cheap. The payload includes only a
377    /// snapshot of the last visible (cursor) row so plugins can detect
378    /// prompt patterns (`(Y/n)`, `Press enter`, `> `) without an extra
379    /// readback API. Plugins that need full output should tail the
380    /// terminal's backing file via the existing buffer.
381    TerminalOutput {
382        /// Stable terminal session id (matches `TerminalId.0`).
383        terminal_id: u64,
384        /// Editor window that owns this terminal (matches `WindowId.0`).
385        /// Lets a plugin attribute output to a *session* — Orchestrator
386        /// keys activity off the window, so output from ANY terminal in
387        /// the window (not just the one the plugin spawned) counts. Fires
388        /// on every PTY read, so in-place redraws and carriage-return
389        /// progress bars register as activity too, not just newlines.
390        window_id: u64,
391        /// Snapshot of the cursor row's text content. May be empty
392        /// (just-resized terminal, cleared screen). Trailing whitespace
393        /// is preserved because prompt detection often depends on it
394        /// (e.g. `"... (Y/n): "` ends in a space).
395        last_line: String,
396    },
397
398    /// PTY terminal's spawned process has ended. Fires once per
399    /// terminal lifetime, after the editor has flushed any final
400    /// scrollback to the backing file.
401    TerminalExited {
402        /// Stable terminal session id (matches `TerminalId.0`).
403        terminal_id: u64,
404        /// Editor window that owned this terminal (matches `WindowId.0`).
405        window_id: u64,
406        /// Process exit code if known. `None` when the platform did
407        /// not report a status (signal, detach, kill before wait).
408        /// Plugins that can't distinguish should treat `None` as
409        /// "errored, cause unknown" rather than "ready".
410        exit_code: Option<i32>,
411    },
412
413    /// A path under a `watchPath`-registered watcher changed.
414    /// Plugins (Orchestrator's collision radar, etc.) use this to
415    /// build path → modifying-session-set matrices. Fires once per
416    /// raw `notify` event — no debouncing in core; plugins coalesce
417    /// per their policy.
418    PathChanged {
419        /// Watch handle that delivered this event. Maps back to
420        /// the `watchPath()` call that registered it; lets plugins
421        /// route events to per-watcher state.
422        handle: u64,
423        /// Absolute path the kernel reported as changed.
424        path: String,
425        /// `"modify"` | `"create"` | `"delete"` | `"rename"` |
426        /// `"other"`. Conservative bucketing of `notify::EventKind`
427        /// — plugins that need finer detail can switch on more
428        /// specific strings the editor learns to emit later.
429        kind: String,
430    },
431
432    /// Buffer language was changed (e.g. via "Set Language" command or Save-As)
433    LanguageChanged {
434        buffer_id: BufferId,
435        /// The new language identifier (e.g., "markdown", "rust", "text")
436        language: String,
437    },
438
439    /// Request to inspect a theme key in the theme editor
440    ThemeInspectKey {
441        /// The name of the current theme
442        theme_name: String,
443        /// The theme key to inspect (e.g. "editor.bg")
444        key: String,
445    },
446
447    /// Mouse scroll event (wheel up/down)
448    MouseScroll {
449        buffer_id: BufferId,
450        /// Scroll delta: negative = up, positive = down (typically ±3)
451        delta: i32,
452        /// Mouse column (0-based, terminal origin top-left)
453        col: u16,
454        /// Mouse row (0-based, terminal origin top-left)
455        row: u16,
456    },
457
458    /// Terminal was resized
459    Resize { width: u16, height: u16 },
460
461    /// Terminal focus was gained (e.g. user switched back to the editor)
462    FocusGained {},
463
464    /// A widget mounted via `MountWidgetPanel` emitted a semantic event.
465    /// Plugins subscribe via `editor.on("widget_event", "<handler>")`
466    /// and dispatch on `(panel_id, widget_key, event_type)`.
467    ///
468    /// `event_type` is one of: `"activate"`, `"toggle"`, `"change"`,
469    /// `"submit"`, `"hover"`, `"dismiss"`, `"focus"`. `payload` is
470    /// event-specific JSON (e.g. `{ "value": "search text" }` for
471    /// `change`, `{ "previous": "<old key>" }` for `focus`).
472    ///
473    /// At v1 only widgets that have user-driven behaviour fire this
474    /// hook. The HintBar widget is read-only and does not emit events.
475    WidgetEvent {
476        /// The plugin-allocated panel ID from the original
477        /// `MountWidgetPanel`.
478        panel_id: u64,
479        /// The stable `key` of the widget node that fired the event,
480        /// or empty when the event originates from the panel root.
481        widget_key: String,
482        /// The kind of event — see variants above.
483        event_type: String,
484        /// Event-specific JSON payload.
485        #[serde(default)]
486        payload: serde_json::Value,
487    },
488}
489
490/// Information about a single line for the LinesChanged hook
491#[derive(Debug, Clone, serde::Serialize)]
492pub struct LineInfo {
493    /// Line number (0-based)
494    pub line_number: usize,
495    /// Byte offset where the line starts in the buffer
496    pub byte_start: usize,
497    /// Byte offset where the line ends (exclusive)
498    pub byte_end: usize,
499    /// The content of the line
500    pub content: String,
501}
502
503/// Location information for LSP references
504#[derive(Debug, Clone, serde::Serialize)]
505pub struct LspLocation {
506    /// File path
507    pub file: String,
508    /// Line number (1-based)
509    pub line: u32,
510    /// Column number (1-based)
511    pub column: u32,
512}
513
514/// Type for hook callbacks
515pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
516
517/// Registry for managing hooks
518pub struct HookRegistry {
519    /// Map from hook name to list of callbacks
520    hooks: HashMap<String, Vec<HookCallback>>,
521}
522
523impl HookRegistry {
524    /// Create a new hook registry
525    pub fn new() -> Self {
526        Self {
527            hooks: HashMap::new(),
528        }
529    }
530
531    /// Add a hook callback for a specific hook name
532    pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
533        self.hooks
534            .entry(name.to_string())
535            .or_default()
536            .push(callback);
537    }
538
539    /// Remove all hooks for a specific name
540    pub fn remove_hooks(&mut self, name: &str) {
541        self.hooks.remove(name);
542    }
543
544    /// Run all hooks for a specific name
545    pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
546        if let Some(hooks) = self.hooks.get(name) {
547            for callback in hooks {
548                if !callback(args) {
549                    return false;
550                }
551            }
552        }
553        true
554    }
555
556    /// Get count of registered callbacks for a hook
557    pub fn hook_count(&self, name: &str) -> usize {
558        self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
559    }
560
561    /// Get all registered hook names
562    pub fn hook_names(&self) -> Vec<String> {
563        self.hooks.keys().cloned().collect()
564    }
565}
566
567impl Default for HookRegistry {
568    fn default() -> Self {
569        Self::new()
570    }
571}
572
573/// Convert HookArgs to a serde_json::Value for plugin communication.
574///
575/// `HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
576/// fields only — no discriminant wrapper. Empty struct variants (`{}`) produce
577/// an empty JSON object rather than `null`.
578pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
579    Ok(serde_json::to_value(args)?)
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use std::sync::atomic::{AtomicUsize, Ordering};
586    use std::sync::Arc;
587
588    fn noop_true() -> HookCallback {
589        Box::new(|_| true)
590    }
591
592    /// Adding, listing, counting, and removing hooks behave consistently:
593    /// counts match the number added, names reflect the keys, and removal
594    /// purges all callbacks for that key.
595    #[test]
596    fn add_count_list_remove_round_trip() {
597        let mut reg = HookRegistry::new();
598        assert_eq!(reg.hook_count("a"), 0);
599        assert!(reg.hook_names().is_empty());
600
601        reg.add_hook("a", noop_true());
602        reg.add_hook("a", noop_true());
603        reg.add_hook("b", noop_true());
604
605        assert_eq!(reg.hook_count("a"), 2);
606        assert_eq!(reg.hook_count("b"), 1);
607        assert_eq!(reg.hook_count("missing"), 0);
608
609        let mut names = reg.hook_names();
610        names.sort();
611        assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
612
613        reg.remove_hooks("a");
614        assert_eq!(reg.hook_count("a"), 0);
615        assert_eq!(reg.hook_count("b"), 1);
616        assert_eq!(reg.hook_names(), vec!["b".to_string()]);
617    }
618
619    /// `run_hooks` returns true iff every callback returned true, short-circuits
620    /// on the first `false`, and returns true for hook names with no callbacks.
621    #[test]
622    fn run_hooks_all_true_and_short_circuits_on_false() {
623        let mut reg = HookRegistry::new();
624        let args = HookArgs::EditorInitialized {};
625
626        // Unknown hook: treated as "no callbacks" → true.
627        assert!(reg.run_hooks("unknown", &args));
628
629        // All-true chain returns true and calls every callback.
630        let calls = Arc::new(AtomicUsize::new(0));
631        for _ in 0..3 {
632            let c = calls.clone();
633            reg.add_hook(
634                "all_true",
635                Box::new(move |_| {
636                    c.fetch_add(1, Ordering::SeqCst);
637                    true
638                }),
639            );
640        }
641        assert!(reg.run_hooks("all_true", &args));
642        assert_eq!(calls.load(Ordering::SeqCst), 3);
643
644        // Short-circuits on the first `false` — the second callback must not run.
645        let calls = Arc::new(AtomicUsize::new(0));
646        let c1 = calls.clone();
647        reg.add_hook(
648            "short",
649            Box::new(move |_| {
650                c1.fetch_add(1, Ordering::SeqCst);
651                false
652            }),
653        );
654        let c2 = calls.clone();
655        reg.add_hook(
656            "short",
657            Box::new(move |_| {
658                c2.fetch_add(1, Ordering::SeqCst);
659                true
660            }),
661        );
662        assert!(!reg.run_hooks("short", &args));
663        assert_eq!(calls.load(Ordering::SeqCst), 1);
664    }
665
666    /// `hook_args_to_json` produces an object with the expected field for
667    /// a representative variant — ensuring the function actually serializes
668    /// the payload instead of returning a default (null) value.
669    #[test]
670    fn hook_args_to_json_serializes_payload_fields() {
671        let json = hook_args_to_json(&HookArgs::DiagnosticsUpdated {
672            uri: "file:///x.rs".into(),
673            count: 7,
674        })
675        .unwrap();
676        assert_eq!(json["uri"], "file:///x.rs");
677        assert_eq!(json["count"], 7);
678    }
679
680    #[test]
681    fn hook_args_to_json_empty_variants_produce_empty_object() {
682        for args in [
683            HookArgs::EditorInitialized {},
684            HookArgs::PluginsLoaded {},
685            HookArgs::Ready {},
686            HookArgs::FocusGained {},
687        ] {
688            let json = hook_args_to_json(&args).unwrap();
689            assert_eq!(
690                json,
691                serde_json::json!({}),
692                "variant should serialize as {{}}"
693            );
694        }
695    }
696
697    #[test]
698    fn hook_args_to_json_terminal_output_fields_are_flat() {
699        let json = hook_args_to_json(&HookArgs::TerminalOutput {
700            terminal_id: 7,
701            window_id: 2,
702            last_line: "Do you want me to attempt a fix? (Y/n): ".into(),
703        })
704        .unwrap();
705        assert_eq!(json["terminal_id"], 7);
706        assert_eq!(json["window_id"], 2);
707        assert_eq!(
708            json["last_line"],
709            "Do you want me to attempt a fix? (Y/n): "
710        );
711    }
712
713    #[test]
714    fn hook_args_to_json_terminal_exited_serializes_exit_code() {
715        let json_some = hook_args_to_json(&HookArgs::TerminalExited {
716            terminal_id: 3,
717            window_id: 1,
718            exit_code: Some(0),
719        })
720        .unwrap();
721        assert_eq!(json_some["terminal_id"], 3);
722        assert_eq!(json_some["window_id"], 1);
723        assert_eq!(json_some["exit_code"], 0);
724
725        let json_err = hook_args_to_json(&HookArgs::TerminalExited {
726            terminal_id: 4,
727            window_id: 1,
728            exit_code: Some(2),
729        })
730        .unwrap();
731        assert_eq!(json_err["exit_code"], 2);
732
733        let json_none = hook_args_to_json(&HookArgs::TerminalExited {
734            terminal_id: 5,
735            window_id: 1,
736            exit_code: None,
737        })
738        .unwrap();
739        assert!(
740            json_none["exit_code"].is_null(),
741            "exit_code: None should serialize as JSON null, not omitted: got {json_none}"
742        );
743    }
744
745    #[test]
746    fn hook_args_to_json_delete_fields_are_flat() {
747        let json = hook_args_to_json(&HookArgs::BeforeDelete {
748            buffer_id: crate::BufferId(1),
749            start: 10,
750            end: 20,
751        })
752        .unwrap();
753        assert_eq!(json["start"], 10);
754        assert_eq!(json["end"], 20);
755        assert!(json.get("range").is_none(), "range must not be nested");
756    }
757}