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    /// LSP go-to-implementation response received
210    LspImplementation {
211        /// The symbol name being queried
212        symbol: String,
213        /// The locations where the symbol is implemented
214        locations: Vec<LspLocation>,
215    },
216
217    /// View transform request
218    ViewTransformRequest {
219        buffer_id: BufferId,
220        split_id: SplitId,
221        /// Byte offset of the viewport start
222        viewport_start: usize,
223        /// Byte offset of the viewport end
224        viewport_end: usize,
225        /// Base tokens (Text, Newline, Space) from the source
226        tokens: Vec<ViewTokenWire>,
227        /// Byte positions of all cursors in this buffer
228        cursor_positions: Vec<usize>,
229    },
230
231    /// Mouse click event
232    MouseClick {
233        /// Column (x coordinate) in screen cells
234        column: u16,
235        /// Row (y coordinate) in screen cells
236        row: u16,
237        /// Mouse button: "left", "right", "middle"
238        button: String,
239        /// Modifier keys
240        modifiers: String,
241        /// Content area X offset
242        content_x: u16,
243        /// Content area Y offset
244        content_y: u16,
245        /// Buffer under the click (None when the click is outside any
246        /// buffer panel).
247        buffer_id: Option<u64>,
248        /// 0-indexed buffer row (line number) of the click, accounting
249        /// for scroll. None when the click is outside any buffer.
250        buffer_row: Option<u32>,
251        /// 0-indexed byte column inside the buffer row. None when the
252        /// click is outside any buffer.
253        buffer_col: Option<u32>,
254    },
255
256    /// Mouse move/hover event
257    MouseMove {
258        /// Column (x coordinate) in screen cells
259        column: u16,
260        /// Row (y coordinate) in screen cells
261        row: u16,
262        /// Content area X offset
263        content_x: u16,
264        /// Content area Y offset
265        content_y: u16,
266    },
267
268    /// LSP server request (server -> client)
269    LspServerRequest {
270        /// The language/server that sent the request
271        language: String,
272        /// The JSON-RPC method name
273        method: String,
274        /// The server command used to spawn this LSP
275        server_command: String,
276        /// The request parameters as a JSON string
277        params: Option<String>,
278    },
279
280    /// Viewport changed (scrolled or resized)
281    ViewportChanged {
282        split_id: SplitId,
283        buffer_id: BufferId,
284        top_byte: usize,
285        top_line: Option<usize>,
286        width: u16,
287        height: u16,
288    },
289
290    /// LSP server failed to start or crashed
291    LspServerError {
292        /// The language that failed
293        language: String,
294        /// The server command that failed
295        server_command: String,
296        /// Error type: "not_found", "spawn_failed", "timeout", "crash"
297        error_type: String,
298        /// Human-readable error message
299        message: String,
300    },
301
302    /// User clicked the LSP status indicator
303    LspStatusClicked {
304        /// The language of the current buffer
305        language: String,
306        /// Whether there's an active error
307        has_error: bool,
308        /// Commands of configured servers whose binaries are not on `$PATH`
309        /// (or absolute-path equivalents). Empty when every configured
310        /// server is installed. Plugins can inspect this to show tailored
311        /// install hints without waiting for a failed spawn.
312        missing_servers: Vec<String>,
313        /// Whether the user previously dismissed the LSP pill for this
314        /// language (via the popup's "Disable" action). Plugins seeing
315        /// this as `true` should offer "Enable" / "Install" rather than
316        /// "Start".
317        user_dismissed: bool,
318    },
319
320    /// User selected an action from an action popup
321    ActionPopupResult {
322        /// The popup ID
323        popup_id: String,
324        /// The action ID selected, or "dismissed"
325        action_id: String,
326    },
327
328    /// User clicked a plugin-registered status-bar token. Fires
329    /// regardless of which plugin registered it; subscribers filter
330    /// by `plugin_name` + `token_name`. Plugins typically use this
331    /// to re-open a deferred prompt or surface the relevant
332    /// settings UI for what the token represents (e.g., trust chip
333    /// click → trust elevation popup, env pill click → env activate
334    /// popup).
335    StatusBarTokenClicked {
336        /// Plugin that originally called `RegisterStatusBarElement`.
337        plugin_name: String,
338        /// Token name within that plugin's namespace.
339        token_name: String,
340    },
341
342    /// Background process output (streaming)
343    ProcessOutput {
344        /// The process ID
345        process_id: u64,
346        /// The output data
347        data: String,
348    },
349
350    /// A new editor session was created. Fires after the session is
351    /// added to `Editor.sessions`, before any UI retarget. Plugins
352    /// (like Orchestrator) use this to reconcile their per-session
353    /// bookkeeping with the editor.
354    WindowCreated {
355        /// The new session's stable id.
356        id: u64,
357        /// Resolved label (basename fallback applied).
358        label: String,
359        /// Absolute project root.
360        root: String,
361    },
362
363    /// An editor session was closed and its state dropped. The id
364    /// is still valid in the payload but is no longer present in
365    /// `editor.listWindows()`.
366    WindowClosed { id: u64 },
367
368    /// The active session changed. Fires after the editor's UI has
369    /// retargeted (file tree, working_dir, snapshot). Plugins
370    /// observing for "the editor's project root just changed" use
371    /// this rather than polling.
372    ActiveWindowChanged {
373        /// The previously active session id, or `None` only on
374        /// first switch from the initial base session — currently
375        /// always `Some` since the base session always exists.
376        previous_id: Option<u64>,
377        /// The newly active session id. Always present in the
378        /// `sessions` list.
379        active_id: u64,
380    },
381
382    /// PTY terminal received output bytes from the spawned process.
383    /// Fires for every async batch the editor reads off the PTY, so it
384    /// is hot — consumers should be cheap. The payload includes only a
385    /// snapshot of the last visible (cursor) row so plugins can detect
386    /// prompt patterns (`(Y/n)`, `Press enter`, `> `) without an extra
387    /// readback API. Plugins that need full output should tail the
388    /// terminal's backing file via the existing buffer.
389    TerminalOutput {
390        /// Stable terminal session id (matches `TerminalId.0`).
391        terminal_id: u64,
392        /// Editor window that owns this terminal (matches `WindowId.0`).
393        /// Lets a plugin attribute output to a *session* — Orchestrator
394        /// keys activity off the window, so output from ANY terminal in
395        /// the window (not just the one the plugin spawned) counts. Fires
396        /// on every PTY read, so in-place redraws and carriage-return
397        /// progress bars register as activity too, not just newlines.
398        window_id: u64,
399        /// Snapshot of the cursor row's text content. May be empty
400        /// (just-resized terminal, cleared screen). Trailing whitespace
401        /// is preserved because prompt detection often depends on it
402        /// (e.g. `"... (Y/n): "` ends in a space).
403        last_line: String,
404    },
405
406    /// PTY terminal's spawned process has ended. Fires once per
407    /// terminal lifetime, after the editor has flushed any final
408    /// scrollback to the backing file.
409    TerminalExited {
410        /// Stable terminal session id (matches `TerminalId.0`).
411        terminal_id: u64,
412        /// Editor window that owned this terminal (matches `WindowId.0`).
413        window_id: u64,
414        /// Process exit code if known. `None` when the platform did
415        /// not report a status (signal, detach, kill before wait).
416        /// Plugins that can't distinguish should treat `None` as
417        /// "errored, cause unknown" rather than "ready".
418        exit_code: Option<i32>,
419    },
420
421    /// A path under a `watchPath`-registered watcher changed.
422    /// Plugins (Orchestrator's collision radar, etc.) use this to
423    /// build path → modifying-session-set matrices. Fires once per
424    /// raw `notify` event — no debouncing in core; plugins coalesce
425    /// per their policy.
426    PathChanged {
427        /// Watch handle that delivered this event. Maps back to
428        /// the `watchPath()` call that registered it; lets plugins
429        /// route events to per-watcher state.
430        handle: u64,
431        /// Absolute path the kernel reported as changed.
432        path: String,
433        /// `"modify"` | `"create"` | `"delete"` | `"rename"` |
434        /// `"other"`. Conservative bucketing of `notify::EventKind`
435        /// — plugins that need finer detail can switch on more
436        /// specific strings the editor learns to emit later.
437        kind: String,
438    },
439
440    /// Buffer language was changed (e.g. via "Set Language" command or Save-As)
441    LanguageChanged {
442        buffer_id: BufferId,
443        /// The new language identifier (e.g., "markdown", "rust", "text")
444        language: String,
445    },
446
447    /// Request to inspect a theme key in the theme editor
448    ThemeInspectKey {
449        /// The name of the current theme
450        theme_name: String,
451        /// The theme key to inspect (e.g. "editor.bg")
452        key: String,
453    },
454
455    /// Mouse scroll event (wheel up/down)
456    MouseScroll {
457        buffer_id: BufferId,
458        /// Scroll delta: negative = up, positive = down (typically ±3)
459        delta: i32,
460        /// Mouse column (0-based, terminal origin top-left)
461        col: u16,
462        /// Mouse row (0-based, terminal origin top-left)
463        row: u16,
464    },
465
466    /// Terminal was resized
467    Resize { width: u16, height: u16 },
468
469    /// Terminal focus was gained (e.g. user switched back to the editor)
470    FocusGained {},
471
472    /// A widget mounted via `MountWidgetPanel` emitted a semantic event.
473    /// Plugins subscribe via `editor.on("widget_event", "<handler>")`
474    /// and dispatch on `(panel_id, widget_key, event_type)`.
475    ///
476    /// `event_type` is one of: `"activate"`, `"toggle"`, `"change"`,
477    /// `"submit"`, `"hover"`, `"dismiss"`, `"focus"`. `payload` is
478    /// event-specific JSON (e.g. `{ "value": "search text" }` for
479    /// `change`, `{ "previous": "<old key>" }` for `focus`).
480    ///
481    /// At v1 only widgets that have user-driven behaviour fire this
482    /// hook. The HintBar widget is read-only and does not emit events.
483    WidgetEvent {
484        /// The plugin-allocated panel ID from the original
485        /// `MountWidgetPanel`.
486        panel_id: u64,
487        /// The stable `key` of the widget node that fired the event,
488        /// or empty when the event originates from the panel root.
489        widget_key: String,
490        /// The kind of event — see variants above.
491        event_type: String,
492        /// Event-specific JSON payload.
493        #[serde(default)]
494        payload: serde_json::Value,
495    },
496}
497
498/// Information about a single line for the LinesChanged hook
499#[derive(Debug, Clone, serde::Serialize)]
500pub struct LineInfo {
501    /// Line number (0-based)
502    pub line_number: usize,
503    /// Byte offset where the line starts in the buffer
504    pub byte_start: usize,
505    /// Byte offset where the line ends (exclusive)
506    pub byte_end: usize,
507    /// The content of the line
508    pub content: String,
509}
510
511/// Location information for LSP references
512#[derive(Debug, Clone, serde::Serialize)]
513pub struct LspLocation {
514    /// File path
515    pub file: String,
516    /// Line number (1-based)
517    pub line: u32,
518    /// Column number (1-based)
519    pub column: u32,
520}
521
522/// Type for hook callbacks
523pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
524
525/// Registry for managing hooks
526pub struct HookRegistry {
527    /// Map from hook name to list of callbacks
528    hooks: HashMap<String, Vec<HookCallback>>,
529}
530
531impl HookRegistry {
532    /// Create a new hook registry
533    pub fn new() -> Self {
534        Self {
535            hooks: HashMap::new(),
536        }
537    }
538
539    /// Add a hook callback for a specific hook name
540    pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
541        self.hooks
542            .entry(name.to_string())
543            .or_default()
544            .push(callback);
545    }
546
547    /// Remove all hooks for a specific name
548    pub fn remove_hooks(&mut self, name: &str) {
549        self.hooks.remove(name);
550    }
551
552    /// Run all hooks for a specific name
553    pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
554        if let Some(hooks) = self.hooks.get(name) {
555            for callback in hooks {
556                if !callback(args) {
557                    return false;
558                }
559            }
560        }
561        true
562    }
563
564    /// Get count of registered callbacks for a hook
565    pub fn hook_count(&self, name: &str) -> usize {
566        self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
567    }
568
569    /// Get all registered hook names
570    pub fn hook_names(&self) -> Vec<String> {
571        self.hooks.keys().cloned().collect()
572    }
573}
574
575impl Default for HookRegistry {
576    fn default() -> Self {
577        Self::new()
578    }
579}
580
581/// Convert HookArgs to a serde_json::Value for plugin communication.
582///
583/// `HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
584/// fields only — no discriminant wrapper. Empty struct variants (`{}`) produce
585/// an empty JSON object rather than `null`.
586pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
587    Ok(serde_json::to_value(args)?)
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use std::sync::atomic::{AtomicUsize, Ordering};
594    use std::sync::Arc;
595
596    fn noop_true() -> HookCallback {
597        Box::new(|_| true)
598    }
599
600    /// Adding, listing, counting, and removing hooks behave consistently:
601    /// counts match the number added, names reflect the keys, and removal
602    /// purges all callbacks for that key.
603    #[test]
604    fn add_count_list_remove_round_trip() {
605        let mut reg = HookRegistry::new();
606        assert_eq!(reg.hook_count("a"), 0);
607        assert!(reg.hook_names().is_empty());
608
609        reg.add_hook("a", noop_true());
610        reg.add_hook("a", noop_true());
611        reg.add_hook("b", noop_true());
612
613        assert_eq!(reg.hook_count("a"), 2);
614        assert_eq!(reg.hook_count("b"), 1);
615        assert_eq!(reg.hook_count("missing"), 0);
616
617        let mut names = reg.hook_names();
618        names.sort();
619        assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
620
621        reg.remove_hooks("a");
622        assert_eq!(reg.hook_count("a"), 0);
623        assert_eq!(reg.hook_count("b"), 1);
624        assert_eq!(reg.hook_names(), vec!["b".to_string()]);
625    }
626
627    /// `run_hooks` returns true iff every callback returned true, short-circuits
628    /// on the first `false`, and returns true for hook names with no callbacks.
629    #[test]
630    fn run_hooks_all_true_and_short_circuits_on_false() {
631        let mut reg = HookRegistry::new();
632        let args = HookArgs::EditorInitialized {};
633
634        // Unknown hook: treated as "no callbacks" → true.
635        assert!(reg.run_hooks("unknown", &args));
636
637        // All-true chain returns true and calls every callback.
638        let calls = Arc::new(AtomicUsize::new(0));
639        for _ in 0..3 {
640            let c = calls.clone();
641            reg.add_hook(
642                "all_true",
643                Box::new(move |_| {
644                    c.fetch_add(1, Ordering::SeqCst);
645                    true
646                }),
647            );
648        }
649        assert!(reg.run_hooks("all_true", &args));
650        assert_eq!(calls.load(Ordering::SeqCst), 3);
651
652        // Short-circuits on the first `false` — the second callback must not run.
653        let calls = Arc::new(AtomicUsize::new(0));
654        let c1 = calls.clone();
655        reg.add_hook(
656            "short",
657            Box::new(move |_| {
658                c1.fetch_add(1, Ordering::SeqCst);
659                false
660            }),
661        );
662        let c2 = calls.clone();
663        reg.add_hook(
664            "short",
665            Box::new(move |_| {
666                c2.fetch_add(1, Ordering::SeqCst);
667                true
668            }),
669        );
670        assert!(!reg.run_hooks("short", &args));
671        assert_eq!(calls.load(Ordering::SeqCst), 1);
672    }
673
674    /// `hook_args_to_json` produces an object with the expected field for
675    /// a representative variant — ensuring the function actually serializes
676    /// the payload instead of returning a default (null) value.
677    #[test]
678    fn hook_args_to_json_serializes_payload_fields() {
679        let json = hook_args_to_json(&HookArgs::DiagnosticsUpdated {
680            uri: "file:///x.rs".into(),
681            count: 7,
682        })
683        .unwrap();
684        assert_eq!(json["uri"], "file:///x.rs");
685        assert_eq!(json["count"], 7);
686    }
687
688    #[test]
689    fn hook_args_to_json_empty_variants_produce_empty_object() {
690        for args in [
691            HookArgs::EditorInitialized {},
692            HookArgs::PluginsLoaded {},
693            HookArgs::Ready {},
694            HookArgs::FocusGained {},
695        ] {
696            let json = hook_args_to_json(&args).unwrap();
697            assert_eq!(
698                json,
699                serde_json::json!({}),
700                "variant should serialize as {{}}"
701            );
702        }
703    }
704
705    #[test]
706    fn hook_args_to_json_terminal_output_fields_are_flat() {
707        let json = hook_args_to_json(&HookArgs::TerminalOutput {
708            terminal_id: 7,
709            window_id: 2,
710            last_line: "Do you want me to attempt a fix? (Y/n): ".into(),
711        })
712        .unwrap();
713        assert_eq!(json["terminal_id"], 7);
714        assert_eq!(json["window_id"], 2);
715        assert_eq!(
716            json["last_line"],
717            "Do you want me to attempt a fix? (Y/n): "
718        );
719    }
720
721    #[test]
722    fn hook_args_to_json_terminal_exited_serializes_exit_code() {
723        let json_some = hook_args_to_json(&HookArgs::TerminalExited {
724            terminal_id: 3,
725            window_id: 1,
726            exit_code: Some(0),
727        })
728        .unwrap();
729        assert_eq!(json_some["terminal_id"], 3);
730        assert_eq!(json_some["window_id"], 1);
731        assert_eq!(json_some["exit_code"], 0);
732
733        let json_err = hook_args_to_json(&HookArgs::TerminalExited {
734            terminal_id: 4,
735            window_id: 1,
736            exit_code: Some(2),
737        })
738        .unwrap();
739        assert_eq!(json_err["exit_code"], 2);
740
741        let json_none = hook_args_to_json(&HookArgs::TerminalExited {
742            terminal_id: 5,
743            window_id: 1,
744            exit_code: None,
745        })
746        .unwrap();
747        assert!(
748            json_none["exit_code"].is_null(),
749            "exit_code: None should serialize as JSON null, not omitted: got {json_none}"
750        );
751    }
752
753    #[test]
754    fn hook_args_to_json_delete_fields_are_flat() {
755        let json = hook_args_to_json(&HookArgs::BeforeDelete {
756            buffer_id: crate::BufferId(1),
757            start: 10,
758            end: 20,
759        })
760        .unwrap();
761        assert_eq!(json["start"], 10);
762        assert_eq!(json["end"], 20);
763        assert!(json.get("range").is_none(), "range must not be nested");
764    }
765}