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