Skip to main content

fresh_core/
api.rs

1//! Plugin API: Safe interface for plugins to interact with the editor
2//!
3//! This module provides a safe, controlled API for plugins (Lua, WASM, etc.)
4//! to interact with the editor without direct access to internal state.
5//!
6//! # Type Safety Architecture
7//!
8//! Rust structs in this module serve as the **single source of truth** for the
9//! TypeScript plugin API. The type safety system works as follows:
10//!
11//! ```text
12//! Rust struct                  Generated TypeScript
13//! ───────────                  ────────────────────
14//! #[derive(TS, Deserialize)]   type ActionPopupOptions = {
15//! #[serde(deny_unknown_fields)]    id: string;
16//! struct ActionPopupOptions {      title: string;
17//!     id: String,                  message: string;
18//!     title: String,               actions: TsActionPopupAction[];
19//!     ...                      };
20//! }
21//! ```
22//!
23//! ## Key Patterns
24//!
25//! 1. **`#[derive(TS)]`** - Generates TypeScript type definitions via ts-rs
26//! 2. **`#[serde(deny_unknown_fields)]`** - Rejects typos/unknown fields at runtime
27//! 3. **`impl FromJs`** - Bridges rquickjs values to typed Rust structs
28//!
29//! ## Validation Layers
30//!
31//! | Layer                  | What it catches                          |
32//! |------------------------|------------------------------------------|
33//! | TypeScript compile     | Wrong field names, missing required fields |
34//! | Rust runtime (serde)   | Typos like `popup_id` instead of `id`    |
35//! | Rust compile           | Type mismatches in method signatures     |
36//!
37//! ## Limitations & Tradeoffs
38//!
39//! - **Manual parsing for complex types**: Some methods (e.g., `submitViewTransform`)
40//!   still use manual object parsing due to enum serialization complexity
41//! - **Two-step deserialization**: Complex nested structs may need
42//!   `rquickjs::Value → serde_json::Value → typed struct` due to rquickjs_serde limits
43//! - **Duplicate attributes**: Both `#[serde(...)]` and `#[ts(...)]` needed since
44//!   they control different things (runtime serialization vs compile-time codegen)
45
46use crate::command::{Command, Suggestion};
47use crate::file_explorer::FileExplorerDecoration;
48use crate::hooks::{HookCallback, HookRegistry};
49use crate::menu::{Menu, MenuItem};
50use crate::overlay::{OverlayHandle, OverlayNamespace};
51use crate::text_property::{TextProperty, TextPropertyEntry};
52use crate::BufferId;
53use crate::SplitId;
54use crate::TerminalId;
55use lsp_types;
56use serde::{Deserialize, Serialize};
57use serde_json::Value as JsonValue;
58use std::collections::HashMap;
59use std::ops::Range;
60use std::path::PathBuf;
61use std::sync::{Arc, RwLock};
62use ts_rs::TS;
63
64/// Minimal command registry for PluginApi.
65/// This is a stub that provides basic command storage for plugin use.
66/// The editor's full CommandRegistry lives in fresh-editor.
67pub struct CommandRegistry {
68    commands: std::sync::RwLock<Vec<Command>>,
69}
70
71impl CommandRegistry {
72    /// Create a new empty command registry
73    pub fn new() -> Self {
74        Self {
75            commands: std::sync::RwLock::new(Vec::new()),
76        }
77    }
78
79    /// Register a command
80    pub fn register(&self, command: Command) {
81        let mut commands = self.commands.write().unwrap();
82        commands.retain(|c| c.name != command.name);
83        commands.push(command);
84    }
85
86    /// Unregister a command by name  
87    pub fn unregister(&self, name: &str) {
88        let mut commands = self.commands.write().unwrap();
89        commands.retain(|c| c.name != name);
90    }
91}
92
93impl Default for CommandRegistry {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99/// A callback ID for JavaScript promises in the plugin runtime.
100///
101/// This newtype distinguishes JS promise callbacks (resolved via `resolve_callback`)
102/// from Rust oneshot channel IDs (resolved via `send_plugin_response`).
103/// Using a newtype prevents accidentally mixing up these two callback mechanisms.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
105#[ts(export)]
106pub struct JsCallbackId(pub u64);
107
108impl JsCallbackId {
109    /// Create a new JS callback ID
110    pub fn new(id: u64) -> Self {
111        Self(id)
112    }
113
114    /// Get the underlying u64 value
115    pub fn as_u64(self) -> u64 {
116        self.0
117    }
118}
119
120impl From<u64> for JsCallbackId {
121    fn from(id: u64) -> Self {
122        Self(id)
123    }
124}
125
126impl From<JsCallbackId> for u64 {
127    fn from(id: JsCallbackId) -> u64 {
128        id.0
129    }
130}
131
132impl std::fmt::Display for JsCallbackId {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        write!(f, "{}", self.0)
135    }
136}
137
138/// Result of creating a terminal
139#[derive(Debug, Clone, Serialize, Deserialize, TS)]
140#[serde(rename_all = "camelCase")]
141#[ts(export, rename_all = "camelCase")]
142pub struct TerminalResult {
143    /// The created buffer ID (for use with setSplitBuffer, etc.)
144    #[ts(type = "number")]
145    pub buffer_id: u64,
146    /// The terminal ID (for use with sendTerminalInput, closeTerminal)
147    #[ts(type = "number")]
148    pub terminal_id: u64,
149    /// The split ID (if created in a new split)
150    #[ts(type = "number | null")]
151    pub split_id: Option<u64>,
152}
153
154/// Result of creating a virtual buffer
155#[derive(Debug, Clone, Serialize, Deserialize, TS)]
156#[serde(rename_all = "camelCase")]
157#[ts(export, rename_all = "camelCase")]
158pub struct VirtualBufferResult {
159    /// The created buffer ID
160    #[ts(type = "number")]
161    pub buffer_id: u64,
162    /// The split ID (if created in a new split)
163    #[ts(type = "number | null")]
164    pub split_id: Option<u64>,
165}
166
167/// A rectangular region, in cells. Used by the animation plugin API so
168/// callers can target arbitrary screen regions without going through a
169/// virtual buffer.
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
171#[serde(rename_all = "camelCase")]
172#[ts(export, rename_all = "camelCase")]
173pub struct AnimationRect {
174    pub x: u16,
175    pub y: u16,
176    pub width: u16,
177    pub height: u16,
178}
179
180/// Edge a slide-in effect enters from.
181#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
182#[serde(rename_all = "camelCase")]
183#[ts(export, rename_all = "camelCase")]
184pub enum PluginAnimationEdge {
185    Top,
186    Bottom,
187    Left,
188    Right,
189}
190
191/// Plugin-facing animation description. Tagged by `kind`. Additional
192/// variants can be added later; plugins must handle the `kind` they send.
193#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
194#[serde(tag = "kind", rename_all = "camelCase")]
195#[ts(export)]
196pub enum PluginAnimationKind {
197    #[serde(rename_all = "camelCase")]
198    SlideIn {
199        from: PluginAnimationEdge,
200        duration_ms: u32,
201        delay_ms: u32,
202    },
203}
204
205/// Result of creating a buffer group
206#[derive(Debug, Clone, Serialize, Deserialize, TS)]
207#[serde(rename_all = "camelCase")]
208#[ts(export, rename_all = "camelCase")]
209pub struct BufferGroupResult {
210    /// The group ID
211    #[ts(type = "number")]
212    pub group_id: u64,
213    /// Panel buffer IDs, keyed by panel name
214    #[ts(type = "Record<string, number>")]
215    pub panels: HashMap<String, u64>,
216}
217
218/// Response from the editor for async plugin operations
219#[derive(Debug, Clone, Serialize, Deserialize, TS)]
220#[ts(export)]
221pub enum PluginResponse {
222    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
223    VirtualBufferCreated {
224        request_id: u64,
225        buffer_id: BufferId,
226        split_id: Option<SplitId>,
227    },
228    /// Response to CreateTerminal with the created buffer, terminal, and split IDs
229    TerminalCreated {
230        request_id: u64,
231        buffer_id: BufferId,
232        terminal_id: TerminalId,
233        split_id: Option<SplitId>,
234    },
235    /// Response to a plugin-initiated LSP request
236    LspRequest {
237        request_id: u64,
238        #[ts(type = "any")]
239        result: Result<JsonValue, String>,
240    },
241    /// Response to RequestHighlights
242    HighlightsComputed {
243        request_id: u64,
244        spans: Vec<TsHighlightSpan>,
245    },
246    /// Response to GetBufferText with the text content
247    BufferText {
248        request_id: u64,
249        text: Result<String, String>,
250    },
251    /// Response to GetLineStartPosition with the byte offset
252    LineStartPosition {
253        request_id: u64,
254        /// None if line is out of range, Some(offset) for valid line
255        position: Option<usize>,
256    },
257    /// Response to GetLineEndPosition with the byte offset
258    LineEndPosition {
259        request_id: u64,
260        /// None if line is out of range, Some(offset) for valid line
261        position: Option<usize>,
262    },
263    /// Response to GetBufferLineCount with the total number of lines
264    BufferLineCount {
265        request_id: u64,
266        /// None if buffer not found, Some(count) for valid buffer
267        count: Option<usize>,
268    },
269    /// Response to CreateCompositeBuffer with the created buffer ID
270    CompositeBufferCreated {
271        request_id: u64,
272        buffer_id: BufferId,
273    },
274    /// Response to GetSplitByLabel with the found split ID (if any)
275    SplitByLabel {
276        request_id: u64,
277        split_id: Option<SplitId>,
278    },
279}
280
281impl PluginResponse {
282    pub fn request_id(&self) -> u64 {
283        match self {
284            Self::VirtualBufferCreated { request_id, .. }
285            | Self::TerminalCreated { request_id, .. }
286            | Self::LspRequest { request_id, .. }
287            | Self::HighlightsComputed { request_id, .. }
288            | Self::BufferText { request_id, .. }
289            | Self::LineStartPosition { request_id, .. }
290            | Self::LineEndPosition { request_id, .. }
291            | Self::BufferLineCount { request_id, .. }
292            | Self::CompositeBufferCreated { request_id, .. }
293            | Self::SplitByLabel { request_id, .. } => *request_id,
294        }
295    }
296}
297
298/// Messages sent from async plugin tasks to the synchronous main loop
299#[derive(Debug, Clone, Serialize, Deserialize, TS)]
300#[ts(export)]
301pub enum PluginAsyncMessage {
302    /// Plugin process completed with output
303    ProcessOutput {
304        /// Unique ID for this process
305        process_id: u64,
306        /// Standard output
307        stdout: String,
308        /// Standard error
309        stderr: String,
310        /// Exit code
311        exit_code: i32,
312    },
313    /// Plugin delay/timer completed
314    DelayComplete {
315        /// Callback ID to resolve
316        callback_id: u64,
317    },
318    /// Background process stdout data
319    ProcessStdout { process_id: u64, data: String },
320    /// Background process stderr data
321    ProcessStderr { process_id: u64, data: String },
322    /// Background process exited
323    ProcessExit {
324        process_id: u64,
325        callback_id: u64,
326        exit_code: i32,
327    },
328    /// Response for a plugin-initiated LSP request
329    LspResponse {
330        language: String,
331        request_id: u64,
332        #[ts(type = "any")]
333        result: Result<JsonValue, String>,
334    },
335    /// Generic plugin response (e.g., GetBufferText result)
336    PluginResponse(crate::api::PluginResponse),
337
338    /// Streaming grep: partial results for one file
339    GrepStreamingProgress {
340        /// Search ID to route to the correct progress callback
341        search_id: u64,
342        /// Matches from a single file
343        matches_json: String,
344    },
345
346    /// Streaming grep: search complete
347    GrepStreamingComplete {
348        /// Search ID
349        search_id: u64,
350        /// Callback ID for the completion promise
351        callback_id: u64,
352        /// Total number of matches found
353        total_matches: usize,
354        /// Whether the search was stopped early due to reaching max_results
355        truncated: bool,
356    },
357}
358
359/// Information about a cursor in the editor
360#[derive(Debug, Clone, Serialize, Deserialize, TS)]
361#[ts(export)]
362pub struct CursorInfo {
363    /// Byte position of the cursor
364    pub position: usize,
365    /// Selection range (if any)
366    #[cfg_attr(
367        feature = "plugins",
368        ts(type = "{ start: number; end: number } | null")
369    )]
370    pub selection: Option<Range<usize>>,
371}
372
373/// Specification for an action to execute, with optional repeat count
374#[derive(Debug, Clone, Serialize, Deserialize, TS)]
375#[serde(deny_unknown_fields)]
376#[ts(export)]
377pub struct ActionSpec {
378    /// Action name (e.g., "move_word_right", "delete_line")
379    pub action: String,
380    /// Number of times to repeat the action (default 1)
381    #[serde(default = "default_action_count")]
382    pub count: u32,
383}
384
385fn default_action_count() -> u32 {
386    1
387}
388
389/// Information about a buffer
390#[derive(Debug, Clone, Serialize, Deserialize, TS)]
391#[ts(export)]
392pub struct BufferInfo {
393    /// Buffer ID
394    #[ts(type = "number")]
395    pub id: BufferId,
396    /// File path (if any)
397    #[serde(serialize_with = "serialize_path")]
398    #[ts(type = "string")]
399    pub path: Option<PathBuf>,
400    /// Whether the buffer has been modified
401    pub modified: bool,
402    /// Length of buffer in bytes
403    pub length: usize,
404    /// Whether this is a virtual buffer (not backed by a file)
405    pub is_virtual: bool,
406    /// Current view mode of the active split: "source" or "compose"
407    pub view_mode: String,
408    /// True if any split showing this buffer has compose mode enabled.
409    /// Plugins should use this (not `view_mode`) to decide whether to maintain
410    /// decorations, since decorations live on the buffer and are filtered
411    /// per-split at render time.
412    pub is_composing_in_any_split: bool,
413    /// Compose width (if set), from the active split's view state
414    pub compose_width: Option<u16>,
415    /// The detected language for this buffer (e.g., "rust", "markdown", "text")
416    pub language: String,
417    /// Whether this tab was opened in "preview" (ephemeral) mode — true when
418    /// opened via single-click in the file explorer and not yet committed
419    /// (no edit, no double-click, no tab-click, no layout change). Plugins
420    /// that react to buffer lifecycle events should generally treat preview
421    /// buffers as transient; e.g. a diagnostics panel may want to skip
422    /// refreshing itself for a preview tab.
423    #[serde(default)]
424    pub is_preview: bool,
425    /// Split ids that currently hold this buffer (empty when the buffer is
426    /// open but not visible in any split — e.g. background-opened tabs
427    /// that haven't been focused). Lets plugins implement "focus existing
428    /// buffer if visible, else open new" without having to track split
429    /// ids across editor restarts (which reassign them). The list is a
430    /// snapshot at the last `update_plugin_state_snapshot` tick.
431    #[serde(default)]
432    #[ts(type = "number[]")]
433    pub splits: Vec<SplitId>,
434}
435
436fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
437    s.serialize_str(
438        &path
439            .as_ref()
440            .map(|p| p.to_string_lossy().to_string())
441            .unwrap_or_default(),
442    )
443}
444
445/// Serialize ranges as [start, end] tuples for JS compatibility
446fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
447where
448    S: serde::Serializer,
449{
450    use serde::ser::SerializeSeq;
451    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
452    for range in ranges {
453        seq.serialize_element(&(range.start, range.end))?;
454    }
455    seq.end()
456}
457
458/// Diff between current buffer content and last saved snapshot
459#[derive(Debug, Clone, Serialize, Deserialize, TS)]
460#[ts(export)]
461pub struct BufferSavedDiff {
462    pub equal: bool,
463    #[serde(serialize_with = "serialize_ranges_as_tuples")]
464    #[ts(type = "Array<[number, number]>")]
465    pub byte_ranges: Vec<Range<usize>>,
466}
467
468/// Information about the viewport
469#[derive(Debug, Clone, Serialize, Deserialize, TS)]
470#[serde(rename_all = "camelCase")]
471#[ts(export, rename_all = "camelCase")]
472pub struct ViewportInfo {
473    /// Byte position of the first visible line
474    pub top_byte: usize,
475    /// Line number of the first visible line (None when line index unavailable, e.g. large file before scan)
476    pub top_line: Option<usize>,
477    /// Left column offset (horizontal scroll)
478    pub left_column: usize,
479    /// Viewport width
480    pub width: u16,
481    /// Viewport height
482    pub height: u16,
483}
484
485/// Per-split state surfaced to plugins via `editor.listSplits()`.
486///
487/// Plugins that need to operate on every visible buffer (multi-split
488/// flash labels, syncing decorations across panes, ...) can iterate
489/// this list rather than only seeing the active split's `getViewport()`.
490#[derive(Debug, Clone, Serialize, Deserialize, TS)]
491#[serde(rename_all = "camelCase")]
492#[ts(export, rename_all = "camelCase")]
493pub struct SplitSnapshot {
494    /// Stable split identifier; matches the values used by
495    /// `setSplitBuffer`, `focusSplit`, `getSplitByLabel`, etc.
496    pub split_id: usize,
497    /// Buffer currently shown in this split.
498    pub buffer_id: BufferId,
499    /// Viewport (top byte / dimensions) for this split's active buffer.
500    pub viewport: ViewportInfo,
501}
502
503/// Payload delivered to a plugin's `editor.getNextKey()` Promise when
504/// the next keypress arrives in the editor's input dispatch.
505///
506/// `key` uses the same naming as `defineMode` bindings: lowercase
507/// names like `"escape"`, `"enter"`, `"tab"`, `"space"`, `"left"`,
508/// `"f1"`–`"f12"`, or a single character (e.g. `"a"`, `"!"`).
509/// Modifier flags are reported separately so plugins can recognise
510/// chord variants without parsing.
511#[derive(Debug, Clone, Serialize, Deserialize, TS)]
512#[serde(rename_all = "camelCase")]
513#[ts(export, rename_all = "camelCase")]
514pub struct KeyEventPayload {
515    /// Key name (e.g. `"a"`, `"escape"`, `"f1"`).
516    pub key: String,
517    /// Ctrl held.
518    pub ctrl: bool,
519    /// Alt held.
520    pub alt: bool,
521    /// Shift held (only meaningful for non-character keys; for
522    /// printable characters the case is already encoded in `key`).
523    pub shift: bool,
524    /// Super / Cmd / Meta held.
525    pub meta: bool,
526}
527
528/// Layout hints supplied by plugins (e.g., Compose mode)
529#[derive(Debug, Clone, Serialize, Deserialize, TS)]
530#[serde(rename_all = "camelCase")]
531#[ts(export, rename_all = "camelCase")]
532pub struct LayoutHints {
533    /// Optional compose width for centering/wrapping
534    #[ts(optional)]
535    pub compose_width: Option<u16>,
536    /// Optional column guides for aligned tables
537    #[ts(optional)]
538    pub column_guides: Option<Vec<u16>>,
539}
540
541// ============================================================================
542// Overlay Types with Theme Support
543// ============================================================================
544
545/// Color specification that can be either RGB values or a theme key.
546///
547/// Theme keys reference colors from the current theme, e.g.:
548/// - "ui.status_bar_bg" - UI status bar background
549/// - "editor.selection_bg" - Editor selection background
550/// - "syntax.keyword" - Syntax highlighting for keywords
551/// - "diagnostic.error" - Error diagnostic color
552///
553/// When a theme key is used, the color is resolved at render time,
554/// so overlays automatically update when the theme changes.
555#[derive(Debug, Clone, Serialize, Deserialize, TS)]
556#[serde(untagged)]
557#[ts(export)]
558pub enum OverlayColorSpec {
559    /// RGB color as [r, g, b] array
560    #[ts(type = "[number, number, number]")]
561    Rgb(u8, u8, u8),
562    /// Theme key reference (e.g., "ui.status_bar_bg")
563    ThemeKey(String),
564}
565
566impl OverlayColorSpec {
567    /// Create an RGB color spec
568    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
569        Self::Rgb(r, g, b)
570    }
571
572    /// Create a theme key color spec
573    pub fn theme_key(key: impl Into<String>) -> Self {
574        Self::ThemeKey(key.into())
575    }
576
577    /// Convert to RGB if this is an RGB spec, None if it's a theme key
578    pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
579        match self {
580            Self::Rgb(r, g, b) => Some((*r, *g, *b)),
581            Self::ThemeKey(_) => None,
582        }
583    }
584
585    /// Get the theme key if this is a theme key spec
586    pub fn as_theme_key(&self) -> Option<&str> {
587        match self {
588            Self::ThemeKey(key) => Some(key),
589            Self::Rgb(_, _, _) => None,
590        }
591    }
592}
593
594/// Options for adding an overlay with theme support.
595///
596/// This struct provides a type-safe way to specify overlay styling
597/// with optional theme key references for colors.
598#[derive(Debug, Clone, Serialize, Deserialize, TS)]
599#[serde(deny_unknown_fields, rename_all = "camelCase")]
600#[ts(export, rename_all = "camelCase")]
601#[derive(Default)]
602pub struct OverlayOptions {
603    /// Foreground color - RGB array or theme key string
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub fg: Option<OverlayColorSpec>,
606
607    /// Background color - RGB array or theme key string
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub bg: Option<OverlayColorSpec>,
610
611    /// Whether to render with underline
612    #[serde(default)]
613    pub underline: bool,
614
615    /// Whether to render in bold
616    #[serde(default)]
617    pub bold: bool,
618
619    /// Whether to render in italic
620    #[serde(default)]
621    pub italic: bool,
622
623    /// Whether to render with strikethrough
624    #[serde(default)]
625    pub strikethrough: bool,
626
627    /// Whether to extend background color to end of line
628    #[serde(default)]
629    pub extend_to_line_end: bool,
630
631    /// Optional URL for OSC 8 terminal hyperlinks.
632    /// When set, the overlay text becomes a clickable hyperlink in terminals
633    /// that support OSC 8 escape sequences.
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub url: Option<String>,
636}
637
638// ============================================================================
639// Composite Buffer Configuration (for multi-buffer single-tab views)
640// ============================================================================
641
642/// Layout configuration for composite buffers
643#[derive(Debug, Clone, Serialize, Deserialize, TS)]
644#[serde(deny_unknown_fields)]
645#[ts(export, rename = "TsCompositeLayoutConfig")]
646pub struct CompositeLayoutConfig {
647    /// Layout type: "side-by-side", "stacked", or "unified"
648    #[serde(rename = "type")]
649    #[ts(rename = "type")]
650    pub layout_type: String,
651    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
652    #[serde(default)]
653    #[ts(optional)]
654    pub ratios: Option<Vec<f32>>,
655    /// Show separator between panes
656    #[serde(default = "default_true", rename = "showSeparator")]
657    #[ts(rename = "showSeparator")]
658    pub show_separator: bool,
659    /// Spacing for stacked layout
660    #[serde(default)]
661    #[ts(optional)]
662    pub spacing: Option<u16>,
663}
664
665fn default_true() -> bool {
666    true
667}
668
669/// Source pane configuration for composite buffers
670#[derive(Debug, Clone, Serialize, Deserialize, TS)]
671#[serde(deny_unknown_fields)]
672#[ts(export, rename = "TsCompositeSourceConfig")]
673pub struct CompositeSourceConfig {
674    /// Buffer ID of the source buffer (required)
675    #[serde(rename = "bufferId")]
676    #[ts(rename = "bufferId")]
677    pub buffer_id: usize,
678    /// Label for this pane (e.g., "OLD", "NEW")
679    pub label: String,
680    /// Whether this pane is editable
681    #[serde(default)]
682    pub editable: bool,
683    /// Style configuration
684    #[serde(default)]
685    pub style: Option<CompositePaneStyle>,
686}
687
688/// Style configuration for a composite pane
689#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
690#[serde(deny_unknown_fields)]
691#[ts(export, rename = "TsCompositePaneStyle")]
692pub struct CompositePaneStyle {
693    /// Background color for added lines (RGB)
694    /// Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
695    #[serde(default, rename = "addBg")]
696    #[ts(optional, rename = "addBg", type = "[number, number, number]")]
697    pub add_bg: Option<[u8; 3]>,
698    /// Background color for removed lines (RGB)
699    #[serde(default, rename = "removeBg")]
700    #[ts(optional, rename = "removeBg", type = "[number, number, number]")]
701    pub remove_bg: Option<[u8; 3]>,
702    /// Background color for modified lines (RGB)
703    #[serde(default, rename = "modifyBg")]
704    #[ts(optional, rename = "modifyBg", type = "[number, number, number]")]
705    pub modify_bg: Option<[u8; 3]>,
706    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
707    #[serde(default, rename = "gutterStyle")]
708    #[ts(optional, rename = "gutterStyle")]
709    pub gutter_style: Option<String>,
710}
711
712/// Diff hunk for composite buffer alignment
713#[derive(Debug, Clone, Serialize, Deserialize, TS)]
714#[serde(deny_unknown_fields)]
715#[ts(export, rename = "TsCompositeHunk")]
716pub struct CompositeHunk {
717    /// Starting line in old buffer (0-indexed)
718    #[serde(rename = "oldStart")]
719    #[ts(rename = "oldStart")]
720    pub old_start: usize,
721    /// Number of lines in old buffer
722    #[serde(rename = "oldCount")]
723    #[ts(rename = "oldCount")]
724    pub old_count: usize,
725    /// Starting line in new buffer (0-indexed)
726    #[serde(rename = "newStart")]
727    #[ts(rename = "newStart")]
728    pub new_start: usize,
729    /// Number of lines in new buffer
730    #[serde(rename = "newCount")]
731    #[ts(rename = "newCount")]
732    pub new_count: usize,
733}
734
735/// Options for creating a composite buffer (used by plugin API)
736#[derive(Debug, Clone, Serialize, Deserialize, TS)]
737#[serde(deny_unknown_fields)]
738#[ts(export, rename = "TsCreateCompositeBufferOptions")]
739pub struct CreateCompositeBufferOptions {
740    /// Buffer name (displayed in tabs/title)
741    #[serde(default)]
742    pub name: String,
743    /// Mode for keybindings
744    #[serde(default)]
745    pub mode: String,
746    /// Layout configuration
747    pub layout: CompositeLayoutConfig,
748    /// Source pane configurations
749    pub sources: Vec<CompositeSourceConfig>,
750    /// Diff hunks for alignment (optional)
751    #[serde(default)]
752    pub hunks: Option<Vec<CompositeHunk>>,
753    /// When set, the first render will scroll to center the Nth hunk (0-indexed).
754    /// This avoids timing issues with imperative scroll commands that depend on
755    /// render-created state (viewport dimensions, view state).
756    #[serde(default, rename = "initialFocusHunk")]
757    #[ts(optional, rename = "initialFocusHunk")]
758    pub initial_focus_hunk: Option<usize>,
759}
760
761/// Wire-format view token kind (serialized for plugin transforms)
762#[derive(Debug, Clone, Serialize, Deserialize, TS)]
763#[ts(export)]
764pub enum ViewTokenWireKind {
765    Text(String),
766    Newline,
767    Space,
768    /// Visual line break inserted by wrapping (not from source)
769    /// Always has source_offset: None
770    Break,
771    /// A single binary byte that should be rendered as <XX>
772    /// Used in binary file mode to ensure cursor positioning works correctly
773    /// (all 4 display chars of <XX> map to the same source byte)
774    BinaryByte(u8),
775}
776
777/// Styling for view tokens (used for injected annotations)
778///
779/// This allows plugins to specify styling for tokens that don't have a source
780/// mapping (sourceOffset: None), such as annotation headers in git blame.
781/// For tokens with sourceOffset: Some(_), syntax highlighting is applied instead.
782#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
783#[serde(deny_unknown_fields)]
784#[ts(export)]
785pub struct ViewTokenStyle {
786    /// Foreground color as RGB tuple
787    #[serde(default)]
788    #[ts(type = "[number, number, number] | null")]
789    pub fg: Option<(u8, u8, u8)>,
790    /// Background color as RGB tuple
791    #[serde(default)]
792    #[ts(type = "[number, number, number] | null")]
793    pub bg: Option<(u8, u8, u8)>,
794    /// Whether to render in bold
795    #[serde(default)]
796    pub bold: bool,
797    /// Whether to render in italic
798    #[serde(default)]
799    pub italic: bool,
800}
801
802/// Wire-format view token with optional source mapping and styling
803#[derive(Debug, Clone, Serialize, Deserialize, TS)]
804#[serde(deny_unknown_fields)]
805#[ts(export)]
806pub struct ViewTokenWire {
807    /// Source byte offset in the buffer. None for injected content (annotations).
808    #[ts(type = "number | null")]
809    pub source_offset: Option<usize>,
810    /// The token content
811    pub kind: ViewTokenWireKind,
812    /// Optional styling for injected content (only used when source_offset is None)
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    #[ts(optional)]
815    pub style: Option<ViewTokenStyle>,
816}
817
818/// Transformed view stream payload (plugin-provided)
819#[derive(Debug, Clone, Serialize, Deserialize, TS)]
820#[ts(export)]
821pub struct ViewTransformPayload {
822    /// Byte range this transform applies to (viewport)
823    pub range: Range<usize>,
824    /// Tokens in wire format
825    pub tokens: Vec<ViewTokenWire>,
826    /// Layout hints
827    pub layout_hints: Option<LayoutHints>,
828}
829
830/// Snapshot of editor state for plugin queries
831/// This is updated by the editor on each loop iteration
832#[derive(Debug, Clone, Serialize, Deserialize, TS)]
833#[ts(export)]
834pub struct EditorStateSnapshot {
835    /// Currently active buffer ID
836    pub active_buffer_id: BufferId,
837    /// Currently active split ID
838    pub active_split_id: usize,
839    /// Information about all open buffers
840    pub buffers: HashMap<BufferId, BufferInfo>,
841    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
842    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
843    /// Primary cursor position for the active buffer
844    pub primary_cursor: Option<CursorInfo>,
845    /// All cursor positions for the active buffer
846    pub all_cursors: Vec<CursorInfo>,
847    /// Viewport information for the active buffer
848    pub viewport: Option<ViewportInfo>,
849    /// Per-split snapshots: split id, buffer shown, viewport.
850    /// Includes the active split.  Order is unspecified.
851    #[serde(default)]
852    pub splits: Vec<SplitSnapshot>,
853    /// Cursor positions per buffer (for buffers other than active)
854    pub buffer_cursor_positions: HashMap<BufferId, usize>,
855    /// Text properties per buffer (for virtual buffers with properties)
856    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
857    /// Selected text from the primary cursor (if any selection exists)
858    /// This is populated on each update to avoid needing full buffer access
859    pub selected_text: Option<String>,
860    /// Internal clipboard content (for plugins that need clipboard access)
861    pub clipboard: String,
862    /// Editor's working directory (for file operations and spawning processes)
863    pub working_dir: PathBuf,
864    /// Status-bar / explorer label for the active authority.
865    ///
866    /// Empty = the local (default) authority with nothing to render.
867    /// Non-empty means a non-local authority is installed (e.g.
868    /// `"Container:abc123def456"` for a devcontainer). Plugins can
869    /// read this via `editor.getAuthorityLabel()` to detect "already
870    /// attached" without having to track state across editor restarts.
871    #[serde(default)]
872    pub authority_label: String,
873    /// LSP diagnostics per file URI.
874    /// Maps file URI string to Vec of diagnostics for that file.
875    ///
876    /// Wrapped in `Arc` so snapshot refresh is a refcount bump rather than
877    /// a deep clone. The editor only mutates its own map through
878    /// `Arc::make_mut`, which CoW-clones while this snapshot still holds
879    /// a reference — a reader can never observe an in-place mutation.
880    ///
881    /// `#[serde(skip)]`: serde out-of-the-box can't serialize `Arc<T>`
882    /// (behind the `rc` cargo feature we don't enable). We never serialize
883    /// the snapshot as a whole — plugin readers pull out these Arcs and
884    /// serialize the *inner* value directly (e.g. `get_all_diagnostics`).
885    #[serde(skip)]
886    #[ts(type = "any")]
887    pub diagnostics: Arc<HashMap<String, Vec<lsp_types::Diagnostic>>>,
888    /// LSP folding ranges per file URI.
889    /// Maps file URI string to Vec of folding ranges for that file.
890    /// Arc-wrapped for the same CoW invariant as `diagnostics`; see that
891    /// field for why this is `#[serde(skip)]`.
892    #[serde(skip)]
893    #[ts(type = "any")]
894    pub folding_ranges: Arc<HashMap<String, Vec<lsp_types::FoldingRange>>>,
895    /// Runtime config as serde_json::Value (merged user config + defaults).
896    /// This is the runtime config, not just the user's config file.
897    ///
898    /// Wrapped in `Arc` so the snapshot update is a refcount bump. The
899    /// editor reserializes its source `Config` only when the underlying
900    /// `Arc<Config>` pointer has moved (i.e., after a real mutation), and
901    /// swaps the whole `Arc<Value>` atomically — callers never see a
902    /// partially-updated blob. `#[serde(skip)]` for the same reason as
903    /// `diagnostics`.
904    #[serde(skip)]
905    #[ts(type = "any")]
906    pub config: Arc<serde_json::Value>,
907    /// User config as serde_json::Value (only what's in the user's config file).
908    /// Fields not present here are using default values.
909    /// Arc-wrapped; swapped as a whole when the user's file is reloaded.
910    /// `#[serde(skip)]` for the same reason as `diagnostics`.
911    #[serde(skip)]
912    #[ts(type = "any")]
913    pub user_config: Arc<serde_json::Value>,
914    /// Available grammars with provenance info, updated when grammar registry changes
915    #[ts(type = "GrammarInfo[]")]
916    pub available_grammars: Vec<GrammarInfoSnapshot>,
917    /// Last-seen grammar registry generation. The state-snapshot updater
918    /// rebuilds `available_grammars` only when this disagrees with the
919    /// registry's current `catalog_gen()`. `#[serde(skip)]` because the
920    /// counter is a host-side detail not exposed to plugins.
921    #[serde(skip)]
922    #[ts(skip)]
923    pub last_grammar_gen: u64,
924    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
925    /// When set, this mode's keybindings take precedence over normal key handling
926    pub editor_mode: Option<String>,
927
928    /// Plugin-managed per-buffer view state for the active split.
929    /// Updated from BufferViewState.plugin_state during snapshot updates.
930    /// Also written directly by JS plugins via setViewState for immediate read-back.
931    #[ts(type = "any")]
932    pub plugin_view_states: HashMap<BufferId, HashMap<String, serde_json::Value>>,
933
934    /// Tracks which split was active when plugin_view_states was last populated.
935    /// When the active split changes, plugin_view_states is fully repopulated.
936    #[serde(skip)]
937    #[ts(skip)]
938    pub plugin_view_states_split: usize,
939
940    /// Keybinding labels for plugin modes, keyed by "action\0mode" for fast lookup.
941    /// Updated when modes are registered via defineMode().
942    #[serde(skip)]
943    #[ts(skip)]
944    pub keybinding_labels: HashMap<String, String>,
945
946    /// Plugin-managed global state, isolated per plugin.
947    /// Outer key is plugin name, inner key is the state key set by the plugin.
948    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
949    /// Currently we isolate by plugin name, but we may want a more robust approach
950    /// (e.g. preventing plugins from reading each other's state, or providing
951    /// explicit cross-plugin state sharing APIs).
952    #[ts(type = "any")]
953    pub plugin_global_states: HashMap<String, HashMap<String, serde_json::Value>>,
954}
955
956impl EditorStateSnapshot {
957    pub fn new() -> Self {
958        Self {
959            active_buffer_id: BufferId(0),
960            active_split_id: 0,
961            buffers: HashMap::new(),
962            buffer_saved_diffs: HashMap::new(),
963            primary_cursor: None,
964            all_cursors: Vec::new(),
965            viewport: None,
966            splits: Vec::new(),
967            buffer_cursor_positions: HashMap::new(),
968            buffer_text_properties: HashMap::new(),
969            selected_text: None,
970            clipboard: String::new(),
971            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
972            authority_label: String::new(),
973            diagnostics: Arc::new(HashMap::new()),
974            folding_ranges: Arc::new(HashMap::new()),
975            config: Arc::new(serde_json::Value::Null),
976            user_config: Arc::new(serde_json::Value::Null),
977            available_grammars: Vec::new(),
978            last_grammar_gen: 0,
979            editor_mode: None,
980            plugin_view_states: HashMap::new(),
981            plugin_view_states_split: 0,
982            keybinding_labels: HashMap::new(),
983            plugin_global_states: HashMap::new(),
984        }
985    }
986}
987
988impl Default for EditorStateSnapshot {
989    fn default() -> Self {
990        Self::new()
991    }
992}
993
994/// Grammar info exposed to plugins, mirroring the editor's grammar provenance tracking.
995#[derive(Debug, Clone, Serialize, Deserialize, TS)]
996#[ts(export)]
997pub struct GrammarInfoSnapshot {
998    /// The grammar name as used in config files (case-insensitive matching)
999    pub name: String,
1000    /// Where this grammar was loaded from (e.g. "built-in", "plugin (myplugin)")
1001    pub source: String,
1002    /// File extensions associated with this grammar
1003    pub file_extensions: Vec<String>,
1004    /// Optional short name alias (e.g., "bash" for "Bourne Again Shell (bash)")
1005    pub short_name: Option<String>,
1006}
1007
1008/// Position for inserting menu items or menus
1009#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1010#[ts(export)]
1011pub enum MenuPosition {
1012    /// Add at the beginning
1013    Top,
1014    /// Add at the end
1015    Bottom,
1016    /// Add before a specific label
1017    Before(String),
1018    /// Add after a specific label
1019    After(String),
1020}
1021
1022/// Plugin command - allows plugins to send commands to the editor
1023#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1024#[ts(export)]
1025pub enum PluginCommand {
1026    /// Insert text at a position in a buffer
1027    InsertText {
1028        buffer_id: BufferId,
1029        position: usize,
1030        text: String,
1031    },
1032
1033    /// Delete a range of text from a buffer
1034    DeleteRange {
1035        buffer_id: BufferId,
1036        range: Range<usize>,
1037    },
1038
1039    /// Add an overlay to a buffer, returns handle via response channel
1040    ///
1041    /// Colors can be specified as RGB tuples or theme keys. When theme keys
1042    /// are provided, they take precedence and are resolved at render time.
1043    AddOverlay {
1044        buffer_id: BufferId,
1045        namespace: Option<OverlayNamespace>,
1046        range: Range<usize>,
1047        /// Overlay styling options (colors, modifiers, etc.)
1048        options: OverlayOptions,
1049    },
1050
1051    /// Remove an overlay by its opaque handle
1052    RemoveOverlay {
1053        buffer_id: BufferId,
1054        handle: OverlayHandle,
1055    },
1056
1057    /// Set status message
1058    SetStatus { message: String },
1059
1060    /// Apply a theme by name
1061    ApplyTheme { theme_name: String },
1062
1063    /// Override specific theme color keys in-memory for the running session.
1064    /// Keys are the same `section.field` strings accepted by
1065    /// `Theme::resolve_theme_key` (e.g. `"editor.bg"`, `"ui.status_bar_fg"`).
1066    /// Values are `[r, g, b]` triplets. Unknown keys are silently dropped so
1067    /// a typo in a fast animation loop doesn't blow up the caller; the
1068    /// return channel isn't used — plugins can do a dry-run look-up via
1069    /// `getThemeSchema` if they want compile-time safety. Overrides are
1070    /// reset the next time the caller (or anyone else) invokes
1071    /// `applyTheme`, because that replaces the whole `Theme` from the
1072    /// registry.
1073    OverrideThemeColors { overrides: HashMap<String, [u8; 3]> },
1074
1075    /// Reload configuration from file
1076    /// After a plugin saves config changes, it should call this to reload the config
1077    ReloadConfig,
1078
1079    /// Write a single setting to the runtime overlay for this session.
1080    /// `path` is dot-separated (e.g. "editor.tab_size"). Last write wins.
1081    SetSetting {
1082        plugin_name: String,
1083        path: String,
1084        #[ts(type = "unknown")]
1085        value: JsonValue,
1086    },
1087
1088    /// Register a custom command
1089    RegisterCommand { command: Command },
1090
1091    /// Unregister a command by name
1092    UnregisterCommand { name: String },
1093
1094    /// Open a file in the editor (in background, without switching focus)
1095    OpenFileInBackground { path: PathBuf },
1096
1097    /// Insert text at the current cursor position in the active buffer
1098    InsertAtCursor { text: String },
1099
1100    /// Spawn an async process
1101    SpawnProcess {
1102        command: String,
1103        args: Vec<String>,
1104        cwd: Option<String>,
1105        callback_id: JsCallbackId,
1106    },
1107
1108    /// Delay/sleep for a duration (async, resolves callback when done)
1109    Delay {
1110        callback_id: JsCallbackId,
1111        duration_ms: u64,
1112    },
1113
1114    /// Spawn a long-running background process
1115    /// Unlike SpawnProcess, this returns immediately with a process handle
1116    /// and provides streaming output via hooks
1117    SpawnBackgroundProcess {
1118        /// Unique ID for this process (generated by plugin runtime)
1119        process_id: u64,
1120        /// Command to execute
1121        command: String,
1122        /// Arguments to pass
1123        args: Vec<String>,
1124        /// Working directory (optional)
1125        cwd: Option<String>,
1126        /// Callback ID to call when process exits
1127        callback_id: JsCallbackId,
1128    },
1129
1130    /// Kill a background process by ID
1131    KillBackgroundProcess { process_id: u64 },
1132
1133    /// Wait for a process to complete and get its result
1134    /// Used with processes started via SpawnProcess
1135    SpawnProcessWait {
1136        /// Process ID to wait for
1137        process_id: u64,
1138        /// Callback ID for async response
1139        callback_id: JsCallbackId,
1140    },
1141
1142    /// Set layout hints for a buffer/viewport
1143    SetLayoutHints {
1144        buffer_id: BufferId,
1145        split_id: Option<SplitId>,
1146        range: Range<usize>,
1147        hints: LayoutHints,
1148    },
1149
1150    /// Enable/disable line numbers for a buffer
1151    SetLineNumbers { buffer_id: BufferId, enabled: bool },
1152
1153    /// Set the view mode for a buffer ("source" or "compose")
1154    SetViewMode { buffer_id: BufferId, mode: String },
1155
1156    /// Enable/disable line wrapping for a buffer
1157    SetLineWrap {
1158        buffer_id: BufferId,
1159        split_id: Option<SplitId>,
1160        enabled: bool,
1161    },
1162
1163    /// Submit a transformed view stream for a viewport
1164    SubmitViewTransform {
1165        buffer_id: BufferId,
1166        split_id: Option<SplitId>,
1167        payload: ViewTransformPayload,
1168    },
1169
1170    /// Clear view transform for a buffer/split (returns to normal rendering)
1171    ClearViewTransform {
1172        buffer_id: BufferId,
1173        split_id: Option<SplitId>,
1174    },
1175
1176    /// Set plugin-managed view state for a buffer in the active split.
1177    /// Stored in BufferViewState.plugin_state and persisted across sessions.
1178    SetViewState {
1179        buffer_id: BufferId,
1180        key: String,
1181        #[ts(type = "any")]
1182        value: Option<serde_json::Value>,
1183    },
1184
1185    /// Set plugin-managed global state (not tied to any buffer or split).
1186    /// Isolated per plugin by plugin_name.
1187    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
1188    SetGlobalState {
1189        plugin_name: String,
1190        key: String,
1191        #[ts(type = "any")]
1192        value: Option<serde_json::Value>,
1193    },
1194
1195    /// Remove all overlays from a buffer
1196    ClearAllOverlays { buffer_id: BufferId },
1197
1198    /// Remove all overlays in a namespace
1199    ClearNamespace {
1200        buffer_id: BufferId,
1201        namespace: OverlayNamespace,
1202    },
1203
1204    /// Remove all overlays that overlap with a byte range
1205    /// Used for targeted invalidation when content in a range changes
1206    ClearOverlaysInRange {
1207        buffer_id: BufferId,
1208        start: usize,
1209        end: usize,
1210    },
1211
1212    /// Add virtual text (inline text that doesn't exist in the buffer)
1213    /// Used for color swatches, type hints, parameter hints, etc.
1214    AddVirtualText {
1215        buffer_id: BufferId,
1216        virtual_text_id: String,
1217        position: usize,
1218        text: String,
1219        color: (u8, u8, u8),
1220        use_bg: bool, // true = use color as background, false = use as foreground
1221        before: bool, // true = before char, false = after char
1222    },
1223
1224    /// Add virtual text with full styling — fg/bg can be RGB or theme
1225    /// keys (resolved at render time so theme changes apply live).
1226    /// This is the richer form of `AddVirtualText` that lets plugins
1227    /// produce themed labels (flash jump, type hints with semantic
1228    /// colours, …) without hard-coding RGB values.
1229    AddVirtualTextStyled {
1230        buffer_id: BufferId,
1231        virtual_text_id: String,
1232        position: usize,
1233        text: String,
1234        fg: Option<OverlayColorSpec>,
1235        bg: Option<OverlayColorSpec>,
1236        bold: bool,
1237        italic: bool,
1238        before: bool,
1239    },
1240
1241    /// Remove a virtual text by ID
1242    RemoveVirtualText {
1243        buffer_id: BufferId,
1244        virtual_text_id: String,
1245    },
1246
1247    /// Remove virtual texts whose ID starts with the given prefix
1248    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
1249
1250    /// Clear all virtual texts from a buffer
1251    ClearVirtualTexts { buffer_id: BufferId },
1252
1253    /// Add a virtual LINE (full line above/below a position)
1254    /// Used for git blame headers, code coverage, inline documentation, etc.
1255    /// These lines do NOT show line numbers in the gutter.
1256    AddVirtualLine {
1257        buffer_id: BufferId,
1258        /// Byte position to anchor the line to
1259        position: usize,
1260        /// Full line content to display
1261        text: String,
1262        /// Foreground color — RGB tuple or theme key string (e.g.
1263        /// `"editor.line_number_fg"`).  Resolved at render time so the line
1264        /// follows theme changes.
1265        fg_color: Option<OverlayColorSpec>,
1266        /// Background color — RGB tuple or theme key string.  None =
1267        /// transparent (inherits from underlying viewport background).
1268        bg_color: Option<OverlayColorSpec>,
1269        /// true = above the line containing position, false = below
1270        above: bool,
1271        /// Namespace for bulk removal (e.g., "git-blame")
1272        namespace: String,
1273        /// Priority for ordering multiple lines at same position (higher = later)
1274        priority: i32,
1275    },
1276
1277    /// Clear all virtual texts in a namespace
1278    /// This is the primary way to remove a plugin's virtual lines before updating them.
1279    ClearVirtualTextNamespace {
1280        buffer_id: BufferId,
1281        namespace: String,
1282    },
1283
1284    /// Add a conceal range that hides or replaces a byte range during rendering.
1285    /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc.
1286    AddConceal {
1287        buffer_id: BufferId,
1288        /// Namespace for bulk removal (shared with overlay namespace system)
1289        namespace: OverlayNamespace,
1290        /// Byte range to conceal
1291        start: usize,
1292        end: usize,
1293        /// Optional replacement text to show instead. None = hide completely.
1294        replacement: Option<String>,
1295    },
1296
1297    /// Clear all conceal ranges in a namespace
1298    ClearConcealNamespace {
1299        buffer_id: BufferId,
1300        namespace: OverlayNamespace,
1301    },
1302
1303    /// Remove all conceal ranges that overlap with a byte range
1304    /// Used for targeted invalidation when content in a range changes
1305    ClearConcealsInRange {
1306        buffer_id: BufferId,
1307        start: usize,
1308        end: usize,
1309    },
1310
1311    /// Add a collapsed fold range. Hides the byte range
1312    /// `[start, end)` from rendering — the line containing `start - 1`
1313    /// (the fold's "header") stays visible while the lines covered by
1314    /// the range are skipped. Used by plugins that want to expose
1315    /// outline-style collapse without rebuilding buffer content.
1316    AddFold {
1317        buffer_id: BufferId,
1318        start: usize,
1319        end: usize,
1320        /// Optional placeholder text to show on the header line
1321        /// (currently unused by the renderer; reserved for future use).
1322        placeholder: Option<String>,
1323    },
1324
1325    /// Clear every collapsed fold range on the buffer.
1326    ClearFolds { buffer_id: BufferId },
1327
1328    /// Add a soft break point for marker-based line wrapping.
1329    /// The break is stored as a marker that auto-adjusts on buffer edits,
1330    /// eliminating the flicker caused by async view_transform round-trips.
1331    AddSoftBreak {
1332        buffer_id: BufferId,
1333        /// Namespace for bulk removal (shared with overlay namespace system)
1334        namespace: OverlayNamespace,
1335        /// Byte offset where the break should be injected
1336        position: usize,
1337        /// Number of hanging indent spaces after the break
1338        indent: u16,
1339    },
1340
1341    /// Clear all soft breaks in a namespace
1342    ClearSoftBreakNamespace {
1343        buffer_id: BufferId,
1344        namespace: OverlayNamespace,
1345    },
1346
1347    /// Remove all soft breaks that fall within a byte range
1348    ClearSoftBreaksInRange {
1349        buffer_id: BufferId,
1350        start: usize,
1351        end: usize,
1352    },
1353
1354    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
1355    RefreshLines { buffer_id: BufferId },
1356
1357    /// Refresh lines for ALL buffers (clear entire seen_lines cache)
1358    /// Sent when a plugin registers for the lines_changed hook to handle the race
1359    /// where render marks lines as "seen" before the plugin has registered.
1360    RefreshAllLines,
1361
1362    /// Sentinel sent by the plugin thread after a hook has been fully processed.
1363    /// Used by the render loop to wait deterministically for plugin responses
1364    /// (e.g., conceal commands from `lines_changed`) instead of polling.
1365    HookCompleted { hook_name: String },
1366
1367    /// Set a line indicator in the gutter's indicator column
1368    /// Used for git gutter, breakpoints, bookmarks, etc.
1369    SetLineIndicator {
1370        buffer_id: BufferId,
1371        /// Line number (0-indexed)
1372        line: usize,
1373        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1374        namespace: String,
1375        /// Symbol to display (e.g., "│", "●", "★")
1376        symbol: String,
1377        /// Color as RGB tuple
1378        color: (u8, u8, u8),
1379        /// Priority for display when multiple indicators exist (higher wins)
1380        priority: i32,
1381    },
1382
1383    /// Batch set line indicators in the gutter's indicator column
1384    /// Optimized for setting many lines with the same namespace/symbol/color/priority
1385    SetLineIndicators {
1386        buffer_id: BufferId,
1387        /// Line numbers (0-indexed)
1388        lines: Vec<usize>,
1389        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1390        namespace: String,
1391        /// Symbol to display (e.g., "│", "●", "★")
1392        symbol: String,
1393        /// Color as RGB tuple
1394        color: (u8, u8, u8),
1395        /// Priority for display when multiple indicators exist (higher wins)
1396        priority: i32,
1397    },
1398
1399    /// Clear all line indicators for a specific namespace
1400    ClearLineIndicators {
1401        buffer_id: BufferId,
1402        /// Namespace to clear (e.g., "git-gutter")
1403        namespace: String,
1404    },
1405
1406    /// Set file explorer decorations for a namespace
1407    SetFileExplorerDecorations {
1408        /// Namespace for grouping (e.g., "git-status")
1409        namespace: String,
1410        /// Decorations to apply
1411        decorations: Vec<FileExplorerDecoration>,
1412    },
1413
1414    /// Clear file explorer decorations for a namespace
1415    ClearFileExplorerDecorations {
1416        /// Namespace to clear (e.g., "git-status")
1417        namespace: String,
1418    },
1419
1420    /// Open a file at a specific line and column
1421    /// Line and column are 1-indexed to match git grep output
1422    OpenFileAtLocation {
1423        path: PathBuf,
1424        line: Option<usize>,   // 1-indexed, None = go to start
1425        column: Option<usize>, // 1-indexed, None = go to line start
1426    },
1427
1428    /// Open a file in a specific split at a given line and column
1429    /// Line and column are 1-indexed to match git grep output
1430    OpenFileInSplit {
1431        split_id: usize,
1432        path: PathBuf,
1433        line: Option<usize>,   // 1-indexed, None = go to start
1434        column: Option<usize>, // 1-indexed, None = go to line start
1435    },
1436
1437    /// Start a prompt (minibuffer) with a custom type identifier
1438    /// This allows plugins to create interactive prompts
1439    StartPrompt {
1440        label: String,
1441        prompt_type: String, // e.g., "git-grep", "git-find-file"
1442        /// When true, the prompt renders as a centred floating
1443        /// overlay rather than a bottom-row minibuffer. Used for
1444        /// Live Grep (issue #1796). Defaults to false at the wire
1445        /// level via `#[serde(default)]`.
1446        #[serde(default)]
1447        floating_overlay: bool,
1448    },
1449
1450    /// Start a prompt with pre-filled initial value
1451    StartPromptWithInitial {
1452        label: String,
1453        prompt_type: String,
1454        initial_value: String,
1455        /// See `StartPrompt::floating_overlay`.
1456        #[serde(default)]
1457        floating_overlay: bool,
1458    },
1459
1460    /// Start an async prompt that returns result via callback
1461    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
1462    StartPromptAsync {
1463        label: String,
1464        initial_value: String,
1465        callback_id: JsCallbackId,
1466    },
1467
1468    /// Request the next keypress for the calling plugin.
1469    ///
1470    /// The editor enqueues `callback_id` and resolves it with a
1471    /// `KeyEventPayload` JSON value the next time a key arrives in
1472    /// `Editor::handle_key`. Multiple pending requests are FIFO.
1473    /// While at least one request is pending, the next key is consumed
1474    /// by the resolution and does not propagate to mode bindings or
1475    /// other dispatch — this is the primitive that lets a plugin run a
1476    /// short input loop (flash labels, vi find-char, replace-char,
1477    /// etc.) without binding every printable key in `defineMode`.
1478    AwaitNextKey { callback_id: JsCallbackId },
1479
1480    /// Begin or end "key capture" mode for the calling plugin.
1481    ///
1482    /// Without this, a plugin running a `getNextKey()` loop has a
1483    /// race: keys typed by the user (or pasted, or auto-repeated)
1484    /// can arrive between two consecutive `getNextKey()` calls while
1485    /// the plugin is still mid-redraw, and would otherwise fall
1486    /// through to the editor's normal dispatch (inserting into the
1487    /// buffer, etc.).
1488    ///
1489    /// While capture is active, every key arriving in
1490    /// `Editor::handle_key` (after terminal-input dispatch) is
1491    /// either resolved against a pending `AwaitNextKey` callback
1492    /// (existing behaviour) or, if no callback is pending, *buffered*
1493    /// in a FIFO queue.  When the next `AwaitNextKey` is processed,
1494    /// the queue is drained first.  This gives plugins lossless,
1495    /// in-order delivery of every key the user typed regardless of
1496    /// timing.
1497    ///
1498    /// `EndKeyCapture` clears any unconsumed buffered keys; they do
1499    /// NOT replay into the editor's normal dispatch path (that would
1500    /// be surprising — the user's intent was for the plugin to
1501    /// consume them).
1502    SetKeyCaptureActive { active: bool },
1503
1504    /// Update the suggestions list for the current prompt
1505    /// Uses the editor's Suggestion type
1506    SetPromptSuggestions { suggestions: Vec<Suggestion> },
1507
1508    /// When enabled, navigating suggestions updates the prompt input text
1509    SetPromptInputSync { sync: bool },
1510
1511    /// Set the title shown in a floating-overlay prompt's frame
1512    /// header (issue #1796). `None` clears any previously-set title
1513    /// and falls back to the prompt-type default. Has no visible
1514    /// effect on non-overlay prompts.
1515    SetPromptTitle { title: Option<String> },
1516
1517    /// Add a menu item to an existing menu
1518    /// Add a menu item to an existing menu
1519    AddMenuItem {
1520        menu_label: String,
1521        item: MenuItem,
1522        position: MenuPosition,
1523    },
1524
1525    /// Add a new top-level menu
1526    AddMenu { menu: Menu, position: MenuPosition },
1527
1528    /// Remove a menu item from a menu
1529    RemoveMenuItem {
1530        menu_label: String,
1531        item_label: String,
1532    },
1533
1534    /// Remove a top-level menu
1535    RemoveMenu { menu_label: String },
1536
1537    /// Create a new virtual buffer (not backed by a file)
1538    CreateVirtualBuffer {
1539        /// Display name (e.g., "*Diagnostics*")
1540        name: String,
1541        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1542        mode: String,
1543        /// Whether the buffer is read-only
1544        read_only: bool,
1545    },
1546
1547    /// Create a virtual buffer and set its content in one operation
1548    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
1549    /// because it doesn't require tracking the buffer ID
1550    CreateVirtualBufferWithContent {
1551        /// Display name (e.g., "*Diagnostics*")
1552        name: String,
1553        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1554        mode: String,
1555        /// Whether the buffer is read-only
1556        read_only: bool,
1557        /// Entries with text and embedded properties
1558        entries: Vec<TextPropertyEntry>,
1559        /// Whether to show line numbers in the gutter
1560        show_line_numbers: bool,
1561        /// Whether to show cursors in the buffer
1562        show_cursors: bool,
1563        /// Whether editing is disabled (blocks editing commands)
1564        editing_disabled: bool,
1565        /// Whether this buffer should be hidden from tabs (for composite source buffers)
1566        hidden_from_tabs: bool,
1567        /// Optional request ID for async response
1568        request_id: Option<u64>,
1569    },
1570
1571    /// Create a virtual buffer in a horizontal split
1572    /// Opens the buffer in a new pane below the current one
1573    CreateVirtualBufferInSplit {
1574        /// Display name (e.g., "*Diagnostics*")
1575        name: String,
1576        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1577        mode: String,
1578        /// Whether the buffer is read-only
1579        read_only: bool,
1580        /// Entries with text and embedded properties
1581        entries: Vec<TextPropertyEntry>,
1582        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
1583        ratio: f32,
1584        /// Split direction ("horizontal" or "vertical"), default horizontal
1585        direction: Option<String>,
1586        /// Optional panel ID for idempotent operations (if panel exists, update content)
1587        panel_id: Option<String>,
1588        /// Whether to show line numbers in the buffer (default true)
1589        show_line_numbers: bool,
1590        /// Whether to show cursors in the buffer (default true)
1591        show_cursors: bool,
1592        /// Whether editing is disabled for this buffer (default false)
1593        editing_disabled: bool,
1594        /// Whether line wrapping is enabled for this split (None = use global setting)
1595        line_wrap: Option<bool>,
1596        /// Place the new buffer before (left/top of) the existing content (default: false/after)
1597        before: bool,
1598        /// Optional split role tag. When `Some("utility_dock")`, the
1599        /// dispatcher routes the buffer to the existing dock leaf if
1600        /// one exists; otherwise it seeds a new dock leaf with the
1601        /// requested direction/ratio.
1602        role: Option<String>,
1603        /// Optional request ID for async response (if set, editor will send back buffer ID)
1604        request_id: Option<u64>,
1605    },
1606
1607    /// Set the content of a virtual buffer with text properties
1608    SetVirtualBufferContent {
1609        buffer_id: BufferId,
1610        /// Entries with text and embedded properties
1611        entries: Vec<TextPropertyEntry>,
1612    },
1613
1614    /// Get text properties at the cursor position in a buffer
1615    GetTextPropertiesAtCursor { buffer_id: BufferId },
1616
1617    /// Create a buffer group: multiple panels appearing as one tab.
1618    /// Each panel is a real buffer with its own scrollbar and viewport.
1619    CreateBufferGroup {
1620        /// Display name (shown in tab bar)
1621        name: String,
1622        /// Mode for keybindings
1623        mode: String,
1624        /// Layout tree as JSON string (parsed by the handler)
1625        layout_json: String,
1626        /// Optional request ID for async response
1627        request_id: Option<u64>,
1628    },
1629
1630    /// Set the content of a panel within a buffer group.
1631    SetPanelContent {
1632        /// Group ID
1633        group_id: usize,
1634        /// Panel name (e.g., "tree", "picker")
1635        panel_name: String,
1636        /// Content entries
1637        entries: Vec<TextPropertyEntry>,
1638    },
1639
1640    /// Close a buffer group (closes all panels and splits)
1641    CloseBufferGroup { group_id: usize },
1642
1643    /// Focus a specific panel within a buffer group
1644    FocusPanel { group_id: usize, panel_name: String },
1645
1646    /// Define a buffer mode with keybindings
1647    DefineMode {
1648        name: String,
1649        bindings: Vec<(String, String)>, // (key_string, command_name)
1650        read_only: bool,
1651        /// When true, unbound character keys dispatch as `mode_text_input:<char>`.
1652        allow_text_input: bool,
1653        /// When true, keys not bound by this mode fall through to the Normal
1654        /// context (motion, selection, copy) instead of being dropped.
1655        inherit_normal_bindings: bool,
1656        /// Name of the plugin that defined this mode (for attribution)
1657        plugin_name: Option<String>,
1658    },
1659
1660    /// Switch the current split to display a buffer
1661    ShowBuffer { buffer_id: BufferId },
1662
1663    /// Start a frame-buffer animation over a given screen region. The `id`
1664    /// is allocated on the plugin side so the JS call can return it
1665    /// synchronously; the editor uses it verbatim.
1666    StartAnimationArea {
1667        id: u64,
1668        rect: AnimationRect,
1669        kind: PluginAnimationKind,
1670    },
1671
1672    /// Start an animation over the on-screen Rect currently occupied by a
1673    /// virtual buffer. If the buffer is not visible, the editor ignores
1674    /// the command.
1675    StartAnimationVirtualBuffer {
1676        id: u64,
1677        buffer_id: BufferId,
1678        kind: PluginAnimationKind,
1679    },
1680
1681    /// Cancel an animation by the ID returned from `animateArea` /
1682    /// `animateVirtualBuffer`. No-op if the ID is unknown or already done.
1683    CancelAnimation { id: u64 },
1684
1685    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1686    CreateVirtualBufferInExistingSplit {
1687        /// Display name (e.g., "*Commit Details*")
1688        name: String,
1689        /// Mode name for buffer-local keybindings
1690        mode: String,
1691        /// Whether the buffer is read-only
1692        read_only: bool,
1693        /// Entries with text and embedded properties
1694        entries: Vec<TextPropertyEntry>,
1695        /// Target split ID where the buffer should be displayed
1696        split_id: SplitId,
1697        /// Whether to show line numbers in the buffer (default true)
1698        show_line_numbers: bool,
1699        /// Whether to show cursors in the buffer (default true)
1700        show_cursors: bool,
1701        /// Whether editing is disabled for this buffer (default false)
1702        editing_disabled: bool,
1703        /// Whether line wrapping is enabled for this split (None = use global setting)
1704        line_wrap: Option<bool>,
1705        /// Optional request ID for async response
1706        request_id: Option<u64>,
1707    },
1708
1709    /// Close a buffer and remove it from all splits
1710    CloseBuffer { buffer_id: BufferId },
1711
1712    /// Create a composite buffer that displays multiple source buffers
1713    /// Used for side-by-side diff, unified diff, and 3-way merge views
1714    CreateCompositeBuffer {
1715        /// Display name (shown in tab bar)
1716        name: String,
1717        /// Mode name for keybindings (e.g., "diff-view")
1718        mode: String,
1719        /// Layout configuration
1720        layout: CompositeLayoutConfig,
1721        /// Source pane configurations
1722        sources: Vec<CompositeSourceConfig>,
1723        /// Diff hunks for line alignment (optional)
1724        hunks: Option<Vec<CompositeHunk>>,
1725        /// When set, first render scrolls to center this hunk (0-indexed)
1726        initial_focus_hunk: Option<usize>,
1727        /// Request ID for async response
1728        request_id: Option<u64>,
1729    },
1730
1731    /// Update alignment for a composite buffer (e.g., after source edit)
1732    UpdateCompositeAlignment {
1733        buffer_id: BufferId,
1734        hunks: Vec<CompositeHunk>,
1735    },
1736
1737    /// Close a composite buffer
1738    CloseCompositeBuffer { buffer_id: BufferId },
1739
1740    /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
1741    ///
1742    /// Creates `CompositeViewState` for any visible composite buffer that doesn't
1743    /// have one, and syncs viewport dimensions from split layout. This ensures
1744    /// subsequent commands can read/modify view state that is normally created
1745    /// lazily during the render cycle.
1746    FlushLayout,
1747
1748    /// Navigate to the next hunk in a composite buffer
1749    CompositeNextHunk { buffer_id: BufferId },
1750
1751    /// Navigate to the previous hunk in a composite buffer
1752    CompositePrevHunk { buffer_id: BufferId },
1753
1754    /// Focus a specific split
1755    FocusSplit { split_id: SplitId },
1756
1757    /// Set the buffer displayed in a specific split
1758    SetSplitBuffer {
1759        split_id: SplitId,
1760        buffer_id: BufferId,
1761    },
1762
1763    /// Set the scroll position of a specific split
1764    SetSplitScroll { split_id: SplitId, top_byte: usize },
1765
1766    /// Request syntax highlights for a buffer range
1767    RequestHighlights {
1768        buffer_id: BufferId,
1769        range: Range<usize>,
1770        request_id: u64,
1771    },
1772
1773    /// Close a split (if not the last one)
1774    CloseSplit { split_id: SplitId },
1775
1776    /// Set the ratio of a split container
1777    SetSplitRatio {
1778        split_id: SplitId,
1779        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1780        ratio: f32,
1781    },
1782
1783    /// Set a label on a leaf split (e.g., "sidebar")
1784    SetSplitLabel { split_id: SplitId, label: String },
1785
1786    /// Remove a label from a split
1787    ClearSplitLabel { split_id: SplitId },
1788
1789    /// Find a split by its label (async)
1790    GetSplitByLabel { label: String, request_id: u64 },
1791
1792    /// Distribute splits evenly - make all given splits equal size
1793    DistributeSplitsEvenly {
1794        /// Split IDs to distribute evenly
1795        split_ids: Vec<SplitId>,
1796    },
1797
1798    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1799    SetBufferCursor {
1800        buffer_id: BufferId,
1801        /// Byte offset position for the cursor
1802        position: usize,
1803    },
1804
1805    /// Toggle whether the editor draws a native caret for this buffer.
1806    ///
1807    /// Buffer-group panel buffers default to `show_cursors = false`, which not
1808    /// only hides the caret but also blocks all movement actions in
1809    /// `action_to_events`. Plugins that want native cursor motion in a panel
1810    /// buffer (e.g. for magit-style row navigation) flip this to `true` after
1811    /// `createBufferGroup` returns.
1812    SetBufferShowCursors { buffer_id: BufferId, show: bool },
1813
1814    /// Send an arbitrary LSP request and return the raw JSON response
1815    SendLspRequest {
1816        language: String,
1817        method: String,
1818        #[ts(type = "any")]
1819        params: Option<JsonValue>,
1820        request_id: u64,
1821    },
1822
1823    /// Set the internal clipboard content
1824    SetClipboard { text: String },
1825
1826    /// Delete the current selection in the active buffer
1827    /// This deletes all selected text across all cursors
1828    DeleteSelection,
1829
1830    /// Set or unset a custom context
1831    /// Custom contexts are plugin-defined states that can be used to control command visibility
1832    /// For example, "config-editor" context could make config editor commands available
1833    SetContext {
1834        /// Context name (e.g., "config-editor")
1835        name: String,
1836        /// Whether the context is active
1837        active: bool,
1838    },
1839
1840    /// Set the hunks for the Review Diff tool
1841    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1842
1843    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1844    /// Used by vi mode plugin to run motions and calculate cursor ranges
1845    ExecuteAction {
1846        /// Action name (e.g., "move_word_right", "move_line_end")
1847        action_name: String,
1848    },
1849
1850    /// Execute multiple actions in sequence, each with an optional repeat count
1851    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1852    /// All actions execute atomically with no plugin roundtrips between them
1853    ExecuteActions {
1854        /// List of actions to execute in sequence
1855        actions: Vec<ActionSpec>,
1856    },
1857
1858    /// Get text from a buffer range (for yank operations)
1859    GetBufferText {
1860        /// Buffer ID
1861        buffer_id: BufferId,
1862        /// Start byte offset
1863        start: usize,
1864        /// End byte offset
1865        end: usize,
1866        /// Request ID for async response
1867        request_id: u64,
1868    },
1869
1870    /// Get byte offset of the start of a line (async)
1871    /// Line is 0-indexed (0 = first line)
1872    GetLineStartPosition {
1873        /// Buffer ID (0 for active buffer)
1874        buffer_id: BufferId,
1875        /// Line number (0-indexed)
1876        line: u32,
1877        /// Request ID for async response
1878        request_id: u64,
1879    },
1880
1881    /// Get byte offset of the end of a line (async)
1882    /// Line is 0-indexed (0 = first line)
1883    /// Returns the byte offset after the last character of the line (before newline)
1884    GetLineEndPosition {
1885        /// Buffer ID (0 for active buffer)
1886        buffer_id: BufferId,
1887        /// Line number (0-indexed)
1888        line: u32,
1889        /// Request ID for async response
1890        request_id: u64,
1891    },
1892
1893    /// Get the total number of lines in a buffer (async)
1894    GetBufferLineCount {
1895        /// Buffer ID (0 for active buffer)
1896        buffer_id: BufferId,
1897        /// Request ID for async response
1898        request_id: u64,
1899    },
1900
1901    /// Scroll a split to center a specific line in the viewport
1902    /// Line is 0-indexed (0 = first line)
1903    ScrollToLineCenter {
1904        /// Split ID to scroll
1905        split_id: SplitId,
1906        /// Buffer ID containing the line
1907        buffer_id: BufferId,
1908        /// Line number to center (0-indexed)
1909        line: usize,
1910    },
1911
1912    /// Scroll any split/panel that displays `buffer_id` so the given
1913    /// line is visible in the viewport. Unlike `ScrollToLineCenter` this
1914    /// does not require a split id — it walks all splits (including
1915    /// inner panels of a buffer group) and updates every viewport that
1916    /// shows this buffer. Line is 0-indexed.
1917    ScrollBufferToLine {
1918        /// Buffer ID to scroll
1919        buffer_id: BufferId,
1920        /// Line number to bring into view (0-indexed)
1921        line: usize,
1922    },
1923
1924    /// Set the global editor mode (for modal editing like vi mode)
1925    /// When set, the mode's keybindings take precedence over normal editing
1926    SetEditorMode {
1927        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1928        mode: Option<String>,
1929    },
1930
1931    /// Show an action popup with buttons for user interaction
1932    /// When the user selects an action, the ActionPopupResult hook is fired
1933    ShowActionPopup {
1934        /// Unique identifier for the popup (used in ActionPopupResult)
1935        popup_id: String,
1936        /// Title text for the popup
1937        title: String,
1938        /// Body message (supports basic formatting)
1939        message: String,
1940        /// Action buttons to display
1941        actions: Vec<ActionPopupAction>,
1942    },
1943
1944    /// Disable LSP for a specific language and persist to config
1945    DisableLspForLanguage {
1946        /// The language to disable LSP for (e.g., "python", "rust")
1947        language: String,
1948    },
1949
1950    /// Restart LSP server for a specific language
1951    RestartLspForLanguage {
1952        /// The language to restart LSP for (e.g., "python", "rust")
1953        language: String,
1954    },
1955
1956    /// Set the workspace root URI for a specific language's LSP server
1957    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
1958    /// If the LSP is already running, it will be restarted with the new root
1959    SetLspRootUri {
1960        /// The language to set root URI for (e.g., "csharp", "rust")
1961        language: String,
1962        /// The root URI (file:// URL format)
1963        uri: String,
1964    },
1965
1966    /// Create a scroll sync group for anchor-based synchronized scrolling
1967    /// Used for side-by-side diff views where two panes need to scroll together
1968    /// The plugin provides the group ID (must be unique per plugin)
1969    CreateScrollSyncGroup {
1970        /// Plugin-assigned group ID
1971        group_id: u32,
1972        /// The left (primary) split - scroll position is tracked in this split's line space
1973        left_split: SplitId,
1974        /// The right (secondary) split - position is derived from anchors
1975        right_split: SplitId,
1976    },
1977
1978    /// Set sync anchors for a scroll sync group
1979    /// Anchors map corresponding line numbers between left and right buffers
1980    SetScrollSyncAnchors {
1981        /// The group ID returned by CreateScrollSyncGroup
1982        group_id: u32,
1983        /// List of (left_line, right_line) pairs marking corresponding positions
1984        anchors: Vec<(usize, usize)>,
1985    },
1986
1987    /// Remove a scroll sync group
1988    RemoveScrollSyncGroup {
1989        /// The group ID returned by CreateScrollSyncGroup
1990        group_id: u32,
1991    },
1992
1993    /// Save a buffer to a specific file path
1994    /// Used by :w filename command to save unnamed buffers or save-as
1995    SaveBufferToPath {
1996        /// Buffer ID to save
1997        buffer_id: BufferId,
1998        /// Path to save to
1999        path: PathBuf,
2000    },
2001
2002    /// Load a plugin from a file path
2003    /// The plugin will be initialized and start receiving events
2004    LoadPlugin {
2005        /// Path to the plugin file (.ts or .js)
2006        path: PathBuf,
2007        /// Callback ID for async response (success/failure)
2008        callback_id: JsCallbackId,
2009    },
2010
2011    /// Unload a plugin by name
2012    /// The plugin will stop receiving events and be removed from memory
2013    UnloadPlugin {
2014        /// Plugin name (as registered)
2015        name: String,
2016        /// Callback ID for async response (success/failure)
2017        callback_id: JsCallbackId,
2018    },
2019
2020    /// Reload a plugin by name (unload + load)
2021    /// Useful for development when plugin code changes
2022    ReloadPlugin {
2023        /// Plugin name (as registered)
2024        name: String,
2025        /// Callback ID for async response (success/failure)
2026        callback_id: JsCallbackId,
2027    },
2028
2029    /// List all loaded plugins
2030    /// Returns plugin info (name, path, enabled) for all loaded plugins
2031    ListPlugins {
2032        /// Callback ID for async response (JSON array of plugin info)
2033        callback_id: JsCallbackId,
2034    },
2035
2036    /// Reload the theme registry from disk
2037    /// Call this after installing a theme package or saving a new theme.
2038    /// If `apply_theme` is set, apply that theme immediately after reloading.
2039    ReloadThemes { apply_theme: Option<String> },
2040
2041    /// Register a TextMate grammar file for a language
2042    /// The grammar will be added to pending_grammars until ReloadGrammars is called
2043    RegisterGrammar {
2044        /// Language identifier (e.g., "elixir", "zig")
2045        language: String,
2046        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
2047        grammar_path: String,
2048        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
2049        extensions: Vec<String>,
2050    },
2051
2052    /// Register language configuration (comment prefix, indentation, formatter)
2053    /// This is applied immediately to the runtime config
2054    RegisterLanguageConfig {
2055        /// Language identifier (e.g., "elixir")
2056        language: String,
2057        /// Language configuration
2058        config: LanguagePackConfig,
2059    },
2060
2061    /// Register an LSP server for a language
2062    /// This is applied immediately to the LSP manager and runtime config
2063    RegisterLspServer {
2064        /// Language identifier (e.g., "elixir")
2065        language: String,
2066        /// LSP server configuration
2067        config: LspServerPackConfig,
2068    },
2069
2070    /// Reload the grammar registry to apply registered grammars (async)
2071    /// Call this after registering one or more grammars to rebuild the syntax set.
2072    /// The callback is resolved when the background grammar build completes.
2073    ReloadGrammars { callback_id: JsCallbackId },
2074
2075    // ==================== Terminal Commands ====================
2076    /// Create a new terminal in a split (async, returns TerminalResult)
2077    /// This spawns a PTY-backed terminal that plugins can write to and read from.
2078    CreateTerminal {
2079        /// Working directory for the terminal (defaults to editor cwd)
2080        cwd: Option<String>,
2081        /// Split direction ("horizontal" or "vertical"), default vertical
2082        direction: Option<String>,
2083        /// Split ratio (0.0 to 1.0), default 0.5
2084        ratio: Option<f32>,
2085        /// Whether to focus the new terminal split (default true)
2086        focus: Option<bool>,
2087        /// Whether this terminal survives editor restarts. When false, the
2088        /// terminal is excluded from workspace serialization and its backing
2089        /// file is kept unique-per-spawn so no scrollback from a prior run
2090        /// leaks in. Plugin-created terminals default to `false` since they
2091        /// are typically one-off tool UIs (rebuilds, exec shells, etc.).
2092        persistent: bool,
2093        /// Callback ID for async response
2094        request_id: u64,
2095    },
2096
2097    /// Send input data to a terminal by its terminal ID
2098    SendTerminalInput {
2099        /// The terminal ID (from TerminalResult)
2100        terminal_id: TerminalId,
2101        /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
2102        data: String,
2103    },
2104
2105    /// Close a terminal by its terminal ID
2106    CloseTerminal {
2107        /// The terminal ID to close
2108        terminal_id: TerminalId,
2109    },
2110
2111    /// Project-wide grep search (async)
2112    /// Searches all project files via FileSystem trait, respecting .gitignore.
2113    /// For open buffers with dirty edits, searches the buffer's piece tree.
2114    GrepProject {
2115        /// Search pattern (literal string)
2116        pattern: String,
2117        /// Whether the pattern is a fixed string (true) or regex (false)
2118        fixed_string: bool,
2119        /// Whether the search is case-sensitive
2120        case_sensitive: bool,
2121        /// Maximum number of results to return
2122        max_results: usize,
2123        /// Whether to match whole words only
2124        whole_words: bool,
2125        /// Callback ID for async response
2126        callback_id: JsCallbackId,
2127    },
2128
2129    /// Project-wide streaming grep search (async, parallel)
2130    /// Like GrepProject but streams results incrementally via progress callback.
2131    /// Searches files in parallel using tokio tasks, sending per-file results
2132    /// back to the plugin as they complete.
2133    GrepProjectStreaming {
2134        /// Search pattern
2135        pattern: String,
2136        /// Whether the pattern is a fixed string (true) or regex (false)
2137        fixed_string: bool,
2138        /// Whether the search is case-sensitive
2139        case_sensitive: bool,
2140        /// Maximum number of results to return
2141        max_results: usize,
2142        /// Whether to match whole words only
2143        whole_words: bool,
2144        /// Search ID — used to route progress callbacks and for cancellation
2145        search_id: u64,
2146        /// Callback ID for the completion promise
2147        callback_id: JsCallbackId,
2148    },
2149
2150    /// Replace matches in a buffer (async)
2151    /// Opens the file if not already open, applies edits through the buffer model,
2152    /// groups as a single undo action, and saves via FileSystem trait.
2153    ReplaceInBuffer {
2154        /// File path to edit (will open if not already in a buffer)
2155        file_path: PathBuf,
2156        /// Matches to replace, each is (byte_offset, length)
2157        matches: Vec<(usize, usize)>,
2158        /// Replacement text
2159        replacement: String,
2160        /// Callback ID for async response
2161        callback_id: JsCallbackId,
2162    },
2163
2164    /// Install a new authority.
2165    ///
2166    /// Authority is opaque to core. The payload is a tagged JSON object
2167    /// (filesystem kind + spawner kind + terminal wrapper + display
2168    /// label) that `fresh-editor` deserializes into its concrete
2169    /// `AuthorityPayload` type. Using `serde_json::Value` here keeps
2170    /// fresh-core from growing backend-specific knowledge; see
2171    /// `crates/fresh-editor/src/services/authority/mod.rs` for the
2172    /// canonical schema.
2173    ///
2174    /// Fire-and-forget: the transition piggy-backs on the existing
2175    /// editor restart flow, so the plugin that sent this command will
2176    /// be re-loaded as part of the restart. Any follow-up work the
2177    /// plugin wants to do after the switch belongs in its post-restart
2178    /// init code, not in a callback here.
2179    SetAuthority {
2180        #[ts(type = "unknown")]
2181        payload: JsonValue,
2182    },
2183
2184    /// Restore the default local authority. Same semantics as
2185    /// `SetAuthority` with a local payload — triggers an editor
2186    /// restart.
2187    ClearAuthority,
2188
2189    /// Override the Remote Indicator's displayed state for the rest
2190    /// of the current editor session (until a restart, or until the
2191    /// plugin sends another override / `ClearRemoteIndicatorState`).
2192    ///
2193    /// The derived state — computed from the active authority's
2194    /// connection info — keeps running underneath and is what the
2195    /// indicator shows whenever an override is not in effect.
2196    /// Plugins use this to surface lifecycle states that have no
2197    /// authority-level truth yet (e.g. "Connecting" during
2198    /// `devcontainer up`, "FailedAttach" after a non-zero exit).
2199    ///
2200    /// `state` is a tagged enum keyed by `kind`:
2201    ///   - `{ "kind": "local" }`
2202    ///   - `{ "kind": "connecting", "label": "..." }`
2203    ///   - `{ "kind": "connected", "label": "..." }`
2204    ///   - `{ "kind": "failed_attach", "error": "..." }`
2205    ///   - `{ "kind": "disconnected", "label": "..." }`
2206    ///
2207    /// The exact schema lives in
2208    /// `crates/fresh-editor/src/view/ui/status_bar.rs`; fresh-core
2209    /// takes it opaquely so new variants can land without touching
2210    /// core plumbing.
2211    SetRemoteIndicatorState {
2212        #[ts(type = "unknown")]
2213        state: JsonValue,
2214    },
2215
2216    /// Drop any active Remote Indicator override and fall back to
2217    /// the authority-derived state. Safe to call without a prior
2218    /// `SetRemoteIndicatorState`.
2219    ClearRemoteIndicatorState,
2220
2221    /// Spawn a process on the host, regardless of the currently
2222    /// installed authority.
2223    ///
2224    /// Intended for plugin internals that must run host-side work
2225    /// (e.g. `devcontainer up`) before installing an authority that
2226    /// would otherwise route the spawn elsewhere. Behaves like
2227    /// `SpawnProcess` but always uses `LocalProcessSpawner`.
2228    ///
2229    /// The TS-side handle exposes `.kill()` on the returned
2230    /// `ProcessHandle`, serviced by `KillHostProcess` below — this
2231    /// lets callers abort a long-running host spawn (e.g.
2232    /// `devcontainer up`) via a user action like "Cancel Startup".
2233    SpawnHostProcess {
2234        command: String,
2235        args: Vec<String>,
2236        cwd: Option<String>,
2237        callback_id: JsCallbackId,
2238    },
2239
2240    /// Cancel a host-side process previously started via
2241    /// `SpawnHostProcess`. `process_id` is the callback id returned
2242    /// by `spawnHostProcess` (the TS handle stores it and forwards
2243    /// when the caller invokes `.kill()`).
2244    ///
2245    /// No-op when the id is unknown — the process may have already
2246    /// exited, or the caller may hold a stale handle. SIGKILL on
2247    /// Unix per `tokio::process::Child::start_kill`; children of the
2248    /// killed process may leak (see Q-C2 in
2249    /// `DEVCONTAINER_SPEC_GAP_PLAN.md`).
2250    KillHostProcess { process_id: u64 },
2251}
2252
2253impl PluginCommand {
2254    /// Extract the enum variant name from the Debug representation.
2255    pub fn debug_variant_name(&self) -> String {
2256        let dbg = format!("{:?}", self);
2257        dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
2258    }
2259}
2260
2261// =============================================================================
2262// Language Pack Configuration Types
2263// =============================================================================
2264
2265/// Language configuration for language packs
2266///
2267/// This is a simplified version of the full LanguageConfig, containing only
2268/// the fields that can be set via the plugin API.
2269#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
2270#[serde(rename_all = "camelCase")]
2271#[ts(export)]
2272pub struct LanguagePackConfig {
2273    /// Comment prefix for line comments (e.g., "//" or "#")
2274    #[serde(default)]
2275    pub comment_prefix: Option<String>,
2276
2277    /// Block comment start marker (e.g., slash-star)
2278    #[serde(default)]
2279    pub block_comment_start: Option<String>,
2280
2281    /// Block comment end marker (e.g., star-slash)
2282    #[serde(default)]
2283    pub block_comment_end: Option<String>,
2284
2285    /// Whether to use tabs instead of spaces for indentation
2286    #[serde(default)]
2287    pub use_tabs: Option<bool>,
2288
2289    /// Tab size (number of spaces per tab level)
2290    #[serde(default)]
2291    pub tab_size: Option<usize>,
2292
2293    /// Whether auto-indent is enabled
2294    #[serde(default)]
2295    pub auto_indent: Option<bool>,
2296
2297    /// Whether to show whitespace tab indicators (→) for this language
2298    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
2299    #[serde(default)]
2300    pub show_whitespace_tabs: Option<bool>,
2301
2302    /// Formatter configuration
2303    #[serde(default)]
2304    pub formatter: Option<FormatterPackConfig>,
2305}
2306
2307/// Formatter configuration for language packs
2308#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2309#[serde(rename_all = "camelCase")]
2310#[ts(export)]
2311pub struct FormatterPackConfig {
2312    /// Command to run (e.g., "prettier", "rustfmt")
2313    pub command: String,
2314
2315    /// Arguments to pass to the formatter
2316    #[serde(default)]
2317    pub args: Vec<String>,
2318}
2319
2320/// Process resource limits for LSP servers
2321#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2322#[serde(rename_all = "camelCase")]
2323#[ts(export)]
2324pub struct ProcessLimitsPackConfig {
2325    /// Maximum memory usage as percentage of total system memory (null = no limit)
2326    #[serde(default)]
2327    pub max_memory_percent: Option<u32>,
2328
2329    /// Maximum CPU usage as percentage of total CPU (null = no limit)
2330    #[serde(default)]
2331    pub max_cpu_percent: Option<u32>,
2332
2333    /// Enable resource limiting
2334    #[serde(default)]
2335    pub enabled: Option<bool>,
2336}
2337
2338/// LSP server configuration for language packs
2339#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2340#[serde(rename_all = "camelCase")]
2341#[ts(export)]
2342pub struct LspServerPackConfig {
2343    /// Command to start the LSP server
2344    pub command: String,
2345
2346    /// Arguments to pass to the command
2347    #[serde(default)]
2348    pub args: Vec<String>,
2349
2350    /// Whether to auto-start the server when a matching file is opened
2351    #[serde(default)]
2352    pub auto_start: Option<bool>,
2353
2354    /// LSP initialization options
2355    #[serde(default)]
2356    #[ts(type = "Record<string, unknown> | null")]
2357    pub initialization_options: Option<JsonValue>,
2358
2359    /// Process resource limits (memory and CPU)
2360    #[serde(default)]
2361    pub process_limits: Option<ProcessLimitsPackConfig>,
2362}
2363
2364/// Hunk status for Review Diff
2365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
2366#[ts(export)]
2367pub enum HunkStatus {
2368    Pending,
2369    Staged,
2370    Discarded,
2371}
2372
2373/// A high-level hunk directive for the Review Diff tool
2374#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2375#[ts(export)]
2376pub struct ReviewHunk {
2377    pub id: String,
2378    pub file: String,
2379    pub context_header: String,
2380    pub status: HunkStatus,
2381    /// 0-indexed line range in the base (HEAD) version
2382    pub base_range: Option<(usize, usize)>,
2383    /// 0-indexed line range in the modified (Working) version
2384    pub modified_range: Option<(usize, usize)>,
2385}
2386
2387/// Action button for action popups
2388#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2389#[serde(deny_unknown_fields)]
2390#[ts(export, rename = "TsActionPopupAction")]
2391pub struct ActionPopupAction {
2392    /// Unique action identifier (returned in ActionPopupResult)
2393    pub id: String,
2394    /// Display text for the button (can include command hints)
2395    pub label: String,
2396}
2397
2398/// Options for showActionPopup
2399#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2400#[serde(deny_unknown_fields)]
2401#[ts(export)]
2402pub struct ActionPopupOptions {
2403    /// Unique identifier for the popup (used in ActionPopupResult)
2404    pub id: String,
2405    /// Title text for the popup
2406    pub title: String,
2407    /// Body message (supports basic formatting)
2408    pub message: String,
2409    /// Action buttons to display
2410    pub actions: Vec<ActionPopupAction>,
2411}
2412
2413/// Syntax highlight span for a buffer range
2414#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2415#[ts(export)]
2416pub struct TsHighlightSpan {
2417    pub start: u32,
2418    pub end: u32,
2419    #[ts(type = "[number, number, number]")]
2420    pub color: (u8, u8, u8),
2421    pub bold: bool,
2422    pub italic: bool,
2423}
2424
2425/// Result from spawning a process with spawnProcess
2426#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2427#[ts(export)]
2428pub struct SpawnResult {
2429    /// Complete stdout as string
2430    pub stdout: String,
2431    /// Complete stderr as string
2432    pub stderr: String,
2433    /// Process exit code (0 usually means success, -1 if killed)
2434    pub exit_code: i32,
2435}
2436
2437/// Result from spawning a background process
2438#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2439#[ts(export)]
2440pub struct BackgroundProcessResult {
2441    /// Unique process ID for later reference
2442    #[ts(type = "number")]
2443    pub process_id: u64,
2444    /// Process exit code (0 usually means success, -1 if killed)
2445    /// Only present when the process has exited
2446    pub exit_code: i32,
2447}
2448
2449/// A single match from project-wide grep
2450#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2451#[serde(rename_all = "camelCase")]
2452#[ts(export, rename_all = "camelCase")]
2453pub struct GrepMatch {
2454    /// Absolute file path
2455    pub file: String,
2456    /// Buffer ID if the file is open (0 if not)
2457    #[ts(type = "number")]
2458    pub buffer_id: usize,
2459    /// Byte offset of match start in the file/buffer content
2460    #[ts(type = "number")]
2461    pub byte_offset: usize,
2462    /// Match length in bytes
2463    #[ts(type = "number")]
2464    pub length: usize,
2465    /// 1-indexed line number
2466    #[ts(type = "number")]
2467    pub line: usize,
2468    /// 1-indexed column number
2469    #[ts(type = "number")]
2470    pub column: usize,
2471    /// The matched line content (for display)
2472    pub context: String,
2473}
2474
2475/// Result from replacing matches in a buffer
2476#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2477#[serde(rename_all = "camelCase")]
2478#[ts(export, rename_all = "camelCase")]
2479pub struct ReplaceResult {
2480    /// Number of replacements made
2481    #[ts(type = "number")]
2482    pub replacements: usize,
2483    /// Buffer ID of the edited buffer
2484    #[ts(type = "number")]
2485    pub buffer_id: usize,
2486}
2487
2488/// Entry for virtual buffer content with optional text properties (JS API version)
2489#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2490#[serde(deny_unknown_fields, rename_all = "camelCase")]
2491#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
2492pub struct JsTextPropertyEntry {
2493    /// Text content for this entry
2494    pub text: String,
2495    /// Optional properties attached to this text (e.g., file path, line number)
2496    #[serde(default)]
2497    #[ts(optional, type = "Record<string, unknown>")]
2498    pub properties: Option<HashMap<String, JsonValue>>,
2499    /// Optional whole-entry styling
2500    #[serde(default)]
2501    #[ts(optional, type = "Partial<OverlayOptions>")]
2502    pub style: Option<OverlayOptions>,
2503    /// Optional sub-range styling within this entry
2504    #[serde(default)]
2505    #[ts(optional)]
2506    pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
2507}
2508
2509/// Directory entry returned by readDir
2510#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2511#[ts(export)]
2512pub struct DirEntry {
2513    /// File/directory name
2514    pub name: String,
2515    /// True if this is a file
2516    pub is_file: bool,
2517    /// True if this is a directory
2518    pub is_dir: bool,
2519}
2520
2521/// Position in a document (line and character)
2522#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2523#[ts(export)]
2524pub struct JsPosition {
2525    /// Zero-indexed line number
2526    pub line: u32,
2527    /// Zero-indexed character offset
2528    pub character: u32,
2529}
2530
2531/// Range in a document (start and end positions)
2532#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2533#[ts(export)]
2534pub struct JsRange {
2535    /// Start position
2536    pub start: JsPosition,
2537    /// End position
2538    pub end: JsPosition,
2539}
2540
2541/// Diagnostic from LSP
2542#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2543#[ts(export)]
2544pub struct JsDiagnostic {
2545    /// Document URI
2546    pub uri: String,
2547    /// Diagnostic message
2548    pub message: String,
2549    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
2550    pub severity: Option<u8>,
2551    /// Range in the document
2552    pub range: JsRange,
2553    /// Source of the diagnostic (e.g., "typescript", "eslint")
2554    #[ts(optional)]
2555    pub source: Option<String>,
2556}
2557
2558/// Options for createVirtualBuffer
2559#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2560#[serde(deny_unknown_fields)]
2561#[ts(export)]
2562pub struct CreateVirtualBufferOptions {
2563    /// Buffer name (displayed in tabs/title)
2564    pub name: String,
2565    /// Mode for keybindings (e.g., "git-log", "search-results")
2566    #[serde(default)]
2567    #[ts(optional)]
2568    pub mode: Option<String>,
2569    /// Whether buffer is read-only (default: false)
2570    #[serde(default, rename = "readOnly")]
2571    #[ts(optional, rename = "readOnly")]
2572    pub read_only: Option<bool>,
2573    /// Show line numbers in gutter (default: false)
2574    #[serde(default, rename = "showLineNumbers")]
2575    #[ts(optional, rename = "showLineNumbers")]
2576    pub show_line_numbers: Option<bool>,
2577    /// Show cursor (default: true)
2578    #[serde(default, rename = "showCursors")]
2579    #[ts(optional, rename = "showCursors")]
2580    pub show_cursors: Option<bool>,
2581    /// Disable text editing (default: false)
2582    #[serde(default, rename = "editingDisabled")]
2583    #[ts(optional, rename = "editingDisabled")]
2584    pub editing_disabled: Option<bool>,
2585    /// Hide from tab bar (default: false)
2586    #[serde(default, rename = "hiddenFromTabs")]
2587    #[ts(optional, rename = "hiddenFromTabs")]
2588    pub hidden_from_tabs: Option<bool>,
2589    /// Initial content entries with optional properties
2590    #[serde(default)]
2591    #[ts(optional)]
2592    pub entries: Option<Vec<JsTextPropertyEntry>>,
2593}
2594
2595/// Options for createVirtualBufferInSplit
2596#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2597#[serde(deny_unknown_fields)]
2598#[ts(export)]
2599pub struct CreateVirtualBufferInSplitOptions {
2600    /// Buffer name (displayed in tabs/title)
2601    pub name: String,
2602    /// Mode for keybindings (e.g., "git-log", "search-results")
2603    #[serde(default)]
2604    #[ts(optional)]
2605    pub mode: Option<String>,
2606    /// Whether buffer is read-only (default: false)
2607    #[serde(default, rename = "readOnly")]
2608    #[ts(optional, rename = "readOnly")]
2609    pub read_only: Option<bool>,
2610    /// Split ratio 0.0-1.0 (default: 0.5)
2611    #[serde(default)]
2612    #[ts(optional)]
2613    pub ratio: Option<f32>,
2614    /// Split direction: "horizontal" or "vertical"
2615    #[serde(default)]
2616    #[ts(optional)]
2617    pub direction: Option<String>,
2618    /// Panel ID to split from
2619    #[serde(default, rename = "panelId")]
2620    #[ts(optional, rename = "panelId")]
2621    pub panel_id: Option<String>,
2622    /// Show line numbers in gutter (default: true)
2623    #[serde(default, rename = "showLineNumbers")]
2624    #[ts(optional, rename = "showLineNumbers")]
2625    pub show_line_numbers: Option<bool>,
2626    /// Show cursor (default: true)
2627    #[serde(default, rename = "showCursors")]
2628    #[ts(optional, rename = "showCursors")]
2629    pub show_cursors: Option<bool>,
2630    /// Disable text editing (default: false)
2631    #[serde(default, rename = "editingDisabled")]
2632    #[ts(optional, rename = "editingDisabled")]
2633    pub editing_disabled: Option<bool>,
2634    /// Enable line wrapping
2635    #[serde(default, rename = "lineWrap")]
2636    #[ts(optional, rename = "lineWrap")]
2637    pub line_wrap: Option<bool>,
2638    /// Place the new buffer before (left/top of) the existing content (default: false)
2639    #[serde(default)]
2640    #[ts(optional)]
2641    pub before: Option<bool>,
2642    /// Initial content entries with optional properties
2643    #[serde(default)]
2644    #[ts(optional)]
2645    pub entries: Option<Vec<JsTextPropertyEntry>>,
2646    /// Split role tag. When set to `"utility_dock"`, the dispatcher
2647    /// routes this buffer to the existing dock leaf if one exists,
2648    /// instead of creating a new split. See
2649    /// `docs/internal/tui-editor-layout-design.md` Section 2.
2650    #[serde(default)]
2651    #[ts(optional)]
2652    pub role: Option<String>,
2653}
2654
2655/// Options for createVirtualBufferInExistingSplit
2656#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2657#[serde(deny_unknown_fields)]
2658#[ts(export)]
2659pub struct CreateVirtualBufferInExistingSplitOptions {
2660    /// Buffer name (displayed in tabs/title)
2661    pub name: String,
2662    /// Target split ID (required)
2663    #[serde(rename = "splitId")]
2664    #[ts(rename = "splitId")]
2665    pub split_id: usize,
2666    /// Mode for keybindings (e.g., "git-log", "search-results")
2667    #[serde(default)]
2668    #[ts(optional)]
2669    pub mode: Option<String>,
2670    /// Whether buffer is read-only (default: false)
2671    #[serde(default, rename = "readOnly")]
2672    #[ts(optional, rename = "readOnly")]
2673    pub read_only: Option<bool>,
2674    /// Show line numbers in gutter (default: true)
2675    #[serde(default, rename = "showLineNumbers")]
2676    #[ts(optional, rename = "showLineNumbers")]
2677    pub show_line_numbers: Option<bool>,
2678    /// Show cursor (default: true)
2679    #[serde(default, rename = "showCursors")]
2680    #[ts(optional, rename = "showCursors")]
2681    pub show_cursors: Option<bool>,
2682    /// Disable text editing (default: false)
2683    #[serde(default, rename = "editingDisabled")]
2684    #[ts(optional, rename = "editingDisabled")]
2685    pub editing_disabled: Option<bool>,
2686    /// Enable line wrapping
2687    #[serde(default, rename = "lineWrap")]
2688    #[ts(optional, rename = "lineWrap")]
2689    pub line_wrap: Option<bool>,
2690    /// Initial content entries with optional properties
2691    #[serde(default)]
2692    #[ts(optional)]
2693    pub entries: Option<Vec<JsTextPropertyEntry>>,
2694}
2695
2696/// Options for createTerminal
2697#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2698#[serde(deny_unknown_fields)]
2699#[ts(export)]
2700pub struct CreateTerminalOptions {
2701    /// Working directory for the terminal (defaults to editor cwd)
2702    #[serde(default)]
2703    #[ts(optional)]
2704    pub cwd: Option<String>,
2705    /// Split direction: "horizontal" or "vertical" (default: "vertical")
2706    #[serde(default)]
2707    #[ts(optional)]
2708    pub direction: Option<String>,
2709    /// Split ratio 0.0-1.0 (default: 0.5)
2710    #[serde(default)]
2711    #[ts(optional)]
2712    pub ratio: Option<f32>,
2713    /// Whether to focus the new terminal split (default: true)
2714    #[serde(default)]
2715    #[ts(optional)]
2716    pub focus: Option<bool>,
2717    /// Whether this terminal is part of the user's persisted workspace.
2718    /// Defaults to `false` for plugin-created terminals — they are typically
2719    /// one-off tool UIs (rebuilds, exec shells, build output) and should
2720    /// start with empty scrollback on each invocation. Set to `true` only
2721    /// when the plugin owns a terminal that the user should see restored
2722    /// across editor restarts.
2723    #[serde(default)]
2724    #[ts(optional)]
2725    pub persistent: Option<bool>,
2726}
2727
2728/// Result of getTextPropertiesAtCursor - array of property objects
2729///
2730/// Each element contains the properties from a text property span that overlaps
2731/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
2732#[derive(Debug, Clone, Serialize, TS)]
2733#[ts(export, type = "Array<Record<string, unknown>>")]
2734pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
2735
2736// Implement FromJs for option types using rquickjs_serde
2737#[cfg(feature = "plugins")]
2738mod fromjs_impls {
2739    use super::*;
2740    use rquickjs::{Ctx, FromJs, Value};
2741
2742    // All types that deserialize from a JS value via rquickjs_serde follow
2743    // the same 8-line pattern differing only in the type name. This macro
2744    // expands that pattern once so adding a new plugin-API type costs one line
2745    // here instead of a copy-pasted block.
2746    macro_rules! impl_from_js_via_serde {
2747        ($($T:ty),+ $(,)?) => {
2748            $(
2749                impl<'js> FromJs<'js> for $T {
2750                    fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2751                        rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2752                            from: "object",
2753                            to: stringify!($T),
2754                            message: Some(e.to_string()),
2755                        })
2756                    }
2757                }
2758            )+
2759        };
2760    }
2761
2762    impl_from_js_via_serde!(
2763        JsTextPropertyEntry,
2764        CreateVirtualBufferOptions,
2765        CreateVirtualBufferInSplitOptions,
2766        CreateVirtualBufferInExistingSplitOptions,
2767        ActionSpec,
2768        ActionPopupAction,
2769        ActionPopupOptions,
2770        ViewTokenWire,
2771        ViewTokenStyle,
2772        LayoutHints,
2773        CompositeHunk,
2774        LanguagePackConfig,
2775        LspServerPackConfig,
2776        ProcessLimitsPackConfig,
2777        CreateTerminalOptions,
2778    );
2779
2780    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
2781        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2782            rquickjs_serde::to_value(ctx.clone(), &self.0)
2783                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2784        }
2785    }
2786
2787    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
2788        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2789            // Two-step deserialization: rquickjs_serde cannot handle the nested
2790            // enums in this struct directly, so go via serde_json as an intermediary.
2791            let json: serde_json::Value =
2792                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2793                    from: "object",
2794                    to: "CreateCompositeBufferOptions (json)",
2795                    message: Some(e.to_string()),
2796                })?;
2797            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
2798                from: "json",
2799                to: "CreateCompositeBufferOptions",
2800                message: Some(e.to_string()),
2801            })
2802        }
2803    }
2804
2805    // ── Tests for FromJs / IntoJs impls ────────────────────────────────────
2806    //
2807    // Each impl is a one-liner that delegates to `rquickjs_serde`. A mutant
2808    // that replaces the body with `Ok(Default::default())` drops the
2809    // decoded payload on the floor. Every test below asserts a
2810    // non-defaultable field value, so the mutant cannot pass.
2811    //
2812    // Note: many of the target structs do not implement `Default`, making
2813    // those mutants unviable (they fail to compile) — cargo-mutants still
2814    // lists them as candidates. The tests below serve double-duty as
2815    // behavioural regression protection for the JS → Rust conversion layer.
2816    #[cfg(test)]
2817    mod tests {
2818        use super::*;
2819        use rquickjs::{Context, Runtime};
2820
2821        /// Run a closure within a fresh QuickJS context so that `FromJs`
2822        /// impls can be exercised end-to-end.
2823        fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
2824            let rt = Runtime::new().expect("create rquickjs runtime");
2825            let ctx = Context::full(&rt).expect("create rquickjs context");
2826            ctx.with(f)
2827        }
2828
2829        /// Evaluate a JS object literal and decode it as `T` via `FromJs`.
2830        fn eval_as<T>(src: &str) -> T
2831        where
2832            for<'js> T: rquickjs::FromJs<'js>,
2833        {
2834            with_js(|ctx| {
2835                let value: Value = ctx
2836                    .eval::<Value, _>(src.as_bytes())
2837                    .expect("eval JS source");
2838                T::from_js(&ctx, value).expect("from_js decode")
2839            })
2840        }
2841
2842        #[test]
2843        fn js_text_property_entry_decodes_text_and_properties() {
2844            let got: JsTextPropertyEntry =
2845                eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
2846            assert_eq!(got.text, "hello");
2847            let props = got.properties.expect("properties present");
2848            assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
2849        }
2850
2851        #[test]
2852        fn create_virtual_buffer_options_decodes_name() {
2853            let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
2854            assert_eq!(got.name, "logs");
2855            assert_eq!(got.read_only, Some(true));
2856        }
2857
2858        #[test]
2859        fn create_virtual_buffer_in_split_options_decodes_ratio() {
2860            let got: CreateVirtualBufferInSplitOptions =
2861                eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
2862            assert_eq!(got.name, "diag");
2863            assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
2864            assert_eq!(got.direction.as_deref(), Some("horizontal"));
2865        }
2866
2867        #[test]
2868        fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
2869            let got: CreateVirtualBufferInExistingSplitOptions =
2870                eval_as("({name: 'n', splitId: 7})");
2871            assert_eq!(got.name, "n");
2872            assert_eq!(got.split_id, 7);
2873        }
2874
2875        #[test]
2876        fn create_terminal_options_decodes_cwd_and_focus() {
2877            let got: CreateTerminalOptions =
2878                eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
2879            assert_eq!(got.cwd.as_deref(), Some("/tmp"));
2880            assert_eq!(got.direction.as_deref(), Some("vertical"));
2881            assert_eq!(got.focus, Some(false));
2882        }
2883
2884        #[test]
2885        fn action_spec_decodes_action_and_count() {
2886            let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
2887            assert_eq!(got.action, "move_word_right");
2888            assert_eq!(got.count, 5);
2889        }
2890
2891        #[test]
2892        fn action_popup_action_decodes_id_and_label() {
2893            let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
2894            assert_eq!(got.id, "ok");
2895            assert_eq!(got.label, "OK");
2896        }
2897
2898        #[test]
2899        fn action_popup_options_decodes_actions_list() {
2900            let got: ActionPopupOptions = eval_as(
2901                "({id: 'p', title: 't', message: 'm', \
2902                   actions: [{id: 'ok', label: 'OK'}]})",
2903            );
2904            assert_eq!(got.id, "p");
2905            assert_eq!(got.title, "t");
2906            assert_eq!(got.message, "m");
2907            assert_eq!(got.actions.len(), 1);
2908            assert_eq!(got.actions[0].id, "ok");
2909        }
2910
2911        #[test]
2912        fn view_token_wire_decodes_offset_and_kind() {
2913            // Using `Newline` (a unit variant) avoids the tuple-variant
2914            // wire-format ambiguity in rquickjs_serde while still exercising
2915            // the `FromJs` impl end-to-end.
2916            let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
2917            assert_eq!(got.source_offset, Some(42));
2918            assert!(matches!(got.kind, ViewTokenWireKind::Newline));
2919        }
2920
2921        #[test]
2922        fn view_token_style_decodes_boolean_flags() {
2923            // `fg`/`bg` are `Option<(u8, u8, u8)>` which rquickjs_serde does
2924            // not decode from plain JS arrays, so we pin down the boolean
2925            // flags — enough to prove the body actually ran.
2926            let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
2927            assert!(got.bold);
2928            assert!(got.italic);
2929            assert!(got.fg.is_none());
2930        }
2931
2932        #[test]
2933        fn layout_hints_decodes_compose_width() {
2934            let got: LayoutHints = eval_as("({composeWidth: 120})");
2935            assert_eq!(got.compose_width, Some(120));
2936            assert!(got.column_guides.is_none());
2937        }
2938
2939        #[test]
2940        fn create_composite_buffer_options_decodes_name_and_sources() {
2941            let got: CreateCompositeBufferOptions = eval_as(
2942                "({name: 'diff', mode: 'm', \
2943                   layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
2944                   sources: [{bufferId: 3, label: 'OLD'}]})",
2945            );
2946            assert_eq!(got.name, "diff");
2947            assert_eq!(got.layout.layout_type, "side-by-side");
2948            assert_eq!(got.sources.len(), 1);
2949            assert_eq!(got.sources[0].buffer_id, 3);
2950            assert_eq!(got.sources[0].label, "OLD");
2951        }
2952
2953        #[test]
2954        fn composite_hunk_decodes_all_fields() {
2955            let got: CompositeHunk =
2956                eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
2957            assert_eq!(got.old_start, 1);
2958            assert_eq!(got.old_count, 2);
2959            assert_eq!(got.new_start, 3);
2960            assert_eq!(got.new_count, 4);
2961        }
2962
2963        #[test]
2964        fn language_pack_config_decodes_comment_prefix_and_tab_size() {
2965            let got: LanguagePackConfig =
2966                eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
2967            assert_eq!(got.comment_prefix.as_deref(), Some("//"));
2968            assert_eq!(got.tab_size, Some(7));
2969            assert_eq!(got.use_tabs, Some(true));
2970        }
2971
2972        #[test]
2973        fn lsp_server_pack_config_decodes_command_and_args() {
2974            let got: LspServerPackConfig =
2975                eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
2976            assert_eq!(got.command, "rust-analyzer");
2977            assert_eq!(got.args, vec!["--log".to_string()]);
2978            assert_eq!(got.auto_start, Some(true));
2979        }
2980
2981        #[test]
2982        fn process_limits_pack_config_decodes_percentages() {
2983            let got: ProcessLimitsPackConfig =
2984                eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
2985            assert_eq!(got.max_memory_percent, Some(75));
2986            assert_eq!(got.max_cpu_percent, Some(50));
2987            assert_eq!(got.enabled, Some(true));
2988        }
2989
2990        /// `TextPropertiesAtCursor::into_js` must serialise the inner vector
2991        /// into a JS array whose length matches the payload. A mutant that
2992        /// returns a default (`undefined` / empty) value would fail either
2993        /// the array check or the length check.
2994        #[test]
2995        fn text_properties_at_cursor_into_js_preserves_length() {
2996            use rquickjs::IntoJs;
2997            with_js(|ctx| {
2998                let mut entry = std::collections::HashMap::new();
2999                entry.insert("k".to_string(), serde_json::json!("v"));
3000                let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
3001
3002                let v = payload.into_js(&ctx).expect("into_js");
3003                let arr = v.as_array().expect("expected JS array");
3004                assert_eq!(arr.len(), 2);
3005            });
3006        }
3007    }
3008}
3009
3010/// Plugin API context - provides safe access to editor functionality
3011pub struct PluginApi {
3012    /// Hook registry (shared with editor)
3013    hooks: Arc<RwLock<HookRegistry>>,
3014
3015    /// Command registry (shared with editor)
3016    commands: Arc<RwLock<CommandRegistry>>,
3017
3018    /// Command queue for sending commands to editor
3019    command_sender: std::sync::mpsc::Sender<PluginCommand>,
3020
3021    /// Snapshot of editor state (read-only for plugins)
3022    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3023}
3024
3025impl PluginApi {
3026    /// Create a new plugin API context
3027    pub fn new(
3028        hooks: Arc<RwLock<HookRegistry>>,
3029        commands: Arc<RwLock<CommandRegistry>>,
3030        command_sender: std::sync::mpsc::Sender<PluginCommand>,
3031        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3032    ) -> Self {
3033        Self {
3034            hooks,
3035            commands,
3036            command_sender,
3037            state_snapshot,
3038        }
3039    }
3040
3041    /// Register a hook callback
3042    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
3043        let mut hooks = self.hooks.write().unwrap();
3044        hooks.add_hook(hook_name, callback);
3045    }
3046
3047    /// Remove all hooks for a specific name
3048    pub fn unregister_hooks(&self, hook_name: &str) {
3049        let mut hooks = self.hooks.write().unwrap();
3050        hooks.remove_hooks(hook_name);
3051    }
3052
3053    /// Register a command
3054    pub fn register_command(&self, command: Command) {
3055        let commands = self.commands.read().unwrap();
3056        commands.register(command);
3057    }
3058
3059    /// Unregister a command by name
3060    pub fn unregister_command(&self, name: &str) {
3061        let commands = self.commands.read().unwrap();
3062        commands.unregister(name);
3063    }
3064
3065    /// Send a command to the editor (async/non-blocking)
3066    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
3067        self.command_sender
3068            .send(command)
3069            .map_err(|e| format!("Failed to send command: {}", e))
3070    }
3071
3072    /// Insert text at a position in a buffer
3073    pub fn insert_text(
3074        &self,
3075        buffer_id: BufferId,
3076        position: usize,
3077        text: String,
3078    ) -> Result<(), String> {
3079        self.send_command(PluginCommand::InsertText {
3080            buffer_id,
3081            position,
3082            text,
3083        })
3084    }
3085
3086    /// Delete a range of text from a buffer
3087    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
3088        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
3089    }
3090
3091    /// Add an overlay (decoration) to a buffer
3092    /// Add an overlay to a buffer with styling options
3093    ///
3094    /// Returns an opaque handle that can be used to remove the overlay later.
3095    ///
3096    /// Colors can be specified as RGB arrays or theme key strings.
3097    /// Theme keys are resolved at render time, so overlays update with theme changes.
3098    pub fn add_overlay(
3099        &self,
3100        buffer_id: BufferId,
3101        namespace: Option<String>,
3102        range: Range<usize>,
3103        options: OverlayOptions,
3104    ) -> Result<(), String> {
3105        self.send_command(PluginCommand::AddOverlay {
3106            buffer_id,
3107            namespace: namespace.map(OverlayNamespace::from_string),
3108            range,
3109            options,
3110        })
3111    }
3112
3113    /// Remove an overlay from a buffer by its handle
3114    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
3115        self.send_command(PluginCommand::RemoveOverlay {
3116            buffer_id,
3117            handle: OverlayHandle::from_string(handle),
3118        })
3119    }
3120
3121    /// Clear all overlays in a namespace from a buffer
3122    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
3123        self.send_command(PluginCommand::ClearNamespace {
3124            buffer_id,
3125            namespace: OverlayNamespace::from_string(namespace),
3126        })
3127    }
3128
3129    /// Clear all overlays that overlap with a byte range
3130    /// Used for targeted invalidation when content changes
3131    pub fn clear_overlays_in_range(
3132        &self,
3133        buffer_id: BufferId,
3134        start: usize,
3135        end: usize,
3136    ) -> Result<(), String> {
3137        self.send_command(PluginCommand::ClearOverlaysInRange {
3138            buffer_id,
3139            start,
3140            end,
3141        })
3142    }
3143
3144    /// Set the status message
3145    pub fn set_status(&self, message: String) -> Result<(), String> {
3146        self.send_command(PluginCommand::SetStatus { message })
3147    }
3148
3149    /// Open a file at a specific line and column (1-indexed)
3150    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
3151    pub fn open_file_at_location(
3152        &self,
3153        path: PathBuf,
3154        line: Option<usize>,
3155        column: Option<usize>,
3156    ) -> Result<(), String> {
3157        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
3158    }
3159
3160    /// Open a file in a specific split at a line and column
3161    ///
3162    /// Similar to open_file_at_location but targets a specific split pane.
3163    /// The split_id is the ID of the split pane to open the file in.
3164    pub fn open_file_in_split(
3165        &self,
3166        split_id: usize,
3167        path: PathBuf,
3168        line: Option<usize>,
3169        column: Option<usize>,
3170    ) -> Result<(), String> {
3171        self.send_command(PluginCommand::OpenFileInSplit {
3172            split_id,
3173            path,
3174            line,
3175            column,
3176        })
3177    }
3178
3179    /// Start a prompt (minibuffer) with a custom type identifier
3180    /// The prompt_type is used to filter hooks in plugin code
3181    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
3182        self.send_command(PluginCommand::StartPrompt {
3183            label,
3184            prompt_type,
3185            floating_overlay: false,
3186        })
3187    }
3188
3189    /// Set the suggestions for the current prompt
3190    /// This updates the prompt's autocomplete/selection list
3191    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
3192        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
3193    }
3194
3195    /// Enable/disable syncing prompt input text when navigating suggestions
3196    pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
3197        self.send_command(PluginCommand::SetPromptInputSync { sync })
3198    }
3199
3200    /// Set the floating-overlay prompt's title (issue #1796). `None`
3201    /// clears the title and falls back to the prompt-type default.
3202    pub fn set_prompt_title(&self, title: Option<String>) -> Result<(), String> {
3203        self.send_command(PluginCommand::SetPromptTitle { title })
3204    }
3205
3206    /// Add a menu item to an existing menu
3207    pub fn add_menu_item(
3208        &self,
3209        menu_label: String,
3210        item: MenuItem,
3211        position: MenuPosition,
3212    ) -> Result<(), String> {
3213        self.send_command(PluginCommand::AddMenuItem {
3214            menu_label,
3215            item,
3216            position,
3217        })
3218    }
3219
3220    /// Add a new top-level menu
3221    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
3222        self.send_command(PluginCommand::AddMenu { menu, position })
3223    }
3224
3225    /// Remove a menu item from a menu
3226    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
3227        self.send_command(PluginCommand::RemoveMenuItem {
3228            menu_label,
3229            item_label,
3230        })
3231    }
3232
3233    /// Remove a top-level menu
3234    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
3235        self.send_command(PluginCommand::RemoveMenu { menu_label })
3236    }
3237
3238    // === Virtual Buffer Methods ===
3239
3240    /// Create a new virtual buffer (not backed by a file)
3241    ///
3242    /// Virtual buffers are used for special displays like diagnostic lists,
3243    /// search results, etc. They have their own mode for keybindings.
3244    pub fn create_virtual_buffer(
3245        &self,
3246        name: String,
3247        mode: String,
3248        read_only: bool,
3249    ) -> Result<(), String> {
3250        self.send_command(PluginCommand::CreateVirtualBuffer {
3251            name,
3252            mode,
3253            read_only,
3254        })
3255    }
3256
3257    /// Create a virtual buffer and set its content in one operation
3258    ///
3259    /// This is the preferred way to create virtual buffers since it doesn't
3260    /// require tracking the buffer ID. The buffer is created and populated
3261    /// atomically.
3262    pub fn create_virtual_buffer_with_content(
3263        &self,
3264        name: String,
3265        mode: String,
3266        read_only: bool,
3267        entries: Vec<TextPropertyEntry>,
3268    ) -> Result<(), String> {
3269        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
3270            name,
3271            mode,
3272            read_only,
3273            entries,
3274            show_line_numbers: true,
3275            show_cursors: true,
3276            editing_disabled: false,
3277            hidden_from_tabs: false,
3278            request_id: None,
3279        })
3280    }
3281
3282    /// Set the content of a virtual buffer with text properties
3283    ///
3284    /// Each entry contains text and metadata properties (e.g., source location).
3285    pub fn set_virtual_buffer_content(
3286        &self,
3287        buffer_id: BufferId,
3288        entries: Vec<TextPropertyEntry>,
3289    ) -> Result<(), String> {
3290        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
3291    }
3292
3293    /// Get text properties at cursor position in a buffer
3294    ///
3295    /// This triggers a command that will make properties available to plugins.
3296    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
3297        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
3298    }
3299
3300    /// Define a buffer mode with keybindings
3301    ///
3302    /// Bindings are specified as (key_string, command_name) pairs.
3303    pub fn define_mode(
3304        &self,
3305        name: String,
3306        bindings: Vec<(String, String)>,
3307        read_only: bool,
3308        allow_text_input: bool,
3309    ) -> Result<(), String> {
3310        self.send_command(PluginCommand::DefineMode {
3311            name,
3312            bindings,
3313            read_only,
3314            allow_text_input,
3315            inherit_normal_bindings: false,
3316            plugin_name: None,
3317        })
3318    }
3319
3320    /// Switch the current split to display a buffer
3321    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
3322        self.send_command(PluginCommand::ShowBuffer { buffer_id })
3323    }
3324
3325    /// Set the scroll position of a specific split
3326    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
3327        self.send_command(PluginCommand::SetSplitScroll {
3328            split_id: SplitId(split_id),
3329            top_byte,
3330        })
3331    }
3332
3333    /// Request syntax highlights for a buffer range
3334    pub fn get_highlights(
3335        &self,
3336        buffer_id: BufferId,
3337        range: Range<usize>,
3338        request_id: u64,
3339    ) -> Result<(), String> {
3340        self.send_command(PluginCommand::RequestHighlights {
3341            buffer_id,
3342            range,
3343            request_id,
3344        })
3345    }
3346
3347    // === Query Methods ===
3348
3349    /// Get the currently active buffer ID
3350    pub fn get_active_buffer_id(&self) -> BufferId {
3351        let snapshot = self.state_snapshot.read().unwrap();
3352        snapshot.active_buffer_id
3353    }
3354
3355    /// Get the currently active split ID
3356    pub fn get_active_split_id(&self) -> usize {
3357        let snapshot = self.state_snapshot.read().unwrap();
3358        snapshot.active_split_id
3359    }
3360
3361    /// Get information about a specific buffer
3362    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
3363        let snapshot = self.state_snapshot.read().unwrap();
3364        snapshot.buffers.get(&buffer_id).cloned()
3365    }
3366
3367    /// Get all buffer IDs
3368    pub fn list_buffers(&self) -> Vec<BufferInfo> {
3369        let snapshot = self.state_snapshot.read().unwrap();
3370        snapshot.buffers.values().cloned().collect()
3371    }
3372
3373    /// Get primary cursor information for the active buffer
3374    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
3375        let snapshot = self.state_snapshot.read().unwrap();
3376        snapshot.primary_cursor.clone()
3377    }
3378
3379    /// Get all cursor information for the active buffer
3380    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
3381        let snapshot = self.state_snapshot.read().unwrap();
3382        snapshot.all_cursors.clone()
3383    }
3384
3385    /// Get viewport information for the active buffer
3386    pub fn get_viewport(&self) -> Option<ViewportInfo> {
3387        let snapshot = self.state_snapshot.read().unwrap();
3388        snapshot.viewport.clone()
3389    }
3390
3391    /// Get access to the state snapshot Arc (for internal use)
3392    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
3393        Arc::clone(&self.state_snapshot)
3394    }
3395}
3396
3397impl Clone for PluginApi {
3398    fn clone(&self) -> Self {
3399        Self {
3400            hooks: Arc::clone(&self.hooks),
3401            commands: Arc::clone(&self.commands),
3402            command_sender: self.command_sender.clone(),
3403            state_snapshot: Arc::clone(&self.state_snapshot),
3404        }
3405    }
3406}
3407
3408// ============================================================================
3409// Pluggable Completion Service — TypeScript Plugin API Types
3410// ============================================================================
3411//
3412// These types are the bridge between the Rust `CompletionService` and
3413// TypeScript plugins that want to provide completion candidates.  They are
3414// serialised to/from JSON via serde and generate TypeScript definitions via
3415// ts-rs so that the plugin API stays in sync automatically.
3416
3417/// A completion candidate produced by a TypeScript plugin provider.
3418///
3419/// This mirrors `CompletionCandidate` in the Rust `completion::provider`
3420/// module but uses serde-friendly primitives for the JS ↔ Rust boundary.
3421#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3422#[serde(rename_all = "camelCase", deny_unknown_fields)]
3423#[ts(export, rename_all = "camelCase")]
3424pub struct TsCompletionCandidate {
3425    /// Display text shown in the completion popup.
3426    pub label: String,
3427
3428    /// Text to insert when accepted. Falls back to `label` if omitted.
3429    #[serde(skip_serializing_if = "Option::is_none")]
3430    pub insert_text: Option<String>,
3431
3432    /// Short detail string shown next to the label.
3433    #[serde(skip_serializing_if = "Option::is_none")]
3434    pub detail: Option<String>,
3435
3436    /// Single-character icon hint (e.g. `"λ"`, `"v"`).
3437    #[serde(skip_serializing_if = "Option::is_none")]
3438    pub icon: Option<String>,
3439
3440    /// Provider-assigned relevance score (higher = better).
3441    #[serde(default)]
3442    pub score: i64,
3443
3444    /// Whether `insert_text` uses LSP snippet syntax (`$0`, `${1:ph}`, …).
3445    #[serde(default)]
3446    pub is_snippet: bool,
3447
3448    /// Opaque data carried through to the `completionAccepted` hook.
3449    #[serde(skip_serializing_if = "Option::is_none")]
3450    pub provider_data: Option<String>,
3451}
3452
3453/// Context sent to a TypeScript plugin's `provideCompletions` handler.
3454///
3455/// Plugins receive this as a read-only snapshot so they never need direct
3456/// buffer access (which would be unsafe for huge files).
3457#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3458#[serde(rename_all = "camelCase")]
3459#[ts(export, rename_all = "camelCase")]
3460pub struct TsCompletionContext {
3461    /// The word prefix typed so far.
3462    pub prefix: String,
3463
3464    /// Byte offset of the cursor.
3465    pub cursor_byte: usize,
3466
3467    /// Byte offset of the word start (for replacement range).
3468    pub word_start_byte: usize,
3469
3470    /// Total buffer size in bytes.
3471    pub buffer_len: usize,
3472
3473    /// Whether the buffer is a lazily-loaded huge file.
3474    pub is_large_file: bool,
3475
3476    /// A text excerpt around the cursor (the contents of the safe scan window).
3477    /// Plugins should search only this string, not request the full buffer.
3478    pub text_around_cursor: String,
3479
3480    /// Byte offset within `text_around_cursor` that corresponds to the cursor.
3481    pub cursor_offset_in_text: usize,
3482
3483    /// File language id (e.g. `"rust"`, `"typescript"`), if known.
3484    #[serde(skip_serializing_if = "Option::is_none")]
3485    pub language_id: Option<String>,
3486}
3487
3488/// Registration payload sent by a plugin to register a completion provider.
3489#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3490#[serde(rename_all = "camelCase", deny_unknown_fields)]
3491#[ts(export, rename_all = "camelCase")]
3492pub struct TsCompletionProviderRegistration {
3493    /// Unique id for this provider (e.g., `"my-snippets"`).
3494    pub id: String,
3495
3496    /// Human-readable name shown in status/debug UI.
3497    pub display_name: String,
3498
3499    /// Priority tier (lower = higher priority). Convention:
3500    /// 0 = LSP, 10 = ctags, 20 = buffer words, 30 = dabbrev, 50 = plugin.
3501    #[serde(default = "default_plugin_provider_priority")]
3502    pub priority: u32,
3503
3504    /// Optional list of language ids this provider is active for.
3505    /// If empty/omitted, the provider is active for all languages.
3506    #[serde(default)]
3507    pub language_ids: Vec<String>,
3508}
3509
3510fn default_plugin_provider_priority() -> u32 {
3511    50
3512}
3513
3514#[cfg(test)]
3515mod tests {
3516    use super::*;
3517
3518    #[test]
3519    fn test_plugin_api_creation() {
3520        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3521        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3522        let (tx, _rx) = std::sync::mpsc::channel();
3523        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3524
3525        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3526
3527        // Should not panic
3528        let _clone = api.clone();
3529    }
3530
3531    #[test]
3532    fn test_register_hook() {
3533        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3534        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3535        let (tx, _rx) = std::sync::mpsc::channel();
3536        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3537
3538        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
3539
3540        api.register_hook("test-hook", Box::new(|_| true));
3541
3542        let hook_registry = hooks.read().unwrap();
3543        assert_eq!(hook_registry.hook_count("test-hook"), 1);
3544    }
3545
3546    #[test]
3547    fn test_send_command() {
3548        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3549        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3550        let (tx, rx) = std::sync::mpsc::channel();
3551        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3552
3553        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3554
3555        let result = api.insert_text(BufferId(1), 0, "test".to_string());
3556        assert!(result.is_ok());
3557
3558        // Verify command was sent
3559        let received = rx.try_recv();
3560        assert!(received.is_ok());
3561
3562        match received.unwrap() {
3563            PluginCommand::InsertText {
3564                buffer_id,
3565                position,
3566                text,
3567            } => {
3568                assert_eq!(buffer_id.0, 1);
3569                assert_eq!(position, 0);
3570                assert_eq!(text, "test");
3571            }
3572            _ => panic!("Wrong command type"),
3573        }
3574    }
3575
3576    #[test]
3577    fn test_add_overlay_command() {
3578        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3579        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3580        let (tx, rx) = std::sync::mpsc::channel();
3581        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3582
3583        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3584
3585        let result = api.add_overlay(
3586            BufferId(1),
3587            Some("test-overlay".to_string()),
3588            0..10,
3589            OverlayOptions {
3590                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
3591                bg: None,
3592                underline: true,
3593                bold: false,
3594                italic: false,
3595                strikethrough: false,
3596                extend_to_line_end: false,
3597                url: None,
3598            },
3599        );
3600        assert!(result.is_ok());
3601
3602        let received = rx.try_recv().unwrap();
3603        match received {
3604            PluginCommand::AddOverlay {
3605                buffer_id,
3606                namespace,
3607                range,
3608                options,
3609            } => {
3610                assert_eq!(buffer_id.0, 1);
3611                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
3612                assert_eq!(range, 0..10);
3613                assert!(matches!(
3614                    options.fg,
3615                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
3616                ));
3617                assert!(options.bg.is_none());
3618                assert!(options.underline);
3619                assert!(!options.bold);
3620                assert!(!options.italic);
3621                assert!(!options.extend_to_line_end);
3622            }
3623            _ => panic!("Wrong command type"),
3624        }
3625    }
3626
3627    #[test]
3628    fn test_set_status_command() {
3629        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3630        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3631        let (tx, rx) = std::sync::mpsc::channel();
3632        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3633
3634        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3635
3636        let result = api.set_status("Test status".to_string());
3637        assert!(result.is_ok());
3638
3639        let received = rx.try_recv().unwrap();
3640        match received {
3641            PluginCommand::SetStatus { message } => {
3642                assert_eq!(message, "Test status");
3643            }
3644            _ => panic!("Wrong command type"),
3645        }
3646    }
3647
3648    #[test]
3649    fn test_get_active_buffer_id() {
3650        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3651        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3652        let (tx, _rx) = std::sync::mpsc::channel();
3653        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3654
3655        // Set active buffer to 5
3656        {
3657            let mut snapshot = state_snapshot.write().unwrap();
3658            snapshot.active_buffer_id = BufferId(5);
3659        }
3660
3661        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3662
3663        let active_id = api.get_active_buffer_id();
3664        assert_eq!(active_id.0, 5);
3665    }
3666
3667    #[test]
3668    fn test_get_buffer_info() {
3669        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3670        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3671        let (tx, _rx) = std::sync::mpsc::channel();
3672        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3673
3674        // Add buffer info
3675        {
3676            let mut snapshot = state_snapshot.write().unwrap();
3677            let buffer_info = BufferInfo {
3678                id: BufferId(1),
3679                path: Some(std::path::PathBuf::from("/test/file.txt")),
3680                modified: true,
3681                length: 100,
3682                is_virtual: false,
3683                view_mode: "source".to_string(),
3684                is_composing_in_any_split: false,
3685                compose_width: None,
3686                language: "text".to_string(),
3687                is_preview: false,
3688                splits: Vec::new(),
3689            };
3690            snapshot.buffers.insert(BufferId(1), buffer_info);
3691        }
3692
3693        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3694
3695        let info = api.get_buffer_info(BufferId(1));
3696        assert!(info.is_some());
3697        let info = info.unwrap();
3698        assert_eq!(info.id.0, 1);
3699        assert_eq!(
3700            info.path.as_ref().unwrap().to_str().unwrap(),
3701            "/test/file.txt"
3702        );
3703        assert!(info.modified);
3704        assert_eq!(info.length, 100);
3705
3706        // Non-existent buffer
3707        let no_info = api.get_buffer_info(BufferId(999));
3708        assert!(no_info.is_none());
3709    }
3710
3711    #[test]
3712    fn test_list_buffers() {
3713        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3714        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3715        let (tx, _rx) = std::sync::mpsc::channel();
3716        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3717
3718        // Add multiple buffers
3719        {
3720            let mut snapshot = state_snapshot.write().unwrap();
3721            snapshot.buffers.insert(
3722                BufferId(1),
3723                BufferInfo {
3724                    id: BufferId(1),
3725                    path: Some(std::path::PathBuf::from("/file1.txt")),
3726                    modified: false,
3727                    length: 50,
3728                    is_virtual: false,
3729                    view_mode: "source".to_string(),
3730                    is_composing_in_any_split: false,
3731                    compose_width: None,
3732                    language: "text".to_string(),
3733                    is_preview: false,
3734                    splits: Vec::new(),
3735                },
3736            );
3737            snapshot.buffers.insert(
3738                BufferId(2),
3739                BufferInfo {
3740                    id: BufferId(2),
3741                    path: Some(std::path::PathBuf::from("/file2.txt")),
3742                    modified: true,
3743                    length: 100,
3744                    is_virtual: false,
3745                    view_mode: "source".to_string(),
3746                    is_composing_in_any_split: false,
3747                    compose_width: None,
3748                    language: "text".to_string(),
3749                    is_preview: false,
3750                    splits: Vec::new(),
3751                },
3752            );
3753            snapshot.buffers.insert(
3754                BufferId(3),
3755                BufferInfo {
3756                    id: BufferId(3),
3757                    path: None,
3758                    modified: false,
3759                    length: 0,
3760                    is_virtual: true,
3761                    view_mode: "source".to_string(),
3762                    is_composing_in_any_split: false,
3763                    compose_width: None,
3764                    language: "text".to_string(),
3765                    is_preview: false,
3766                    splits: Vec::new(),
3767                },
3768            );
3769        }
3770
3771        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3772
3773        let buffers = api.list_buffers();
3774        assert_eq!(buffers.len(), 3);
3775
3776        // Verify all buffers are present
3777        assert!(buffers.iter().any(|b| b.id.0 == 1));
3778        assert!(buffers.iter().any(|b| b.id.0 == 2));
3779        assert!(buffers.iter().any(|b| b.id.0 == 3));
3780    }
3781
3782    #[test]
3783    fn test_get_primary_cursor() {
3784        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3785        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3786        let (tx, _rx) = std::sync::mpsc::channel();
3787        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3788
3789        // Add cursor info
3790        {
3791            let mut snapshot = state_snapshot.write().unwrap();
3792            snapshot.primary_cursor = Some(CursorInfo {
3793                position: 42,
3794                selection: Some(10..42),
3795            });
3796        }
3797
3798        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3799
3800        let cursor = api.get_primary_cursor();
3801        assert!(cursor.is_some());
3802        let cursor = cursor.unwrap();
3803        assert_eq!(cursor.position, 42);
3804        assert_eq!(cursor.selection, Some(10..42));
3805    }
3806
3807    #[test]
3808    fn test_get_all_cursors() {
3809        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3810        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3811        let (tx, _rx) = std::sync::mpsc::channel();
3812        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3813
3814        // Add multiple cursors
3815        {
3816            let mut snapshot = state_snapshot.write().unwrap();
3817            snapshot.all_cursors = vec![
3818                CursorInfo {
3819                    position: 10,
3820                    selection: None,
3821                },
3822                CursorInfo {
3823                    position: 20,
3824                    selection: Some(15..20),
3825                },
3826                CursorInfo {
3827                    position: 30,
3828                    selection: Some(25..30),
3829                },
3830            ];
3831        }
3832
3833        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3834
3835        let cursors = api.get_all_cursors();
3836        assert_eq!(cursors.len(), 3);
3837        assert_eq!(cursors[0].position, 10);
3838        assert_eq!(cursors[0].selection, None);
3839        assert_eq!(cursors[1].position, 20);
3840        assert_eq!(cursors[1].selection, Some(15..20));
3841        assert_eq!(cursors[2].position, 30);
3842        assert_eq!(cursors[2].selection, Some(25..30));
3843    }
3844
3845    #[test]
3846    fn test_get_viewport() {
3847        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3848        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3849        let (tx, _rx) = std::sync::mpsc::channel();
3850        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3851
3852        // Add viewport info
3853        {
3854            let mut snapshot = state_snapshot.write().unwrap();
3855            snapshot.viewport = Some(ViewportInfo {
3856                top_byte: 100,
3857                top_line: Some(5),
3858                left_column: 5,
3859                width: 80,
3860                height: 24,
3861            });
3862        }
3863
3864        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3865
3866        let viewport = api.get_viewport();
3867        assert!(viewport.is_some());
3868        let viewport = viewport.unwrap();
3869        assert_eq!(viewport.top_byte, 100);
3870        assert_eq!(viewport.left_column, 5);
3871        assert_eq!(viewport.width, 80);
3872        assert_eq!(viewport.height, 24);
3873    }
3874
3875    #[test]
3876    fn test_composite_buffer_options_rejects_unknown_fields() {
3877        // Valid JSON with correct field names
3878        let valid_json = r#"{
3879            "name": "test",
3880            "mode": "diff",
3881            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3882            "sources": [{"bufferId": 1, "label": "old"}]
3883        }"#;
3884        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
3885        assert!(
3886            result.is_ok(),
3887            "Valid JSON should parse: {:?}",
3888            result.err()
3889        );
3890
3891        // Invalid JSON with unknown field (buffer_id instead of bufferId)
3892        let invalid_json = r#"{
3893            "name": "test",
3894            "mode": "diff",
3895            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3896            "sources": [{"buffer_id": 1, "label": "old"}]
3897        }"#;
3898        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
3899        assert!(
3900            result.is_err(),
3901            "JSON with unknown field should fail to parse"
3902        );
3903        let err = result.unwrap_err().to_string();
3904        assert!(
3905            err.contains("unknown field") || err.contains("buffer_id"),
3906            "Error should mention unknown field: {}",
3907            err
3908        );
3909    }
3910
3911    #[test]
3912    fn test_composite_hunk_rejects_unknown_fields() {
3913        // Valid JSON with correct field names
3914        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3915        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
3916        assert!(
3917            result.is_ok(),
3918            "Valid JSON should parse: {:?}",
3919            result.err()
3920        );
3921
3922        // Invalid JSON with unknown field (old_start instead of oldStart)
3923        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3924        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
3925        assert!(
3926            result.is_err(),
3927            "JSON with unknown field should fail to parse"
3928        );
3929        let err = result.unwrap_err().to_string();
3930        assert!(
3931            err.contains("unknown field") || err.contains("old_start"),
3932            "Error should mention unknown field: {}",
3933            err
3934        );
3935    }
3936
3937    #[test]
3938    fn test_plugin_response_line_end_position() {
3939        let response = PluginResponse::LineEndPosition {
3940            request_id: 42,
3941            position: Some(100),
3942        };
3943        let json = serde_json::to_string(&response).unwrap();
3944        assert!(json.contains("LineEndPosition"));
3945        assert!(json.contains("42"));
3946        assert!(json.contains("100"));
3947
3948        // Test None case
3949        let response_none = PluginResponse::LineEndPosition {
3950            request_id: 1,
3951            position: None,
3952        };
3953        let json_none = serde_json::to_string(&response_none).unwrap();
3954        assert!(json_none.contains("null"));
3955    }
3956
3957    #[test]
3958    fn test_plugin_response_buffer_line_count() {
3959        let response = PluginResponse::BufferLineCount {
3960            request_id: 99,
3961            count: Some(500),
3962        };
3963        let json = serde_json::to_string(&response).unwrap();
3964        assert!(json.contains("BufferLineCount"));
3965        assert!(json.contains("99"));
3966        assert!(json.contains("500"));
3967    }
3968
3969    #[test]
3970    fn test_plugin_command_get_line_end_position() {
3971        let command = PluginCommand::GetLineEndPosition {
3972            buffer_id: BufferId(1),
3973            line: 10,
3974            request_id: 123,
3975        };
3976        let json = serde_json::to_string(&command).unwrap();
3977        assert!(json.contains("GetLineEndPosition"));
3978        assert!(json.contains("10"));
3979    }
3980
3981    #[test]
3982    fn test_plugin_command_get_buffer_line_count() {
3983        let command = PluginCommand::GetBufferLineCount {
3984            buffer_id: BufferId(0),
3985            request_id: 456,
3986        };
3987        let json = serde_json::to_string(&command).unwrap();
3988        assert!(json.contains("GetBufferLineCount"));
3989        assert!(json.contains("456"));
3990    }
3991
3992    #[test]
3993    fn test_plugin_command_scroll_to_line_center() {
3994        let command = PluginCommand::ScrollToLineCenter {
3995            split_id: SplitId(1),
3996            buffer_id: BufferId(2),
3997            line: 50,
3998        };
3999        let json = serde_json::to_string(&command).unwrap();
4000        assert!(json.contains("ScrollToLineCenter"));
4001        assert!(json.contains("50"));
4002    }
4003
4004    /// `JsCallbackId` round-trips through `u64` via `new` / `as_u64` / `From`
4005    /// and renders as its underlying integer via `Display`.
4006    #[test]
4007    fn js_callback_id_conversions_and_display() {
4008        for raw in [0u64, 1, 42, u64::MAX] {
4009            let id = JsCallbackId::new(raw);
4010            assert_eq!(id.as_u64(), raw);
4011            assert_eq!(u64::from(id), raw);
4012            assert_eq!(JsCallbackId::from(raw), id);
4013            assert_eq!(id.to_string(), raw.to_string());
4014        }
4015    }
4016
4017    /// Serde `default = ...` helpers fire when the field is omitted and are
4018    /// overridden by explicit values. One test per struct pins each helper
4019    /// to its documented default.
4020    #[test]
4021    fn serde_defaults_fire_when_fields_are_omitted() {
4022        // default_action_count → 1
4023        let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
4024        assert_eq!(spec.count, 1);
4025        let spec: ActionSpec =
4026            serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
4027        assert_eq!(spec.count, 5);
4028
4029        // default_true → showSeparator = true
4030        let layout: CompositeLayoutConfig =
4031            serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
4032        assert!(layout.show_separator);
4033        let layout: CompositeLayoutConfig =
4034            serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
4035        assert!(!layout.show_separator);
4036
4037        // default_plugin_provider_priority → 50
4038        let reg: TsCompletionProviderRegistration =
4039            serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
4040        assert_eq!(reg.priority, 50);
4041        let reg: TsCompletionProviderRegistration =
4042            serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
4043        assert_eq!(reg.priority, 3);
4044    }
4045
4046    // ── Behavioural tests added to kill the mutants reported by cargo-mutants ──
4047    //
4048    // These tests pin down observable behaviour for tiny methods whose bodies
4049    // were replaceable with a constant (e.g. `()`, `Ok(())`, `None`, or a
4050    // default value) without any existing test noticing.
4051
4052    /// Helper: build a minimal `Command` with a given name.
4053    fn mk_cmd(name: &str) -> Command {
4054        Command {
4055            name: name.to_string(),
4056            description: String::new(),
4057            action_name: String::new(),
4058            plugin_name: String::new(),
4059            custom_contexts: Vec::new(),
4060        }
4061    }
4062
4063    /// `CommandRegistry::register` appends new commands and replaces any
4064    /// existing entry with the same name; `unregister` removes exactly the
4065    /// matching entry and is a no-op for unknown names.
4066    ///
4067    /// Kills: replace register with `()`; `!= → ==` in register;
4068    ///        replace unregister with `()`; `!= → ==` in unregister.
4069    #[test]
4070    fn command_registry_register_and_unregister_semantics() {
4071        let r = CommandRegistry::new();
4072
4073        r.register(mk_cmd("a"));
4074        r.register(mk_cmd("b"));
4075        assert_eq!(r.commands.read().unwrap().len(), 2);
4076
4077        // Re-registering "a" must keep "b" (retain filters by `!=`); the
4078        // `== → !=` mutant would drop "b" and leave two copies of "a".
4079        r.register(mk_cmd("a"));
4080        let names: Vec<String> = r
4081            .commands
4082            .read()
4083            .unwrap()
4084            .iter()
4085            .map(|c| c.name.clone())
4086            .collect();
4087        assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
4088
4089        // Unregister must remove exactly "a" and preserve "b"; the `== → !=`
4090        // mutant would keep "a" and drop "b".
4091        r.unregister("a");
4092        let names: Vec<String> = r
4093            .commands
4094            .read()
4095            .unwrap()
4096            .iter()
4097            .map(|c| c.name.clone())
4098            .collect();
4099        assert_eq!(names, vec!["b".to_string()]);
4100
4101        // Unregistering an unknown name is a no-op.
4102        r.unregister("nope");
4103        assert_eq!(r.commands.read().unwrap().len(), 1);
4104    }
4105
4106    /// `OverlayColorSpec::as_rgb` returns the exact stored tuple for the RGB
4107    /// variant and `None` for the theme-key variant; `as_theme_key` is the
4108    /// dual. Uses a triple with no zero or one components and a theme key
4109    /// that is neither empty nor `"xyzzy"` to kill every constant-return
4110    /// mutant reported by cargo-mutants at once.
4111    #[test]
4112    fn overlay_color_spec_accessors_are_variant_specific() {
4113        let rgb = OverlayColorSpec::rgb(12, 34, 56);
4114        assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
4115        assert_eq!(rgb.as_theme_key(), None);
4116
4117        let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
4118        assert_eq!(tk.as_rgb(), None);
4119        assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
4120    }
4121
4122    /// `PluginCommand::debug_variant_name` returns the actual variant name
4123    /// derived from the `Debug` impl, not an empty or hard-coded string.
4124    #[test]
4125    fn plugin_command_debug_variant_name_returns_real_variant() {
4126        let c = PluginCommand::SetStatus {
4127            message: "hi".into(),
4128        };
4129        assert_eq!(c.debug_variant_name(), "SetStatus");
4130
4131        let c2 = PluginCommand::InsertText {
4132            buffer_id: BufferId(1),
4133            position: 0,
4134            text: String::new(),
4135        };
4136        assert_eq!(c2.debug_variant_name(), "InsertText");
4137    }
4138
4139    // ── PluginApi dispatch / mutation tests ────────────────────────────────
4140    //
4141    // Each `PluginApi` method is a one-liner that either pushes a
4142    // `PluginCommand` onto the channel or mutates a shared registry. The
4143    // mutants replace the body with `Ok(())` / `()`, i.e. the side effect
4144    // disappears. One assertion per method ties the side effect down.
4145
4146    fn mk_api() -> (
4147        PluginApi,
4148        std::sync::mpsc::Receiver<PluginCommand>,
4149        Arc<RwLock<HookRegistry>>,
4150        Arc<RwLock<CommandRegistry>>,
4151        Arc<RwLock<EditorStateSnapshot>>,
4152    ) {
4153        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
4154        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
4155        let (tx, rx) = std::sync::mpsc::channel();
4156        let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4157        let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
4158        (api, rx, hooks, commands, snap)
4159    }
4160
4161    /// `unregister_hooks` must actually clear hooks registered under the
4162    /// same name; replacing the body with `()` leaves the count at 1.
4163    #[test]
4164    fn plugin_api_unregister_hooks_clears_registry() {
4165        let (api, _rx, hooks, _cmds, _snap) = mk_api();
4166        api.register_hook("h", Box::new(|_| true));
4167        assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
4168        api.unregister_hooks("h");
4169        assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
4170    }
4171
4172    /// `register_command` / `unregister_command` must actually write through
4173    /// to the shared `CommandRegistry`.
4174    #[test]
4175    fn plugin_api_register_and_unregister_command_write_through() {
4176        let (api, _rx, _hooks, cmds, _snap) = mk_api();
4177
4178        api.register_command(mk_cmd("x"));
4179        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
4180
4181        api.unregister_command("x");
4182        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
4183    }
4184
4185    /// Macro: assert that calling `$call` on a fresh `PluginApi` produces
4186    /// exactly one `PluginCommand` matching `$pattern` with the additional
4187    /// invariants in `$guard`.
4188    macro_rules! assert_dispatches {
4189        ($call:expr, $pattern:pat $(if $guard:expr)?) => {{
4190            let (api, rx, _h, _c, _s) = mk_api();
4191            let _ = $call(&api);
4192            match rx.try_recv().expect("no command sent") {
4193                $pattern $(if $guard)? => {}
4194                other => panic!("unexpected command variant: {:?}", other),
4195            }
4196        }};
4197    }
4198
4199    /// Every simple `send_command`-based method on `PluginApi` translates
4200    /// its arguments into the documented `PluginCommand` variant with the
4201    /// expected fields.
4202    #[test]
4203    fn plugin_api_send_command_methods_dispatch_correctly() {
4204        // delete_range
4205        assert_dispatches!(
4206            |a: &PluginApi| a.delete_range(BufferId(7), 3..9),
4207            PluginCommand::DeleteRange { buffer_id, range }
4208                if buffer_id == BufferId(7) && range == (3..9)
4209        );
4210
4211        // remove_overlay
4212        assert_dispatches!(
4213            |a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
4214            PluginCommand::RemoveOverlay { buffer_id, handle }
4215                if buffer_id == BufferId(2) && handle.as_str() == "h-1"
4216        );
4217
4218        // clear_namespace
4219        assert_dispatches!(
4220            |a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
4221            PluginCommand::ClearNamespace { buffer_id, namespace }
4222                if buffer_id == BufferId(3) && namespace.as_str() == "diag"
4223        );
4224
4225        // clear_overlays_in_range
4226        assert_dispatches!(
4227            |a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
4228            PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
4229                if buffer_id == BufferId(4) && start == 10 && end == 20
4230        );
4231
4232        // open_file_at_location
4233        assert_dispatches!(
4234            |a: &PluginApi| a.open_file_at_location(
4235                PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
4236            ),
4237            PluginCommand::OpenFileAtLocation { path, line, column }
4238                if path == PathBuf::from("/tmp/x.rs")
4239                    && line == Some(4)
4240                    && column == Some(8)
4241        );
4242
4243        // open_file_in_split
4244        assert_dispatches!(
4245            |a: &PluginApi| a.open_file_in_split(
4246                2, PathBuf::from("/tmp/y.rs"), Some(5), None
4247            ),
4248            PluginCommand::OpenFileInSplit { split_id, path, line, column }
4249                if split_id == 2
4250                    && path == PathBuf::from("/tmp/y.rs")
4251                    && line == Some(5)
4252                    && column.is_none()
4253        );
4254
4255        // start_prompt
4256        assert_dispatches!(
4257            |a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
4258            PluginCommand::StartPrompt { label, prompt_type, floating_overlay }
4259                if label == "label" && prompt_type == "cmd" && !floating_overlay
4260        );
4261
4262        // set_prompt_suggestions
4263        assert_dispatches!(
4264            |a: &PluginApi| a.set_prompt_suggestions(vec![
4265                Suggestion::new("one".into()),
4266                Suggestion::new("two".into()),
4267            ]),
4268            PluginCommand::SetPromptSuggestions { suggestions }
4269                if suggestions.len() == 2
4270                    && suggestions[0].text == "one"
4271                    && suggestions[1].text == "two"
4272        );
4273
4274        // set_prompt_input_sync
4275        assert_dispatches!(
4276            |a: &PluginApi| a.set_prompt_input_sync(true),
4277            PluginCommand::SetPromptInputSync { sync } if sync
4278        );
4279        assert_dispatches!(
4280            |a: &PluginApi| a.set_prompt_input_sync(false),
4281            PluginCommand::SetPromptInputSync { sync } if !sync
4282        );
4283
4284        // add_menu_item
4285        assert_dispatches!(
4286            |a: &PluginApi| a.add_menu_item(
4287                "File".into(),
4288                MenuItem::Label { info: "info".into() },
4289                MenuPosition::Bottom,
4290            ),
4291            PluginCommand::AddMenuItem { menu_label, item, position }
4292                if menu_label == "File"
4293                    && matches!(item, MenuItem::Label { ref info } if info == "info")
4294                    && matches!(position, MenuPosition::Bottom)
4295        );
4296
4297        // add_menu
4298        assert_dispatches!(
4299            |a: &PluginApi| a.add_menu(
4300                Menu {
4301                    id: None,
4302                    label: "Help".into(),
4303                    items: vec![],
4304                    when: None,
4305                },
4306                MenuPosition::After("Edit".into()),
4307            ),
4308            PluginCommand::AddMenu { menu, position }
4309                if menu.label == "Help"
4310                    && matches!(position, MenuPosition::After(ref s) if s == "Edit")
4311        );
4312
4313        // remove_menu_item
4314        assert_dispatches!(
4315            |a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
4316            PluginCommand::RemoveMenuItem { menu_label, item_label }
4317                if menu_label == "File" && item_label == "Open"
4318        );
4319
4320        // remove_menu
4321        assert_dispatches!(
4322            |a: &PluginApi| a.remove_menu("File".into()),
4323            PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
4324        );
4325
4326        // create_virtual_buffer
4327        assert_dispatches!(
4328            |a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
4329            PluginCommand::CreateVirtualBuffer { name, mode, read_only }
4330                if name == "buf" && mode == "mode" && read_only
4331        );
4332
4333        // create_virtual_buffer_with_content
4334        assert_dispatches!(
4335            |a: &PluginApi| a.create_virtual_buffer_with_content(
4336                "n".into(), "m".into(), false, vec![]
4337            ),
4338            PluginCommand::CreateVirtualBufferWithContent {
4339                name, mode, read_only, show_line_numbers, show_cursors,
4340                editing_disabled, hidden_from_tabs, request_id, ..
4341            }
4342                if name == "n" && mode == "m" && !read_only
4343                    && show_line_numbers && show_cursors
4344                    && !editing_disabled && !hidden_from_tabs
4345                    && request_id.is_none()
4346        );
4347
4348        // set_virtual_buffer_content
4349        assert_dispatches!(
4350            |a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
4351            PluginCommand::SetVirtualBufferContent { buffer_id, entries }
4352                if buffer_id == BufferId(9) && entries.is_empty()
4353        );
4354
4355        // get_text_properties_at_cursor
4356        assert_dispatches!(
4357            |a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
4358            PluginCommand::GetTextPropertiesAtCursor { buffer_id }
4359                if buffer_id == BufferId(11)
4360        );
4361
4362        // define_mode
4363        assert_dispatches!(
4364            |a: &PluginApi| a.define_mode(
4365                "m".into(),
4366                vec![("j".into(), "move_down".into())],
4367                true,
4368                false,
4369            ),
4370            PluginCommand::DefineMode {
4371                name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
4372            }
4373                if name == "m"
4374                    && bindings.len() == 1
4375                    && bindings[0].0 == "j"
4376                    && bindings[0].1 == "move_down"
4377                    && read_only
4378                    && !allow_text_input
4379                    && !inherit_normal_bindings
4380                    && plugin_name.is_none()
4381        );
4382
4383        // show_buffer
4384        assert_dispatches!(
4385            |a: &PluginApi| a.show_buffer(BufferId(77)),
4386            PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
4387        );
4388
4389        // set_split_scroll
4390        assert_dispatches!(
4391            |a: &PluginApi| a.set_split_scroll(5, 128),
4392            PluginCommand::SetSplitScroll { split_id, top_byte }
4393                if split_id == SplitId(5) && top_byte == 128
4394        );
4395
4396        // get_highlights
4397        assert_dispatches!(
4398            |a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
4399            PluginCommand::RequestHighlights { buffer_id, range, request_id }
4400                if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
4401        );
4402    }
4403
4404    /// `get_active_split_id` reads the snapshot verbatim; a non-{0,1}
4405    /// sentinel value kills both the `0` and `1` constant-return mutants.
4406    #[test]
4407    fn plugin_api_get_active_split_id_reads_snapshot() {
4408        let (api, _rx, _h, _c, snap) = mk_api();
4409        snap.write().unwrap().active_split_id = 42;
4410        assert_eq!(api.get_active_split_id(), 42);
4411    }
4412
4413    /// `state_snapshot_handle` returns a clone of the same `Arc`, not a
4414    /// freshly-defaulted snapshot. A distinguishing field value on the
4415    /// original state proves that the handle sees it.
4416    #[test]
4417    fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
4418        let (api, _rx, _h, _c, snap) = mk_api();
4419        snap.write().unwrap().active_buffer_id = BufferId(42);
4420
4421        let h = api.state_snapshot_handle();
4422        assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
4423        assert!(Arc::ptr_eq(&h, &snap));
4424    }
4425
4426    /// `KillHostProcess` survives a round-trip through serde: the
4427    /// `process_id` field stays identified by name and the variant
4428    /// retains its tag shape. If a future contributor renames the
4429    /// field or splits it into a tuple, the plugin-runtime TS side
4430    /// (which hand-builds the command JSON for the dispatcher) would
4431    /// silently break — this test pins the wire format.
4432    #[test]
4433    fn plugin_command_kill_host_process_serde_round_trip() {
4434        let cmd = PluginCommand::KillHostProcess { process_id: 1234 };
4435        let json = serde_json::to_value(&cmd).unwrap();
4436        assert_eq!(json["KillHostProcess"]["process_id"], 1234);
4437        let decoded: PluginCommand = serde_json::from_value(json).unwrap();
4438        match decoded {
4439            PluginCommand::KillHostProcess { process_id } => assert_eq!(process_id, 1234),
4440            other => panic!("expected KillHostProcess, got {:?}", other),
4441        }
4442    }
4443}