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 crate::WindowId;
56use lsp_types;
57use serde::{Deserialize, Serialize};
58use serde_json::Value as JsonValue;
59use std::collections::HashMap;
60use std::ops::Range;
61use std::path::PathBuf;
62use std::sync::{Arc, RwLock};
63use ts_rs::TS;
64
65/// Minimal command registry for PluginApi.
66/// This is a stub that provides basic command storage for plugin use.
67/// The editor's full CommandRegistry lives in fresh-editor.
68pub struct CommandRegistry {
69    commands: std::sync::RwLock<Vec<Command>>,
70}
71
72impl CommandRegistry {
73    /// Create a new empty command registry
74    pub fn new() -> Self {
75        Self {
76            commands: std::sync::RwLock::new(Vec::new()),
77        }
78    }
79
80    /// Register a command
81    pub fn register(&self, command: Command) {
82        let mut commands = self.commands.write().unwrap();
83        commands.retain(|c| c.name != command.name);
84        commands.push(command);
85    }
86
87    /// Unregister a command by name
88    pub fn unregister(&self, name: &str) {
89        let mut commands = self.commands.write().unwrap();
90        commands.retain(|c| c.name != name);
91    }
92}
93
94impl Default for CommandRegistry {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100/// A callback ID for JavaScript promises in the plugin runtime.
101///
102/// This newtype distinguishes JS promise callbacks (resolved via `resolve_callback`)
103/// from Rust oneshot channel IDs (resolved via `send_plugin_response`).
104/// Using a newtype prevents accidentally mixing up these two callback mechanisms.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
106#[ts(export)]
107pub struct JsCallbackId(pub u64);
108
109impl JsCallbackId {
110    /// Create a new JS callback ID
111    pub fn new(id: u64) -> Self {
112        Self(id)
113    }
114
115    /// Get the underlying u64 value
116    pub fn as_u64(self) -> u64 {
117        self.0
118    }
119}
120
121impl From<u64> for JsCallbackId {
122    fn from(id: u64) -> Self {
123        Self(id)
124    }
125}
126
127impl From<JsCallbackId> for u64 {
128    fn from(id: JsCallbackId) -> u64 {
129        id.0
130    }
131}
132
133impl std::fmt::Display for JsCallbackId {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        write!(f, "{}", self.0)
136    }
137}
138
139/// Result of creating a terminal
140#[derive(Debug, Clone, Serialize, Deserialize, TS)]
141#[serde(rename_all = "camelCase")]
142#[ts(export, rename_all = "camelCase")]
143pub struct TerminalResult {
144    /// The created buffer ID (for use with setSplitBuffer, etc.)
145    #[ts(type = "number")]
146    pub buffer_id: u64,
147    /// The terminal ID (for use with sendTerminalInput, closeTerminal)
148    #[ts(type = "number")]
149    pub terminal_id: u64,
150    /// The split ID (if created in a new split)
151    #[ts(type = "number | null")]
152    pub split_id: Option<u64>,
153}
154
155/// Result of creating a virtual buffer
156#[derive(Debug, Clone, Serialize, Deserialize, TS)]
157#[serde(rename_all = "camelCase")]
158#[ts(export, rename_all = "camelCase")]
159pub struct VirtualBufferResult {
160    /// The created buffer ID
161    #[ts(type = "number")]
162    pub buffer_id: u64,
163    /// The split ID (if created in a new split)
164    #[ts(type = "number | null")]
165    pub split_id: Option<u64>,
166}
167
168/// A rectangular region, in cells. Used by the animation plugin API so
169/// callers can target arbitrary screen regions without going through a
170/// virtual buffer.
171#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
172#[serde(rename_all = "camelCase")]
173#[ts(export, rename_all = "camelCase")]
174pub struct AnimationRect {
175    pub x: u16,
176    pub y: u16,
177    pub width: u16,
178    pub height: u16,
179}
180
181/// Edge a slide-in effect enters from.
182#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
183#[serde(rename_all = "camelCase")]
184#[ts(export, rename_all = "camelCase")]
185pub enum PluginAnimationEdge {
186    Top,
187    Bottom,
188    Left,
189    Right,
190}
191
192/// Plugin-facing animation description. Tagged by `kind`. Additional
193/// variants can be added later; plugins must handle the `kind` they send.
194#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
195#[serde(tag = "kind", rename_all = "camelCase")]
196#[ts(export)]
197pub enum PluginAnimationKind {
198    #[serde(rename_all = "camelCase")]
199    SlideIn {
200        from: PluginAnimationEdge,
201        duration_ms: u32,
202        delay_ms: u32,
203    },
204}
205
206/// Result of creating a buffer group
207#[derive(Debug, Clone, Serialize, Deserialize, TS)]
208#[serde(rename_all = "camelCase")]
209#[ts(export, rename_all = "camelCase")]
210pub struct BufferGroupResult {
211    /// The group ID
212    #[ts(type = "number")]
213    pub group_id: u64,
214    /// Panel buffer IDs, keyed by panel name
215    #[ts(type = "Record<string, number>")]
216    pub panels: HashMap<String, u64>,
217}
218
219/// Response from the editor for async plugin operations
220#[derive(Debug, Clone, Serialize, Deserialize, TS)]
221#[ts(export)]
222pub enum PluginResponse {
223    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
224    VirtualBufferCreated {
225        request_id: u64,
226        buffer_id: BufferId,
227        split_id: Option<SplitId>,
228    },
229    /// Response to CreateTerminal with the created buffer, terminal, and split IDs
230    TerminalCreated {
231        request_id: u64,
232        buffer_id: BufferId,
233        terminal_id: TerminalId,
234        split_id: Option<SplitId>,
235    },
236    /// Response to a plugin-initiated LSP request
237    LspRequest {
238        request_id: u64,
239        #[ts(type = "any")]
240        result: Result<JsonValue, String>,
241    },
242    /// Response to RequestHighlights
243    HighlightsComputed {
244        request_id: u64,
245        spans: Vec<TsHighlightSpan>,
246    },
247    /// Response to GetBufferText with the text content
248    BufferText {
249        request_id: u64,
250        text: Result<String, String>,
251    },
252    /// Response to GetLineStartPosition with the byte offset
253    LineStartPosition {
254        request_id: u64,
255        /// None if line is out of range, Some(offset) for valid line
256        position: Option<usize>,
257    },
258    /// Response to GetLineEndPosition with the byte offset
259    LineEndPosition {
260        request_id: u64,
261        /// None if line is out of range, Some(offset) for valid line
262        position: Option<usize>,
263    },
264    /// Response to GetBufferLineCount with the total number of lines
265    BufferLineCount {
266        request_id: u64,
267        /// None if buffer not found, Some(count) for valid buffer
268        count: Option<usize>,
269    },
270    /// Response to CreateCompositeBuffer with the created buffer ID
271    CompositeBufferCreated {
272        request_id: u64,
273        buffer_id: BufferId,
274    },
275    /// Response to GetSplitByLabel with the found split ID (if any)
276    SplitByLabel {
277        request_id: u64,
278        split_id: Option<SplitId>,
279    },
280    /// Response to `WatchPath`. `handle` is the editor's stable
281    /// id for this watcher, used both as the cancellation token
282    /// for `UnwatchPath` and as the routing key in
283    /// `path_changed` event payloads. `Err` indicates the watcher
284    /// could not be installed (path missing, kernel limit, etc.).
285    WatchPathRegistered {
286        request_id: u64,
287        result: Result<u64, String>,
288    },
289}
290
291impl PluginResponse {
292    pub fn request_id(&self) -> u64 {
293        match self {
294            Self::VirtualBufferCreated { request_id, .. }
295            | Self::TerminalCreated { request_id, .. }
296            | Self::LspRequest { request_id, .. }
297            | Self::HighlightsComputed { request_id, .. }
298            | Self::BufferText { request_id, .. }
299            | Self::LineStartPosition { request_id, .. }
300            | Self::LineEndPosition { request_id, .. }
301            | Self::BufferLineCount { request_id, .. }
302            | Self::CompositeBufferCreated { request_id, .. }
303            | Self::SplitByLabel { request_id, .. }
304            | Self::WatchPathRegistered { request_id, .. } => *request_id,
305        }
306    }
307}
308
309/// Messages sent from async plugin tasks to the synchronous main loop
310#[derive(Debug, Clone, Serialize, Deserialize, TS)]
311#[ts(export)]
312pub enum PluginAsyncMessage {
313    /// Plugin process completed with output
314    ProcessOutput {
315        /// Unique ID for this process
316        process_id: u64,
317        /// Standard output
318        stdout: String,
319        /// Standard error
320        stderr: String,
321        /// Exit code
322        exit_code: i32,
323    },
324    /// Plugin delay/timer completed
325    DelayComplete {
326        /// Callback ID to resolve
327        callback_id: u64,
328    },
329    /// Background process stdout data
330    ProcessStdout { process_id: u64, data: String },
331    /// Background process stderr data
332    ProcessStderr { process_id: u64, data: String },
333    /// Background process exited
334    ProcessExit {
335        process_id: u64,
336        callback_id: u64,
337        exit_code: i32,
338    },
339    /// Response for a plugin-initiated LSP request
340    LspResponse {
341        language: String,
342        request_id: u64,
343        #[ts(type = "any")]
344        result: Result<JsonValue, String>,
345    },
346    /// Generic plugin response (e.g., GetBufferText result)
347    PluginResponse(crate::api::PluginResponse),
348}
349
350/// Information about a cursor in the editor
351#[derive(Debug, Clone, Serialize, Deserialize, TS)]
352#[ts(export)]
353pub struct CursorInfo {
354    /// Byte position of the cursor
355    pub position: usize,
356    /// Selection range (if any)
357    #[cfg_attr(
358        feature = "plugins",
359        ts(type = "{ start: number; end: number } | null")
360    )]
361    pub selection: Option<Range<usize>>,
362    /// 0-indexed line number of the cursor. `null` when the line index is
363    /// unavailable — e.g. a huge file whose line scan hasn't completed, where
364    /// the editor positions purely by byte offset. Plugins must treat `null`
365    /// as "unknown", never as line 0.
366    #[serde(default)]
367    pub line: Option<usize>,
368}
369
370/// Specification for an action to execute, with optional repeat count
371#[derive(Debug, Clone, Serialize, Deserialize, TS)]
372#[serde(deny_unknown_fields)]
373#[ts(export)]
374pub struct ActionSpec {
375    /// Action name (e.g., "move_word_right", "delete_line")
376    pub action: String,
377    /// Number of times to repeat the action (default 1)
378    #[serde(default = "default_action_count")]
379    pub count: u32,
380}
381
382fn default_action_count() -> u32 {
383    1
384}
385
386/// `serde(default)` fallback for `EditorStateSnapshot.active_window_id`
387/// — old serialized snapshots predate the field. Falls back to the
388/// always-present base session (id 1).
389fn default_window_id() -> WindowId {
390    WindowId(1)
391}
392
393/// Information about an editor session (plugin-visible). Returned
394/// by `editor.listWindows()` and carried in the snapshot. Mirrors
395/// the editor-side `Session` struct — see
396/// `crates/fresh-editor/src/app/session.rs` and
397/// `docs/internal/orchestrator-sessions-design.md`.
398#[derive(Debug, Clone, Serialize, Deserialize, TS)]
399#[ts(export)]
400pub struct WindowInfo {
401    /// Stable session id. The base session is always `1`.
402    #[ts(type = "number")]
403    pub id: WindowId,
404    /// User-visible label (defaults to root basename).
405    pub label: String,
406    /// Absolute project root.
407    #[ts(type = "string")]
408    pub root: PathBuf,
409    /// Project this session belongs to — the canonical repo
410    /// root (or arbitrary directory) the user pointed the
411    /// new-session form at. `null` for legacy sessions that
412    /// predate the Project Path field. The Orchestrator Open
413    /// dialog filters by this so the "this project's sessions"
414    /// view is one keystroke away from the all-projects view.
415    #[ts(type = "string | null")]
416    #[serde(skip_serializing_if = "Option::is_none", default)]
417    pub project_path: Option<PathBuf>,
418    /// `true` when the session shares its working tree with
419    /// other sessions (worktree-creation was off at session
420    /// time, or the session lives in a non-git directory).
421    /// Persistence-only field; defaults to `false` and isn't
422    /// emitted when false.
423    #[ts(type = "boolean")]
424    #[serde(skip_serializing_if = "is_false_field", default)]
425    pub shared_worktree: bool,
426}
427
428fn is_false_field(b: &bool) -> bool {
429    !b
430}
431
432/// Information about a buffer
433#[derive(Debug, Clone, Serialize, Deserialize, TS)]
434#[ts(export)]
435pub struct BufferInfo {
436    /// Buffer ID
437    #[ts(type = "number")]
438    pub id: BufferId,
439    /// File path (if any)
440    #[serde(serialize_with = "serialize_path")]
441    #[ts(type = "string")]
442    pub path: Option<PathBuf>,
443    /// Whether the buffer has been modified
444    pub modified: bool,
445    /// Length of buffer in bytes
446    pub length: usize,
447    /// Whether this is a virtual buffer (not backed by a file)
448    pub is_virtual: bool,
449    /// Current view mode of the active split: "source" or "compose"
450    pub view_mode: String,
451    /// True if any split showing this buffer has compose mode enabled.
452    /// Plugins should use this (not `view_mode`) to decide whether to maintain
453    /// decorations, since decorations live on the buffer and are filtered
454    /// per-split at render time.
455    pub is_composing_in_any_split: bool,
456    /// Compose width (if set), from the active split's view state
457    pub compose_width: Option<u16>,
458    /// The detected language for this buffer (e.g., "rust", "markdown", "text")
459    pub language: String,
460    /// Whether this tab was opened in "preview" (ephemeral) mode — true when
461    /// opened via single-click in the file explorer and not yet committed
462    /// (no edit, no double-click, no tab-click, no layout change). Plugins
463    /// that react to buffer lifecycle events should generally treat preview
464    /// buffers as transient; e.g. a diagnostics panel may want to skip
465    /// refreshing itself for a preview tab.
466    #[serde(default)]
467    pub is_preview: bool,
468    /// Split ids that currently hold this buffer (empty when the buffer is
469    /// open but not visible in any split — e.g. background-opened tabs
470    /// that haven't been focused). Lets plugins implement "focus existing
471    /// buffer if visible, else open new" without having to track split
472    /// ids across editor restarts (which reassign them). The list is a
473    /// snapshot at the last `update_plugin_state_snapshot` tick.
474    #[serde(default)]
475    #[ts(type = "number[]")]
476    pub splits: Vec<SplitId>,
477}
478
479fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
480    s.serialize_str(
481        &path
482            .as_ref()
483            .map(|p| p.to_string_lossy().to_string())
484            .unwrap_or_default(),
485    )
486}
487
488/// Serialize ranges as [start, end] tuples for JS compatibility
489fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
490where
491    S: serde::Serializer,
492{
493    use serde::ser::SerializeSeq;
494    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
495    for range in ranges {
496        seq.serialize_element(&(range.start, range.end))?;
497    }
498    seq.end()
499}
500
501/// Diff between current buffer content and last saved snapshot
502#[derive(Debug, Clone, Serialize, Deserialize, TS)]
503#[ts(export)]
504pub struct BufferSavedDiff {
505    pub equal: bool,
506    #[serde(serialize_with = "serialize_ranges_as_tuples")]
507    #[ts(type = "Array<[number, number]>")]
508    pub byte_ranges: Vec<Range<usize>>,
509}
510
511/// Information about the viewport
512#[derive(Debug, Clone, Serialize, Deserialize, TS)]
513#[serde(rename_all = "camelCase")]
514#[ts(export, rename_all = "camelCase")]
515pub struct ViewportInfo {
516    /// Byte position of the first visible line
517    pub top_byte: usize,
518    /// Line number of the first visible line (None when line index unavailable, e.g. large file before scan)
519    pub top_line: Option<usize>,
520    /// Left column offset (horizontal scroll)
521    pub left_column: usize,
522    /// Viewport width
523    pub width: u16,
524    /// Viewport height
525    pub height: u16,
526}
527
528/// Per-split state surfaced to plugins via `editor.listSplits()`.
529///
530/// Plugins that need to operate on every visible buffer (multi-split
531/// flash labels, syncing decorations across panes, ...) can iterate
532/// this list rather than only seeing the active split's `getViewport()`.
533#[derive(Debug, Clone, Serialize, Deserialize, TS)]
534#[serde(rename_all = "camelCase")]
535#[ts(export, rename_all = "camelCase")]
536pub struct SplitSnapshot {
537    /// Stable split identifier; matches the values used by
538    /// `setSplitBuffer`, `focusSplit`, `getSplitByLabel`, etc.
539    pub split_id: usize,
540    /// Buffer currently shown in this split.
541    pub buffer_id: BufferId,
542    /// Viewport (top byte / dimensions) for this split's active buffer.
543    pub viewport: ViewportInfo,
544}
545
546/// Payload delivered to a plugin's `editor.getNextKey()` Promise when
547/// the next keypress arrives in the editor's input dispatch.
548///
549/// `key` uses the same naming as `defineMode` bindings: lowercase
550/// names like `"escape"`, `"enter"`, `"tab"`, `"space"`, `"left"`,
551/// `"f1"`–`"f12"`, or a single character (e.g. `"a"`, `"!"`).
552/// Modifier flags are reported separately so plugins can recognise
553/// chord variants without parsing.
554#[derive(Debug, Clone, Serialize, Deserialize, TS)]
555#[serde(rename_all = "camelCase")]
556#[ts(export, rename_all = "camelCase")]
557pub struct KeyEventPayload {
558    /// Key name (e.g. `"a"`, `"escape"`, `"f1"`).
559    pub key: String,
560    /// Ctrl held.
561    pub ctrl: bool,
562    /// Alt held.
563    pub alt: bool,
564    /// Shift held (only meaningful for non-character keys; for
565    /// printable characters the case is already encoded in `key`).
566    pub shift: bool,
567    /// Super / Cmd / Meta held.
568    pub meta: bool,
569}
570
571/// Layout hints supplied by plugins (e.g., Compose mode)
572#[derive(Debug, Clone, Serialize, Deserialize, TS)]
573#[serde(rename_all = "camelCase")]
574#[ts(export, rename_all = "camelCase")]
575pub struct LayoutHints {
576    /// Optional compose width for centering/wrapping
577    #[ts(optional)]
578    pub compose_width: Option<u16>,
579    /// Optional column guides for aligned tables
580    #[ts(optional)]
581    pub column_guides: Option<Vec<u16>>,
582}
583
584// ============================================================================
585// Overlay Types with Theme Support
586// ============================================================================
587
588/// Color specification that can be either RGB values or a theme key.
589///
590/// Theme keys reference colors from the current theme, e.g.:
591/// - "ui.status_bar_bg" - UI status bar background
592/// - "editor.selection_bg" - Editor selection background
593/// - "syntax.keyword" - Syntax highlighting for keywords
594/// - "diagnostic.error" - Error diagnostic color
595///
596/// When a theme key is used, the color is resolved at render time,
597/// so overlays automatically update when the theme changes.
598#[derive(Debug, Clone, Serialize, Deserialize, TS)]
599#[serde(untagged)]
600#[ts(export)]
601pub enum OverlayColorSpec {
602    /// RGB color as [r, g, b] array
603    #[ts(type = "[number, number, number]")]
604    Rgb(u8, u8, u8),
605    /// Theme key reference (e.g., "ui.status_bar_bg")
606    ThemeKey(String),
607}
608
609/// Modifier-only overlay applied to a byte range within a virtual line's
610/// text. Used by plugins (live-diff) to bold + underline removed words on
611/// a deletion virtual line without varying the line's overall fg/bg.
612#[derive(Debug, Clone, Serialize, Deserialize, TS)]
613#[serde(rename_all = "camelCase")]
614#[ts(export, rename_all = "camelCase")]
615pub struct VirtualLineTextOverlay {
616    /// Inclusive byte offset within the virtual line's `text`.
617    pub start: u32,
618    /// Exclusive byte offset within the virtual line's `text`.
619    pub end: u32,
620    #[serde(default)]
621    pub bold: bool,
622    #[serde(default)]
623    pub underline: bool,
624}
625
626impl OverlayColorSpec {
627    /// Create an RGB color spec
628    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
629        Self::Rgb(r, g, b)
630    }
631
632    /// Create a theme key color spec
633    pub fn theme_key(key: impl Into<String>) -> Self {
634        Self::ThemeKey(key.into())
635    }
636
637    /// Convert to RGB if this is an RGB spec, None if it's a theme key
638    pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
639        match self {
640            Self::Rgb(r, g, b) => Some((*r, *g, *b)),
641            Self::ThemeKey(_) => None,
642        }
643    }
644
645    /// Get the theme key if this is a theme key spec
646    pub fn as_theme_key(&self) -> Option<&str> {
647        match self {
648            Self::ThemeKey(key) => Some(key),
649            Self::Rgb(_, _, _) => None,
650        }
651    }
652}
653
654/// Options for adding an overlay with theme support.
655///
656/// This struct provides a type-safe way to specify overlay styling
657/// with optional theme key references for colors.
658#[derive(Debug, Clone, Serialize, Deserialize, TS)]
659#[serde(deny_unknown_fields, rename_all = "camelCase")]
660#[ts(export, rename_all = "camelCase")]
661#[derive(Default)]
662pub struct OverlayOptions {
663    /// Foreground color - RGB array or theme key string
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub fg: Option<OverlayColorSpec>,
666
667    /// Background color - RGB array or theme key string
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub bg: Option<OverlayColorSpec>,
670
671    /// Whether to render with underline
672    #[serde(default)]
673    pub underline: bool,
674
675    /// Whether to render in bold
676    #[serde(default)]
677    pub bold: bool,
678
679    /// Whether to render in italic
680    #[serde(default)]
681    pub italic: bool,
682
683    /// Whether to render with strikethrough
684    #[serde(default)]
685    pub strikethrough: bool,
686
687    /// Whether to extend background color to end of line
688    #[serde(default)]
689    pub extend_to_line_end: bool,
690
691    /// When `true`, `fg` is applied only on cells whose existing fg
692    /// matches this overlay's resolved bg — i.e. a same-colour fg/bg
693    /// collision. Lets a row-wide overlay stay legible on tokens that
694    /// share the bg's colour without repainting unrelated tokens.
695    #[serde(default)]
696    pub fg_on_collision_only: bool,
697
698    /// Optional URL for OSC 8 terminal hyperlinks.
699    /// When set, the overlay text becomes a clickable hyperlink in terminals
700    /// that support OSC 8 escape sequences.
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    pub url: Option<String>,
703}
704
705/// A run of text with optional styling. `style` reuses
706/// [`OverlayOptions`] — the same primitive plugins use for virtual
707/// text — so a hint is just `{ text: "Alt+P cycle", style: { fg:
708/// "ui.help_key_fg" } }`. `None` style means "no styling override";
709/// each consumer applies its own default (e.g. the floating-prompt
710/// title uses `prompt_fg` + bold).
711#[derive(Debug, Clone, Serialize, Deserialize, TS)]
712#[serde(deny_unknown_fields, rename_all = "camelCase")]
713#[ts(export, rename_all = "camelCase")]
714pub struct StyledText {
715    pub text: String,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    #[ts(optional, type = "Partial<OverlayOptions>")]
718    pub style: Option<OverlayOptions>,
719}
720
721#[cfg(feature = "plugins")]
722impl<'js> rquickjs::FromJs<'js> for StyledText {
723    fn from_js(_ctx: &rquickjs::Ctx<'js>, value: rquickjs::Value<'js>) -> rquickjs::Result<Self> {
724        rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
725            from: "object",
726            to: "StyledText",
727            message: Some(e.to_string()),
728        })
729    }
730}
731
732/// One candidate row in a Text widget's completion popup. `value` is
733/// what gets sent back to the plugin as the `completion_accept`
734/// payload when the user picks the row. `kind` is an optional
735/// presentation hint the renderer reads to style certain rows
736/// differently from the rest — e.g. `"history"` rows render with
737/// a leading marker glyph + italic so the user can tell at-a-glance
738/// that the entry came from their submission history rather than
739/// from the live completion source. `None` is the default "regular"
740/// candidate.
741///
742/// Serializes from JS either as a bare string (treated as
743/// `{ value: <string>, kind: null }` for the legacy
744/// `string[]` setCompletions signature) or as a full object.
745#[derive(Debug, Clone, Serialize, Deserialize, TS)]
746#[serde(deny_unknown_fields, rename_all = "camelCase")]
747#[ts(export, rename_all = "camelCase")]
748pub struct CompletionItem {
749    pub value: String,
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    #[ts(optional)]
752    pub kind: Option<String>,
753}
754
755impl From<String> for CompletionItem {
756    fn from(value: String) -> Self {
757        Self { value, kind: None }
758    }
759}
760
761impl From<&str> for CompletionItem {
762    fn from(value: &str) -> Self {
763        Self {
764            value: value.to_string(),
765            kind: None,
766        }
767    }
768}
769
770/// Custom deserializer module that accepts either a `Vec<String>`
771/// (legacy bare-string completions) or a `Vec<CompletionItem>` (new
772/// typed shape). Lets plugins call `setCompletions(key, ["a", "b"])`
773/// and `setCompletions(key, [{ value: "a", kind: "history" }])`
774/// interchangeably.
775pub mod completion_items_serde {
776    use super::CompletionItem;
777    use serde::{Deserialize, Deserializer, Serialize, Serializer};
778
779    #[derive(Deserialize)]
780    #[serde(untagged)]
781    enum Either {
782        Bare(String),
783        Typed(CompletionItem),
784    }
785
786    pub fn serialize<S>(items: &[CompletionItem], s: S) -> Result<S::Ok, S::Error>
787    where
788        S: Serializer,
789    {
790        items.serialize(s)
791    }
792
793    pub fn deserialize<'de, D>(d: D) -> Result<Vec<CompletionItem>, D::Error>
794    where
795        D: Deserializer<'de>,
796    {
797        let raw: Vec<Either> = Vec::deserialize(d)?;
798        Ok(raw
799            .into_iter()
800            .map(|e| match e {
801                Either::Bare(s) => CompletionItem {
802                    value: s,
803                    kind: None,
804                },
805                Either::Typed(item) => item,
806            })
807            .collect())
808    }
809}
810
811// ============================================================================
812// Composite Buffer Configuration (for multi-buffer single-tab views)
813// ============================================================================
814
815/// Layout configuration for composite buffers
816#[derive(Debug, Clone, Serialize, Deserialize, TS)]
817#[serde(deny_unknown_fields)]
818#[ts(export, rename = "TsCompositeLayoutConfig")]
819pub struct CompositeLayoutConfig {
820    /// Layout type: "side-by-side", "stacked", or "unified"
821    #[serde(rename = "type")]
822    #[ts(rename = "type")]
823    pub layout_type: String,
824    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
825    #[serde(default)]
826    #[ts(optional)]
827    pub ratios: Option<Vec<f32>>,
828    /// Show separator between panes
829    #[serde(default = "default_true", rename = "showSeparator")]
830    #[ts(rename = "showSeparator")]
831    pub show_separator: bool,
832    /// Spacing for stacked layout
833    #[serde(default)]
834    #[ts(optional)]
835    pub spacing: Option<u16>,
836}
837
838fn default_true() -> bool {
839    true
840}
841
842/// Source pane configuration for composite buffers
843#[derive(Debug, Clone, Serialize, Deserialize, TS)]
844#[serde(deny_unknown_fields)]
845#[ts(export, rename = "TsCompositeSourceConfig")]
846pub struct CompositeSourceConfig {
847    /// Buffer ID of the source buffer (required)
848    #[serde(rename = "bufferId")]
849    #[ts(rename = "bufferId")]
850    pub buffer_id: usize,
851    /// Label for this pane (e.g., "OLD", "NEW")
852    pub label: String,
853    /// Whether this pane is editable
854    #[serde(default)]
855    pub editable: bool,
856    /// Style configuration
857    #[serde(default)]
858    pub style: Option<CompositePaneStyle>,
859}
860
861/// Style configuration for a composite pane
862#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
863#[serde(deny_unknown_fields)]
864#[ts(export, rename = "TsCompositePaneStyle")]
865pub struct CompositePaneStyle {
866    /// Background color for added lines (RGB)
867    /// Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
868    #[serde(default, rename = "addBg")]
869    #[ts(optional, rename = "addBg", type = "[number, number, number]")]
870    pub add_bg: Option<[u8; 3]>,
871    /// Background color for removed lines (RGB)
872    #[serde(default, rename = "removeBg")]
873    #[ts(optional, rename = "removeBg", type = "[number, number, number]")]
874    pub remove_bg: Option<[u8; 3]>,
875    /// Background color for modified lines (RGB)
876    #[serde(default, rename = "modifyBg")]
877    #[ts(optional, rename = "modifyBg", type = "[number, number, number]")]
878    pub modify_bg: Option<[u8; 3]>,
879    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
880    #[serde(default, rename = "gutterStyle")]
881    #[ts(optional, rename = "gutterStyle")]
882    pub gutter_style: Option<String>,
883}
884
885/// Diff hunk for composite buffer alignment
886#[derive(Debug, Clone, Serialize, Deserialize, TS)]
887#[serde(deny_unknown_fields)]
888#[ts(export, rename = "TsCompositeHunk")]
889pub struct CompositeHunk {
890    /// Starting line in old buffer (0-indexed)
891    #[serde(rename = "oldStart")]
892    #[ts(rename = "oldStart")]
893    pub old_start: usize,
894    /// Number of lines in old buffer
895    #[serde(rename = "oldCount")]
896    #[ts(rename = "oldCount")]
897    pub old_count: usize,
898    /// Starting line in new buffer (0-indexed)
899    #[serde(rename = "newStart")]
900    #[ts(rename = "newStart")]
901    pub new_start: usize,
902    /// Number of lines in new buffer
903    #[serde(rename = "newCount")]
904    #[ts(rename = "newCount")]
905    pub new_count: usize,
906}
907
908/// Options for creating a composite buffer (used by plugin API)
909#[derive(Debug, Clone, Serialize, Deserialize, TS)]
910#[serde(deny_unknown_fields)]
911#[ts(export, rename = "TsCreateCompositeBufferOptions")]
912pub struct CreateCompositeBufferOptions {
913    /// Buffer name (displayed in tabs/title)
914    #[serde(default)]
915    pub name: String,
916    /// Mode for keybindings
917    #[serde(default)]
918    pub mode: String,
919    /// Layout configuration
920    pub layout: CompositeLayoutConfig,
921    /// Source pane configurations
922    pub sources: Vec<CompositeSourceConfig>,
923    /// Diff hunks for alignment (optional)
924    #[serde(default)]
925    pub hunks: Option<Vec<CompositeHunk>>,
926    /// When set, the first render will scroll to center the Nth hunk (0-indexed).
927    /// This avoids timing issues with imperative scroll commands that depend on
928    /// render-created state (viewport dimensions, view state).
929    #[serde(default, rename = "initialFocusHunk")]
930    #[ts(optional, rename = "initialFocusHunk")]
931    pub initial_focus_hunk: Option<usize>,
932}
933
934/// Wire-format view token kind (serialized for plugin transforms)
935#[derive(Debug, Clone, Serialize, Deserialize, TS)]
936#[ts(export)]
937pub enum ViewTokenWireKind {
938    Text(String),
939    Newline,
940    Space,
941    /// Visual line break inserted by wrapping (not from source)
942    /// Always has source_offset: None
943    Break,
944    /// A single binary byte that should be rendered as <XX>
945    /// Used in binary file mode to ensure cursor positioning works correctly
946    /// (all 4 display chars of <XX> map to the same source byte)
947    BinaryByte(u8),
948}
949
950/// Color carried by a `ViewTokenStyle`. Untagged so JSON plugins can
951/// keep passing `[r, g, b]` arrays, while richer themes can use named
952/// ANSI colors (`"Red"`, `"LightGreen"`, `"Default"`) or theme keys
953/// (`"editor.diff_remove_bg"`). The renderer resolves named/theme
954/// strings against the active theme at draw time; unknown strings
955/// fall through to the terminal's default color.
956///
957/// `Color::Indexed(N)` round-trips through the `"Indexed:N"` form so
958/// 256-color values from a ratatui `Color` survive the
959/// `ViewTokenStyle` boundary.
960#[derive(Debug, Clone, Serialize, Deserialize, TS)]
961#[serde(untagged)]
962#[ts(export)]
963pub enum TokenColor {
964    /// RGB color as [r, g, b] array
965    #[ts(type = "[number, number, number]")]
966    Rgb(u8, u8, u8),
967    /// Named ANSI color, `"Default"`, `"Indexed:N"`, or a theme key.
968    Named(String),
969}
970
971/// Styling for view tokens (used for injected annotations)
972///
973/// This allows plugins to specify styling for tokens that don't have a source
974/// mapping (sourceOffset: None), such as annotation headers in git blame.
975/// For tokens with sourceOffset: Some(_), syntax highlighting is applied instead.
976#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
977#[serde(deny_unknown_fields)]
978#[ts(export)]
979pub struct ViewTokenStyle {
980    /// Foreground color. Either `[r, g, b]` or a named/theme string —
981    /// see [`TokenColor`].
982    #[serde(default)]
983    pub fg: Option<TokenColor>,
984    /// Background color. Either `[r, g, b]` or a named/theme string —
985    /// see [`TokenColor`].
986    #[serde(default)]
987    pub bg: Option<TokenColor>,
988    /// Whether to render in bold
989    #[serde(default)]
990    pub bold: bool,
991    /// Whether to render in italic
992    #[serde(default)]
993    pub italic: bool,
994    /// Whether to render with underline
995    #[serde(default)]
996    pub underline: bool,
997}
998
999/// Wire-format view token with optional source mapping and styling
1000#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1001#[serde(deny_unknown_fields)]
1002#[ts(export)]
1003pub struct ViewTokenWire {
1004    /// Source byte offset in the buffer. None for injected content (annotations).
1005    #[ts(type = "number | null")]
1006    pub source_offset: Option<usize>,
1007    /// The token content
1008    pub kind: ViewTokenWireKind,
1009    /// Optional styling for injected content (only used when source_offset is None)
1010    #[serde(default, skip_serializing_if = "Option::is_none")]
1011    #[ts(optional)]
1012    pub style: Option<ViewTokenStyle>,
1013}
1014
1015/// Transformed view stream payload (plugin-provided)
1016#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1017#[ts(export)]
1018pub struct ViewTransformPayload {
1019    /// Byte range this transform applies to (viewport)
1020    pub range: Range<usize>,
1021    /// Tokens in wire format
1022    pub tokens: Vec<ViewTokenWire>,
1023    /// Layout hints
1024    pub layout_hints: Option<LayoutHints>,
1025}
1026
1027/// Snapshot of editor state for plugin queries
1028/// This is updated by the editor on each loop iteration
1029#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1030#[ts(export)]
1031pub struct EditorStateSnapshot {
1032    /// Currently active buffer ID
1033    pub active_buffer_id: BufferId,
1034    /// Currently active split ID
1035    pub active_split_id: usize,
1036    /// Information about all open buffers
1037    pub buffers: HashMap<BufferId, BufferInfo>,
1038    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
1039    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
1040    /// Primary cursor position for the active buffer
1041    pub primary_cursor: Option<CursorInfo>,
1042    /// All cursor positions for the active buffer
1043    pub all_cursors: Vec<CursorInfo>,
1044    /// Viewport information for the active buffer
1045    pub viewport: Option<ViewportInfo>,
1046    /// Per-split snapshots: split id, buffer shown, viewport.
1047    /// Includes the active split.  Order is unspecified.
1048    #[serde(default)]
1049    pub splits: Vec<SplitSnapshot>,
1050    /// Cursor positions per buffer (for buffers other than active)
1051    pub buffer_cursor_positions: HashMap<BufferId, usize>,
1052    /// Text properties per buffer (for virtual buffers with properties)
1053    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
1054    /// Selected text from the primary cursor (if any selection exists)
1055    /// This is populated on each update to avoid needing full buffer access
1056    pub selected_text: Option<String>,
1057    /// Internal clipboard content (for plugins that need clipboard access)
1058    pub clipboard: String,
1059    /// Editor's working directory (for file operations and spawning processes).
1060    ///
1061    /// Equal to `sessions[i].root` where `sessions[i].id == active_window_id`.
1062    /// Plugins that just need "where am I" can read this directly; plugins
1063    /// orchestrating multiple sessions (Orchestrator) iterate `sessions`.
1064    pub working_dir: PathBuf,
1065    /// All editor sessions, in id order. Always non-empty (the base
1066    /// session is `id == 1`). Updated when sessions are
1067    /// created/closed or relabelled.
1068    #[serde(default)]
1069    pub windows: Vec<WindowInfo>,
1070    /// Id of the currently active session. Always present in
1071    /// `sessions`. Read by plugins via `editor.activeWindow()`.
1072    #[serde(default = "default_window_id")]
1073    pub active_window_id: WindowId,
1074    /// Status-bar / explorer label for the active authority.
1075    ///
1076    /// Empty = the local (default) authority with nothing to render.
1077    /// Non-empty means a non-local authority is installed (e.g.
1078    /// `"Container:abc123def456"` for a devcontainer). Plugins can
1079    /// read this via `editor.getAuthorityLabel()` to detect "already
1080    /// attached" without having to track state across editor restarts.
1081    #[serde(default)]
1082    pub authority_label: String,
1083    /// Current Workspace Trust level for the active project: `"restricted"`,
1084    /// `"trusted"`, or `"blocked"`. Empty when trust state is unavailable
1085    /// (e.g. the default local authority before a guarded one is installed).
1086    /// Plugins that run repo-controlled work read this via
1087    /// `editor.workspaceTrustLevel()` and should treat anything other than
1088    /// `"trusted"` as "do not execute".
1089    #[serde(default)]
1090    pub workspace_trust_level: String,
1091    /// Whether an environment is currently active (the env-manager has set a
1092    /// recipe via `editor.setEnv`). Plugins read this via `editor.envActive()`
1093    /// to reflect activation in the status bar and re-establish file watches
1094    /// after the restart that activation triggers.
1095    #[serde(default)]
1096    pub env_active: bool,
1097    /// LSP diagnostics per file URI.
1098    /// Maps file URI string to Vec of diagnostics for that file.
1099    ///
1100    /// Wrapped in `Arc` so snapshot refresh is a refcount bump rather than
1101    /// a deep clone. The editor only mutates its own map through
1102    /// `Arc::make_mut`, which CoW-clones while this snapshot still holds
1103    /// a reference — a reader can never observe an in-place mutation.
1104    ///
1105    /// `#[serde(skip)]`: serde out-of-the-box can't serialize `Arc<T>`
1106    /// (behind the `rc` cargo feature we don't enable). We never serialize
1107    /// the snapshot as a whole — plugin readers pull out these Arcs and
1108    /// serialize the *inner* value directly (e.g. `get_all_diagnostics`).
1109    #[serde(skip)]
1110    #[ts(type = "any")]
1111    pub diagnostics: Arc<HashMap<String, Vec<lsp_types::Diagnostic>>>,
1112    /// LSP folding ranges per file URI.
1113    /// Maps file URI string to Vec of folding ranges for that file.
1114    /// Arc-wrapped for the same CoW invariant as `diagnostics`; see that
1115    /// field for why this is `#[serde(skip)]`.
1116    #[serde(skip)]
1117    #[ts(type = "any")]
1118    pub folding_ranges: Arc<HashMap<String, Vec<lsp_types::FoldingRange>>>,
1119    /// Runtime config as serde_json::Value (merged user config + defaults).
1120    /// This is the runtime config, not just the user's config file.
1121    ///
1122    /// Wrapped in `Arc` so the snapshot update is a refcount bump. The
1123    /// editor reserializes its source `Config` only when the underlying
1124    /// `Arc<Config>` pointer has moved (i.e., after a real mutation), and
1125    /// swaps the whole `Arc<Value>` atomically — callers never see a
1126    /// partially-updated blob. `#[serde(skip)]` for the same reason as
1127    /// `diagnostics`.
1128    #[serde(skip)]
1129    #[ts(type = "any")]
1130    pub config: Arc<serde_json::Value>,
1131    /// User config as serde_json::Value (only what's in the user's config file).
1132    /// Fields not present here are using default values.
1133    /// Arc-wrapped; swapped as a whole when the user's file is reloaded.
1134    /// `#[serde(skip)]` for the same reason as `diagnostics`.
1135    #[serde(skip)]
1136    #[ts(type = "any")]
1137    pub user_config: Arc<serde_json::Value>,
1138    /// Available grammars with provenance info, updated when grammar registry changes
1139    #[ts(type = "GrammarInfo[]")]
1140    pub available_grammars: Vec<GrammarInfoSnapshot>,
1141    /// Last-seen grammar registry generation. The state-snapshot updater
1142    /// rebuilds `available_grammars` only when this disagrees with the
1143    /// registry's current `catalog_gen()`. `#[serde(skip)]` because the
1144    /// counter is a host-side detail not exposed to plugins.
1145    #[serde(skip)]
1146    #[ts(skip)]
1147    pub last_grammar_gen: u64,
1148    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
1149    /// When set, this mode's keybindings take precedence over normal key handling
1150    pub editor_mode: Option<String>,
1151
1152    /// Plugin-managed per-buffer view state for the active split.
1153    /// Updated from BufferViewState.plugin_state during snapshot updates.
1154    /// Also written directly by JS plugins via setViewState for immediate read-back.
1155    #[ts(type = "any")]
1156    pub plugin_view_states: HashMap<BufferId, HashMap<String, serde_json::Value>>,
1157
1158    /// Tracks which split was active when plugin_view_states was last populated.
1159    /// When the active split changes, plugin_view_states is fully repopulated.
1160    #[serde(skip)]
1161    #[ts(skip)]
1162    pub plugin_view_states_split: usize,
1163
1164    /// Keybinding labels for plugin modes, keyed by "action\0mode" for fast lookup.
1165    /// Updated when modes are registered via defineMode().
1166    #[serde(skip)]
1167    #[ts(skip)]
1168    pub keybinding_labels: HashMap<String, String>,
1169
1170    /// Plugin-managed global state, isolated per plugin.
1171    /// Outer key is plugin name, inner key is the state key set by the plugin.
1172    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
1173    /// Currently we isolate by plugin name, but we may want a more robust approach
1174    /// (e.g. preventing plugins from reading each other's state, or providing
1175    /// explicit cross-plugin state sharing APIs).
1176    #[ts(type = "any")]
1177    pub plugin_global_states: HashMap<String, HashMap<String, serde_json::Value>>,
1178
1179    /// Plugin-managed per-session state, snapshotted as the
1180    /// **active** session's plugin_state map. Updated wholesale
1181    /// on `setActiveWindow` (alongside the rest of the
1182    /// per-session state) — plugins that read this via
1183    /// `editor.getWindowState(key)` see the active session's
1184    /// values without crossing the IPC boundary on every read.
1185    /// Outer key is plugin name, inner is the plugin-defined key.
1186    #[serde(default)]
1187    #[ts(type = "any")]
1188    pub active_session_plugin_states: HashMap<String, HashMap<String, serde_json::Value>>,
1189
1190    /// Total terminal dimensions in cells. Refreshed on every
1191    /// resize event. Plugins read this via `editor.getScreenSize()`
1192    /// when they need to size floating overlays against the whole
1193    /// terminal — `getViewport()` only reports the active split,
1194    /// which is smaller than the screen whenever splits exist.
1195    #[serde(default)]
1196    pub terminal_width: u16,
1197    #[serde(default)]
1198    pub terminal_height: u16,
1199}
1200
1201/// Total terminal size in cells. Returned by `editor.getScreenSize()`.
1202#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
1203#[serde(rename_all = "camelCase")]
1204#[ts(export, rename_all = "camelCase")]
1205pub struct ScreenSize {
1206    pub width: u16,
1207    pub height: u16,
1208}
1209
1210impl EditorStateSnapshot {
1211    pub fn new() -> Self {
1212        Self {
1213            active_buffer_id: BufferId(0),
1214            active_split_id: 0,
1215            buffers: HashMap::new(),
1216            buffer_saved_diffs: HashMap::new(),
1217            primary_cursor: None,
1218            all_cursors: Vec::new(),
1219            viewport: None,
1220            splits: Vec::new(),
1221            buffer_cursor_positions: HashMap::new(),
1222            buffer_text_properties: HashMap::new(),
1223            selected_text: None,
1224            clipboard: String::new(),
1225            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
1226            windows: Vec::new(),
1227            active_window_id: WindowId(1),
1228            authority_label: String::new(),
1229            workspace_trust_level: String::new(),
1230            env_active: false,
1231            diagnostics: Arc::new(HashMap::new()),
1232            folding_ranges: Arc::new(HashMap::new()),
1233            config: Arc::new(serde_json::Value::Null),
1234            user_config: Arc::new(serde_json::Value::Null),
1235            available_grammars: Vec::new(),
1236            last_grammar_gen: 0,
1237            editor_mode: None,
1238            plugin_view_states: HashMap::new(),
1239            plugin_view_states_split: 0,
1240            keybinding_labels: HashMap::new(),
1241            plugin_global_states: HashMap::new(),
1242            active_session_plugin_states: HashMap::new(),
1243            terminal_width: 0,
1244            terminal_height: 0,
1245        }
1246    }
1247}
1248
1249impl Default for EditorStateSnapshot {
1250    fn default() -> Self {
1251        Self::new()
1252    }
1253}
1254
1255/// Grammar info exposed to plugins, mirroring the editor's grammar provenance tracking.
1256#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1257#[ts(export)]
1258pub struct GrammarInfoSnapshot {
1259    /// The grammar name as used in config files (case-insensitive matching)
1260    pub name: String,
1261    /// Where this grammar was loaded from (e.g. "built-in", "plugin (myplugin)")
1262    pub source: String,
1263    /// File extensions associated with this grammar
1264    pub file_extensions: Vec<String>,
1265    /// Optional short name alias (e.g., "bash" for "Bourne Again Shell (bash)")
1266    pub short_name: Option<String>,
1267}
1268
1269/// Position for inserting menu items or menus
1270#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1271#[ts(export)]
1272pub enum MenuPosition {
1273    /// Add at the beginning
1274    Top,
1275    /// Add at the end
1276    Bottom,
1277    /// Add before a specific label
1278    Before(String),
1279    /// Add after a specific label
1280    After(String),
1281}
1282
1283// ===========================================================================
1284// Widget library — plugin-facing declarative UI.
1285//
1286// Plugins describe a widget tree as a `WidgetSpec`; the host reconciles the
1287// tree against the previous spec for the same panel and produces rendered
1288// output. This is the foundation laid out in
1289// `docs/internal/plugin-widget-library-design.md`.
1290//
1291// The set of widget kinds is intentionally narrow at v1 (`HintBar` and the
1292// `Row`/`Col`/`Raw` composition primitives). Additional kinds (`Toggle`,
1293// `Button`, `TextInput`, `List`, `Tree`, `Layer`, `Transient`, `Table`)
1294// extend the enum without changing the `MountWidgetPanel`/`UpdateWidgetPanel`
1295// IPC shape.
1296// ===========================================================================
1297
1298/// One entry in a `HintBar` — a key chord plus its label.
1299/// Renders as `<keys> <label>` with the key portion styled by the
1300/// `ui.help_key_fg` theme key.
1301#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1302#[serde(deny_unknown_fields, rename_all = "camelCase")]
1303#[ts(export, rename_all = "camelCase")]
1304pub struct HintEntry {
1305    /// The key chord, e.g. `"Tab"`, `"Alt+P"`, `"Esc"`.
1306    pub keys: String,
1307    /// The human-readable label for the action.
1308    pub label: String,
1309}
1310
1311/// Default for `TextInput::cursor_byte` when the plugin doesn't
1312/// supply one. -1 ⇒ "no cursor visible" (the field is unfocused
1313/// or read-only).
1314fn default_cursor_byte() -> i32 {
1315    -1
1316}
1317
1318/// Default for `List::selected_index` when the plugin doesn't
1319/// supply one. -1 ⇒ "no selection".
1320fn default_list_selected() -> i32 {
1321    -1
1322}
1323
1324/// Default visible-rows for a `List` when the plugin doesn't supply
1325/// one. 20 is a reasonable terminal-panel default.
1326fn default_list_visible_rows() -> u32 {
1327    20
1328}
1329
1330/// Default for `Tree::selected_index`. -1 ⇒ "no selection".
1331fn default_tree_selected() -> i32 {
1332    -1
1333}
1334
1335/// Default visible-rows for a `Tree`. Same default as `List`.
1336fn default_tree_visible_rows() -> u32 {
1337    20
1338}
1339
1340/// Default `rows` for a `Text` widget — `1` ⇒ single-line. Plugins
1341/// opt into multi-line by setting `rows >= 2`.
1342fn default_text_rows() -> u32 {
1343    1
1344}
1345
1346/// One node in a `Tree` widget's flat-list spec. The plugin walks
1347/// its hierarchy depth-first and emits one `TreeNode` per node;
1348/// `depth` controls indent, `has_children` controls whether the
1349/// disclosure glyph (and its hit area) is rendered. The host filters
1350/// the visible window — descendants of collapsed nodes are skipped.
1351///
1352/// `text` is the pre-rendered row content. The host prepends the
1353/// indent + disclosure glyph at render time and shifts the entry's
1354/// inline overlays accordingly; plugins emit `text` (and overlays)
1355/// in the row's own coordinate space, starting at column 0.
1356#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1357#[serde(deny_unknown_fields, rename_all = "camelCase")]
1358#[ts(export, rename_all = "camelCase")]
1359pub struct TreeNode {
1360    /// The pre-rendered row content (text + per-row overlays).
1361    /// The host renders this verbatim after the indent + disclosure
1362    /// prefix; plugin overlays are byte-shifted by the prefix
1363    /// length.
1364    pub text: crate::text_property::TextPropertyEntry,
1365    /// 0-based depth — controls leading indent (`depth * 2` spaces).
1366    #[serde(default)]
1367    pub depth: u32,
1368    /// When true, render a disclosure glyph (`▶` collapsed / `▼`
1369    /// expanded) and emit a hit area over it that fires the `expand`
1370    /// event. Leaf nodes (`false`) get no glyph and no expand hit;
1371    /// the row width occupies the full row.
1372    #[serde(default)]
1373    pub has_children: bool,
1374    /// Per-node checkbox state. Only rendered when the parent
1375    /// `Tree` has `checkable: true`. `None` = no checkbox glyph;
1376    /// `Some(true)` = `[v]`; `Some(false)` = `[ ]`. The plugin
1377    /// owns the truth — the host fires `widget_event { event_type:
1378    /// "toggle" }` and the plugin pushes the new state back via
1379    /// `WidgetMutation::SetCheckedKeys`.
1380    #[serde(default, skip_serializing_if = "Option::is_none")]
1381    pub checked: Option<bool>,
1382}
1383
1384/// Visual role for a `Button`. Maps to theme keys at render time —
1385/// plugins describe intent, not colors. See §7 of the design doc.
1386#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, TS, PartialEq, Eq)]
1387#[serde(rename_all = "camelCase")]
1388#[ts(export, rename_all = "camelCase")]
1389pub enum ButtonKind {
1390    /// A regular action button — no special emphasis.
1391    #[default]
1392    Normal,
1393    /// The primary affirmative action (e.g. "Submit", "Replace All").
1394    /// Rendered with bold weight; the focused state uses the active
1395    /// menu/selection theme keys.
1396    Primary,
1397    /// A destructive action (e.g. "Delete"). Rendered with the
1398    /// theme's error/warning palette.
1399    Danger,
1400}
1401
1402/// Declarative widget tree. Each variant is one node; nested
1403/// composition is via `Row { children }` / `Col { children }`.
1404///
1405/// `key` is the stable identifier used by the reconciler to match a
1406/// node across `MountWidgetPanel` / `UpdateWidgetPanel` calls — when
1407/// the plugin re-emits a Spec, instance state (cursor offset, scroll,
1408/// expanded keys, hover) is preserved on nodes whose `key` matches.
1409/// Plugins should provide stable keys for any widget that owns
1410/// instance state; stateless widgets (`HintBar`, `Toggle`, `Button`,
1411/// `Spacer`) can omit it.
1412#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1413#[serde(
1414    tag = "kind",
1415    rename_all = "camelCase",
1416    rename_all_fields = "camelCase"
1417)]
1418#[ts(export, rename_all = "camelCase")]
1419pub enum WidgetSpec {
1420    /// Horizontal layout: children laid out left-to-right.
1421    Row {
1422        children: Vec<WidgetSpec>,
1423        #[serde(default, skip_serializing_if = "Option::is_none")]
1424        key: Option<String>,
1425        /// When true, children that don't fit on one line reflow onto
1426        /// additional lines (growing the row's height) instead of being
1427        /// truncated. Children are never split — wrap happens at child
1428        /// boundaries — so wrap a logical group (e.g. a toggle + its
1429        /// accelerator) in a nested non-wrapping `Row` to keep it intact.
1430        /// Ignored when the row contains multi-line (block) children.
1431        #[serde(default)]
1432        wrap: bool,
1433    },
1434    /// Vertical layout: children stacked top-to-bottom.
1435    Col {
1436        children: Vec<WidgetSpec>,
1437        #[serde(default, skip_serializing_if = "Option::is_none")]
1438        key: Option<String>,
1439    },
1440    /// Keyboard-hint footer (one row, comma-separated `<keys> <label>` items).
1441    HintBar {
1442        entries: Vec<HintEntry>,
1443        #[serde(default, skip_serializing_if = "Option::is_none")]
1444        key: Option<String>,
1445    },
1446    /// Boolean toggle, rendered as `[v] label` / `[ ] label`. The
1447    /// `focused` flag controls the focus-styling overlay; the host
1448    /// will own focus once the keymap layer is wired (today the
1449    /// plugin passes it explicitly per render).
1450    Toggle {
1451        checked: bool,
1452        label: String,
1453        #[serde(default)]
1454        focused: bool,
1455        #[serde(default, skip_serializing_if = "Option::is_none")]
1456        key: Option<String>,
1457    },
1458    /// Action button, rendered as `[ Label ]` (or `[ Label ]` with
1459    /// emphasized styling for `Primary`/`Danger`). Focused buttons
1460    /// flip foreground/background using the active menu theme keys.
1461    ///
1462    /// `intent` is the button's visual role (`Normal` / `Primary` /
1463    /// `Danger`); the field is named `intent` rather than `kind`
1464    /// because `kind` is the discriminator for the outer `WidgetSpec`
1465    /// tag.
1466    Button {
1467        label: String,
1468        #[serde(default)]
1469        focused: bool,
1470        #[serde(default)]
1471        intent: ButtonKind,
1472        #[serde(default, skip_serializing_if = "Option::is_none")]
1473        key: Option<String>,
1474        /// When true, the button renders in a muted style, is dropped
1475        /// from the Tab cycle, and clicks on it are ignored. Use for
1476        /// actions that aren't currently available against the
1477        /// surrounding state (e.g. "Archive" on the base session). The
1478        /// button still occupies its layout cell so the surrounding
1479        /// row doesn't reshuffle when the disabled flag flips.
1480        #[serde(default)]
1481        disabled: bool,
1482    },
1483    /// Horizontal whitespace eater. In a `Row`, produces `cols`
1484    /// spaces (or fills remaining width if `flex: true`); in a
1485    /// `Col`, produces `cols` blank lines (`flex` is ignored).
1486    ///
1487    /// `flex: true` distributes the row's leftover width — `panel
1488    /// width - sum(non-flex child widths)` — across flex spacers.
1489    /// With multiple flex spacers in one row the leftover splits
1490    /// evenly. With no leftover (children already exceed panel
1491    /// width), the flex spacer collapses to zero.
1492    Spacer {
1493        #[serde(default)]
1494        cols: u32,
1495        #[serde(default)]
1496        flex: bool,
1497        #[serde(default, skip_serializing_if = "Option::is_none")]
1498        key: Option<String>,
1499    },
1500    /// Vertical list of pre-rendered rows with host-managed
1501    /// selection styling, click routing, **and virtual scrolling**.
1502    ///
1503    /// The plugin passes the *full dataset* of items + a
1504    /// `visible_rows` count (typically the panel's available
1505    /// height). The host owns the scroll offset as widget instance
1506    /// state, keyed by the spec's `key` — so a `key` is required for
1507    /// any List that should preserve scroll across re-renders. The
1508    /// scroll offset auto-clamps to keep `selected_index` in view;
1509    /// plugins never compute scroll math.
1510    ///
1511    /// Each item is one rendered row (`TextPropertyEntry`).
1512    /// `item_keys` is a parallel array of stable per-item identifiers
1513    /// the plugin uses to map a click event back to its model
1514    /// (e.g. `"file:5/match:23"`); the array length must match
1515    /// `items.len()`. Missing keys default to empty string.
1516    ///
1517    /// `selected_index` is the *absolute* index into `items`
1518    /// (`-1` for no selection); the host paints the selected row
1519    /// with `ui.menu_active_bg` extended to line end. Clicks fire
1520    /// `widget_event { event_type: "select",
1521    ///                payload: { index, key } }`
1522    /// where `index` is the absolute (not visible-window) index.
1523    List {
1524        items: Vec<crate::text_property::TextPropertyEntry>,
1525        #[serde(default)]
1526        item_keys: Vec<String>,
1527        #[serde(default = "default_list_selected")]
1528        selected_index: i32,
1529        /// Number of rows of the panel's available height the list
1530        /// should occupy. Plugin computes from its viewport. The
1531        /// host shows up to this many items per render.
1532        #[serde(default = "default_list_visible_rows")]
1533        visible_rows: u32,
1534        /// Whether `Tab` / `Shift+Tab` will land focus on this
1535        /// list. Defaults to `true` (lists are normal tabbable
1536        /// widgets). Picker-style usage typically sets this to
1537        /// `false` so Tab moves between the filter input and
1538        /// the action buttons, while Up/Down on the focused
1539        /// filter still forwards to the list via host smart-key
1540        /// dispatch.
1541        #[serde(default = "default_true")]
1542        focusable: bool,
1543        #[serde(default, skip_serializing_if = "Option::is_none")]
1544        key: Option<String>,
1545    },
1546    /// Hierarchical list with host-managed expand/collapse, selection
1547    /// styling, click routing, and virtual scrolling.
1548    ///
1549    /// The plugin emits its tree as a depth-first flat list of
1550    /// `TreeNode`s (each carrying a `depth` and `has_children` flag)
1551    /// plus a parallel `item_keys` array. The host filters out
1552    /// descendants of collapsed nodes when rendering the visible
1553    /// window, so the plugin always emits the *full* tree — toggling
1554    /// expansion is host-owned (instance state) rather than the
1555    /// plugin re-emitting on every `▶`/`▼` press.
1556    ///
1557    /// `expanded_keys` is initial-only (seeded into instance state
1558    /// on first render); subsequent expansion changes flow through
1559    /// `WidgetCommand::Key` (Right/Left) or click on the disclosure
1560    /// glyph — neither requires the plugin to re-emit. Plugins that
1561    /// need to react to expansion changes listen for
1562    /// `widget_event { event_type: "expand" }`.
1563    ///
1564    /// `selected_index` is the *absolute* index into `nodes`
1565    /// (initial-only; instance state takes over). Click on a row
1566    /// fires `widget_event { event_type: "select", payload: { index,
1567    /// key } }`; click on the disclosure column fires
1568    /// `widget_event { event_type: "expand", payload: { index, key,
1569    /// expanded } }`. Enter/Space on the focused tree fires
1570    /// `widget_event { event_type: "activate", payload: { index, key } }`.
1571    Tree {
1572        nodes: Vec<TreeNode>,
1573        #[serde(default)]
1574        item_keys: Vec<String>,
1575        #[serde(default = "default_tree_selected")]
1576        selected_index: i32,
1577        #[serde(default = "default_tree_visible_rows")]
1578        visible_rows: u32,
1579        /// Initial-only set of expanded item keys. Once the widget
1580        /// has rendered, the host's instance-state `expanded_keys`
1581        /// is authoritative; updating this field on subsequent specs
1582        /// has no effect (use `WidgetMutation::SetExpandedKeys` to
1583        /// override host state).
1584        #[serde(default)]
1585        expanded_keys: Vec<String>,
1586        /// When true, every node with `checked: Some(_)` renders a
1587        /// `[v]` / `[ ]` glyph and emits a `toggle` hit area over
1588        /// the glyph. Click on the glyph fires `widget_event {
1589        /// event_type: "toggle", payload: { key, checked: <new> } }`;
1590        /// the plugin updates its model and pushes the new state
1591        /// back via `WidgetMutation::SetCheckedKeys`.
1592        #[serde(default)]
1593        checkable: bool,
1594        #[serde(default, skip_serializing_if = "Option::is_none")]
1595        key: Option<String>,
1596    },
1597    /// Single-line text input, rendered as `[value]` with a cursor
1598    /// highlight at the byte position given by `cursor_byte` (when
1599    /// `cursor_byte >= 0`). When `value` is empty and the input is
1600    /// not focused, `placeholder` (if set) is shown instead.
1601    ///
1602    /// v1 is a *render-only* widget: the host owns visual cursor
1603    /// styling and theme-keyed focus, but the plugin still owns the
1604    /// value string and cursor position. Keystrokes (Backspace,
1605    /// arrows, character input) flow through the plugin's existing
1606    /// `defineMode` + `mode_text_input` plumbing; the plugin re-emits
1607    /// the spec on every change. The keymap-routing layer (host
1608    /// claims widget keys before the plugin sees them) lands in a
1609    /// later commit.
1610    /// Text input — single-line (`rows == 1`, default) or multi-line
1611    /// (`rows > 1`). The host owns `value` and `cursor_byte` as
1612    /// instance state once the widget renders for the first time;
1613    /// the spec's values are initial-only.
1614    ///
1615    /// Single-line vs multi-line behaviour is selected by `rows`:
1616    /// * `rows == 1` — renders as `[value]` with the cursor pinned
1617    ///   to a constant `field_width` (head-truncate when the value
1618    ///   exceeds it). `Enter` advances focus (form-like UX).
1619    ///   `Up`/`Down` are no-ops. `Home`/`End` jump to the start /
1620    ///   end of the whole value.
1621    /// * `rows > 1` — renders as `rows` lines tall (padded with
1622    ///   blanks when `value` is shorter). `Enter` inserts a newline
1623    ///   at the cursor. `Up`/`Down` move between lines (clamped to
1624    ///   each line's column count). `Home`/`End` jump within the
1625    ///   current line. The host auto-scrolls vertically to keep
1626    ///   the cursor's line visible.
1627    ///
1628    /// Smart-key dispatch (`WidgetCommand::Key`) selects the right
1629    /// behaviour from `rows`. Plugins that want a different `Enter`
1630    /// binding intercept the key in their own mode binding before
1631    /// dispatching it through the smart-key router.
1632    ///
1633    /// `label` (when non-empty) renders inline before `[` for
1634    /// single-line, and as a row above the editing region for
1635    /// multi-line. `placeholder` shows when `value` is empty and
1636    /// the field is unfocused (first row only for multi-line).
1637    /// `field_width` controls visible column width: `0` = auto-fit
1638    /// (single-line) or panel width (multi-line). `max_visible_chars`
1639    /// is a single-line soft cap applied after the field-width pad
1640    /// (`0` = no cap; ignored when `rows > 1`).
1641    Text {
1642        /// Initial text. Spec value is read at first render only;
1643        /// instance state takes over thereafter.
1644        #[serde(default)]
1645        value: String,
1646        /// Initial byte-offset cursor within `value`. Negative
1647        /// (encoded as `i32` in JSON) means "no cursor" — clamped
1648        /// to `[0, value.len()]` host-side.
1649        #[serde(default = "default_cursor_byte")]
1650        cursor_byte: i32,
1651        /// Whether this widget has visual focus.
1652        #[serde(default)]
1653        focused: bool,
1654        /// Optional label rendered before / above the editing
1655        /// region. Empty = omitted.
1656        #[serde(default, skip_serializing_if = "String::is_empty")]
1657        label: String,
1658        /// Placeholder shown when unfocused and `value` is empty.
1659        #[serde(default, skip_serializing_if = "Option::is_none")]
1660        placeholder: Option<String>,
1661        /// Number of visible rows of editing region. `0` falls back
1662        /// to `1` (single-line). `1` = single-line behaviour;
1663        /// `>= 2` = multi-line behaviour. See the type-level doc
1664        /// for the per-mode semantics.
1665        #[serde(default = "default_text_rows")]
1666        rows: u32,
1667        /// Visible column width. `0` = auto-fit (single-line) or
1668        /// panel width (multi-line). When set, single-line
1669        /// head-truncates with `…` and multi-line tail-truncates
1670        /// per-line.
1671        #[serde(default)]
1672        field_width: u32,
1673        /// Single-line soft cap on visible chars after the
1674        /// `field_width` pad. `0` = no cap. Ignored when `rows > 1`.
1675        #[serde(default)]
1676        max_visible_chars: u32,
1677        /// Stretch the visible field to fill the available
1678        /// width of the enclosing container. Overrides
1679        /// `field_width` when set: the renderer computes
1680        /// `panel_width - label_overhead - bracket_overhead` as
1681        /// the effective visible width. Multi-line widgets
1682        /// already fill the panel width by default; this flag is
1683        /// most useful for single-line inputs inside a
1684        /// `LabeledSection` or a flexible row.
1685        #[serde(default)]
1686        full_width: bool,
1687        /// Optional completion candidates. When non-empty AND
1688        /// `label` is non-empty (the chrome trigger), the
1689        /// renderer paints a popup directly under the input,
1690        /// inside a unified box: the input's normal `╰─...─╯`
1691        /// bottom border becomes a dimmed `┄` separator, the
1692        /// labeled section's side borders extend down through
1693        /// the candidate rows, and a single `╰─...─╯` bottom
1694        /// closes the whole block. Candidates render left-
1695        /// aligned with the input's text (the position right
1696        /// after `[`), with the host-managed selected index
1697        /// highlighted.
1698        ///
1699        /// Smart-key dispatch on a focused Text-with-completions:
1700        /// Up/Down moves selection (host-internal, no event),
1701        /// Tab fires `completion_accept` with the selected
1702        /// candidate, Enter / Escape fire `completion_dismiss`
1703        /// (the dispatcher's normal "Enter focus-advance / Esc
1704        /// close panel" only runs once the popup is closed).
1705        ///
1706        /// Plugins push candidates in response to the text
1707        /// widget's `change` event via
1708        /// `WidgetMutation::SetCompletions`. An empty `items`
1709        /// closes the popup.
1710        #[serde(
1711            default,
1712            skip_serializing_if = "Vec::is_empty",
1713            with = "completion_items_serde"
1714        )]
1715        #[ts(type = "Array<string | CompletionItem>")]
1716        completions: Vec<CompletionItem>,
1717        /// How many candidate rows the popup paints at once
1718        /// when it opens. Excess candidates stay reachable
1719        /// via Up/Down (host auto-scrolls to keep selection
1720        /// in view) or the mouse wheel; a thumb glyph paints
1721        /// in the right edge of the popup whenever there's
1722        /// more to scroll. `0` (default) falls back to `5`.
1723        #[serde(default)]
1724        completions_visible_rows: u32,
1725        #[serde(default, skip_serializing_if = "Option::is_none")]
1726        key: Option<String>,
1727    },
1728    /// Visual grouping container: renders a rounded thin border
1729    /// around a single child widget, with `label` printed as a
1730    /// top-left legend overlapping the border (HTML `<fieldset>`
1731    /// semantics).
1732    ///
1733    /// Layout (border drawn with `╭─╮│╰─╯`):
1734    /// ```text
1735    /// ╭─ Label ──────────────────╮
1736    /// │ <child rendered content> │
1737    /// ╰──────────────────────────╯
1738    /// ```
1739    ///
1740    /// Width: the section always occupies the full `panel_width`
1741    /// passed down by its parent container. The child is rendered
1742    /// with `panel_width - 4` (two border columns + two padding
1743    /// columns) so widgets that honour `full_width` size
1744    /// themselves to the inner area.
1745    ///
1746    /// The child can be any single `WidgetSpec` — typically a
1747    /// `Text` input, but a `Toggle`/`Button`/nested `Col` also
1748    /// works. Focus, hit areas and cursor positions bubble up
1749    /// from the child unchanged, shifted by the section's border
1750    /// offset (1 row down, 2 columns in).
1751    LabeledSection {
1752        /// Legend text printed in the top border. Empty = no
1753        /// legend (the top border becomes one unbroken line).
1754        #[serde(default)]
1755        label: String,
1756        /// The single wrapped widget. Boxed because `WidgetSpec`
1757        /// is recursive.
1758        child: Box<WidgetSpec>,
1759        /// When this section is a Block child of a Row, request
1760        /// `width_pct` percent of the row's `panel_width` instead
1761        /// of the equal-split default. Multiple siblings with
1762        /// `width_pct` set sum to ≤ 100; the remainder splits
1763        /// equally among siblings without an explicit width.
1764        /// Out-of-range values (0 or > 100) fall back to the
1765        /// equal-split path.
1766        #[serde(default, skip_serializing_if = "Option::is_none")]
1767        width_pct: Option<u32>,
1768        #[serde(default, skip_serializing_if = "Option::is_none")]
1769        key: Option<String>,
1770    },
1771    /// Reserve a rectangle in the widget layout for the host to
1772    /// natively paint the editor `Window` identified by
1773    /// `window_id`. The widget itself renders only blank lines
1774    /// so subsequent passes (split tree, terminal grids, syntax
1775    /// highlighting, decorations) can be drawn into the
1776    /// reserved cells by the existing per-window render path.
1777    ///
1778    /// `rows` controls the embed's height. Width is whatever
1779    /// the parent container allocates (`panel_width` for a
1780    /// direct Col child; the block's `column_width` inside a
1781    /// Row's horizontal-zip path). Used by Orchestrator's open
1782    /// dialog so the preview pane shows a live render of the
1783    /// highlighted session.
1784    WindowEmbed {
1785        /// Numeric editor-window id, matching `WindowId(N).0`.
1786        /// `0` (or any unknown id) renders empty placeholder
1787        /// rows without dispatching the per-window render.
1788        /// `u32` rather than `u64` to keep the TS binding a
1789        /// plain `number`; window ids never exceed 4B in
1790        /// practice.
1791        window_id: u32,
1792        /// Number of visible rows the embed should occupy.
1793        rows: u32,
1794        #[serde(default, skip_serializing_if = "Option::is_none")]
1795        key: Option<String>,
1796    },
1797    /// Imperative-virtual-buffer escape hatch. The plugin supplies
1798    /// `TextPropertyEntry[]` exactly as it would for
1799    /// `setVirtualBufferContent`; the host inlines those entries into
1800    /// the rendered panel without further interpretation. Used during
1801    /// migration to wrap existing hand-rolled rendering inside a new
1802    /// widget panel.
1803    Raw {
1804        entries: Vec<crate::text_property::TextPropertyEntry>,
1805        #[serde(default, skip_serializing_if = "Option::is_none")]
1806        key: Option<String>,
1807    },
1808    /// Float `child` over the rest of the layout instead of
1809    /// consuming vertical space. Placed inside a `Col`, the
1810    /// overlay anchors at the row it would have occupied if it
1811    /// were a regular child — but the rows below it DO NOT
1812    /// shift down. At paint time the overlay is drawn last,
1813    /// over whatever's beneath it, like a tooltip / popup.
1814    ///
1815    /// Use case: dropdown completions, hover popups, transient
1816    /// hints that should appear right next to the focused
1817    /// widget without reflowing the rest of the panel each
1818    /// time they show / hide.
1819    ///
1820    /// Hit testing: overlays paint on top, so clicks inside an
1821    /// overlay's region go to the overlay (not whatever's
1822    /// underneath). Tab cycle: the host's `collect_tabbable`
1823    /// walks into the overlay's child like any other widget;
1824    /// give the child a `key` if you want it focusable, or
1825    /// leave it keyless to keep it out of the cycle.
1826    Overlay {
1827        child: Box<WidgetSpec>,
1828        #[serde(default, skip_serializing_if = "Option::is_none")]
1829        key: Option<String>,
1830    },
1831}
1832
1833impl WidgetSpec {
1834    /// Iterate this widget's immediate child specs in declaration
1835    /// order. Container kinds (`Row`, `Col`, `LabeledSection`)
1836    /// return their nested children; leaf kinds return an empty
1837    /// iterator.
1838    ///
1839    /// Generic tree walkers (focus dispatch, hit-area lookup,
1840    /// scrollable-widget detection, instance-state mutation) call
1841    /// this instead of pattern-matching every container variant
1842    /// by hand, so adding a new container kind is a single update
1843    /// here rather than touching every walker. The box is the
1844    /// price for returning an iterator whose type depends on the
1845    /// variant; the allocation is single-digit-byte and dwarfed
1846    /// by everything else in the dispatch path.
1847    pub fn children(&self) -> Box<dyn Iterator<Item = &WidgetSpec> + '_> {
1848        match self {
1849            WidgetSpec::Row { children, .. } | WidgetSpec::Col { children, .. } => {
1850                Box::new(children.iter())
1851            }
1852            WidgetSpec::LabeledSection { child, .. } | WidgetSpec::Overlay { child, .. } => {
1853                Box::new(std::iter::once(child.as_ref()))
1854            }
1855            _ => Box::new(std::iter::empty()),
1856        }
1857    }
1858
1859    /// Mutable counterpart of [`children`]. Same set of container
1860    /// kinds, same semantics — the iterator yields exclusive
1861    /// references so walkers that mutate (e.g. `set_*_in_spec`)
1862    /// can recurse generically.
1863    pub fn children_mut(&mut self) -> Box<dyn Iterator<Item = &mut WidgetSpec> + '_> {
1864        match self {
1865            WidgetSpec::Row { children, .. } | WidgetSpec::Col { children, .. } => {
1866                Box::new(children.iter_mut())
1867            }
1868            WidgetSpec::LabeledSection { child, .. } | WidgetSpec::Overlay { child, .. } => {
1869                Box::new(std::iter::once(child.as_mut()))
1870            }
1871            _ => Box::new(std::iter::empty()),
1872        }
1873    }
1874}
1875
1876/// Action a plugin can request the widget runtime to perform on a
1877/// mounted panel. Bundled into a single `WidgetCommand` PluginCommand
1878/// so the plugin's TypeScript layer exposes one routing method
1879/// (`editor.widgetCommand(panel_id, action)`) rather than a fanout
1880/// of per-key IPC.
1881///
1882/// All actions target the panel's currently focused widget (the host
1883/// tracks focus per panel). They are fired by the plugin's mode
1884/// bindings — Tab → `FocusAdvance{+1}`, Enter → `Activate`,
1885/// Up/Down → `SelectMove{±1}`, Backspace → `TextInputKey{"Backspace"}`,
1886/// printable chars (via `mode_text_input`) → `TextInputChar{"x"}`.
1887#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1888#[serde(
1889    tag = "kind",
1890    rename_all = "camelCase",
1891    rename_all_fields = "camelCase"
1892)]
1893#[ts(export, rename_all = "camelCase")]
1894pub enum WidgetAction {
1895    /// Cycle focus to the next (`delta=+1`) or previous (`delta=-1`)
1896    /// tabbable widget in declaration order. Wraps at the ends.
1897    FocusAdvance { delta: i32 },
1898    /// "Activate" the focused widget — fires a semantic event
1899    /// keyed on widget kind: `Button` → `widget_event { event_type:
1900    /// "activate" }`; `Toggle` → `widget_event { event_type:
1901    /// "toggle", payload: { checked: !old } }`. No-op for other
1902    /// kinds.
1903    Activate,
1904    /// Move the focused `List`'s selection by `delta`. Plugins
1905    /// listen for `widget_event { event_type: "select" }` to mirror
1906    /// the new index into their model. No-op when the focused
1907    /// widget isn't a List.
1908    SelectMove { delta: i32 },
1909    /// Apply a non-printable editing key to the focused
1910    /// `TextInput`: `"Backspace"`, `"Delete"`, `"Left"`, `"Right"`,
1911    /// `"Home"`, `"End"`. Host computes the new value/cursor and
1912    /// fires `widget_event { event_type: "change", payload: { value,
1913    /// cursorByte } }`. No-op when the focused widget isn't a
1914    /// TextInput or the key isn't recognised.
1915    TextInputKey { key: String },
1916    /// Append printable text to the focused `TextInput` at the
1917    /// current cursor position. Used for the `mode_text_input`
1918    /// fall-through path. Fires `widget_event` as for `TextInputKey`.
1919    TextInputChar { text: String },
1920    /// "Smart" keystroke dispatch — the host routes based on the
1921    /// focused widget's kind without the plugin needing to know
1922    /// what's focused. This is the recommended path for plugin
1923    /// mode bindings: bind every relevant key to one handler that
1924    /// calls `editor.widgetCommand(panel_id, key("Tab"))` etc.
1925    ///
1926    /// Dispatch table:
1927    ///
1928    /// | Key                                   | TextInput   | TextArea          | Toggle / Button | List       | Tree                | (no focus) |
1929    /// |---------------------------------------|-------------|-------------------|-----------------|------------|---------------------|------------|
1930    /// | `Tab`                                 | focus +1    | focus +1          | focus +1        | focus +1   | focus +1            | no-op      |
1931    /// | `Shift+Tab`                           | focus -1    | focus -1          | focus -1        | focus -1   | focus -1            | no-op      |
1932    /// | `Backspace` / `Delete`                | text-edit   | text-edit         | no-op           | no-op      | no-op               | no-op      |
1933    /// | `Home` / `End`                        | text-edit   | line-start / -end | no-op           | no-op      | no-op               | no-op      |
1934    /// | `Left`                                | text-edit   | text-edit         | no-op           | no-op      | collapse / parent   | no-op      |
1935    /// | `Right`                               | text-edit   | text-edit         | no-op           | no-op      | expand              | no-op      |
1936    /// | `Up`                                  | no-op       | line up           | no-op           | select -1  | select -1 (visible) | no-op      |
1937    /// | `Down`                                | no-op       | line down         | no-op           | select +1  | select +1 (visible) | no-op      |
1938    /// | `Enter`                               | focus +1    | insert `\n`       | activate        | activate   | activate            | no-op      |
1939    /// | `Space`                               | char " "    | char " "          | activate        | activate   | activate            | no-op      |
1940    /// | (anything else)                       | no-op       | no-op             | no-op           | no-op      | no-op               | no-op      |
1941    ///
1942    /// "no-op" still returns successfully — plugins can rely on the
1943    /// command not erroring when the focused widget can't handle the
1944    /// key. Plugins that want to fall back to their own behaviour
1945    /// when the widget doesn't claim a key should bind those keys
1946    /// to plugin-specific handlers instead.
1947    Key { key: String },
1948}
1949
1950/// Targeted in-place mutation of a mounted widget panel — the
1951/// IPC fast path. Plugins use these when the model change touches
1952/// one widget; the host applies the mutation directly to the
1953/// panel's spec / instance state and re-renders without
1954/// re-transmitting the full spec.
1955///
1956/// `UpdateWidgetPanel` remains the right tool for structural
1957/// changes (adding/removing widgets, restructuring layout). Both
1958/// paths preserve instance state via widget keys.
1959#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1960#[serde(
1961    tag = "kind",
1962    rename_all = "camelCase",
1963    rename_all_fields = "camelCase"
1964)]
1965#[ts(export, rename_all = "camelCase")]
1966pub enum WidgetMutation {
1967    /// Set a `TextInput`'s value and (optionally) cursor byte.
1968    /// Mutates instance state directly.
1969    SetValue {
1970        widget_key: String,
1971        value: String,
1972        #[serde(default, skip_serializing_if = "Option::is_none")]
1973        cursor_byte: Option<i32>,
1974    },
1975    /// Set a `Text` widget's completion candidates (instance
1976    /// state). Empty `items` closes the popup; non-empty opens
1977    /// it and resets the selection to index 0. Plugins call
1978    /// this from their `change` event handler after computing
1979    /// candidates against the new value — same flow as
1980    /// `setPromptSuggestions` for the legacy prompt UI.
1981    SetCompletions {
1982        widget_key: String,
1983        #[serde(with = "completion_items_serde")]
1984        #[ts(type = "Array<string | CompletionItem>")]
1985        items: Vec<CompletionItem>,
1986    },
1987    /// Set a `Toggle`'s checked state. Mutates the Toggle's
1988    /// `checked` field in the spec.
1989    SetChecked { widget_key: String, checked: bool },
1990    /// Set a `List`'s selected index (instance state).
1991    SetSelectedIndex { widget_key: String, index: i32 },
1992    /// Replace a `List`'s items + parallel `item_keys`. Mutates
1993    /// the List in the spec.
1994    SetItems {
1995        widget_key: String,
1996        items: Vec<crate::text_property::TextPropertyEntry>,
1997        #[serde(default)]
1998        item_keys: Vec<String>,
1999    },
2000    /// Replace a `Tree`'s expanded-keys instance state. Plugins use
2001    /// this when a non-user action needs to drive expansion (e.g.
2002    /// "expand all", reveal-on-search). `Right`/`Left` arrow keys
2003    /// and disclosure clicks already mutate expansion host-side
2004    /// without the plugin's involvement.
2005    SetExpandedKeys {
2006        widget_key: String,
2007        keys: Vec<String>,
2008    },
2009    /// Set `checked` to the given value on every node in `keys`.
2010    /// Mutates the Tree's nodes in the spec; the renderer sees
2011    /// the change on the next paint without a full spec re-emit.
2012    /// Used by the `toggle` event handler in plugins to push the
2013    /// new checkbox state back after a user click or `x` keypress.
2014    SetCheckedKeys {
2015        widget_key: String,
2016        checked: bool,
2017        keys: Vec<String>,
2018    },
2019    /// Append `new_nodes` (and parallel `new_item_keys`) to an
2020    /// existing Tree's node list. Streaming-friendly counterpart to
2021    /// `SetItems`: a plugin streaming thousands of results sends only
2022    /// the per-batch delta instead of re-transmitting the entire tree
2023    /// on every batch. Existing selection / scroll / expansion state
2024    /// is preserved across the append.
2025    AppendTreeNodes {
2026        widget_key: String,
2027        new_nodes: Vec<crate::api::TreeNode>,
2028        #[serde(default)]
2029        new_item_keys: Vec<String>,
2030    },
2031    /// Replace a `Raw` widget's entries in place. The streaming search
2032    /// pump uses this to update small bits of chrome (the matchStats
2033    /// label, the separator "Matches (N in M files)" header) without
2034    /// re-emitting the full panel spec — the latter would force a
2035    /// `js_to_json` walk of every node in a 5 000-row tree once
2036    /// streaming finishes, blocking the JS thread for ~1 second.
2037    SetRawEntries {
2038        widget_key: String,
2039        entries: Vec<crate::text_property::TextPropertyEntry>,
2040    },
2041    /// Set the panel's focused widget to `widget_key`. Passing a key
2042    /// that isn't a tabbable in the current spec is harmless — the
2043    /// next render clamps focus to the first tabbable, same as for an
2044    /// empty key. Use this to land initial focus on a specific widget
2045    /// after mount, or to snap focus back to a "home" widget after a
2046    /// navigation event.
2047    SetFocusKey { widget_key: String },
2048}
2049
2050/// Plugin command - allows plugins to send commands to the editor
2051#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2052#[ts(export)]
2053pub enum PluginCommand {
2054    /// Insert text at a position in a buffer
2055    InsertText {
2056        buffer_id: BufferId,
2057        position: usize,
2058        text: String,
2059    },
2060
2061    /// Delete a range of text from a buffer
2062    DeleteRange {
2063        buffer_id: BufferId,
2064        range: Range<usize>,
2065    },
2066
2067    /// Add an overlay to a buffer, returns handle via response channel
2068    ///
2069    /// Colors can be specified as RGB tuples or theme keys. When theme keys
2070    /// are provided, they take precedence and are resolved at render time.
2071    AddOverlay {
2072        buffer_id: BufferId,
2073        namespace: Option<OverlayNamespace>,
2074        range: Range<usize>,
2075        /// Overlay styling options (colors, modifiers, etc.)
2076        options: OverlayOptions,
2077    },
2078
2079    /// Remove an overlay by its opaque handle
2080    RemoveOverlay {
2081        buffer_id: BufferId,
2082        handle: OverlayHandle,
2083    },
2084
2085    /// Set status message
2086    SetStatus { message: String },
2087
2088    /// Apply a theme by name
2089    ApplyTheme { theme_name: String },
2090
2091    /// Override specific theme color keys in-memory for the running session.
2092    /// Keys are the same `section.field` strings accepted by
2093    /// `Theme::resolve_theme_key` (e.g. `"editor.bg"`, `"ui.status_bar_fg"`).
2094    /// Values are `[r, g, b]` triplets. Unknown keys are silently dropped so
2095    /// a typo in a fast animation loop doesn't blow up the caller; the
2096    /// return channel isn't used — plugins can do a dry-run look-up via
2097    /// `getThemeSchema` if they want compile-time safety. Overrides are
2098    /// reset the next time the caller (or anyone else) invokes
2099    /// `applyTheme`, because that replaces the whole `Theme` from the
2100    /// registry.
2101    OverrideThemeColors { overrides: HashMap<String, [u8; 3]> },
2102
2103    /// Reload configuration from file
2104    /// After a plugin saves config changes, it should call this to reload the config
2105    ReloadConfig,
2106
2107    /// Write a single setting to the runtime overlay for this session.
2108    /// `path` is dot-separated (e.g. "editor.tab_size"). Last write wins.
2109    SetSetting {
2110        plugin_name: String,
2111        path: String,
2112        #[ts(type = "unknown")]
2113        value: JsonValue,
2114    },
2115
2116    /// Register one field of a plugin-defined config schema. Each field
2117    /// arrives independently (one per `defineConfigBoolean` / `Integer` /
2118    /// etc. call from the plugin's TypeScript). The host accumulates
2119    /// fields into `plugins.<plugin_name>` schema and pre-populates the
2120    /// declared default into `plugins.<plugin_name>.settings.<field>`.
2121    AddPluginConfigField {
2122        plugin_name: String,
2123        field_name: String,
2124        /// JSON Schema fragment for this single field (e.g.
2125        /// `{"type":"boolean","default":false,"description":"..."}`).
2126        #[ts(type = "unknown")]
2127        field_schema: JsonValue,
2128    },
2129
2130    /// Register a custom command
2131    RegisterCommand { command: Command },
2132
2133    /// Register a custom statusbar token
2134    RegisterStatusBarElement {
2135        plugin_name: String,
2136        token_name: String,
2137        title: String,
2138    },
2139
2140    /// Set the value of a status-bar token for a specific buffer.
2141    /// `key` is the full "plugin_name:token_name" form.
2142    SetStatusBarValue {
2143        buffer_id: u64,
2144        key: String,
2145        value: String,
2146    },
2147
2148    /// Unregister a command by name
2149    UnregisterCommand { name: String },
2150
2151    /// Create a new editor session rooted at `root`.
2152    ///
2153    /// `root` must be an absolute path; relative paths are rejected
2154    /// rather than silently joined onto the active session's root —
2155    /// that ambiguity would leak the wrong cwd into agent processes.
2156    /// `label` may be empty; the editor falls back to the basename
2157    /// of `root` (matching `Session::new`).
2158    ///
2159    /// The new session's id is assigned by the editor and reported
2160    /// back via the `session_created` plugin hook (id, label, root
2161    /// in payload). Sessions are not made active on creation;
2162    /// follow up with `SetActiveWindow` to dive.
2163    CreateWindow { root: PathBuf, label: String },
2164
2165    /// Create a new window rooted at `root` AND seed it with a
2166    /// terminal as its only buffer. Atomic alternative to
2167    /// `CreateWindow` + `SetActiveWindow` + `CreateTerminal`
2168    /// used by Orchestrator's new-session flow — bundling them
2169    /// avoids the transient `[No Name]` placeholder that the
2170    /// three-step sequence leaves as a leftover first tab, and
2171    /// removes any window of time in which the new window is
2172    /// visible to other plugins/preview rendering without its
2173    /// agent terminal attached. Returns
2174    /// `SessionWithTerminalResult` via the async callback.
2175    CreateWindowWithTerminal {
2176        root: PathBuf,
2177        label: String,
2178        cwd: Option<String>,
2179        command: Option<Vec<String>>,
2180        title: Option<String>,
2181        request_id: u64,
2182    },
2183
2184    /// Make `id` the active session. No-op if `id` is already
2185    /// active. Fires `active_session_changed` on transition.
2186    /// Errors (id not found) are logged via tracing rather than
2187    /// surfaced to the plugin — the plugin can verify by reading
2188    /// `editor.activeWindow()` after.
2189    SetActiveWindow { id: WindowId },
2190
2191    /// Close a session and drop its associated state. Refuses to
2192    /// close the currently active session — the caller must switch
2193    /// first. Fires `session_closed` on success.
2194    CloseWindow { id: WindowId },
2195
2196    /// Eagerly initialise an inactive session's per-session state
2197    /// (file tree walk, ignore matcher, etc.) without diving. The
2198    /// only thing that's actually pre-warmed today is the file
2199    /// explorer's root walk; LSP servers boot on first buffer
2200    /// open and watcher setup happens on first `watchPath` call,
2201    /// so those are unaffected. No-op for the active session
2202    /// (already warm) or unknown id.
2203    PrewarmWindow { id: WindowId },
2204
2205    /// Register a filesystem path watcher. The editor returns the
2206    /// allocated `handle` via the async response so the plugin can
2207    /// match `path_changed` events back to the call. `recursive`
2208    /// follows `notify::RecursiveMode`; non-recursive watches
2209    /// cover only the named path itself (or its direct children
2210    /// for directories on macOS — kqueue limitation).
2211    WatchPath {
2212        path: PathBuf,
2213        recursive: bool,
2214        request_id: u64,
2215    },
2216
2217    /// Drop a path watcher previously registered via
2218    /// `WatchPath`. Unknown handles are silently ignored — the
2219    /// editor's view of "what's still watched" can drift if a
2220    /// plugin reloads, and the design doesn't make plugins
2221    /// reconcile.
2222    UnwatchPath { handle: u64 },
2223
2224    /// Tell the editor that the floating-overlay prompt's
2225    /// preview pane should render the **entire** split tree of
2226    /// session `id` (Primitive #1 in
2227    /// `docs/internal/orchestrator-sessions-design.md`). `None`
2228    /// clears the override and falls back to the existing
2229    /// path-based phantom-leaf preview.
2230    ///
2231    /// Orchestrator sets this when the user navigates the session
2232    /// list so the right-hand pane shows the highlighted
2233    /// session's full editor UI live (splits, terminals,
2234    /// syntax highlighting, decorations) — rendered natively
2235    /// by reusing the editor's existing render_content path
2236    /// against the previewed session's stashed split tree.
2237    PreviewWindowInRect { id: Option<WindowId> },
2238
2239    /// Open a file in the editor (in background, without switching focus).
2240    ///
2241    /// `window_id` defaults to the active session at dispatch
2242    /// time. When set to an inactive session, the file's buffer
2243    /// is loaded as usual but attached to that session's
2244    /// membership and split tree — the active session's UI is
2245    /// undisturbed.
2246    OpenFileInBackground {
2247        path: PathBuf,
2248        #[serde(default)]
2249        window_id: Option<WindowId>,
2250    },
2251
2252    /// Insert text at the current cursor position in the active buffer
2253    InsertAtCursor { text: String },
2254
2255    /// Spawn an async process
2256    ///
2257    /// When `stdout_to` is `Some(path)`, the child's stdout is piped
2258    /// directly into that file on disk (via `tokio::io::copy`) rather
2259    /// than being buffered in memory. The resulting `SpawnResult.stdout`
2260    /// is empty in that case; `stderr` and `exit_code` are populated as
2261    /// usual. This lets large outputs (e.g. `git show` for a big commit)
2262    /// stay on disk and be opened as a file-backed buffer without ever
2263    /// crossing the JS bridge.
2264    SpawnProcess {
2265        command: String,
2266        args: Vec<String>,
2267        cwd: Option<String>,
2268        #[serde(default)]
2269        stdout_to: Option<PathBuf>,
2270        callback_id: JsCallbackId,
2271    },
2272
2273    /// Delay/sleep for a duration (async, resolves callback when done)
2274    Delay {
2275        callback_id: JsCallbackId,
2276        duration_ms: u64,
2277    },
2278
2279    /// Fetch a URL over HTTP(S) and write the response body to a file.
2280    ///
2281    /// Streams the response body directly to `target_path` rather than
2282    /// buffering in memory, so large downloads stay off the JS bridge. The
2283    /// callback resolves with a `SpawnResult`-shaped value: `exit_code` is
2284    /// `0` on 2xx, the HTTP status code on non-2xx responses, and `-1` on
2285    /// transport errors; `stderr` carries any error message; `stdout` is
2286    /// always empty.
2287    HttpFetch {
2288        url: String,
2289        target_path: PathBuf,
2290        callback_id: JsCallbackId,
2291    },
2292
2293    /// Spawn a long-running background process
2294    /// Unlike SpawnProcess, this returns immediately with a process handle
2295    /// and provides streaming output via hooks
2296    SpawnBackgroundProcess {
2297        /// Unique ID for this process (generated by plugin runtime)
2298        process_id: u64,
2299        /// Command to execute
2300        command: String,
2301        /// Arguments to pass
2302        args: Vec<String>,
2303        /// Working directory (optional)
2304        cwd: Option<String>,
2305        /// Callback ID to call when process exits
2306        callback_id: JsCallbackId,
2307    },
2308
2309    /// Kill a background process by ID
2310    KillBackgroundProcess { process_id: u64 },
2311
2312    /// Wait for a process to complete and get its result
2313    /// Used with processes started via SpawnProcess
2314    SpawnProcessWait {
2315        /// Process ID to wait for
2316        process_id: u64,
2317        /// Callback ID for async response
2318        callback_id: JsCallbackId,
2319    },
2320
2321    /// Set layout hints for a buffer/viewport
2322    SetLayoutHints {
2323        buffer_id: BufferId,
2324        split_id: Option<SplitId>,
2325        range: Range<usize>,
2326        hints: LayoutHints,
2327    },
2328
2329    /// Enable/disable line numbers for a buffer
2330    SetLineNumbers { buffer_id: BufferId, enabled: bool },
2331
2332    /// Set the view mode for a buffer ("source" or "compose")
2333    SetViewMode { buffer_id: BufferId, mode: String },
2334
2335    /// Enable/disable line wrapping for a buffer
2336    SetLineWrap {
2337        buffer_id: BufferId,
2338        split_id: Option<SplitId>,
2339        enabled: bool,
2340    },
2341
2342    /// Submit a transformed view stream for a viewport
2343    SubmitViewTransform {
2344        buffer_id: BufferId,
2345        split_id: Option<SplitId>,
2346        payload: ViewTransformPayload,
2347    },
2348
2349    /// Clear view transform for a buffer/split (returns to normal rendering)
2350    ClearViewTransform {
2351        buffer_id: BufferId,
2352        split_id: Option<SplitId>,
2353    },
2354
2355    /// Set plugin-managed view state for a buffer in the active split.
2356    /// Stored in BufferViewState.plugin_state and persisted across sessions.
2357    SetViewState {
2358        buffer_id: BufferId,
2359        key: String,
2360        #[ts(type = "any")]
2361        value: Option<serde_json::Value>,
2362    },
2363
2364    /// Set plugin-managed global state (not tied to any buffer or split).
2365    /// Isolated per plugin by plugin_name.
2366    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
2367    SetGlobalState {
2368        plugin_name: String,
2369        key: String,
2370        #[ts(type = "any")]
2371        value: Option<serde_json::Value>,
2372    },
2373
2374    /// Plugin-managed per-session state. Writes to the **currently
2375    /// active** session's `plugin_state` map keyed by
2376    /// `(plugin_name, key)`. Other sessions' state is unaffected.
2377    /// `None` means delete (matches `SetGlobalState` semantics).
2378    SetWindowState {
2379        plugin_name: String,
2380        key: String,
2381        #[ts(type = "any")]
2382        value: Option<serde_json::Value>,
2383    },
2384
2385    /// Remove all overlays from a buffer
2386    ClearAllOverlays { buffer_id: BufferId },
2387
2388    /// Remove all overlays in a namespace
2389    ClearNamespace {
2390        buffer_id: BufferId,
2391        namespace: OverlayNamespace,
2392    },
2393
2394    /// Remove all overlays that overlap with a byte range
2395    /// Used for targeted invalidation when content in a range changes
2396    ClearOverlaysInRange {
2397        buffer_id: BufferId,
2398        start: usize,
2399        end: usize,
2400    },
2401
2402    /// Remove overlays in a namespace that overlap with a byte range.
2403    /// Like [`ClearOverlaysInRange`] but scoped to a single namespace, so a
2404    /// plugin can invalidate its own decorations for a line without clobbering
2405    /// editor-owned overlays (e.g. LSP diagnostics) in the same range.
2406    ClearOverlaysInRangeForNamespace {
2407        buffer_id: BufferId,
2408        namespace: OverlayNamespace,
2409        start: usize,
2410        end: usize,
2411    },
2412
2413    /// Add virtual text (inline text that doesn't exist in the buffer)
2414    /// Used for color swatches, type hints, parameter hints, etc.
2415    AddVirtualText {
2416        buffer_id: BufferId,
2417        virtual_text_id: String,
2418        position: usize,
2419        text: String,
2420        color: (u8, u8, u8),
2421        use_bg: bool, // true = use color as background, false = use as foreground
2422        before: bool, // true = before char, false = after char
2423    },
2424
2425    /// Add virtual text with full styling — fg/bg can be RGB or theme
2426    /// keys (resolved at render time so theme changes apply live).
2427    /// This is the richer form of `AddVirtualText` that lets plugins
2428    /// produce themed labels (flash jump, type hints with semantic
2429    /// colours, …) without hard-coding RGB values.
2430    AddVirtualTextStyled {
2431        buffer_id: BufferId,
2432        virtual_text_id: String,
2433        position: usize,
2434        text: String,
2435        fg: Option<OverlayColorSpec>,
2436        bg: Option<OverlayColorSpec>,
2437        bold: bool,
2438        italic: bool,
2439        before: bool,
2440    },
2441
2442    /// Remove a virtual text by ID
2443    RemoveVirtualText {
2444        buffer_id: BufferId,
2445        virtual_text_id: String,
2446    },
2447
2448    /// Remove virtual texts whose ID starts with the given prefix
2449    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
2450
2451    /// Clear all virtual texts from a buffer
2452    ClearVirtualTexts { buffer_id: BufferId },
2453
2454    /// Add a virtual LINE (full line above/below a position)
2455    /// Used for git blame headers, code coverage, inline documentation, etc.
2456    /// These lines do NOT show line numbers in the gutter.
2457    AddVirtualLine {
2458        buffer_id: BufferId,
2459        /// Byte position to anchor the line to
2460        position: usize,
2461        /// Full line content to display
2462        text: String,
2463        /// Foreground color — RGB tuple or theme key string (e.g.
2464        /// `"editor.line_number_fg"`).  Resolved at render time so the line
2465        /// follows theme changes.
2466        fg_color: Option<OverlayColorSpec>,
2467        /// Background color — RGB tuple or theme key string.  None =
2468        /// transparent (inherits from underlying viewport background).
2469        bg_color: Option<OverlayColorSpec>,
2470        /// true = above the line containing position, false = below
2471        above: bool,
2472        /// Namespace for bulk removal (e.g., "git-blame")
2473        namespace: String,
2474        /// Priority for ordering multiple lines at same position (higher = later)
2475        priority: i32,
2476        /// Optional gutter glyph rendered in the line-number column on
2477        /// the first visual row of this virtual line. Used by diff
2478        /// plugins to put a "-" directly on the deletion line itself
2479        /// instead of the source line that follows it.
2480        gutter_glyph: Option<String>,
2481        /// Color for `gutter_glyph` (RGB or theme key). Falls back to
2482        /// `theme.line_number_fg` when `None`.
2483        gutter_color: Option<OverlayColorSpec>,
2484        /// Per-range modifier overlays applied on top of the base fg/bg.
2485        /// Offsets are byte offsets within `text`, not buffer bytes.
2486        /// Used e.g. by live-diff to bold + underline removed words on
2487        /// a deletion virtual line.
2488        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2489        text_overlays: Vec<VirtualLineTextOverlay>,
2490    },
2491
2492    /// Clear all virtual texts in a namespace
2493    /// This is the primary way to remove a plugin's virtual lines before updating them.
2494    ClearVirtualTextNamespace {
2495        buffer_id: BufferId,
2496        namespace: String,
2497    },
2498
2499    /// Add a conceal range that hides or replaces a byte range during rendering.
2500    /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc.
2501    AddConceal {
2502        buffer_id: BufferId,
2503        /// Namespace for bulk removal (shared with overlay namespace system)
2504        namespace: OverlayNamespace,
2505        /// Byte range to conceal
2506        start: usize,
2507        end: usize,
2508        /// Optional replacement text to show instead. None = hide completely.
2509        replacement: Option<String>,
2510    },
2511
2512    /// Clear all conceal ranges in a namespace
2513    ClearConcealNamespace {
2514        buffer_id: BufferId,
2515        namespace: OverlayNamespace,
2516    },
2517
2518    /// Remove all conceal ranges that overlap with a byte range
2519    /// Used for targeted invalidation when content in a range changes
2520    ClearConcealsInRange {
2521        buffer_id: BufferId,
2522        start: usize,
2523        end: usize,
2524    },
2525
2526    /// Add a collapsed fold range. Hides the byte range
2527    /// `[start, end)` from rendering — the line containing `start - 1`
2528    /// (the fold's "header") stays visible while the lines covered by
2529    /// the range are skipped. Used by plugins that want to expose
2530    /// outline-style collapse without rebuilding buffer content.
2531    AddFold {
2532        buffer_id: BufferId,
2533        start: usize,
2534        end: usize,
2535        /// Optional placeholder text to show on the header line
2536        /// (currently unused by the renderer; reserved for future use).
2537        placeholder: Option<String>,
2538    },
2539
2540    /// Clear every collapsed fold range on the buffer.
2541    ClearFolds { buffer_id: BufferId },
2542
2543    /// Publish a set of fold ranges on the buffer in the same shape
2544    /// LSP `textDocument/foldingRange` populates. The ranges are
2545    /// stored as **toggleable** — the standard `toggle_fold` keybinding
2546    /// finds them via `state.folding_ranges` and collapses/expands on
2547    /// demand. Unlike `AddFold`, this does not pre-collapse anything.
2548    ///
2549    /// Designed for plugins that derive structural folds from buffer
2550    /// content (e.g. git-log's per-file / per-hunk diff structure)
2551    /// without driving an LSP. Replacing call replaces the prior set.
2552    SetFoldingRanges {
2553        buffer_id: BufferId,
2554        #[ts(type = "any")]
2555        ranges: Vec<lsp_types::FoldingRange>,
2556    },
2557
2558    /// Add a soft break point for marker-based line wrapping.
2559    /// The break is stored as a marker that auto-adjusts on buffer edits,
2560    /// eliminating the flicker caused by async view_transform round-trips.
2561    AddSoftBreak {
2562        buffer_id: BufferId,
2563        /// Namespace for bulk removal (shared with overlay namespace system)
2564        namespace: OverlayNamespace,
2565        /// Byte offset where the break should be injected
2566        position: usize,
2567        /// Number of hanging indent spaces after the break
2568        indent: u16,
2569    },
2570
2571    /// Clear all soft breaks in a namespace
2572    ClearSoftBreakNamespace {
2573        buffer_id: BufferId,
2574        namespace: OverlayNamespace,
2575    },
2576
2577    /// Remove all soft breaks that fall within a byte range
2578    ClearSoftBreaksInRange {
2579        buffer_id: BufferId,
2580        start: usize,
2581        end: usize,
2582    },
2583
2584    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
2585    RefreshLines { buffer_id: BufferId },
2586
2587    /// Refresh lines for ALL buffers (clear entire seen_lines cache)
2588    /// Sent when a plugin registers for the lines_changed hook to handle the race
2589    /// where render marks lines as "seen" before the plugin has registered.
2590    RefreshAllLines,
2591
2592    /// Sentinel sent by the plugin thread after a hook has been fully processed.
2593    /// Used by the render loop to wait deterministically for plugin responses
2594    /// (e.g., conceal commands from `lines_changed`) instead of polling.
2595    HookCompleted { hook_name: String },
2596
2597    /// Set a line indicator in the gutter's indicator column
2598    /// Used for git gutter, breakpoints, bookmarks, etc.
2599    SetLineIndicator {
2600        buffer_id: BufferId,
2601        /// Line number (0-indexed)
2602        line: usize,
2603        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
2604        namespace: String,
2605        /// Symbol to display (e.g., "│", "●", "★")
2606        symbol: String,
2607        /// Color as RGB tuple
2608        color: (u8, u8, u8),
2609        /// Priority for display when multiple indicators exist (higher wins)
2610        priority: i32,
2611    },
2612
2613    /// Batch set line indicators in the gutter's indicator column
2614    /// Optimized for setting many lines with the same namespace/symbol/color/priority
2615    SetLineIndicators {
2616        buffer_id: BufferId,
2617        /// Line numbers (0-indexed)
2618        lines: Vec<usize>,
2619        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
2620        namespace: String,
2621        /// Symbol to display (e.g., "│", "●", "★")
2622        symbol: String,
2623        /// Color as RGB tuple
2624        color: (u8, u8, u8),
2625        /// Priority for display when multiple indicators exist (higher wins)
2626        priority: i32,
2627    },
2628
2629    /// Clear all line indicators for a specific namespace
2630    ClearLineIndicators {
2631        buffer_id: BufferId,
2632        /// Namespace to clear (e.g., "git-gutter")
2633        namespace: String,
2634    },
2635
2636    /// Set file explorer decorations for a namespace
2637    SetFileExplorerDecorations {
2638        /// Namespace for grouping (e.g., "git-status")
2639        namespace: String,
2640        /// Decorations to apply
2641        decorations: Vec<FileExplorerDecoration>,
2642    },
2643
2644    /// Clear file explorer decorations for a namespace
2645    ClearFileExplorerDecorations {
2646        /// Namespace to clear (e.g., "git-status")
2647        namespace: String,
2648    },
2649
2650    /// Open a file at a specific line and column
2651    /// Line and column are 1-indexed to match git grep output
2652    OpenFileAtLocation {
2653        path: PathBuf,
2654        line: Option<usize>,   // 1-indexed, None = go to start
2655        column: Option<usize>, // 1-indexed, None = go to line start
2656    },
2657
2658    /// Open a file in a specific split at a given line and column
2659    /// Line and column are 1-indexed to match git grep output
2660    OpenFileInSplit {
2661        split_id: usize,
2662        path: PathBuf,
2663        line: Option<usize>,   // 1-indexed, None = go to start
2664        column: Option<usize>, // 1-indexed, None = go to line start
2665    },
2666
2667    /// Cancel the active prompt / overlay, equivalent to the user
2668    /// pressing Escape. Lets a plugin dismiss a prompt it opened.
2669    CancelPrompt,
2670    /// Start a prompt (minibuffer) with a custom type identifier
2671    /// This allows plugins to create interactive prompts
2672    StartPrompt {
2673        label: String,
2674        prompt_type: String, // e.g., "git-grep", "git-find-file"
2675        /// When true, the prompt renders as a centred floating
2676        /// overlay rather than a bottom-row minibuffer. Used for
2677        /// Live Grep (issue #1796). Defaults to false at the wire
2678        /// level via `#[serde(default)]`.
2679        #[serde(default)]
2680        floating_overlay: bool,
2681    },
2682
2683    /// Start a prompt with pre-filled initial value
2684    StartPromptWithInitial {
2685        label: String,
2686        prompt_type: String,
2687        initial_value: String,
2688        /// See `StartPrompt::floating_overlay`.
2689        #[serde(default)]
2690        floating_overlay: bool,
2691    },
2692
2693    /// Start an async prompt that returns result via callback
2694    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
2695    StartPromptAsync {
2696        label: String,
2697        initial_value: String,
2698        callback_id: JsCallbackId,
2699    },
2700
2701    /// Request the next keypress for the calling plugin.
2702    ///
2703    /// The editor enqueues `callback_id` and resolves it with a
2704    /// `KeyEventPayload` JSON value the next time a key arrives in
2705    /// `Editor::handle_key`. Multiple pending requests are FIFO.
2706    /// While at least one request is pending, the next key is consumed
2707    /// by the resolution and does not propagate to mode bindings or
2708    /// other dispatch — this is the primitive that lets a plugin run a
2709    /// short input loop (flash labels, vi find-char, replace-char,
2710    /// etc.) without binding every printable key in `defineMode`.
2711    AwaitNextKey { callback_id: JsCallbackId },
2712
2713    /// Begin or end "key capture" mode for the calling plugin.
2714    ///
2715    /// Without this, a plugin running a `getNextKey()` loop has a
2716    /// race: keys typed by the user (or pasted, or auto-repeated)
2717    /// can arrive between two consecutive `getNextKey()` calls while
2718    /// the plugin is still mid-redraw, and would otherwise fall
2719    /// through to the editor's normal dispatch (inserting into the
2720    /// buffer, etc.).
2721    ///
2722    /// While capture is active, every key arriving in
2723    /// `Editor::handle_key` (after terminal-input dispatch) is
2724    /// either resolved against a pending `AwaitNextKey` callback
2725    /// (existing behaviour) or, if no callback is pending, *buffered*
2726    /// in a FIFO queue.  When the next `AwaitNextKey` is processed,
2727    /// the queue is drained first.  This gives plugins lossless,
2728    /// in-order delivery of every key the user typed regardless of
2729    /// timing.
2730    ///
2731    /// `EndKeyCapture` clears any unconsumed buffered keys; they do
2732    /// NOT replay into the editor's normal dispatch path (that would
2733    /// be surprising — the user's intent was for the plugin to
2734    /// consume them).
2735    SetKeyCaptureActive { active: bool },
2736
2737    /// Update the suggestions list for the current prompt
2738    /// Uses the editor's Suggestion type
2739    SetPromptSuggestions { suggestions: Vec<Suggestion> },
2740
2741    /// When enabled, navigating suggestions updates the prompt input text
2742    SetPromptInputSync { sync: bool },
2743
2744    /// Set the title shown in a floating-overlay prompt's frame
2745    /// header (issue #1796) as styled segments. Each segment carries
2746    /// optional `OverlayOptions`, so plugins can theme keybinding
2747    /// hints with `fg: "ui.help_key_fg"`, separators with
2748    /// `fg: "ui.popup_border_fg"`, etc. An empty vec clears the
2749    /// title and falls back to the prompt-type default. Has no
2750    /// visible effect on non-overlay prompts.
2751    SetPromptTitle { title: Vec<StyledText> },
2752
2753    /// Plugin-supplied footer chrome rendered along the bottom
2754    /// row of the floating-overlay's results pane (Primitive #2
2755    /// chrome region in
2756    /// `docs/internal/orchestrator-sessions-design.md`). Orchestrator
2757    /// uses this for hotkey-hint rows. Empty vec clears the
2758    /// footer. Has no visible effect on non-overlay prompts.
2759    SetPromptFooter { footer: Vec<StyledText> },
2760
2761    /// Plugin-supplied toolbar for the floating-overlay prompt's header
2762    /// band, as a `WidgetSpec` (a `Row`/`Col` of `Toggle`s/`Button`s). Unlike
2763    /// `SetPromptTitle` (styled text), these are real widgets: they render
2764    /// with the theme's toggle/button styling and are clickable (each carries
2765    /// a stable `key`; the host maps a click to the
2766    /// `live_grep_toggle_<key>`-style action). `None`/absent leaves the
2767    /// styled-text title in place. Has no visible effect on non-overlay
2768    /// prompts.
2769    SetPromptToolbar { spec: Option<WidgetSpec> },
2770
2771    /// Short status text shown right-aligned on the floating-overlay prompt's
2772    /// input row, just left of the `selected / total` count (e.g.
2773    /// "Searching…", "No matches"). Empty clears it. No effect on non-overlay
2774    /// prompts.
2775    SetPromptStatus { status: String },
2776
2777    /// Flip a toolbar toggle in the floating-overlay prompt by its widget
2778    /// `key`. The host owns the toggle's checked state: it updates the spec in
2779    /// place and emits a `widget_event` so the plugin can react (re-run its
2780    /// search, etc.). Lets a plugin's Alt+… shortcut funnel through the same
2781    /// host path as a click or Space on the focused toggle.
2782    ToggleOverlayToolbarWidget { key: String },
2783
2784    /// Override the currently-highlighted suggestion row in the
2785    /// open prompt. Clamped to the suggestion list's bounds; out-
2786    /// of-range indices snap to the last row. No-op when there is
2787    /// no open prompt or the list is empty. The renderer scrolls
2788    /// the selection into view on the next frame.
2789    SetPromptSelectedIndex { index: u32 },
2790
2791    /// Add a menu item to an existing menu
2792    /// Add a menu item to an existing menu
2793    AddMenuItem {
2794        menu_label: String,
2795        item: MenuItem,
2796        position: MenuPosition,
2797    },
2798
2799    /// Add a new top-level menu
2800    AddMenu { menu: Menu, position: MenuPosition },
2801
2802    /// Remove a menu item from a menu
2803    RemoveMenuItem {
2804        menu_label: String,
2805        item_label: String,
2806    },
2807
2808    /// Remove a top-level menu
2809    RemoveMenu { menu_label: String },
2810
2811    /// Create a new virtual buffer (not backed by a file)
2812    CreateVirtualBuffer {
2813        /// Display name (e.g., "*Diagnostics*")
2814        name: String,
2815        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
2816        mode: String,
2817        /// Whether the buffer is read-only
2818        read_only: bool,
2819    },
2820
2821    /// Create a virtual buffer and set its content in one operation
2822    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
2823    /// because it doesn't require tracking the buffer ID
2824    CreateVirtualBufferWithContent {
2825        /// Display name (e.g., "*Diagnostics*")
2826        name: String,
2827        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
2828        mode: String,
2829        /// Whether the buffer is read-only
2830        read_only: bool,
2831        /// Entries with text and embedded properties
2832        entries: Vec<TextPropertyEntry>,
2833        /// Whether to show line numbers in the gutter
2834        show_line_numbers: bool,
2835        /// Whether to show cursors in the buffer
2836        show_cursors: bool,
2837        /// Whether editing is disabled (blocks editing commands)
2838        editing_disabled: bool,
2839        /// Whether this buffer should be hidden from tabs (for composite source buffers)
2840        hidden_from_tabs: bool,
2841        /// Optional request ID for async response
2842        request_id: Option<u64>,
2843    },
2844
2845    /// Create a virtual buffer in a horizontal split
2846    /// Opens the buffer in a new pane below the current one
2847    CreateVirtualBufferInSplit {
2848        /// Display name (e.g., "*Diagnostics*")
2849        name: String,
2850        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
2851        mode: String,
2852        /// Whether the buffer is read-only
2853        read_only: bool,
2854        /// Entries with text and embedded properties
2855        entries: Vec<TextPropertyEntry>,
2856        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
2857        ratio: f32,
2858        /// Split direction ("horizontal" or "vertical"), default horizontal
2859        direction: Option<String>,
2860        /// Optional panel ID for idempotent operations (if panel exists, update content)
2861        panel_id: Option<String>,
2862        /// Whether to show line numbers in the buffer (default true)
2863        show_line_numbers: bool,
2864        /// Whether to show cursors in the buffer (default true)
2865        show_cursors: bool,
2866        /// Whether editing is disabled for this buffer (default false)
2867        editing_disabled: bool,
2868        /// Whether line wrapping is enabled for this split (None = use global setting)
2869        line_wrap: Option<bool>,
2870        /// Place the new buffer before (left/top of) the existing content (default: false/after)
2871        before: bool,
2872        /// Optional split role tag. When `Some("utility_dock")`, the
2873        /// dispatcher routes the buffer to the existing dock leaf if
2874        /// one exists; otherwise it seeds a new dock leaf with the
2875        /// requested direction/ratio.
2876        role: Option<String>,
2877        /// Optional request ID for async response (if set, editor will send back buffer ID)
2878        request_id: Option<u64>,
2879    },
2880
2881    /// Set the content of a virtual buffer with text properties
2882    SetVirtualBufferContent {
2883        buffer_id: BufferId,
2884        /// Entries with text and embedded properties
2885        entries: Vec<TextPropertyEntry>,
2886    },
2887
2888    /// Get text properties at the cursor position in a buffer
2889    GetTextPropertiesAtCursor { buffer_id: BufferId },
2890
2891    /// Create a buffer group: multiple panels appearing as one tab.
2892    /// Each panel is a real buffer with its own scrollbar and viewport.
2893    CreateBufferGroup {
2894        /// Display name (shown in tab bar)
2895        name: String,
2896        /// Mode for keybindings
2897        mode: String,
2898        /// Layout tree as JSON string (parsed by the handler)
2899        layout_json: String,
2900        /// Optional request ID for async response
2901        request_id: Option<u64>,
2902    },
2903
2904    /// Set the content of a panel within a buffer group.
2905    SetPanelContent {
2906        /// Group ID
2907        group_id: usize,
2908        /// Panel name (e.g., "tree", "picker")
2909        panel_name: String,
2910        /// Content entries
2911        entries: Vec<TextPropertyEntry>,
2912    },
2913
2914    /// Close a buffer group (closes all panels and splits)
2915    CloseBufferGroup { group_id: usize },
2916
2917    /// Focus a specific panel within a buffer group
2918    FocusPanel { group_id: usize, panel_name: String },
2919
2920    /// Define a buffer mode with keybindings
2921    DefineMode {
2922        name: String,
2923        bindings: Vec<(String, String)>, // (key_string, command_name)
2924        read_only: bool,
2925        /// When true, unbound character keys dispatch as `mode_text_input:<char>`.
2926        allow_text_input: bool,
2927        /// When true, keys not bound by this mode fall through to the Normal
2928        /// context (motion, selection, copy) instead of being dropped.
2929        inherit_normal_bindings: bool,
2930        /// Name of the plugin that defined this mode (for attribution)
2931        plugin_name: Option<String>,
2932    },
2933
2934    /// Switch the current split to display a buffer
2935    ShowBuffer { buffer_id: BufferId },
2936
2937    /// Start a frame-buffer animation over a given screen region. The `id`
2938    /// is allocated on the plugin side so the JS call can return it
2939    /// synchronously; the editor uses it verbatim.
2940    StartAnimationArea {
2941        id: u64,
2942        rect: AnimationRect,
2943        kind: PluginAnimationKind,
2944    },
2945
2946    /// Start an animation over the on-screen Rect currently occupied by a
2947    /// virtual buffer. If the buffer is not visible, the editor ignores
2948    /// the command.
2949    StartAnimationVirtualBuffer {
2950        id: u64,
2951        buffer_id: BufferId,
2952        kind: PluginAnimationKind,
2953    },
2954
2955    /// Cancel an animation by the ID returned from `animateArea` /
2956    /// `animateVirtualBuffer`. No-op if the ID is unknown or already done.
2957    CancelAnimation { id: u64 },
2958
2959    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
2960    CreateVirtualBufferInExistingSplit {
2961        /// Display name (e.g., "*Commit Details*")
2962        name: String,
2963        /// Mode name for buffer-local keybindings
2964        mode: String,
2965        /// Whether the buffer is read-only
2966        read_only: bool,
2967        /// Entries with text and embedded properties
2968        entries: Vec<TextPropertyEntry>,
2969        /// Target split ID where the buffer should be displayed
2970        split_id: SplitId,
2971        /// Whether to show line numbers in the buffer (default true)
2972        show_line_numbers: bool,
2973        /// Whether to show cursors in the buffer (default true)
2974        show_cursors: bool,
2975        /// Whether editing is disabled for this buffer (default false)
2976        editing_disabled: bool,
2977        /// Whether line wrapping is enabled for this split (None = use global setting)
2978        line_wrap: Option<bool>,
2979        /// Optional request ID for async response
2980        request_id: Option<u64>,
2981    },
2982
2983    /// Close a buffer and remove it from all splits
2984    CloseBuffer { buffer_id: BufferId },
2985
2986    /// Close all buffers in the split except the specified one
2987    CloseOtherBuffersInSplit {
2988        buffer_id: BufferId,
2989        split_id: SplitId,
2990    },
2991
2992    /// Close all buffers in the split
2993    CloseAllBuffersInSplit { split_id: SplitId },
2994
2995    /// Close all buffers to the right of the specified buffer in the split
2996    CloseBuffersToRightInSplit {
2997        buffer_id: BufferId,
2998        split_id: SplitId,
2999    },
3000
3001    /// Close all buffers to the left of the specified buffer in the split
3002    CloseBuffersToLeftInSplit {
3003        buffer_id: BufferId,
3004        split_id: SplitId,
3005    },
3006
3007    /// Move the active tab one position to the left within its split
3008    MoveTabLeft,
3009
3010    /// Move the active tab one position to the right within its split
3011    MoveTabRight,
3012
3013    /// Create a composite buffer that displays multiple source buffers
3014    /// Used for side-by-side diff, unified diff, and 3-way merge views
3015    CreateCompositeBuffer {
3016        /// Display name (shown in tab bar)
3017        name: String,
3018        /// Mode name for keybindings (e.g., "diff-view")
3019        mode: String,
3020        /// Layout configuration
3021        layout: CompositeLayoutConfig,
3022        /// Source pane configurations
3023        sources: Vec<CompositeSourceConfig>,
3024        /// Diff hunks for line alignment (optional)
3025        hunks: Option<Vec<CompositeHunk>>,
3026        /// When set, first render scrolls to center this hunk (0-indexed)
3027        initial_focus_hunk: Option<usize>,
3028        /// Request ID for async response
3029        request_id: Option<u64>,
3030    },
3031
3032    /// Update alignment for a composite buffer (e.g., after source edit)
3033    UpdateCompositeAlignment {
3034        buffer_id: BufferId,
3035        hunks: Vec<CompositeHunk>,
3036    },
3037
3038    /// Close a composite buffer
3039    CloseCompositeBuffer { buffer_id: BufferId },
3040
3041    /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
3042    ///
3043    /// Creates `CompositeViewState` for any visible composite buffer that doesn't
3044    /// have one, and syncs viewport dimensions from split layout. This ensures
3045    /// subsequent commands can read/modify view state that is normally created
3046    /// lazily during the render cycle.
3047    FlushLayout,
3048
3049    /// Navigate to the next hunk in a composite buffer
3050    CompositeNextHunk { buffer_id: BufferId },
3051
3052    /// Navigate to the previous hunk in a composite buffer
3053    CompositePrevHunk { buffer_id: BufferId },
3054
3055    /// Focus a specific split
3056    FocusSplit { split_id: SplitId },
3057
3058    /// Set the buffer displayed in a specific split
3059    SetSplitBuffer {
3060        split_id: SplitId,
3061        buffer_id: BufferId,
3062    },
3063
3064    /// Set the scroll position of a specific split
3065    SetSplitScroll { split_id: SplitId, top_byte: usize },
3066
3067    /// Request syntax highlights for a buffer range
3068    RequestHighlights {
3069        buffer_id: BufferId,
3070        range: Range<usize>,
3071        request_id: u64,
3072    },
3073
3074    /// Close a split (if not the last one)
3075    CloseSplit { split_id: SplitId },
3076
3077    /// Set the ratio of a split container
3078    SetSplitRatio {
3079        split_id: SplitId,
3080        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
3081        ratio: f32,
3082    },
3083
3084    /// Set a label on a leaf split (e.g., "sidebar")
3085    SetSplitLabel { split_id: SplitId, label: String },
3086
3087    /// Remove a label from a split
3088    ClearSplitLabel { split_id: SplitId },
3089
3090    /// Find a split by its label (async)
3091    GetSplitByLabel { label: String, request_id: u64 },
3092
3093    /// Distribute splits evenly - make all given splits equal size
3094    DistributeSplitsEvenly {
3095        /// Split IDs to distribute evenly
3096        split_ids: Vec<SplitId>,
3097    },
3098
3099    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
3100    SetBufferCursor {
3101        buffer_id: BufferId,
3102        /// Byte offset position for the cursor
3103        position: usize,
3104    },
3105
3106    /// Toggle whether the editor draws a native caret for this buffer.
3107    ///
3108    /// Buffer-group panel buffers default to `show_cursors = false`, which not
3109    /// only hides the caret but also blocks all movement actions in
3110    /// `action_to_events`. Plugins that want native cursor motion in a panel
3111    /// buffer (e.g. for magit-style row navigation) flip this to `true` after
3112    /// `createBufferGroup` returns.
3113    SetBufferShowCursors { buffer_id: BufferId, show: bool },
3114
3115    /// Send an arbitrary LSP request and return the raw JSON response
3116    SendLspRequest {
3117        language: String,
3118        method: String,
3119        #[ts(type = "any")]
3120        params: Option<JsonValue>,
3121        request_id: u64,
3122    },
3123
3124    /// Set the internal clipboard content
3125    SetClipboard { text: String },
3126
3127    /// Delete the current selection in the active buffer
3128    /// This deletes all selected text across all cursors
3129    DeleteSelection,
3130
3131    /// Set or unset a custom context
3132    /// Custom contexts are plugin-defined states that can be used to control command visibility
3133    /// For example, "config-editor" context could make config editor commands available
3134    SetContext {
3135        /// Context name (e.g., "config-editor")
3136        name: String,
3137        /// Whether the context is active
3138        active: bool,
3139    },
3140
3141    /// Set the hunks for the Review Diff tool
3142    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
3143
3144    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
3145    /// Used by vi mode plugin to run motions and calculate cursor ranges
3146    ExecuteAction {
3147        /// Action name (e.g., "move_word_right", "move_line_end")
3148        action_name: String,
3149    },
3150
3151    /// Execute multiple actions in sequence, each with an optional repeat count
3152    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
3153    /// All actions execute atomically with no plugin roundtrips between them
3154    ExecuteActions {
3155        /// List of actions to execute in sequence
3156        actions: Vec<ActionSpec>,
3157    },
3158
3159    /// Get text from a buffer range (for yank operations)
3160    GetBufferText {
3161        /// Buffer ID
3162        buffer_id: BufferId,
3163        /// Start byte offset
3164        start: usize,
3165        /// End byte offset
3166        end: usize,
3167        /// Request ID for async response
3168        request_id: u64,
3169    },
3170
3171    /// Get byte offset of the start of a line (async)
3172    /// Line is 0-indexed (0 = first line)
3173    GetLineStartPosition {
3174        /// Buffer ID (0 for active buffer)
3175        buffer_id: BufferId,
3176        /// Line number (0-indexed)
3177        line: u32,
3178        /// Request ID for async response
3179        request_id: u64,
3180    },
3181
3182    /// Get byte offset of the end of a line (async)
3183    /// Line is 0-indexed (0 = first line)
3184    /// Returns the byte offset after the last character of the line (before newline)
3185    GetLineEndPosition {
3186        /// Buffer ID (0 for active buffer)
3187        buffer_id: BufferId,
3188        /// Line number (0-indexed)
3189        line: u32,
3190        /// Request ID for async response
3191        request_id: u64,
3192    },
3193
3194    /// Get the total number of lines in a buffer (async)
3195    GetBufferLineCount {
3196        /// Buffer ID (0 for active buffer)
3197        buffer_id: BufferId,
3198        /// Request ID for async response
3199        request_id: u64,
3200    },
3201
3202    /// Open `path` as a regular buffer in forced large-file (file-backed)
3203    /// mode regardless of file size. Designed for buffers whose backing
3204    /// file will grow under them (e.g. a temp file fed by `spawnProcess`
3205    /// with `stdoutTo`). Resolves with the new buffer's id.
3206    ///
3207    /// Pair with `RefreshBufferFromDisk` to grow the buffer as the file
3208    /// is written.
3209    OpenFileStreaming {
3210        /// Path to open. May not yet exist or may be empty.
3211        path: PathBuf,
3212        /// Request ID for async response (the buffer_id).
3213        request_id: u64,
3214    },
3215
3216    /// Re-stat the file backing `buffer_id` and extend the buffer if
3217    /// the file has grown. No-op if the buffer has no file path or the
3218    /// file didn't grow. Resolves with the new total byte length.
3219    RefreshBufferFromDisk {
3220        buffer_id: BufferId,
3221        /// Request ID for async response.
3222        request_id: u64,
3223    },
3224
3225    /// Re-point a buffer-group's panel at a different buffer id.
3226    /// Used by streaming plugins (e.g. git-log) to swap one
3227    /// file-backed buffer for another when the user navigates to a
3228    /// new commit, without rebuilding the group layout. Both
3229    /// `group.panel_buffers[panel_name]` and the corresponding
3230    /// `SplitViewState.active_buffer` are updated; layout is marked
3231    /// dirty for the next render.
3232    ///
3233    /// Resolves with `true` on success, `false` if the group or panel
3234    /// is missing.
3235    SetBufferGroupPanelBuffer {
3236        group_id: usize,
3237        panel_name: String,
3238        buffer_id: BufferId,
3239        request_id: u64,
3240    },
3241
3242    /// Scroll a split to center a specific line in the viewport
3243    /// Line is 0-indexed (0 = first line)
3244    ScrollToLineCenter {
3245        /// Split ID to scroll
3246        split_id: SplitId,
3247        /// Buffer ID containing the line
3248        buffer_id: BufferId,
3249        /// Line number to center (0-indexed)
3250        line: usize,
3251    },
3252
3253    /// Scroll any split/panel that displays `buffer_id` so the given
3254    /// line is visible in the viewport. Unlike `ScrollToLineCenter` this
3255    /// does not require a split id — it walks all splits (including
3256    /// inner panels of a buffer group) and updates every viewport that
3257    /// shows this buffer. Line is 0-indexed.
3258    ScrollBufferToLine {
3259        /// Buffer ID to scroll
3260        buffer_id: BufferId,
3261        /// Line number to bring into view (0-indexed)
3262        line: usize,
3263    },
3264
3265    /// Set the global editor mode (for modal editing like vi mode)
3266    /// When set, the mode's keybindings take precedence over normal editing
3267    SetEditorMode {
3268        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
3269        mode: Option<String>,
3270    },
3271
3272    /// Show an action popup with buttons for user interaction
3273    /// When the user selects an action, the ActionPopupResult hook is fired
3274    ShowActionPopup {
3275        /// Unique identifier for the popup (used in ActionPopupResult)
3276        popup_id: String,
3277        /// Title text for the popup
3278        title: String,
3279        /// Body message (supports basic formatting)
3280        message: String,
3281        /// Action buttons to display
3282        actions: Vec<ActionPopupAction>,
3283    },
3284
3285    /// Contribute (or replace, or clear) a set of menu rows for the
3286    /// LSP-Servers popup (the popup opened by clicking the LSP
3287    /// indicator). Each plugin owns its own slice keyed by
3288    /// `plugin_id`; passing an empty `items` clears that slice.
3289    ///
3290    /// Rationale: previously plugins reacting to `lsp_status_clicked`
3291    /// pushed their own separate action popup via `ShowActionPopup`,
3292    /// which stacked over the built-in LSP-Servers popup and created
3293    /// the UX conflict in PR #1941. This command lets plugins
3294    /// contribute rows that merge into the existing popup instead.
3295    /// Selecting a contributed row fires `action_popup_result` with
3296    /// `popup_id = "lsp_status"` and `action_id =
3297    /// "{plugin_id}|{id}"`.
3298    SetLspMenuContributions {
3299        /// Stable plugin identifier used both as the namespace for
3300        /// this slice of contributions and as the prefix of the
3301        /// resulting `action_popup_result.action_id`.
3302        plugin_id: String,
3303        /// Language whose LSP-Servers popup should display these
3304        /// rows (e.g. "rust", "python").
3305        language: String,
3306        /// The rows to install. Empty clears any previous
3307        /// contribution from this `plugin_id` for this `language`.
3308        items: Vec<LspMenuItem>,
3309    },
3310
3311    /// Disable LSP for a specific language and persist to config
3312    DisableLspForLanguage {
3313        /// The language to disable LSP for (e.g., "python", "rust")
3314        language: String,
3315    },
3316
3317    /// Restart LSP server for a specific language
3318    RestartLspForLanguage {
3319        /// The language to restart LSP for (e.g., "python", "rust")
3320        language: String,
3321    },
3322
3323    /// Set the workspace root URI for a specific language's LSP server
3324    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
3325    /// If the LSP is already running, it will be restarted with the new root
3326    SetLspRootUri {
3327        /// The language to set root URI for (e.g., "csharp", "rust")
3328        language: String,
3329        /// The root URI (file:// URL format)
3330        uri: String,
3331    },
3332
3333    /// Create a scroll sync group for anchor-based synchronized scrolling
3334    /// Used for side-by-side diff views where two panes need to scroll together
3335    /// The plugin provides the group ID (must be unique per plugin)
3336    CreateScrollSyncGroup {
3337        /// Plugin-assigned group ID
3338        group_id: u32,
3339        /// The left (primary) split - scroll position is tracked in this split's line space
3340        left_split: SplitId,
3341        /// The right (secondary) split - position is derived from anchors
3342        right_split: SplitId,
3343    },
3344
3345    /// Set sync anchors for a scroll sync group
3346    /// Anchors map corresponding line numbers between left and right buffers
3347    SetScrollSyncAnchors {
3348        /// The group ID returned by CreateScrollSyncGroup
3349        group_id: u32,
3350        /// List of (left_line, right_line) pairs marking corresponding positions
3351        anchors: Vec<(usize, usize)>,
3352    },
3353
3354    /// Remove a scroll sync group
3355    RemoveScrollSyncGroup {
3356        /// The group ID returned by CreateScrollSyncGroup
3357        group_id: u32,
3358    },
3359
3360    /// Save a buffer to a specific file path
3361    /// Used by :w filename command to save unnamed buffers or save-as
3362    SaveBufferToPath {
3363        /// Buffer ID to save
3364        buffer_id: BufferId,
3365        /// Path to save to
3366        path: PathBuf,
3367    },
3368
3369    /// Load a plugin from a file path
3370    /// The plugin will be initialized and start receiving events
3371    LoadPlugin {
3372        /// Path to the plugin file (.ts or .js)
3373        path: PathBuf,
3374        /// Callback ID for async response (success/failure)
3375        callback_id: JsCallbackId,
3376    },
3377
3378    /// Unload a plugin by name
3379    /// The plugin will stop receiving events and be removed from memory
3380    UnloadPlugin {
3381        /// Plugin name (as registered)
3382        name: String,
3383        /// Callback ID for async response (success/failure)
3384        callback_id: JsCallbackId,
3385    },
3386
3387    /// Reload a plugin by name (unload + load)
3388    /// Useful for development when plugin code changes
3389    ReloadPlugin {
3390        /// Plugin name (as registered)
3391        name: String,
3392        /// Callback ID for async response (success/failure)
3393        callback_id: JsCallbackId,
3394    },
3395
3396    /// List all loaded plugins
3397    /// Returns plugin info (name, path, enabled) for all loaded plugins
3398    ListPlugins {
3399        /// Callback ID for async response (JSON array of plugin info)
3400        callback_id: JsCallbackId,
3401    },
3402
3403    /// Reload the theme registry from disk
3404    /// Call this after installing a theme package or saving a new theme.
3405    /// If `apply_theme` is set, apply that theme immediately after reloading.
3406    ReloadThemes { apply_theme: Option<String> },
3407
3408    /// Register a TextMate grammar file for a language
3409    /// The grammar will be added to pending_grammars until ReloadGrammars is called
3410    RegisterGrammar {
3411        /// Language identifier (e.g., "elixir", "zig")
3412        language: String,
3413        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
3414        grammar_path: String,
3415        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
3416        extensions: Vec<String>,
3417    },
3418
3419    /// Register language configuration (comment prefix, indentation, formatter)
3420    /// This is applied immediately to the runtime config
3421    RegisterLanguageConfig {
3422        /// Language identifier (e.g., "elixir")
3423        language: String,
3424        /// Language configuration
3425        config: LanguagePackConfig,
3426    },
3427
3428    /// Register an LSP server for a language
3429    /// This is applied immediately to the LSP manager and runtime config
3430    RegisterLspServer {
3431        /// Language identifier (e.g., "elixir")
3432        language: String,
3433        /// LSP server configuration
3434        config: LspServerPackConfig,
3435    },
3436
3437    /// Reload the grammar registry to apply registered grammars (async)
3438    /// Call this after registering one or more grammars to rebuild the syntax set.
3439    /// The callback is resolved when the background grammar build completes.
3440    ReloadGrammars { callback_id: JsCallbackId },
3441
3442    // ==================== Terminal Commands ====================
3443    /// Create a new terminal in a split (async, returns TerminalResult)
3444    /// This spawns a PTY-backed terminal that plugins can write to and read from.
3445    CreateTerminal {
3446        /// Working directory for the terminal (defaults to editor cwd)
3447        cwd: Option<String>,
3448        /// Split direction ("horizontal" or "vertical"), default vertical
3449        direction: Option<String>,
3450        /// Split ratio (0.0 to 1.0), default 0.5
3451        ratio: Option<f32>,
3452        /// Whether to focus the new terminal split (default true)
3453        focus: Option<bool>,
3454        /// Whether this terminal survives editor restarts. When false, the
3455        /// terminal is excluded from workspace serialization and its backing
3456        /// file is kept unique-per-spawn so no scrollback from a prior run
3457        /// leaks in. Plugin-created terminals default to `false` since they
3458        /// are typically one-off tool UIs (rebuilds, exec shells, etc.).
3459        persistent: bool,
3460        /// Optional session id to attach the new terminal buffer to.
3461        /// `None` (default) attaches to the active session at creation
3462        /// time — the historical behaviour. `Some(id)` lets Orchestrator
3463        /// (and any plugin spawning agents in worktrees) attach the
3464        /// terminal to its target session without diving first; the
3465        /// terminal's split is created in that session's stashed split
3466        /// tree, and the buffer is added to the target session's
3467        /// `Session.buffers` membership rather than the active one's.
3468        /// Falls back to active session if the id is unknown.
3469        #[serde(default)]
3470        window_id: Option<WindowId>,
3471        /// Argv to spawn directly in the PTY in lieu of the host's
3472        /// configured shell. See `CreateTerminalOptions::command` for
3473        /// the full semantics — `None` keeps the shell-and-type
3474        /// behaviour, `Some(argv)` runs `argv` as the PTY child.
3475        #[serde(default)]
3476        command: Option<Vec<String>>,
3477        /// Tab title override. Defaults to `command[0]` (when
3478        /// `command` is set) or `"Terminal N"` (when it isn't).
3479        /// See `CreateTerminalOptions::title`.
3480        #[serde(default)]
3481        title: Option<String>,
3482        /// Callback ID for async response
3483        request_id: u64,
3484    },
3485
3486    /// Send input data to a terminal by its terminal ID
3487    SendTerminalInput {
3488        /// The terminal ID (from TerminalResult)
3489        terminal_id: TerminalId,
3490        /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
3491        data: String,
3492    },
3493
3494    /// Close a terminal by its terminal ID
3495    CloseTerminal {
3496        /// The terminal ID to close
3497        terminal_id: TerminalId,
3498    },
3499
3500    /// Send `signal` to every process group tracked by the
3501    /// window `id`. `signal` is one of `"SIGTERM"` / `"SIGKILL"`
3502    /// / `"SIGINT"` / `"SIGHUP"`; the window's authority
3503    /// determines the actual delivery mechanism (local
3504    /// `kill(-pgid, …)` on host, `docker exec kill …` for
3505    /// container authorities, SSH agent for remote ones —
3506    /// see `app/window/process_group.rs`). Idempotent across
3507    /// already-exited groups: callers can retry safely.
3508    SignalWindow { id: WindowId, signal: String },
3509
3510    /// Project-wide grep search (async)
3511    /// Searches all project files via FileSystem trait, respecting .gitignore.
3512    /// For open buffers with dirty edits, searches the buffer's piece tree.
3513    GrepProject {
3514        /// Search pattern (literal string)
3515        pattern: String,
3516        /// Whether the pattern is a fixed string (true) or regex (false)
3517        fixed_string: bool,
3518        /// Whether the search is case-sensitive
3519        case_sensitive: bool,
3520        /// Maximum number of results to return
3521        max_results: usize,
3522        /// Whether to match whole words only
3523        whole_words: bool,
3524        /// Callback ID for async response
3525        callback_id: JsCallbackId,
3526    },
3527
3528    /// Project-wide streaming search using a pull-based handle.
3529    ///
3530    /// The plugin allocates `handle_id` and registers an `Arc<SearchHandleState>`
3531    /// in the shared `SearchHandleRegistry` before sending this command. The
3532    /// editor's searcher tasks look up the same entry and write matches
3533    /// directly into its `pending` vec — no per-chunk JS dispatch. The plugin
3534    /// drains state via `editor._searchHandleTake(handle_id)` at its own pace.
3535    BeginSearch {
3536        /// Search pattern
3537        pattern: String,
3538        /// Whether the pattern is a fixed string (true) or regex (false)
3539        fixed_string: bool,
3540        /// Whether the search is case-sensitive
3541        case_sensitive: bool,
3542        /// Maximum number of results before the search self-truncates
3543        max_results: usize,
3544        /// Whether to match whole words only
3545        whole_words: bool,
3546        /// Source buffer id to additionally search in-memory when it has no
3547        /// file path (an unnamed/unsaved buffer). The on-disk walk can't see
3548        /// such a buffer, so the host searches its piece-tree content directly
3549        /// and emits matches carrying this buffer id. 0 means "no such buffer".
3550        source_buffer_id: usize,
3551        /// Handle ID — key into the shared `SearchHandleRegistry`
3552        handle_id: u64,
3553    },
3554
3555    /// Replace matches in a buffer (async)
3556    /// Opens the file if not already open, applies edits through the buffer model,
3557    /// groups as a single undo action, and saves via FileSystem trait.
3558    ReplaceInBuffer {
3559        /// File path to edit (will open if not already in a buffer)
3560        file_path: PathBuf,
3561        /// Buffer id to edit directly when non-zero and still live. Used for
3562        /// unnamed/unsaved buffers that have no path to resolve by. When 0, the
3563        /// target is resolved via `file_path` (opening the file if needed).
3564        buffer_id: usize,
3565        /// Matches to replace, each is (byte_offset, length)
3566        matches: Vec<(usize, usize)>,
3567        /// Replacement text
3568        replacement: String,
3569        /// Callback ID for async response
3570        callback_id: JsCallbackId,
3571    },
3572
3573    /// Install a new authority.
3574    ///
3575    /// Authority is opaque to core. The payload is a tagged JSON object
3576    /// (filesystem kind + spawner kind + terminal wrapper + display
3577    /// label) that `fresh-editor` deserializes into its concrete
3578    /// `AuthorityPayload` type. Using `serde_json::Value` here keeps
3579    /// fresh-core from growing backend-specific knowledge; see
3580    /// `crates/fresh-editor/src/services/authority/mod.rs` for the
3581    /// canonical schema.
3582    ///
3583    /// Fire-and-forget: the transition piggy-backs on the existing
3584    /// editor restart flow, so the plugin that sent this command will
3585    /// be re-loaded as part of the restart. Any follow-up work the
3586    /// plugin wants to do after the switch belongs in its post-restart
3587    /// init code, not in a callback here.
3588    SetAuthority {
3589        #[ts(type = "unknown")]
3590        payload: JsonValue,
3591    },
3592
3593    /// Restore the default local authority. Same semantics as
3594    /// `SetAuthority` with a local payload — triggers an editor
3595    /// restart.
3596    ClearAuthority,
3597
3598    /// Activate an environment: set the live env provider's recipe (an
3599    /// activation shell `snippet` run in `dir`). Re-evaluated on demand on the
3600    /// active backend and applied to every spawn — no authority rebuild. Only
3601    /// honored when the workspace is Trusted (it runs repo-controlled code).
3602    SetEnv {
3603        snippet: String,
3604        #[serde(default)]
3605        dir: Option<String>,
3606    },
3607
3608    /// Deactivate the environment — clear the live provider so spawns use the
3609    /// inherited environment again.
3610    ClearEnv,
3611
3612    /// Override the Remote Indicator's displayed state for the rest
3613    /// of the current editor session (until a restart, or until the
3614    /// plugin sends another override / `ClearRemoteIndicatorState`).
3615    ///
3616    /// The derived state — computed from the active authority's
3617    /// connection info — keeps running underneath and is what the
3618    /// indicator shows whenever an override is not in effect.
3619    /// Plugins use this to surface lifecycle states that have no
3620    /// authority-level truth yet (e.g. "Connecting" during
3621    /// `devcontainer up`, "FailedAttach" after a non-zero exit).
3622    ///
3623    /// `state` is a tagged enum keyed by `kind`:
3624    ///   - `{ "kind": "local" }`
3625    ///   - `{ "kind": "connecting", "label": "..." }`
3626    ///   - `{ "kind": "connected", "label": "..." }`
3627    ///   - `{ "kind": "failed_attach", "error": "..." }`
3628    ///   - `{ "kind": "disconnected", "label": "..." }`
3629    ///
3630    /// The exact schema lives in
3631    /// `crates/fresh-editor/src/view/ui/status_bar.rs`; fresh-core
3632    /// takes it opaquely so new variants can land without touching
3633    /// core plumbing.
3634    SetRemoteIndicatorState {
3635        #[ts(type = "unknown")]
3636        state: JsonValue,
3637    },
3638
3639    /// Drop any active Remote Indicator override and fall back to
3640    /// the authority-derived state. Safe to call without a prior
3641    /// `SetRemoteIndicatorState`.
3642    ClearRemoteIndicatorState,
3643
3644    /// Spawn a process on the host, regardless of the currently
3645    /// installed authority.
3646    ///
3647    /// Intended for plugin internals that must run host-side work
3648    /// (e.g. `devcontainer up`) before installing an authority that
3649    /// would otherwise route the spawn elsewhere. Behaves like
3650    /// `SpawnProcess` but always uses `LocalProcessSpawner`.
3651    ///
3652    /// The TS-side handle exposes `.kill()` on the returned
3653    /// `ProcessHandle`, serviced by `KillHostProcess` below — this
3654    /// lets callers abort a long-running host spawn (e.g.
3655    /// `devcontainer up`) via a user action like "Cancel Startup".
3656    SpawnHostProcess {
3657        command: String,
3658        args: Vec<String>,
3659        cwd: Option<String>,
3660        callback_id: JsCallbackId,
3661    },
3662
3663    /// Cancel a host-side process previously started via
3664    /// `SpawnHostProcess`. `process_id` is the callback id returned
3665    /// by `spawnHostProcess` (the TS handle stores it and forwards
3666    /// when the caller invokes `.kill()`).
3667    ///
3668    /// No-op when the id is unknown — the process may have already
3669    /// exited, or the caller may hold a stale handle. SIGKILL on
3670    /// Unix per `tokio::process::Child::start_kill`; children of the
3671    /// killed process may leak (see Q-C2 in
3672    /// `DEVCONTAINER_SPEC_GAP_PLAN.md`).
3673    KillHostProcess { process_id: u64 },
3674
3675    /// Mount a declarative widget panel inside an existing virtual
3676    /// buffer. The host renders the `WidgetSpec` and writes the
3677    /// resulting text-property entries into the buffer. The
3678    /// `panel_id` is plugin-allocated (any unique u64 for that
3679    /// plugin) and is used to address the panel for later
3680    /// `UpdateWidgetPanel` / `UnmountWidgetPanel` calls.
3681    ///
3682    /// See `docs/internal/plugin-widget-library-design.md`.
3683    MountWidgetPanel {
3684        panel_id: u64,
3685        buffer_id: BufferId,
3686        spec: WidgetSpec,
3687    },
3688
3689    /// Replace the spec of a previously-mounted widget panel.
3690    /// The reconciler diffs against the previous spec and applies
3691    /// the minimum mutation; widget instance state is preserved on
3692    /// nodes whose `key` matches.
3693    UpdateWidgetPanel { panel_id: u64, spec: WidgetSpec },
3694
3695    /// Tear down a widget panel. Subsequent `UpdateWidgetPanel`
3696    /// calls for the same `panel_id` are no-ops.
3697    UnmountWidgetPanel { panel_id: u64 },
3698
3699    /// Route a keystroke / nav action to the panel's currently
3700    /// focused widget. The plugin's `defineMode` bindings dispatch
3701    /// here for keys that should be handled by the widget layer
3702    /// (Tab cycle, Enter to activate, Up/Down to navigate a List,
3703    /// Backspace / arrows / printable input to edit a TextInput).
3704    /// See `WidgetAction` for the action shapes.
3705    WidgetCommand { panel_id: u64, action: WidgetAction },
3706
3707    /// Apply a targeted mutation to a mounted widget panel
3708    /// without re-transmitting the full spec. The IPC fast path
3709    /// for hot-path updates (typing, selection moves, partial
3710    /// list refreshes). See `WidgetMutation` for the shapes.
3711    WidgetMutate {
3712        panel_id: u64,
3713        mutation: WidgetMutation,
3714    },
3715
3716    /// Mount a declarative widget panel as a centered floating
3717    /// overlay rather than into a virtual buffer. `width_pct` and
3718    /// `height_pct` size the overlay rect relative to the terminal
3719    /// (clamped 1..=100). Only one floating widget panel may be
3720    /// mounted at a time; a second `MountFloatingWidget` replaces
3721    /// any existing one.
3722    MountFloatingWidget {
3723        panel_id: u64,
3724        spec: WidgetSpec,
3725        width_pct: u8,
3726        height_pct: u8,
3727    },
3728
3729    /// Replace the spec of the currently-mounted floating widget
3730    /// panel. No-op when no floating panel is mounted, or when the
3731    /// `panel_id` doesn't match the mounted one.
3732    UpdateFloatingWidget { panel_id: u64, spec: WidgetSpec },
3733
3734    /// Tear down the floating widget panel. No-op when no floating
3735    /// panel is mounted, or when the `panel_id` doesn't match.
3736    UnmountFloatingWidget { panel_id: u64 },
3737}
3738
3739impl PluginCommand {
3740    /// Extract the enum variant name from the Debug representation.
3741    pub fn debug_variant_name(&self) -> String {
3742        let dbg = format!("{:?}", self);
3743        dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
3744    }
3745}
3746
3747// =============================================================================
3748// Language Pack Configuration Types
3749// =============================================================================
3750
3751/// Language configuration for language packs
3752///
3753/// This is a simplified version of the full LanguageConfig, containing only
3754/// the fields that can be set via the plugin API.
3755#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
3756#[serde(rename_all = "camelCase")]
3757#[ts(export)]
3758pub struct LanguagePackConfig {
3759    /// Comment prefix for line comments (e.g., "//" or "#")
3760    #[serde(default)]
3761    pub comment_prefix: Option<String>,
3762
3763    /// Block comment start marker (e.g., slash-star)
3764    #[serde(default)]
3765    pub block_comment_start: Option<String>,
3766
3767    /// Block comment end marker (e.g., star-slash)
3768    #[serde(default)]
3769    pub block_comment_end: Option<String>,
3770
3771    /// Whether to use tabs instead of spaces for indentation
3772    #[serde(default)]
3773    pub use_tabs: Option<bool>,
3774
3775    /// Tab size (number of spaces per tab level)
3776    #[serde(default)]
3777    pub tab_size: Option<usize>,
3778
3779    /// Whether auto-indent is enabled
3780    #[serde(default)]
3781    pub auto_indent: Option<bool>,
3782
3783    /// Whether to show whitespace tab indicators (→) for this language
3784    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
3785    #[serde(default)]
3786    pub show_whitespace_tabs: Option<bool>,
3787
3788    /// Formatter configuration
3789    #[serde(default)]
3790    pub formatter: Option<FormatterPackConfig>,
3791}
3792
3793/// Formatter configuration for language packs
3794#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3795#[serde(rename_all = "camelCase")]
3796#[ts(export)]
3797pub struct FormatterPackConfig {
3798    /// Command to run (e.g., "prettier", "rustfmt")
3799    pub command: String,
3800
3801    /// Arguments to pass to the formatter
3802    #[serde(default)]
3803    pub args: Vec<String>,
3804}
3805
3806/// Process resource limits for LSP servers
3807#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3808#[serde(rename_all = "camelCase")]
3809#[ts(export)]
3810pub struct ProcessLimitsPackConfig {
3811    /// Maximum memory usage as percentage of total system memory (null = no limit)
3812    #[serde(default)]
3813    pub max_memory_percent: Option<u32>,
3814
3815    /// Maximum CPU usage as percentage of total CPU (null = no limit)
3816    #[serde(default)]
3817    pub max_cpu_percent: Option<u32>,
3818
3819    /// Enable resource limiting
3820    #[serde(default)]
3821    pub enabled: Option<bool>,
3822}
3823
3824/// LSP server configuration for language packs
3825#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3826#[serde(rename_all = "camelCase")]
3827#[ts(export)]
3828pub struct LspServerPackConfig {
3829    /// Command to start the LSP server
3830    pub command: String,
3831
3832    /// Arguments to pass to the command
3833    #[serde(default)]
3834    pub args: Vec<String>,
3835
3836    /// Whether to auto-start the server when a matching file is opened
3837    #[serde(default)]
3838    pub auto_start: Option<bool>,
3839
3840    /// LSP initialization options
3841    #[serde(default)]
3842    #[ts(type = "Record<string, unknown> | null")]
3843    pub initialization_options: Option<JsonValue>,
3844
3845    /// Process resource limits (memory and CPU)
3846    #[serde(default)]
3847    pub process_limits: Option<ProcessLimitsPackConfig>,
3848}
3849
3850/// Hunk status for Review Diff
3851#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
3852#[ts(export)]
3853pub enum HunkStatus {
3854    Pending,
3855    Staged,
3856    Discarded,
3857}
3858
3859/// A high-level hunk directive for the Review Diff tool
3860#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3861#[ts(export)]
3862pub struct ReviewHunk {
3863    pub id: String,
3864    pub file: String,
3865    pub context_header: String,
3866    pub status: HunkStatus,
3867    /// 0-indexed line range in the base (HEAD) version
3868    pub base_range: Option<(usize, usize)>,
3869    /// 0-indexed line range in the modified (Working) version
3870    pub modified_range: Option<(usize, usize)>,
3871}
3872
3873/// Action button for action popups
3874#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3875#[serde(deny_unknown_fields)]
3876#[ts(export, rename = "TsActionPopupAction")]
3877pub struct ActionPopupAction {
3878    /// Unique action identifier (returned in ActionPopupResult)
3879    pub id: String,
3880    /// Display text for the button (can include command hints)
3881    pub label: String,
3882}
3883
3884/// Plugin-contributed row in the LSP-Servers popup.
3885/// See `PluginCommand::SetLspMenuContributions`.
3886#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3887#[serde(deny_unknown_fields)]
3888#[ts(export, rename = "TsLspMenuItem")]
3889pub struct LspMenuItem {
3890    /// Stable identifier used as the `action_id` in the resulting
3891    /// `action_popup_result` event (prefixed by `{plugin_id}|`).
3892    pub id: String,
3893    /// Display label shown in the popup row.
3894    pub label: String,
3895}
3896
3897/// Options for showActionPopup
3898#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3899#[serde(deny_unknown_fields)]
3900#[ts(export)]
3901pub struct ActionPopupOptions {
3902    /// Unique identifier for the popup (used in ActionPopupResult)
3903    pub id: String,
3904    /// Title text for the popup
3905    pub title: String,
3906    /// Body message (supports basic formatting)
3907    pub message: String,
3908    /// Action buttons to display
3909    pub actions: Vec<ActionPopupAction>,
3910}
3911
3912/// Syntax highlight span for a buffer range
3913#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3914#[ts(export)]
3915pub struct TsHighlightSpan {
3916    pub start: u32,
3917    pub end: u32,
3918    #[ts(type = "[number, number, number]")]
3919    pub color: (u8, u8, u8),
3920    pub bold: bool,
3921    pub italic: bool,
3922}
3923
3924/// Result from spawning a process with spawnProcess
3925#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3926#[ts(export)]
3927pub struct SpawnResult {
3928    /// Complete stdout as string
3929    pub stdout: String,
3930    /// Complete stderr as string
3931    pub stderr: String,
3932    /// Process exit code (0 usually means success, -1 if killed)
3933    pub exit_code: i32,
3934}
3935
3936/// Result from spawning a background process
3937#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3938#[ts(export)]
3939pub struct BackgroundProcessResult {
3940    /// Unique process ID for later reference
3941    #[ts(type = "number")]
3942    pub process_id: u64,
3943    /// Process exit code (0 usually means success, -1 if killed)
3944    /// Only present when the process has exited
3945    pub exit_code: i32,
3946}
3947
3948/// A single match from project-wide grep
3949#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3950#[serde(rename_all = "camelCase")]
3951#[ts(export, rename_all = "camelCase")]
3952pub struct GrepMatch {
3953    /// Absolute file path
3954    pub file: String,
3955    /// Buffer ID if the file is open (0 if not)
3956    #[ts(type = "number")]
3957    pub buffer_id: usize,
3958    /// Byte offset of match start in the file/buffer content
3959    #[ts(type = "number")]
3960    pub byte_offset: usize,
3961    /// Match length in bytes
3962    #[ts(type = "number")]
3963    pub length: usize,
3964    /// 1-indexed line number
3965    #[ts(type = "number")]
3966    pub line: usize,
3967    /// 1-indexed column number
3968    #[ts(type = "number")]
3969    pub column: usize,
3970    /// The matched line content (for display)
3971    pub context: String,
3972}
3973
3974/// Per-call result from `SearchHandle.take()` — the matches accumulated since
3975/// the previous call plus terminal-state flags.
3976#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3977#[serde(rename_all = "camelCase")]
3978#[ts(export, rename_all = "camelCase")]
3979pub struct SearchTakeResult {
3980    /// Matches discovered since the previous take()
3981    pub matches: Vec<GrepMatch>,
3982    /// Whether the producer has finished (no more matches will arrive)
3983    pub done: bool,
3984    /// Total number of matches the producer has emitted across all batches
3985    /// (including ones already drained on prior take() calls)
3986    #[ts(type = "number")]
3987    pub total_seen: usize,
3988    /// Whether the producer stopped early because it hit `maxResults`
3989    pub truncated: bool,
3990    /// Producer error, if any (e.g., invalid regex). When set, `done` is also true.
3991    #[ts(optional, type = "string | null")]
3992    pub error: Option<String>,
3993}
3994
3995/// Inner state of a streaming search, written by the host's parallel
3996/// searchers and drained by the plugin via `SearchHandle.take()`. The plugin
3997/// observes deltas (`mem::take` on `pending`) at its own cadence; producers
3998/// write at full speed without per-chunk dispatches.
3999#[derive(Debug, Default)]
4000pub struct SearchState {
4001    /// Matches accumulated since the consumer's last drain
4002    pub pending: Vec<GrepMatch>,
4003    /// Total matches the producer has emitted across the search's lifetime
4004    pub total_seen: usize,
4005    /// Set when the producer stopped early due to hitting max_results
4006    pub truncated: bool,
4007    /// Set when the producer is fully done — no more writes will occur
4008    pub done: bool,
4009    /// Producer error, if any (final state)
4010    pub error: Option<String>,
4011}
4012
4013/// A search handle's shared state plus its cancellation flag. Owned by an
4014/// `Arc` so producers (host searcher tasks) and consumers (the JS plugin via
4015/// the registry) can both reference it.
4016#[derive(Debug)]
4017pub struct SearchHandleState {
4018    pub state: std::sync::Mutex<SearchState>,
4019    pub cancel: std::sync::atomic::AtomicBool,
4020}
4021
4022impl SearchHandleState {
4023    pub fn new() -> Self {
4024        Self {
4025            state: std::sync::Mutex::new(SearchState::default()),
4026            cancel: std::sync::atomic::AtomicBool::new(false),
4027        }
4028    }
4029}
4030
4031impl Default for SearchHandleState {
4032    fn default() -> Self {
4033        Self::new()
4034    }
4035}
4036
4037/// Registry mapping a handle ID to its shared `SearchHandleState`. Shared
4038/// between the JS thread (where `JsEditorApi` registers handles and serves
4039/// `take()`/`cancel()`) and the editor thread (where the host's searcher
4040/// tasks write into the same state).
4041pub type SearchHandleRegistry = Arc<std::sync::Mutex<HashMap<u64, Arc<SearchHandleState>>>>;
4042
4043/// Result from replacing matches in a buffer
4044#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4045#[serde(rename_all = "camelCase")]
4046#[ts(export, rename_all = "camelCase")]
4047pub struct ReplaceResult {
4048    /// Number of replacements made
4049    #[ts(type = "number")]
4050    pub replacements: usize,
4051    /// Buffer ID of the edited buffer
4052    #[ts(type = "number")]
4053    pub buffer_id: usize,
4054}
4055
4056/// Entry for virtual buffer content with optional text properties (JS API version)
4057#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4058#[serde(deny_unknown_fields, rename_all = "camelCase")]
4059#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
4060pub struct JsTextPropertyEntry {
4061    /// Text content for this entry
4062    pub text: String,
4063    /// Optional properties attached to this text (e.g., file path, line number)
4064    #[serde(default)]
4065    #[ts(optional, type = "Record<string, unknown>")]
4066    pub properties: Option<HashMap<String, JsonValue>>,
4067    /// Optional whole-entry styling
4068    #[serde(default)]
4069    #[ts(optional, type = "Partial<OverlayOptions>")]
4070    pub style: Option<OverlayOptions>,
4071    /// Optional sub-range styling within this entry
4072    #[serde(default)]
4073    #[ts(optional)]
4074    pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
4075    /// See `TextPropertyEntry::pad_to_chars`.
4076    #[serde(default)]
4077    #[ts(optional)]
4078    pub pad_to_chars: Option<u32>,
4079    /// See `TextPropertyEntry::truncate_to_chars`.
4080    #[serde(default)]
4081    #[ts(optional)]
4082    pub truncate_to_chars: Option<u32>,
4083    /// See `TextPropertyEntry::segments`.
4084    #[serde(default)]
4085    #[ts(optional)]
4086    pub segments: Option<Vec<crate::text_property::StyledSegment>>,
4087}
4088
4089/// Directory entry returned by readDir
4090#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4091#[ts(export)]
4092pub struct DirEntry {
4093    /// File/directory name
4094    pub name: String,
4095    /// True if this is a file
4096    pub is_file: bool,
4097    /// True if this is a directory
4098    pub is_dir: bool,
4099}
4100
4101/// Position in a document (line and character)
4102#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4103#[ts(export)]
4104pub struct JsPosition {
4105    /// Zero-indexed line number
4106    pub line: u32,
4107    /// Zero-indexed character offset
4108    pub character: u32,
4109}
4110
4111/// Range in a document (start and end positions)
4112#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4113#[ts(export)]
4114pub struct JsRange {
4115    /// Start position
4116    pub start: JsPosition,
4117    /// End position
4118    pub end: JsPosition,
4119}
4120
4121/// Diagnostic from LSP
4122#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4123#[ts(export)]
4124pub struct JsDiagnostic {
4125    /// Document URI
4126    pub uri: String,
4127    /// Diagnostic message
4128    pub message: String,
4129    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
4130    pub severity: Option<u8>,
4131    /// Range in the document
4132    pub range: JsRange,
4133    /// Source of the diagnostic (e.g., "typescript", "eslint")
4134    #[ts(optional)]
4135    pub source: Option<String>,
4136}
4137
4138/// Options for createVirtualBuffer
4139#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4140#[serde(deny_unknown_fields)]
4141#[ts(export)]
4142pub struct CreateVirtualBufferOptions {
4143    /// Buffer name (displayed in tabs/title)
4144    pub name: String,
4145    /// Mode for keybindings (e.g., "git-log", "search-results")
4146    #[serde(default)]
4147    #[ts(optional)]
4148    pub mode: Option<String>,
4149    /// Whether buffer is read-only (default: false)
4150    #[serde(default, rename = "readOnly")]
4151    #[ts(optional, rename = "readOnly")]
4152    pub read_only: Option<bool>,
4153    /// Show line numbers in gutter (default: false)
4154    #[serde(default, rename = "showLineNumbers")]
4155    #[ts(optional, rename = "showLineNumbers")]
4156    pub show_line_numbers: Option<bool>,
4157    /// Show cursor (default: true)
4158    #[serde(default, rename = "showCursors")]
4159    #[ts(optional, rename = "showCursors")]
4160    pub show_cursors: Option<bool>,
4161    /// Disable text editing (default: false)
4162    #[serde(default, rename = "editingDisabled")]
4163    #[ts(optional, rename = "editingDisabled")]
4164    pub editing_disabled: Option<bool>,
4165    /// Hide from tab bar (default: false)
4166    #[serde(default, rename = "hiddenFromTabs")]
4167    #[ts(optional, rename = "hiddenFromTabs")]
4168    pub hidden_from_tabs: Option<bool>,
4169    /// Initial content entries with optional properties
4170    #[serde(default)]
4171    #[ts(optional)]
4172    pub entries: Option<Vec<JsTextPropertyEntry>>,
4173}
4174
4175/// Options for createVirtualBufferInSplit
4176#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4177#[serde(deny_unknown_fields)]
4178#[ts(export)]
4179pub struct CreateVirtualBufferInSplitOptions {
4180    /// Buffer name (displayed in tabs/title)
4181    pub name: String,
4182    /// Mode for keybindings (e.g., "git-log", "search-results")
4183    #[serde(default)]
4184    #[ts(optional)]
4185    pub mode: Option<String>,
4186    /// Whether buffer is read-only (default: false)
4187    #[serde(default, rename = "readOnly")]
4188    #[ts(optional, rename = "readOnly")]
4189    pub read_only: Option<bool>,
4190    /// Split ratio 0.0-1.0 (default: 0.5)
4191    #[serde(default)]
4192    #[ts(optional)]
4193    pub ratio: Option<f32>,
4194    /// Split direction: "horizontal" or "vertical"
4195    #[serde(default)]
4196    #[ts(optional)]
4197    pub direction: Option<String>,
4198    /// Panel ID to split from
4199    #[serde(default, rename = "panelId")]
4200    #[ts(optional, rename = "panelId")]
4201    pub panel_id: Option<String>,
4202    /// Show line numbers in gutter (default: true)
4203    #[serde(default, rename = "showLineNumbers")]
4204    #[ts(optional, rename = "showLineNumbers")]
4205    pub show_line_numbers: Option<bool>,
4206    /// Show cursor (default: true)
4207    #[serde(default, rename = "showCursors")]
4208    #[ts(optional, rename = "showCursors")]
4209    pub show_cursors: Option<bool>,
4210    /// Disable text editing (default: false)
4211    #[serde(default, rename = "editingDisabled")]
4212    #[ts(optional, rename = "editingDisabled")]
4213    pub editing_disabled: Option<bool>,
4214    /// Enable line wrapping
4215    #[serde(default, rename = "lineWrap")]
4216    #[ts(optional, rename = "lineWrap")]
4217    pub line_wrap: Option<bool>,
4218    /// Place the new buffer before (left/top of) the existing content (default: false)
4219    #[serde(default)]
4220    #[ts(optional)]
4221    pub before: Option<bool>,
4222    /// Initial content entries with optional properties
4223    #[serde(default)]
4224    #[ts(optional)]
4225    pub entries: Option<Vec<JsTextPropertyEntry>>,
4226    /// Split role tag. When set to `"utility_dock"`, the dispatcher
4227    /// routes this buffer to the existing dock leaf if one exists,
4228    /// instead of creating a new split. See
4229    /// `docs/internal/tui-editor-layout-design.md` Section 2.
4230    #[serde(default)]
4231    #[ts(optional)]
4232    pub role: Option<String>,
4233}
4234
4235/// Options for createVirtualBufferInExistingSplit
4236#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4237#[serde(deny_unknown_fields)]
4238#[ts(export)]
4239pub struct CreateVirtualBufferInExistingSplitOptions {
4240    /// Buffer name (displayed in tabs/title)
4241    pub name: String,
4242    /// Target split ID (required)
4243    #[serde(rename = "splitId")]
4244    #[ts(rename = "splitId")]
4245    pub split_id: usize,
4246    /// Mode for keybindings (e.g., "git-log", "search-results")
4247    #[serde(default)]
4248    #[ts(optional)]
4249    pub mode: Option<String>,
4250    /// Whether buffer is read-only (default: false)
4251    #[serde(default, rename = "readOnly")]
4252    #[ts(optional, rename = "readOnly")]
4253    pub read_only: Option<bool>,
4254    /// Show line numbers in gutter (default: true)
4255    #[serde(default, rename = "showLineNumbers")]
4256    #[ts(optional, rename = "showLineNumbers")]
4257    pub show_line_numbers: Option<bool>,
4258    /// Show cursor (default: true)
4259    #[serde(default, rename = "showCursors")]
4260    #[ts(optional, rename = "showCursors")]
4261    pub show_cursors: Option<bool>,
4262    /// Disable text editing (default: false)
4263    #[serde(default, rename = "editingDisabled")]
4264    #[ts(optional, rename = "editingDisabled")]
4265    pub editing_disabled: Option<bool>,
4266    /// Enable line wrapping
4267    #[serde(default, rename = "lineWrap")]
4268    #[ts(optional, rename = "lineWrap")]
4269    pub line_wrap: Option<bool>,
4270    /// Initial content entries with optional properties
4271    #[serde(default)]
4272    #[ts(optional)]
4273    pub entries: Option<Vec<JsTextPropertyEntry>>,
4274}
4275
4276/// Options for createTerminal
4277#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4278#[serde(deny_unknown_fields)]
4279#[ts(export)]
4280pub struct CreateTerminalOptions {
4281    /// Working directory for the terminal (defaults to editor cwd)
4282    #[serde(default)]
4283    #[ts(optional)]
4284    pub cwd: Option<String>,
4285    /// Split direction: "horizontal" or "vertical" (default: "vertical")
4286    #[serde(default)]
4287    #[ts(optional)]
4288    pub direction: Option<String>,
4289    /// Split ratio 0.0-1.0 (default: 0.5)
4290    #[serde(default)]
4291    #[ts(optional)]
4292    pub ratio: Option<f32>,
4293    /// Whether to focus the new terminal split (default: true)
4294    #[serde(default)]
4295    #[ts(optional)]
4296    pub focus: Option<bool>,
4297    /// Whether this terminal is part of the user's persisted workspace.
4298    /// Defaults to `false` for plugin-created terminals — they are typically
4299    /// one-off tool UIs (rebuilds, exec shells, build output) and should
4300    /// start with empty scrollback on each invocation. Set to `true` only
4301    /// when the plugin owns a terminal that the user should see restored
4302    /// across editor restarts.
4303    #[serde(default)]
4304    #[ts(optional)]
4305    pub persistent: Option<bool>,
4306    /// Optional session id to attach the new terminal buffer to.
4307    /// Defaults to the active session at creation time. Setting this
4308    /// lets Orchestrator and similar plugins spawn a terminal *into* an
4309    /// inactive session (e.g. an agent in a worktree the user hasn't
4310    /// dived into yet). The terminal's split is created in that
4311    /// session's stashed split tree; the buffer is attached to the
4312    /// target session's membership set rather than the active one's.
4313    #[serde(default, rename = "windowId")]
4314    #[ts(optional, rename = "windowId")]
4315    pub window_id: Option<WindowId>,
4316    /// Argv to spawn directly inside the PTY instead of the host's
4317    /// configured shell. `None` (default) keeps the historical
4318    /// behaviour: spawn the user's shell and let the caller type into
4319    /// it via `sendTerminalInput`. `Some([cmd, ...args])` runs that
4320    /// exact command as the PTY child — no shell middleman, so the
4321    /// process exits cleanly when the agent does and the
4322    /// terminal-buffer's `terminal_exit` plugin hook reflects the
4323    /// agent's real exit status. Used by Orchestrator so a session
4324    /// with agent `python3` is just python3 in the PTY rather than
4325    /// bash-running-python3-as-a-subshell-command.
4326    #[serde(default)]
4327    #[ts(optional)]
4328    pub command: Option<Vec<String>>,
4329    /// Tab title for the terminal buffer. Defaults to `command[0]`
4330    /// (when `command` is set) or `"Terminal N"` (the historical
4331    /// auto-numbered title). If another terminal in the same window
4332    /// already uses the requested title, the host appends `" (k)"`
4333    /// to disambiguate. Empty string is treated the same as `None`.
4334    #[serde(default)]
4335    #[ts(optional)]
4336    pub title: Option<String>,
4337}
4338
4339/// Options for `createWindowWithTerminal` — the atomic
4340/// "spawn a new editor session that hosts an agent terminal"
4341/// entry point used by Orchestrator. Bundles window creation,
4342/// dive, and terminal spawn so the new window is born with the
4343/// terminal as its seed buffer (no transient `[No Name]` tab,
4344/// no race between create-window and create-terminal completing).
4345#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4346#[serde(deny_unknown_fields, rename_all = "camelCase")]
4347#[ts(export, rename_all = "camelCase")]
4348pub struct CreateWindowWithTerminalOptions {
4349    /// Absolute path to the new session's worktree / project
4350    /// root. Relative paths are rejected (logged, no window
4351    /// created).
4352    pub root: String,
4353    /// Human-readable label for the new session. When empty,
4354    /// defaults to the basename of `root`.
4355    #[serde(default)]
4356    pub label: String,
4357    /// Working directory for the spawned terminal. Defaults to
4358    /// `root` when omitted.
4359    #[serde(default)]
4360    #[ts(optional)]
4361    pub cwd: Option<String>,
4362    /// Argv to spawn directly inside the PTY. `None` keeps the
4363    /// shell-and-type behaviour; `Some([cmd, ...args])` runs the
4364    /// command as the PTY child (used by Orchestrator so the
4365    /// agent process is the PTY's direct child).
4366    #[serde(default)]
4367    #[ts(optional)]
4368    pub command: Option<Vec<String>>,
4369    /// Tab title override. Defaults to `command[0]`'s basename
4370    /// when `command` is set, or "Terminal N" otherwise.
4371    #[serde(default)]
4372    #[ts(optional)]
4373    pub title: Option<String>,
4374}
4375
4376/// Result of `createWindowWithTerminal` — the ids of the new
4377/// window plus the terminal seeded into its split layout.
4378#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4379#[serde(rename_all = "camelCase")]
4380#[ts(export, rename_all = "camelCase")]
4381pub struct SessionWithTerminalResult {
4382    /// The new window's id.
4383    #[ts(type = "number")]
4384    pub window_id: u64,
4385    /// The seeded terminal's id (for `sendTerminalInput`, etc.).
4386    #[ts(type = "number")]
4387    pub terminal_id: u64,
4388    /// The seeded terminal buffer's id.
4389    #[ts(type = "number")]
4390    pub buffer_id: u64,
4391}
4392
4393/// Result of getTextPropertiesAtCursor - array of property objects
4394///
4395/// Each element contains the properties from a text property span that overlaps
4396/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
4397#[derive(Debug, Clone, Serialize, TS)]
4398#[ts(export, type = "Array<Record<string, unknown>>")]
4399pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
4400
4401// Implement FromJs for option types using rquickjs_serde
4402#[cfg(feature = "plugins")]
4403mod fromjs_impls {
4404    use super::*;
4405    use rquickjs::{Ctx, FromJs, Value};
4406
4407    // All types that deserialize from a JS value via rquickjs_serde follow
4408    // the same 8-line pattern differing only in the type name. This macro
4409    // expands that pattern once so adding a new plugin-API type costs one line
4410    // here instead of a copy-pasted block.
4411    macro_rules! impl_from_js_via_serde {
4412        ($($T:ty),+ $(,)?) => {
4413            $(
4414                impl<'js> FromJs<'js> for $T {
4415                    fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
4416                        rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
4417                            from: "object",
4418                            to: stringify!($T),
4419                            message: Some(e.to_string()),
4420                        })
4421                    }
4422                }
4423            )+
4424        };
4425    }
4426
4427    impl_from_js_via_serde!(
4428        JsTextPropertyEntry,
4429        CreateVirtualBufferOptions,
4430        CreateVirtualBufferInSplitOptions,
4431        CreateVirtualBufferInExistingSplitOptions,
4432        ActionSpec,
4433        ActionPopupAction,
4434        ActionPopupOptions,
4435        LspMenuItem,
4436        ViewTokenWire,
4437        ViewTokenStyle,
4438        LayoutHints,
4439        CompositeHunk,
4440        LanguagePackConfig,
4441        LspServerPackConfig,
4442        ProcessLimitsPackConfig,
4443        CreateTerminalOptions,
4444        CreateWindowWithTerminalOptions,
4445    );
4446
4447    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
4448        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
4449            rquickjs_serde::to_value(ctx.clone(), &self.0)
4450                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
4451        }
4452    }
4453
4454    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
4455        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
4456            // Two-step deserialization: rquickjs_serde cannot handle the nested
4457            // enums in this struct directly, so go via serde_json as an intermediary.
4458            let json: serde_json::Value =
4459                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
4460                    from: "object",
4461                    to: "CreateCompositeBufferOptions (json)",
4462                    message: Some(e.to_string()),
4463                })?;
4464            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
4465                from: "json",
4466                to: "CreateCompositeBufferOptions",
4467                message: Some(e.to_string()),
4468            })
4469        }
4470    }
4471
4472    // ── Tests for FromJs / IntoJs impls ────────────────────────────────────
4473    //
4474    // Each impl is a one-liner that delegates to `rquickjs_serde`. A mutant
4475    // that replaces the body with `Ok(Default::default())` drops the
4476    // decoded payload on the floor. Every test below asserts a
4477    // non-defaultable field value, so the mutant cannot pass.
4478    //
4479    // Note: many of the target structs do not implement `Default`, making
4480    // those mutants unviable (they fail to compile) — cargo-mutants still
4481    // lists them as candidates. The tests below serve double-duty as
4482    // behavioural regression protection for the JS → Rust conversion layer.
4483    #[cfg(test)]
4484    mod tests {
4485        use super::*;
4486        use rquickjs::{Context, Runtime};
4487
4488        /// Run a closure within a fresh QuickJS context so that `FromJs`
4489        /// impls can be exercised end-to-end.
4490        fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
4491            let rt = Runtime::new().expect("create rquickjs runtime");
4492            let ctx = Context::full(&rt).expect("create rquickjs context");
4493            ctx.with(f)
4494        }
4495
4496        /// Evaluate a JS object literal and decode it as `T` via `FromJs`.
4497        fn eval_as<T>(src: &str) -> T
4498        where
4499            for<'js> T: rquickjs::FromJs<'js>,
4500        {
4501            with_js(|ctx| {
4502                let value: Value = ctx
4503                    .eval::<Value, _>(src.as_bytes())
4504                    .expect("eval JS source");
4505                T::from_js(&ctx, value).expect("from_js decode")
4506            })
4507        }
4508
4509        #[test]
4510        fn js_text_property_entry_decodes_text_and_properties() {
4511            let got: JsTextPropertyEntry =
4512                eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
4513            assert_eq!(got.text, "hello");
4514            let props = got.properties.expect("properties present");
4515            assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
4516        }
4517
4518        #[test]
4519        fn create_virtual_buffer_options_decodes_name() {
4520            let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
4521            assert_eq!(got.name, "logs");
4522            assert_eq!(got.read_only, Some(true));
4523        }
4524
4525        #[test]
4526        fn create_virtual_buffer_in_split_options_decodes_ratio() {
4527            let got: CreateVirtualBufferInSplitOptions =
4528                eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
4529            assert_eq!(got.name, "diag");
4530            assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
4531            assert_eq!(got.direction.as_deref(), Some("horizontal"));
4532        }
4533
4534        #[test]
4535        fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
4536            let got: CreateVirtualBufferInExistingSplitOptions =
4537                eval_as("({name: 'n', splitId: 7})");
4538            assert_eq!(got.name, "n");
4539            assert_eq!(got.split_id, 7);
4540        }
4541
4542        #[test]
4543        fn create_terminal_options_decodes_cwd_and_focus() {
4544            let got: CreateTerminalOptions =
4545                eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
4546            assert_eq!(got.cwd.as_deref(), Some("/tmp"));
4547            assert_eq!(got.direction.as_deref(), Some("vertical"));
4548            assert_eq!(got.focus, Some(false));
4549        }
4550
4551        #[test]
4552        fn action_spec_decodes_action_and_count() {
4553            let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
4554            assert_eq!(got.action, "move_word_right");
4555            assert_eq!(got.count, 5);
4556        }
4557
4558        #[test]
4559        fn action_popup_action_decodes_id_and_label() {
4560            let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
4561            assert_eq!(got.id, "ok");
4562            assert_eq!(got.label, "OK");
4563        }
4564
4565        #[test]
4566        fn action_popup_options_decodes_actions_list() {
4567            let got: ActionPopupOptions = eval_as(
4568                "({id: 'p', title: 't', message: 'm', \
4569                   actions: [{id: 'ok', label: 'OK'}]})",
4570            );
4571            assert_eq!(got.id, "p");
4572            assert_eq!(got.title, "t");
4573            assert_eq!(got.message, "m");
4574            assert_eq!(got.actions.len(), 1);
4575            assert_eq!(got.actions[0].id, "ok");
4576        }
4577
4578        #[test]
4579        fn view_token_wire_decodes_offset_and_kind() {
4580            // Using `Newline` (a unit variant) avoids the tuple-variant
4581            // wire-format ambiguity in rquickjs_serde while still exercising
4582            // the `FromJs` impl end-to-end.
4583            let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
4584            assert_eq!(got.source_offset, Some(42));
4585            assert!(matches!(got.kind, ViewTokenWireKind::Newline));
4586        }
4587
4588        #[test]
4589        fn view_token_style_decodes_boolean_flags() {
4590            // `fg`/`bg` are `Option<TokenColor>` (untagged: RGB array or
4591            // named string). rquickjs_serde struggles with the untagged
4592            // variant from a plain JS array, so we pin down the boolean
4593            // flags — enough to prove the body actually ran.
4594            let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
4595            assert!(got.bold);
4596            assert!(got.italic);
4597            assert!(got.fg.is_none());
4598        }
4599
4600        #[test]
4601        fn layout_hints_decodes_compose_width() {
4602            let got: LayoutHints = eval_as("({composeWidth: 120})");
4603            assert_eq!(got.compose_width, Some(120));
4604            assert!(got.column_guides.is_none());
4605        }
4606
4607        #[test]
4608        fn create_composite_buffer_options_decodes_name_and_sources() {
4609            let got: CreateCompositeBufferOptions = eval_as(
4610                "({name: 'diff', mode: 'm', \
4611                   layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
4612                   sources: [{bufferId: 3, label: 'OLD'}]})",
4613            );
4614            assert_eq!(got.name, "diff");
4615            assert_eq!(got.layout.layout_type, "side-by-side");
4616            assert_eq!(got.sources.len(), 1);
4617            assert_eq!(got.sources[0].buffer_id, 3);
4618            assert_eq!(got.sources[0].label, "OLD");
4619        }
4620
4621        #[test]
4622        fn composite_hunk_decodes_all_fields() {
4623            let got: CompositeHunk =
4624                eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
4625            assert_eq!(got.old_start, 1);
4626            assert_eq!(got.old_count, 2);
4627            assert_eq!(got.new_start, 3);
4628            assert_eq!(got.new_count, 4);
4629        }
4630
4631        #[test]
4632        fn language_pack_config_decodes_comment_prefix_and_tab_size() {
4633            let got: LanguagePackConfig =
4634                eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
4635            assert_eq!(got.comment_prefix.as_deref(), Some("//"));
4636            assert_eq!(got.tab_size, Some(7));
4637            assert_eq!(got.use_tabs, Some(true));
4638        }
4639
4640        #[test]
4641        fn lsp_server_pack_config_decodes_command_and_args() {
4642            let got: LspServerPackConfig =
4643                eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
4644            assert_eq!(got.command, "rust-analyzer");
4645            assert_eq!(got.args, vec!["--log".to_string()]);
4646            assert_eq!(got.auto_start, Some(true));
4647        }
4648
4649        #[test]
4650        fn process_limits_pack_config_decodes_percentages() {
4651            let got: ProcessLimitsPackConfig =
4652                eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
4653            assert_eq!(got.max_memory_percent, Some(75));
4654            assert_eq!(got.max_cpu_percent, Some(50));
4655            assert_eq!(got.enabled, Some(true));
4656        }
4657
4658        /// `TextPropertiesAtCursor::into_js` must serialise the inner vector
4659        /// into a JS array whose length matches the payload. A mutant that
4660        /// returns a default (`undefined` / empty) value would fail either
4661        /// the array check or the length check.
4662        #[test]
4663        fn text_properties_at_cursor_into_js_preserves_length() {
4664            use rquickjs::IntoJs;
4665            with_js(|ctx| {
4666                let mut entry = std::collections::HashMap::new();
4667                entry.insert("k".to_string(), serde_json::json!("v"));
4668                let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
4669
4670                let v = payload.into_js(&ctx).expect("into_js");
4671                let arr = v.as_array().expect("expected JS array");
4672                assert_eq!(arr.len(), 2);
4673            });
4674        }
4675    }
4676}
4677
4678/// Plugin API context - provides safe access to editor functionality
4679pub struct PluginApi {
4680    /// Hook registry (shared with editor)
4681    hooks: Arc<RwLock<HookRegistry>>,
4682
4683    /// Command registry (shared with editor)
4684    commands: Arc<RwLock<CommandRegistry>>,
4685
4686    /// Command queue for sending commands to editor
4687    command_sender: std::sync::mpsc::Sender<PluginCommand>,
4688
4689    /// Snapshot of editor state (read-only for plugins)
4690    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4691}
4692
4693impl PluginApi {
4694    /// Create a new plugin API context
4695    pub fn new(
4696        hooks: Arc<RwLock<HookRegistry>>,
4697        commands: Arc<RwLock<CommandRegistry>>,
4698        command_sender: std::sync::mpsc::Sender<PluginCommand>,
4699        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4700    ) -> Self {
4701        Self {
4702            hooks,
4703            commands,
4704            command_sender,
4705            state_snapshot,
4706        }
4707    }
4708
4709    /// Register a hook callback
4710    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
4711        let mut hooks = self.hooks.write().unwrap();
4712        hooks.add_hook(hook_name, callback);
4713    }
4714
4715    /// Remove all hooks for a specific name
4716    pub fn unregister_hooks(&self, hook_name: &str) {
4717        let mut hooks = self.hooks.write().unwrap();
4718        hooks.remove_hooks(hook_name);
4719    }
4720
4721    /// Register a command
4722    pub fn register_command(&self, command: Command) {
4723        let commands = self.commands.read().unwrap();
4724        commands.register(command);
4725    }
4726
4727    /// Unregister a command by name
4728    pub fn unregister_command(&self, name: &str) {
4729        let commands = self.commands.read().unwrap();
4730        commands.unregister(name);
4731    }
4732
4733    /// Send a command to the editor (async/non-blocking)
4734    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
4735        self.command_sender
4736            .send(command)
4737            .map_err(|e| format!("Failed to send command: {}", e))
4738    }
4739
4740    /// Insert text at a position in a buffer
4741    pub fn insert_text(
4742        &self,
4743        buffer_id: BufferId,
4744        position: usize,
4745        text: String,
4746    ) -> Result<(), String> {
4747        self.send_command(PluginCommand::InsertText {
4748            buffer_id,
4749            position,
4750            text,
4751        })
4752    }
4753
4754    /// Delete a range of text from a buffer
4755    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
4756        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
4757    }
4758
4759    /// Add an overlay (decoration) to a buffer
4760    /// Add an overlay to a buffer with styling options
4761    ///
4762    /// Returns an opaque handle that can be used to remove the overlay later.
4763    ///
4764    /// Colors can be specified as RGB arrays or theme key strings.
4765    /// Theme keys are resolved at render time, so overlays update with theme changes.
4766    pub fn add_overlay(
4767        &self,
4768        buffer_id: BufferId,
4769        namespace: Option<String>,
4770        range: Range<usize>,
4771        options: OverlayOptions,
4772    ) -> Result<(), String> {
4773        self.send_command(PluginCommand::AddOverlay {
4774            buffer_id,
4775            namespace: namespace.map(OverlayNamespace::from_string),
4776            range,
4777            options,
4778        })
4779    }
4780
4781    /// Remove an overlay from a buffer by its handle
4782    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
4783        self.send_command(PluginCommand::RemoveOverlay {
4784            buffer_id,
4785            handle: OverlayHandle::from_string(handle),
4786        })
4787    }
4788
4789    /// Clear all overlays in a namespace from a buffer
4790    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
4791        self.send_command(PluginCommand::ClearNamespace {
4792            buffer_id,
4793            namespace: OverlayNamespace::from_string(namespace),
4794        })
4795    }
4796
4797    /// Clear all overlays that overlap with a byte range
4798    /// Used for targeted invalidation when content changes
4799    pub fn clear_overlays_in_range(
4800        &self,
4801        buffer_id: BufferId,
4802        start: usize,
4803        end: usize,
4804    ) -> Result<(), String> {
4805        self.send_command(PluginCommand::ClearOverlaysInRange {
4806            buffer_id,
4807            start,
4808            end,
4809        })
4810    }
4811
4812    /// Clear overlays in a single namespace that overlap with a byte range.
4813    /// Unlike [`clear_overlays_in_range`], overlays in other namespaces
4814    /// (e.g. editor-owned LSP diagnostics) are left untouched.
4815    pub fn clear_overlays_in_range_for_namespace(
4816        &self,
4817        buffer_id: BufferId,
4818        namespace: String,
4819        start: usize,
4820        end: usize,
4821    ) -> Result<(), String> {
4822        self.send_command(PluginCommand::ClearOverlaysInRangeForNamespace {
4823            buffer_id,
4824            namespace: OverlayNamespace::from_string(namespace),
4825            start,
4826            end,
4827        })
4828    }
4829
4830    /// Set the status message
4831    pub fn set_status(&self, message: String) -> Result<(), String> {
4832        self.send_command(PluginCommand::SetStatus { message })
4833    }
4834
4835    /// Open a file at a specific line and column (1-indexed)
4836    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
4837    pub fn open_file_at_location(
4838        &self,
4839        path: PathBuf,
4840        line: Option<usize>,
4841        column: Option<usize>,
4842    ) -> Result<(), String> {
4843        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
4844    }
4845
4846    /// Open a file in a specific split at a line and column
4847    ///
4848    /// Similar to open_file_at_location but targets a specific split pane.
4849    /// The split_id is the ID of the split pane to open the file in.
4850    pub fn open_file_in_split(
4851        &self,
4852        split_id: usize,
4853        path: PathBuf,
4854        line: Option<usize>,
4855        column: Option<usize>,
4856    ) -> Result<(), String> {
4857        self.send_command(PluginCommand::OpenFileInSplit {
4858            split_id,
4859            path,
4860            line,
4861            column,
4862        })
4863    }
4864
4865    /// Start a prompt (minibuffer) with a custom type identifier
4866    /// The prompt_type is used to filter hooks in plugin code
4867    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
4868        self.send_command(PluginCommand::StartPrompt {
4869            label,
4870            prompt_type,
4871            floating_overlay: false,
4872        })
4873    }
4874
4875    /// Set the suggestions for the current prompt
4876    /// This updates the prompt's autocomplete/selection list
4877    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
4878        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
4879    }
4880
4881    /// Enable/disable syncing prompt input text when navigating suggestions
4882    pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
4883        self.send_command(PluginCommand::SetPromptInputSync { sync })
4884    }
4885
4886    /// Set the floating-overlay prompt's title (issue #1796) as
4887    /// styled segments. An empty vec clears the title and falls
4888    /// back to the prompt-type default.
4889    pub fn set_prompt_title(&self, title: Vec<StyledText>) -> Result<(), String> {
4890        self.send_command(PluginCommand::SetPromptTitle { title })
4891    }
4892
4893    /// Set the floating-overlay prompt's footer chrome row.
4894    /// Plugins use this for hotkey hints / footer banners along
4895    /// the bottom of the results pane. Empty vec clears.
4896    pub fn set_prompt_footer(&self, footer: Vec<StyledText>) -> Result<(), String> {
4897        self.send_command(PluginCommand::SetPromptFooter { footer })
4898    }
4899
4900    /// Set the floating-overlay prompt's toolbar as a `WidgetSpec` (real,
4901    /// clickable `Toggle`/`Button` widgets). `None` clears it.
4902    pub fn set_prompt_toolbar(&self, spec: Option<WidgetSpec>) -> Result<(), String> {
4903        self.send_command(PluginCommand::SetPromptToolbar { spec })
4904    }
4905
4906    /// Set the floating-overlay prompt's input-row status text. Empty clears.
4907    pub fn set_prompt_status(&self, status: String) -> Result<(), String> {
4908        self.send_command(PluginCommand::SetPromptStatus { status })
4909    }
4910
4911    /// Override the currently-highlighted suggestion row in the
4912    /// open prompt. Useful when re-opening a picker and wanting
4913    /// the previously-active entry to come up pre-selected
4914    /// (e.g. Orchestrator highlighting the active session). The
4915    /// editor clamps `index` to the list's bounds.
4916    pub fn set_prompt_selected_index(&self, index: u32) -> Result<(), String> {
4917        self.send_command(PluginCommand::SetPromptSelectedIndex { index })
4918    }
4919
4920    /// Add a menu item to an existing menu
4921    pub fn add_menu_item(
4922        &self,
4923        menu_label: String,
4924        item: MenuItem,
4925        position: MenuPosition,
4926    ) -> Result<(), String> {
4927        self.send_command(PluginCommand::AddMenuItem {
4928            menu_label,
4929            item,
4930            position,
4931        })
4932    }
4933
4934    /// Add a new top-level menu
4935    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
4936        self.send_command(PluginCommand::AddMenu { menu, position })
4937    }
4938
4939    /// Remove a menu item from a menu
4940    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
4941        self.send_command(PluginCommand::RemoveMenuItem {
4942            menu_label,
4943            item_label,
4944        })
4945    }
4946
4947    /// Remove a top-level menu
4948    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
4949        self.send_command(PluginCommand::RemoveMenu { menu_label })
4950    }
4951
4952    // === Virtual Buffer Methods ===
4953
4954    /// Create a new virtual buffer (not backed by a file)
4955    ///
4956    /// Virtual buffers are used for special displays like diagnostic lists,
4957    /// search results, etc. They have their own mode for keybindings.
4958    pub fn create_virtual_buffer(
4959        &self,
4960        name: String,
4961        mode: String,
4962        read_only: bool,
4963    ) -> Result<(), String> {
4964        self.send_command(PluginCommand::CreateVirtualBuffer {
4965            name,
4966            mode,
4967            read_only,
4968        })
4969    }
4970
4971    /// Create a virtual buffer and set its content in one operation
4972    ///
4973    /// This is the preferred way to create virtual buffers since it doesn't
4974    /// require tracking the buffer ID. The buffer is created and populated
4975    /// atomically.
4976    pub fn create_virtual_buffer_with_content(
4977        &self,
4978        name: String,
4979        mode: String,
4980        read_only: bool,
4981        entries: Vec<TextPropertyEntry>,
4982    ) -> Result<(), String> {
4983        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
4984            name,
4985            mode,
4986            read_only,
4987            entries,
4988            show_line_numbers: true,
4989            show_cursors: true,
4990            editing_disabled: false,
4991            hidden_from_tabs: false,
4992            request_id: None,
4993        })
4994    }
4995
4996    /// Set the content of a virtual buffer with text properties
4997    ///
4998    /// Each entry contains text and metadata properties (e.g., source location).
4999    pub fn set_virtual_buffer_content(
5000        &self,
5001        buffer_id: BufferId,
5002        entries: Vec<TextPropertyEntry>,
5003    ) -> Result<(), String> {
5004        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
5005    }
5006
5007    /// Get text properties at cursor position in a buffer
5008    ///
5009    /// This triggers a command that will make properties available to plugins.
5010    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
5011        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
5012    }
5013
5014    /// Define a buffer mode with keybindings
5015    ///
5016    /// Bindings are specified as (key_string, command_name) pairs.
5017    pub fn define_mode(
5018        &self,
5019        name: String,
5020        bindings: Vec<(String, String)>,
5021        read_only: bool,
5022        allow_text_input: bool,
5023    ) -> Result<(), String> {
5024        self.send_command(PluginCommand::DefineMode {
5025            name,
5026            bindings,
5027            read_only,
5028            allow_text_input,
5029            inherit_normal_bindings: false,
5030            plugin_name: None,
5031        })
5032    }
5033
5034    /// Switch the current split to display a buffer
5035    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
5036        self.send_command(PluginCommand::ShowBuffer { buffer_id })
5037    }
5038
5039    /// Set the scroll position of a specific split
5040    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
5041        self.send_command(PluginCommand::SetSplitScroll {
5042            split_id: SplitId(split_id),
5043            top_byte,
5044        })
5045    }
5046
5047    /// Request syntax highlights for a buffer range
5048    pub fn get_highlights(
5049        &self,
5050        buffer_id: BufferId,
5051        range: Range<usize>,
5052        request_id: u64,
5053    ) -> Result<(), String> {
5054        self.send_command(PluginCommand::RequestHighlights {
5055            buffer_id,
5056            range,
5057            request_id,
5058        })
5059    }
5060
5061    // === Query Methods ===
5062
5063    /// Get the currently active buffer ID
5064    pub fn get_active_buffer_id(&self) -> BufferId {
5065        let snapshot = self.state_snapshot.read().unwrap();
5066        snapshot.active_buffer_id
5067    }
5068
5069    /// Get the currently active split ID
5070    pub fn get_active_split_id(&self) -> usize {
5071        let snapshot = self.state_snapshot.read().unwrap();
5072        snapshot.active_split_id
5073    }
5074
5075    /// Get information about a specific buffer
5076    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
5077        let snapshot = self.state_snapshot.read().unwrap();
5078        snapshot.buffers.get(&buffer_id).cloned()
5079    }
5080
5081    /// Get all buffer IDs
5082    pub fn list_buffers(&self) -> Vec<BufferInfo> {
5083        let snapshot = self.state_snapshot.read().unwrap();
5084        snapshot.buffers.values().cloned().collect()
5085    }
5086
5087    /// Get primary cursor information for the active buffer
5088    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
5089        let snapshot = self.state_snapshot.read().unwrap();
5090        snapshot.primary_cursor.clone()
5091    }
5092
5093    /// Get all cursor information for the active buffer
5094    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
5095        let snapshot = self.state_snapshot.read().unwrap();
5096        snapshot.all_cursors.clone()
5097    }
5098
5099    /// Get viewport information for the active buffer
5100    pub fn get_viewport(&self) -> Option<ViewportInfo> {
5101        let snapshot = self.state_snapshot.read().unwrap();
5102        snapshot.viewport.clone()
5103    }
5104
5105    /// Get total terminal dimensions.
5106    pub fn get_screen_size(&self) -> ScreenSize {
5107        let snapshot = self.state_snapshot.read().unwrap();
5108        ScreenSize {
5109            width: snapshot.terminal_width,
5110            height: snapshot.terminal_height,
5111        }
5112    }
5113
5114    /// Get access to the state snapshot Arc (for internal use)
5115    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
5116        Arc::clone(&self.state_snapshot)
5117    }
5118}
5119
5120impl Clone for PluginApi {
5121    fn clone(&self) -> Self {
5122        Self {
5123            hooks: Arc::clone(&self.hooks),
5124            commands: Arc::clone(&self.commands),
5125            command_sender: self.command_sender.clone(),
5126            state_snapshot: Arc::clone(&self.state_snapshot),
5127        }
5128    }
5129}
5130
5131// ============================================================================
5132// Pluggable Completion Service — TypeScript Plugin API Types
5133// ============================================================================
5134//
5135// These types are the bridge between the Rust `CompletionService` and
5136// TypeScript plugins that want to provide completion candidates.  They are
5137// serialised to/from JSON via serde and generate TypeScript definitions via
5138// ts-rs so that the plugin API stays in sync automatically.
5139
5140/// A completion candidate produced by a TypeScript plugin provider.
5141///
5142/// This mirrors `CompletionCandidate` in the Rust `completion::provider`
5143/// module but uses serde-friendly primitives for the JS ↔ Rust boundary.
5144#[derive(Debug, Clone, Serialize, Deserialize, TS)]
5145#[serde(rename_all = "camelCase", deny_unknown_fields)]
5146#[ts(export, rename_all = "camelCase")]
5147pub struct TsCompletionCandidate {
5148    /// Display text shown in the completion popup.
5149    pub label: String,
5150
5151    /// Text to insert when accepted. Falls back to `label` if omitted.
5152    #[serde(skip_serializing_if = "Option::is_none")]
5153    pub insert_text: Option<String>,
5154
5155    /// Short detail string shown next to the label.
5156    #[serde(skip_serializing_if = "Option::is_none")]
5157    pub detail: Option<String>,
5158
5159    /// Single-character icon hint (e.g. `"λ"`, `"v"`).
5160    #[serde(skip_serializing_if = "Option::is_none")]
5161    pub icon: Option<String>,
5162
5163    /// Provider-assigned relevance score (higher = better).
5164    #[serde(default)]
5165    pub score: i64,
5166
5167    /// Whether `insert_text` uses LSP snippet syntax (`$0`, `${1:ph}`, …).
5168    #[serde(default)]
5169    pub is_snippet: bool,
5170
5171    /// Opaque data carried through to the `completionAccepted` hook.
5172    #[serde(skip_serializing_if = "Option::is_none")]
5173    pub provider_data: Option<String>,
5174}
5175
5176/// Context sent to a TypeScript plugin's `provideCompletions` handler.
5177///
5178/// Plugins receive this as a read-only snapshot so they never need direct
5179/// buffer access (which would be unsafe for huge files).
5180#[derive(Debug, Clone, Serialize, Deserialize, TS)]
5181#[serde(rename_all = "camelCase")]
5182#[ts(export, rename_all = "camelCase")]
5183pub struct TsCompletionContext {
5184    /// The word prefix typed so far.
5185    pub prefix: String,
5186
5187    /// Byte offset of the cursor.
5188    pub cursor_byte: usize,
5189
5190    /// Byte offset of the word start (for replacement range).
5191    pub word_start_byte: usize,
5192
5193    /// Total buffer size in bytes.
5194    pub buffer_len: usize,
5195
5196    /// Whether the buffer is a lazily-loaded huge file.
5197    pub is_large_file: bool,
5198
5199    /// A text excerpt around the cursor (the contents of the safe scan window).
5200    /// Plugins should search only this string, not request the full buffer.
5201    pub text_around_cursor: String,
5202
5203    /// Byte offset within `text_around_cursor` that corresponds to the cursor.
5204    pub cursor_offset_in_text: usize,
5205
5206    /// File language id (e.g. `"rust"`, `"typescript"`), if known.
5207    #[serde(skip_serializing_if = "Option::is_none")]
5208    pub language_id: Option<String>,
5209}
5210
5211/// Registration payload sent by a plugin to register a completion provider.
5212#[derive(Debug, Clone, Serialize, Deserialize, TS)]
5213#[serde(rename_all = "camelCase", deny_unknown_fields)]
5214#[ts(export, rename_all = "camelCase")]
5215pub struct TsCompletionProviderRegistration {
5216    /// Unique id for this provider (e.g., `"my-snippets"`).
5217    pub id: String,
5218
5219    /// Human-readable name shown in status/debug UI.
5220    pub display_name: String,
5221
5222    /// Priority tier (lower = higher priority). Convention:
5223    /// 0 = LSP, 10 = ctags, 20 = buffer words, 30 = dabbrev, 50 = plugin.
5224    #[serde(default = "default_plugin_provider_priority")]
5225    pub priority: u32,
5226
5227    /// Optional list of language ids this provider is active for.
5228    /// If empty/omitted, the provider is active for all languages.
5229    #[serde(default)]
5230    pub language_ids: Vec<String>,
5231}
5232
5233fn default_plugin_provider_priority() -> u32 {
5234    50
5235}
5236
5237#[cfg(test)]
5238mod tests {
5239    use super::*;
5240    use std::path::Path;
5241
5242    #[test]
5243    fn test_plugin_api_creation() {
5244        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5245        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5246        let (tx, _rx) = std::sync::mpsc::channel();
5247        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5248
5249        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5250
5251        // Should not panic
5252        let _clone = api.clone();
5253    }
5254
5255    #[test]
5256    fn test_register_hook() {
5257        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5258        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5259        let (tx, _rx) = std::sync::mpsc::channel();
5260        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5261
5262        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
5263
5264        api.register_hook("test-hook", Box::new(|_| true));
5265
5266        let hook_registry = hooks.read().unwrap();
5267        assert_eq!(hook_registry.hook_count("test-hook"), 1);
5268    }
5269
5270    #[test]
5271    fn test_send_command() {
5272        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5273        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5274        let (tx, rx) = std::sync::mpsc::channel();
5275        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5276
5277        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5278
5279        let result = api.insert_text(BufferId(1), 0, "test".to_string());
5280        assert!(result.is_ok());
5281
5282        // Verify command was sent
5283        let received = rx.try_recv();
5284        assert!(received.is_ok());
5285
5286        match received.unwrap() {
5287            PluginCommand::InsertText {
5288                buffer_id,
5289                position,
5290                text,
5291            } => {
5292                assert_eq!(buffer_id.0, 1);
5293                assert_eq!(position, 0);
5294                assert_eq!(text, "test");
5295            }
5296            _ => panic!("Wrong command type"),
5297        }
5298    }
5299
5300    #[test]
5301    fn test_add_overlay_command() {
5302        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5303        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5304        let (tx, rx) = std::sync::mpsc::channel();
5305        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5306
5307        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5308
5309        let result = api.add_overlay(
5310            BufferId(1),
5311            Some("test-overlay".to_string()),
5312            0..10,
5313            OverlayOptions {
5314                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
5315                bg: None,
5316                underline: true,
5317                bold: false,
5318                italic: false,
5319                strikethrough: false,
5320                extend_to_line_end: false,
5321                fg_on_collision_only: false,
5322                url: None,
5323            },
5324        );
5325        assert!(result.is_ok());
5326
5327        let received = rx.try_recv().unwrap();
5328        match received {
5329            PluginCommand::AddOverlay {
5330                buffer_id,
5331                namespace,
5332                range,
5333                options,
5334            } => {
5335                assert_eq!(buffer_id.0, 1);
5336                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
5337                assert_eq!(range, 0..10);
5338                assert!(matches!(
5339                    options.fg,
5340                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
5341                ));
5342                assert!(options.bg.is_none());
5343                assert!(options.underline);
5344                assert!(!options.bold);
5345                assert!(!options.italic);
5346                assert!(!options.extend_to_line_end);
5347            }
5348            _ => panic!("Wrong command type"),
5349        }
5350    }
5351
5352    #[test]
5353    fn test_set_status_command() {
5354        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5355        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5356        let (tx, rx) = std::sync::mpsc::channel();
5357        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5358
5359        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5360
5361        let result = api.set_status("Test status".to_string());
5362        assert!(result.is_ok());
5363
5364        let received = rx.try_recv().unwrap();
5365        match received {
5366            PluginCommand::SetStatus { message } => {
5367                assert_eq!(message, "Test status");
5368            }
5369            _ => panic!("Wrong command type"),
5370        }
5371    }
5372
5373    #[test]
5374    fn test_get_active_buffer_id() {
5375        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5376        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5377        let (tx, _rx) = std::sync::mpsc::channel();
5378        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5379
5380        // Set active buffer to 5
5381        {
5382            let mut snapshot = state_snapshot.write().unwrap();
5383            snapshot.active_buffer_id = BufferId(5);
5384        }
5385
5386        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5387
5388        let active_id = api.get_active_buffer_id();
5389        assert_eq!(active_id.0, 5);
5390    }
5391
5392    #[test]
5393    fn test_get_buffer_info() {
5394        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5395        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5396        let (tx, _rx) = std::sync::mpsc::channel();
5397        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5398
5399        // Add buffer info
5400        {
5401            let mut snapshot = state_snapshot.write().unwrap();
5402            let buffer_info = BufferInfo {
5403                id: BufferId(1),
5404                path: Some(std::path::PathBuf::from("/test/file.txt")),
5405                modified: true,
5406                length: 100,
5407                is_virtual: false,
5408                view_mode: "source".to_string(),
5409                is_composing_in_any_split: false,
5410                compose_width: None,
5411                language: "text".to_string(),
5412                is_preview: false,
5413                splits: Vec::new(),
5414            };
5415            snapshot.buffers.insert(BufferId(1), buffer_info);
5416        }
5417
5418        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5419
5420        let info = api.get_buffer_info(BufferId(1));
5421        assert!(info.is_some());
5422        let info = info.unwrap();
5423        assert_eq!(info.id.0, 1);
5424        assert_eq!(
5425            info.path.as_ref().unwrap().to_str().unwrap(),
5426            "/test/file.txt"
5427        );
5428        assert!(info.modified);
5429        assert_eq!(info.length, 100);
5430
5431        // Non-existent buffer
5432        let no_info = api.get_buffer_info(BufferId(999));
5433        assert!(no_info.is_none());
5434    }
5435
5436    #[test]
5437    fn test_list_buffers() {
5438        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5439        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5440        let (tx, _rx) = std::sync::mpsc::channel();
5441        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5442
5443        // Add multiple buffers
5444        {
5445            let mut snapshot = state_snapshot.write().unwrap();
5446            snapshot.buffers.insert(
5447                BufferId(1),
5448                BufferInfo {
5449                    id: BufferId(1),
5450                    path: Some(std::path::PathBuf::from("/file1.txt")),
5451                    modified: false,
5452                    length: 50,
5453                    is_virtual: false,
5454                    view_mode: "source".to_string(),
5455                    is_composing_in_any_split: false,
5456                    compose_width: None,
5457                    language: "text".to_string(),
5458                    is_preview: false,
5459                    splits: Vec::new(),
5460                },
5461            );
5462            snapshot.buffers.insert(
5463                BufferId(2),
5464                BufferInfo {
5465                    id: BufferId(2),
5466                    path: Some(std::path::PathBuf::from("/file2.txt")),
5467                    modified: true,
5468                    length: 100,
5469                    is_virtual: false,
5470                    view_mode: "source".to_string(),
5471                    is_composing_in_any_split: false,
5472                    compose_width: None,
5473                    language: "text".to_string(),
5474                    is_preview: false,
5475                    splits: Vec::new(),
5476                },
5477            );
5478            snapshot.buffers.insert(
5479                BufferId(3),
5480                BufferInfo {
5481                    id: BufferId(3),
5482                    path: None,
5483                    modified: false,
5484                    length: 0,
5485                    is_virtual: true,
5486                    view_mode: "source".to_string(),
5487                    is_composing_in_any_split: false,
5488                    compose_width: None,
5489                    language: "text".to_string(),
5490                    is_preview: false,
5491                    splits: Vec::new(),
5492                },
5493            );
5494        }
5495
5496        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5497
5498        let buffers = api.list_buffers();
5499        assert_eq!(buffers.len(), 3);
5500
5501        // Verify all buffers are present
5502        assert!(buffers.iter().any(|b| b.id.0 == 1));
5503        assert!(buffers.iter().any(|b| b.id.0 == 2));
5504        assert!(buffers.iter().any(|b| b.id.0 == 3));
5505    }
5506
5507    #[test]
5508    fn test_get_primary_cursor() {
5509        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5510        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5511        let (tx, _rx) = std::sync::mpsc::channel();
5512        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5513
5514        // Add cursor info
5515        {
5516            let mut snapshot = state_snapshot.write().unwrap();
5517            snapshot.primary_cursor = Some(CursorInfo {
5518                position: 42,
5519                selection: Some(10..42),
5520                line: Some(3),
5521            });
5522        }
5523
5524        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5525
5526        let cursor = api.get_primary_cursor();
5527        assert!(cursor.is_some());
5528        let cursor = cursor.unwrap();
5529        assert_eq!(cursor.position, 42);
5530        assert_eq!(cursor.selection, Some(10..42));
5531    }
5532
5533    #[test]
5534    fn test_get_all_cursors() {
5535        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5536        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5537        let (tx, _rx) = std::sync::mpsc::channel();
5538        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5539
5540        // Add multiple cursors
5541        {
5542            let mut snapshot = state_snapshot.write().unwrap();
5543            snapshot.all_cursors = vec![
5544                CursorInfo {
5545                    position: 10,
5546                    selection: None,
5547                    line: Some(0),
5548                },
5549                CursorInfo {
5550                    position: 20,
5551                    selection: Some(15..20),
5552                    line: Some(1),
5553                },
5554                CursorInfo {
5555                    position: 30,
5556                    selection: Some(25..30),
5557                    line: Some(2),
5558                },
5559            ];
5560        }
5561
5562        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5563
5564        let cursors = api.get_all_cursors();
5565        assert_eq!(cursors.len(), 3);
5566        assert_eq!(cursors[0].position, 10);
5567        assert_eq!(cursors[0].selection, None);
5568        assert_eq!(cursors[1].position, 20);
5569        assert_eq!(cursors[1].selection, Some(15..20));
5570        assert_eq!(cursors[2].position, 30);
5571        assert_eq!(cursors[2].selection, Some(25..30));
5572    }
5573
5574    #[test]
5575    fn test_get_viewport() {
5576        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5577        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5578        let (tx, _rx) = std::sync::mpsc::channel();
5579        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5580
5581        // Add viewport info
5582        {
5583            let mut snapshot = state_snapshot.write().unwrap();
5584            snapshot.viewport = Some(ViewportInfo {
5585                top_byte: 100,
5586                top_line: Some(5),
5587                left_column: 5,
5588                width: 80,
5589                height: 24,
5590            });
5591        }
5592
5593        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5594
5595        let viewport = api.get_viewport();
5596        assert!(viewport.is_some());
5597        let viewport = viewport.unwrap();
5598        assert_eq!(viewport.top_byte, 100);
5599        assert_eq!(viewport.left_column, 5);
5600        assert_eq!(viewport.width, 80);
5601        assert_eq!(viewport.height, 24);
5602    }
5603
5604    #[test]
5605    fn test_composite_buffer_options_rejects_unknown_fields() {
5606        // Valid JSON with correct field names
5607        let valid_json = r#"{
5608            "name": "test",
5609            "mode": "diff",
5610            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
5611            "sources": [{"bufferId": 1, "label": "old"}]
5612        }"#;
5613        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
5614        assert!(
5615            result.is_ok(),
5616            "Valid JSON should parse: {:?}",
5617            result.err()
5618        );
5619
5620        // Invalid JSON with unknown field (buffer_id instead of bufferId)
5621        let invalid_json = r#"{
5622            "name": "test",
5623            "mode": "diff",
5624            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
5625            "sources": [{"buffer_id": 1, "label": "old"}]
5626        }"#;
5627        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
5628        assert!(
5629            result.is_err(),
5630            "JSON with unknown field should fail to parse"
5631        );
5632        let err = result.unwrap_err().to_string();
5633        assert!(
5634            err.contains("unknown field") || err.contains("buffer_id"),
5635            "Error should mention unknown field: {}",
5636            err
5637        );
5638    }
5639
5640    #[test]
5641    fn test_composite_hunk_rejects_unknown_fields() {
5642        // Valid JSON with correct field names
5643        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
5644        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
5645        assert!(
5646            result.is_ok(),
5647            "Valid JSON should parse: {:?}",
5648            result.err()
5649        );
5650
5651        // Invalid JSON with unknown field (old_start instead of oldStart)
5652        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
5653        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
5654        assert!(
5655            result.is_err(),
5656            "JSON with unknown field should fail to parse"
5657        );
5658        let err = result.unwrap_err().to_string();
5659        assert!(
5660            err.contains("unknown field") || err.contains("old_start"),
5661            "Error should mention unknown field: {}",
5662            err
5663        );
5664    }
5665
5666    #[test]
5667    fn test_plugin_response_line_end_position() {
5668        let response = PluginResponse::LineEndPosition {
5669            request_id: 42,
5670            position: Some(100),
5671        };
5672        let json = serde_json::to_string(&response).unwrap();
5673        assert!(json.contains("LineEndPosition"));
5674        assert!(json.contains("42"));
5675        assert!(json.contains("100"));
5676
5677        // Test None case
5678        let response_none = PluginResponse::LineEndPosition {
5679            request_id: 1,
5680            position: None,
5681        };
5682        let json_none = serde_json::to_string(&response_none).unwrap();
5683        assert!(json_none.contains("null"));
5684    }
5685
5686    #[test]
5687    fn test_plugin_response_buffer_line_count() {
5688        let response = PluginResponse::BufferLineCount {
5689            request_id: 99,
5690            count: Some(500),
5691        };
5692        let json = serde_json::to_string(&response).unwrap();
5693        assert!(json.contains("BufferLineCount"));
5694        assert!(json.contains("99"));
5695        assert!(json.contains("500"));
5696    }
5697
5698    #[test]
5699    fn test_plugin_command_get_line_end_position() {
5700        let command = PluginCommand::GetLineEndPosition {
5701            buffer_id: BufferId(1),
5702            line: 10,
5703            request_id: 123,
5704        };
5705        let json = serde_json::to_string(&command).unwrap();
5706        assert!(json.contains("GetLineEndPosition"));
5707        assert!(json.contains("10"));
5708    }
5709
5710    #[test]
5711    fn test_plugin_command_get_buffer_line_count() {
5712        let command = PluginCommand::GetBufferLineCount {
5713            buffer_id: BufferId(0),
5714            request_id: 456,
5715        };
5716        let json = serde_json::to_string(&command).unwrap();
5717        assert!(json.contains("GetBufferLineCount"));
5718        assert!(json.contains("456"));
5719    }
5720
5721    #[test]
5722    fn test_plugin_command_scroll_to_line_center() {
5723        let command = PluginCommand::ScrollToLineCenter {
5724            split_id: SplitId(1),
5725            buffer_id: BufferId(2),
5726            line: 50,
5727        };
5728        let json = serde_json::to_string(&command).unwrap();
5729        assert!(json.contains("ScrollToLineCenter"));
5730        assert!(json.contains("50"));
5731    }
5732
5733    /// `JsCallbackId` round-trips through `u64` via `new` / `as_u64` / `From`
5734    /// and renders as its underlying integer via `Display`.
5735    #[test]
5736    fn js_callback_id_conversions_and_display() {
5737        for raw in [0u64, 1, 42, u64::MAX] {
5738            let id = JsCallbackId::new(raw);
5739            assert_eq!(id.as_u64(), raw);
5740            assert_eq!(u64::from(id), raw);
5741            assert_eq!(JsCallbackId::from(raw), id);
5742            assert_eq!(id.to_string(), raw.to_string());
5743        }
5744    }
5745
5746    /// Serde `default = ...` helpers fire when the field is omitted and are
5747    /// overridden by explicit values. One test per struct pins each helper
5748    /// to its documented default.
5749    #[test]
5750    fn serde_defaults_fire_when_fields_are_omitted() {
5751        // default_action_count → 1
5752        let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
5753        assert_eq!(spec.count, 1);
5754        let spec: ActionSpec =
5755            serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
5756        assert_eq!(spec.count, 5);
5757
5758        // default_true → showSeparator = true
5759        let layout: CompositeLayoutConfig =
5760            serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
5761        assert!(layout.show_separator);
5762        let layout: CompositeLayoutConfig =
5763            serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
5764        assert!(!layout.show_separator);
5765
5766        // default_plugin_provider_priority → 50
5767        let reg: TsCompletionProviderRegistration =
5768            serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
5769        assert_eq!(reg.priority, 50);
5770        let reg: TsCompletionProviderRegistration =
5771            serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
5772        assert_eq!(reg.priority, 3);
5773    }
5774
5775    // ── Behavioural tests added to kill the mutants reported by cargo-mutants ──
5776    //
5777    // These tests pin down observable behaviour for tiny methods whose bodies
5778    // were replaceable with a constant (e.g. `()`, `Ok(())`, `None`, or a
5779    // default value) without any existing test noticing.
5780
5781    /// Helper: build a minimal `Command` with a given name.
5782    fn mk_cmd(name: &str) -> Command {
5783        Command {
5784            name: name.to_string(),
5785            description: String::new(),
5786            action_name: String::new(),
5787            plugin_name: String::new(),
5788            custom_contexts: Vec::new(),
5789            terminal_bypass: false,
5790        }
5791    }
5792
5793    /// `CommandRegistry::register` appends new commands and replaces any
5794    /// existing entry with the same name; `unregister` removes exactly the
5795    /// matching entry and is a no-op for unknown names.
5796    ///
5797    /// Kills: replace register with `()`; `!= → ==` in register;
5798    ///        replace unregister with `()`; `!= → ==` in unregister.
5799    #[test]
5800    fn command_registry_register_and_unregister_semantics() {
5801        let r = CommandRegistry::new();
5802
5803        r.register(mk_cmd("a"));
5804        r.register(mk_cmd("b"));
5805        assert_eq!(r.commands.read().unwrap().len(), 2);
5806
5807        // Re-registering "a" must keep "b" (retain filters by `!=`); the
5808        // `== → !=` mutant would drop "b" and leave two copies of "a".
5809        r.register(mk_cmd("a"));
5810        let names: Vec<String> = r
5811            .commands
5812            .read()
5813            .unwrap()
5814            .iter()
5815            .map(|c| c.name.clone())
5816            .collect();
5817        assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
5818
5819        // Unregister must remove exactly "a" and preserve "b"; the `== → !=`
5820        // mutant would keep "a" and drop "b".
5821        r.unregister("a");
5822        let names: Vec<String> = r
5823            .commands
5824            .read()
5825            .unwrap()
5826            .iter()
5827            .map(|c| c.name.clone())
5828            .collect();
5829        assert_eq!(names, vec!["b".to_string()]);
5830
5831        // Unregistering an unknown name is a no-op.
5832        r.unregister("nope");
5833        assert_eq!(r.commands.read().unwrap().len(), 1);
5834    }
5835
5836    /// `OverlayColorSpec::as_rgb` returns the exact stored tuple for the RGB
5837    /// variant and `None` for the theme-key variant; `as_theme_key` is the
5838    /// dual. Uses a triple with no zero or one components and a theme key
5839    /// that is neither empty nor `"xyzzy"` to kill every constant-return
5840    /// mutant reported by cargo-mutants at once.
5841    #[test]
5842    fn overlay_color_spec_accessors_are_variant_specific() {
5843        let rgb = OverlayColorSpec::rgb(12, 34, 56);
5844        assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
5845        assert_eq!(rgb.as_theme_key(), None);
5846
5847        let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
5848        assert_eq!(tk.as_rgb(), None);
5849        assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
5850    }
5851
5852    /// `PluginCommand::debug_variant_name` returns the actual variant name
5853    /// derived from the `Debug` impl, not an empty or hard-coded string.
5854    #[test]
5855    fn plugin_command_debug_variant_name_returns_real_variant() {
5856        let c = PluginCommand::SetStatus {
5857            message: "hi".into(),
5858        };
5859        assert_eq!(c.debug_variant_name(), "SetStatus");
5860
5861        let c2 = PluginCommand::InsertText {
5862            buffer_id: BufferId(1),
5863            position: 0,
5864            text: String::new(),
5865        };
5866        assert_eq!(c2.debug_variant_name(), "InsertText");
5867    }
5868
5869    // ── PluginApi dispatch / mutation tests ────────────────────────────────
5870    //
5871    // Each `PluginApi` method is a one-liner that either pushes a
5872    // `PluginCommand` onto the channel or mutates a shared registry. The
5873    // mutants replace the body with `Ok(())` / `()`, i.e. the side effect
5874    // disappears. One assertion per method ties the side effect down.
5875
5876    type MkApi = (
5877        PluginApi,
5878        std::sync::mpsc::Receiver<PluginCommand>,
5879        Arc<RwLock<HookRegistry>>,
5880        Arc<RwLock<CommandRegistry>>,
5881        Arc<RwLock<EditorStateSnapshot>>,
5882    );
5883
5884    fn mk_api() -> MkApi {
5885        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5886        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5887        let (tx, rx) = std::sync::mpsc::channel();
5888        let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5889        let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
5890        (api, rx, hooks, commands, snap)
5891    }
5892
5893    /// `unregister_hooks` must actually clear hooks registered under the
5894    /// same name; replacing the body with `()` leaves the count at 1.
5895    #[test]
5896    fn plugin_api_unregister_hooks_clears_registry() {
5897        let (api, _rx, hooks, _cmds, _snap) = mk_api();
5898        api.register_hook("h", Box::new(|_| true));
5899        assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
5900        api.unregister_hooks("h");
5901        assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
5902    }
5903
5904    /// `register_command` / `unregister_command` must actually write through
5905    /// to the shared `CommandRegistry`.
5906    #[test]
5907    fn plugin_api_register_and_unregister_command_write_through() {
5908        let (api, _rx, _hooks, cmds, _snap) = mk_api();
5909
5910        api.register_command(mk_cmd("x"));
5911        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
5912
5913        api.unregister_command("x");
5914        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
5915    }
5916
5917    /// Macro: assert that calling `$call` on a fresh `PluginApi` produces
5918    /// exactly one `PluginCommand` matching `$pattern` with the additional
5919    /// invariants in `$guard`.
5920    macro_rules! assert_dispatches {
5921        ($call:expr, $pattern:pat $(if $guard:expr)?) => {{
5922            let (api, rx, _h, _c, _s) = mk_api();
5923            let _ = $call(&api);
5924            match rx.try_recv().expect("no command sent") {
5925                $pattern $(if $guard)? => {}
5926                other => panic!("unexpected command variant: {:?}", other),
5927            }
5928        }};
5929    }
5930
5931    /// Every simple `send_command`-based method on `PluginApi` translates
5932    /// its arguments into the documented `PluginCommand` variant with the
5933    /// expected fields.
5934    #[test]
5935    fn plugin_api_send_command_methods_dispatch_correctly() {
5936        // delete_range
5937        assert_dispatches!(
5938            |a: &PluginApi| a.delete_range(BufferId(7), 3..9),
5939            PluginCommand::DeleteRange { buffer_id, range }
5940                if buffer_id == BufferId(7) && range == (3..9)
5941        );
5942
5943        // remove_overlay
5944        assert_dispatches!(
5945            |a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
5946            PluginCommand::RemoveOverlay { buffer_id, handle }
5947                if buffer_id == BufferId(2) && handle.as_str() == "h-1"
5948        );
5949
5950        // clear_namespace
5951        assert_dispatches!(
5952            |a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
5953            PluginCommand::ClearNamespace { buffer_id, namespace }
5954                if buffer_id == BufferId(3) && namespace.as_str() == "diag"
5955        );
5956
5957        // clear_overlays_in_range
5958        assert_dispatches!(
5959            |a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
5960            PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
5961                if buffer_id == BufferId(4) && start == 10 && end == 20
5962        );
5963
5964        // clear_overlays_in_range_for_namespace
5965        assert_dispatches!(
5966            |a: &PluginApi| a.clear_overlays_in_range_for_namespace(
5967                BufferId(5),
5968                "md-emphasis".into(),
5969                10,
5970                20
5971            ),
5972            PluginCommand::ClearOverlaysInRangeForNamespace { buffer_id, namespace, start, end }
5973                if buffer_id == BufferId(5)
5974                    && namespace.as_str() == "md-emphasis"
5975                    && start == 10
5976                    && end == 20
5977        );
5978
5979        // open_file_at_location
5980        assert_dispatches!(
5981            |a: &PluginApi| a.open_file_at_location(
5982                PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
5983            ),
5984            PluginCommand::OpenFileAtLocation { path, line, column }
5985                if path == Path::new("/tmp/x.rs")
5986                    && line == Some(4)
5987                    && column == Some(8)
5988        );
5989
5990        // open_file_in_split
5991        assert_dispatches!(
5992            |a: &PluginApi| a.open_file_in_split(
5993                2, PathBuf::from("/tmp/y.rs"), Some(5), None
5994            ),
5995            PluginCommand::OpenFileInSplit { split_id, path, line, column }
5996                if split_id == 2
5997                    && path == Path::new("/tmp/y.rs")
5998                    && line == Some(5)
5999                    && column.is_none()
6000        );
6001
6002        // start_prompt
6003        assert_dispatches!(
6004            |a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
6005            PluginCommand::StartPrompt { label, prompt_type, floating_overlay }
6006                if label == "label" && prompt_type == "cmd" && !floating_overlay
6007        );
6008
6009        // set_prompt_suggestions
6010        assert_dispatches!(
6011            |a: &PluginApi| a.set_prompt_suggestions(vec![
6012                Suggestion::new("one".into()),
6013                Suggestion::new("two".into()),
6014            ]),
6015            PluginCommand::SetPromptSuggestions { suggestions }
6016                if suggestions.len() == 2
6017                    && suggestions[0].text == "one"
6018                    && suggestions[1].text == "two"
6019        );
6020
6021        // set_prompt_input_sync
6022        assert_dispatches!(
6023            |a: &PluginApi| a.set_prompt_input_sync(true),
6024            PluginCommand::SetPromptInputSync { sync } if sync
6025        );
6026        assert_dispatches!(
6027            |a: &PluginApi| a.set_prompt_input_sync(false),
6028            PluginCommand::SetPromptInputSync { sync } if !sync
6029        );
6030
6031        // add_menu_item
6032        assert_dispatches!(
6033            |a: &PluginApi| a.add_menu_item(
6034                "File".into(),
6035                MenuItem::Label { info: "info".into() },
6036                MenuPosition::Bottom,
6037            ),
6038            PluginCommand::AddMenuItem { menu_label, item, position }
6039                if menu_label == "File"
6040                    && matches!(item, MenuItem::Label { ref info } if info == "info")
6041                    && matches!(position, MenuPosition::Bottom)
6042        );
6043
6044        // add_menu
6045        assert_dispatches!(
6046            |a: &PluginApi| a.add_menu(
6047                Menu {
6048                    id: None,
6049                    label: "Help".into(),
6050                    items: vec![],
6051                    when: None,
6052                },
6053                MenuPosition::After("Edit".into()),
6054            ),
6055            PluginCommand::AddMenu { menu, position }
6056                if menu.label == "Help"
6057                    && matches!(position, MenuPosition::After(ref s) if s == "Edit")
6058        );
6059
6060        // remove_menu_item
6061        assert_dispatches!(
6062            |a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
6063            PluginCommand::RemoveMenuItem { menu_label, item_label }
6064                if menu_label == "File" && item_label == "Open"
6065        );
6066
6067        // remove_menu
6068        assert_dispatches!(
6069            |a: &PluginApi| a.remove_menu("File".into()),
6070            PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
6071        );
6072
6073        // create_virtual_buffer
6074        assert_dispatches!(
6075            |a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
6076            PluginCommand::CreateVirtualBuffer { name, mode, read_only }
6077                if name == "buf" && mode == "mode" && read_only
6078        );
6079
6080        // create_virtual_buffer_with_content
6081        assert_dispatches!(
6082            |a: &PluginApi| a.create_virtual_buffer_with_content(
6083                "n".into(), "m".into(), false, vec![]
6084            ),
6085            PluginCommand::CreateVirtualBufferWithContent {
6086                name, mode, read_only, show_line_numbers, show_cursors,
6087                editing_disabled, hidden_from_tabs, request_id, ..
6088            }
6089                if name == "n" && mode == "m" && !read_only
6090                    && show_line_numbers && show_cursors
6091                    && !editing_disabled && !hidden_from_tabs
6092                    && request_id.is_none()
6093        );
6094
6095        // set_virtual_buffer_content
6096        assert_dispatches!(
6097            |a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
6098            PluginCommand::SetVirtualBufferContent { buffer_id, entries }
6099                if buffer_id == BufferId(9) && entries.is_empty()
6100        );
6101
6102        // get_text_properties_at_cursor
6103        assert_dispatches!(
6104            |a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
6105            PluginCommand::GetTextPropertiesAtCursor { buffer_id }
6106                if buffer_id == BufferId(11)
6107        );
6108
6109        // define_mode
6110        assert_dispatches!(
6111            |a: &PluginApi| a.define_mode(
6112                "m".into(),
6113                vec![("j".into(), "move_down".into())],
6114                true,
6115                false,
6116            ),
6117            PluginCommand::DefineMode {
6118                name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
6119            }
6120                if name == "m"
6121                    && bindings.len() == 1
6122                    && bindings[0].0 == "j"
6123                    && bindings[0].1 == "move_down"
6124                    && read_only
6125                    && !allow_text_input
6126                    && !inherit_normal_bindings
6127                    && plugin_name.is_none()
6128        );
6129
6130        // show_buffer
6131        assert_dispatches!(
6132            |a: &PluginApi| a.show_buffer(BufferId(77)),
6133            PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
6134        );
6135
6136        // set_split_scroll
6137        assert_dispatches!(
6138            |a: &PluginApi| a.set_split_scroll(5, 128),
6139            PluginCommand::SetSplitScroll { split_id, top_byte }
6140                if split_id == SplitId(5) && top_byte == 128
6141        );
6142
6143        // get_highlights
6144        assert_dispatches!(
6145            |a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
6146            PluginCommand::RequestHighlights { buffer_id, range, request_id }
6147                if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
6148        );
6149    }
6150
6151    /// `get_active_split_id` reads the snapshot verbatim; a non-{0,1}
6152    /// sentinel value kills both the `0` and `1` constant-return mutants.
6153    #[test]
6154    fn plugin_api_get_active_split_id_reads_snapshot() {
6155        let (api, _rx, _h, _c, snap) = mk_api();
6156        snap.write().unwrap().active_split_id = 42;
6157        assert_eq!(api.get_active_split_id(), 42);
6158    }
6159
6160    /// `state_snapshot_handle` returns a clone of the same `Arc`, not a
6161    /// freshly-defaulted snapshot. A distinguishing field value on the
6162    /// original state proves that the handle sees it.
6163    #[test]
6164    fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
6165        let (api, _rx, _h, _c, snap) = mk_api();
6166        snap.write().unwrap().active_buffer_id = BufferId(42);
6167
6168        let h = api.state_snapshot_handle();
6169        assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
6170        assert!(Arc::ptr_eq(&h, &snap));
6171    }
6172
6173    /// `KillHostProcess` survives a round-trip through serde: the
6174    /// `process_id` field stays identified by name and the variant
6175    /// retains its tag shape. If a future contributor renames the
6176    /// field or splits it into a tuple, the plugin-runtime TS side
6177    /// (which hand-builds the command JSON for the dispatcher) would
6178    /// silently break — this test pins the wire format.
6179    #[test]
6180    fn plugin_command_kill_host_process_serde_round_trip() {
6181        let cmd = PluginCommand::KillHostProcess { process_id: 1234 };
6182        let json = serde_json::to_value(&cmd).unwrap();
6183        assert_eq!(json["KillHostProcess"]["process_id"], 1234);
6184        let decoded: PluginCommand = serde_json::from_value(json).unwrap();
6185        match decoded {
6186            PluginCommand::KillHostProcess { process_id } => assert_eq!(process_id, 1234),
6187            other => panic!("expected KillHostProcess, got {:?}", other),
6188        }
6189    }
6190
6191    // ==================== SearchHandle behavior ====================
6192
6193    fn dummy_match(line: usize) -> GrepMatch {
6194        GrepMatch {
6195            file: "fixture.rs".to_string(),
6196            buffer_id: 0,
6197            byte_offset: 0,
6198            length: 4,
6199            line,
6200            column: 1,
6201            context: "match".to_string(),
6202        }
6203    }
6204
6205    /// Pull-based handle batches matches between drains: a producer that
6206    /// pushes N matches across multiple writes hands them to the consumer
6207    /// in a single take(), and a follow-up take() with no new writes
6208    /// returns an empty batch — proving the architectural property the
6209    /// new API was built around (no per-chunk dispatch).
6210    #[test]
6211    fn search_handle_batches_between_takes() {
6212        let handle = Arc::new(SearchHandleState::new());
6213
6214        // Three independent writer batches simulate three searcher tasks
6215        // pushing into the shared state.
6216        for chunk in [vec![dummy_match(1), dummy_match(2)], vec![dummy_match(3)]] {
6217            let count = chunk.len();
6218            let mut state = handle.state.lock().unwrap();
6219            state.pending.extend(chunk);
6220            state.total_seen += count;
6221        }
6222
6223        // First take drains everything written so far.
6224        let drained: Vec<_> = {
6225            let mut s = handle.state.lock().unwrap();
6226            std::mem::take(&mut s.pending)
6227        };
6228        assert_eq!(drained.len(), 3);
6229        assert_eq!(handle.state.lock().unwrap().total_seen, 3);
6230
6231        // Second take with no producer activity yields an empty batch.
6232        let empty: Vec<_> = {
6233            let mut s = handle.state.lock().unwrap();
6234            std::mem::take(&mut s.pending)
6235        };
6236        assert!(empty.is_empty());
6237    }
6238
6239    /// `cancel` is a one-way latch visible to producers and consumers.
6240    /// Setting it does not implicitly mark `done` — completion is the
6241    /// producer's responsibility — but a producer observing the flag
6242    /// should stop pushing.
6243    #[test]
6244    fn search_handle_cancel_is_observable() {
6245        let handle = Arc::new(SearchHandleState::new());
6246        assert!(!handle.cancel.load(std::sync::atomic::Ordering::Relaxed));
6247
6248        handle
6249            .cancel
6250            .store(true, std::sync::atomic::Ordering::Relaxed);
6251
6252        assert!(handle.cancel.load(std::sync::atomic::Ordering::Relaxed));
6253        assert!(!handle.state.lock().unwrap().done);
6254    }
6255
6256    /// The terminal state transition: producers flip `done = true` once
6257    /// no more matches will arrive, with `truncated` reflecting whether
6258    /// the search hit `max_results`. Consumers learn the search is
6259    /// finished from the same `take()` that drains the final batch.
6260    #[test]
6261    fn search_handle_done_transition_is_visible_to_consumer() {
6262        let handle = Arc::new(SearchHandleState::new());
6263
6264        // Producer pushes a final batch, then marks done.
6265        {
6266            let mut s = handle.state.lock().unwrap();
6267            s.pending.push(dummy_match(7));
6268            s.total_seen += 1;
6269            s.truncated = true;
6270            s.done = true;
6271        }
6272
6273        let (matches, done, truncated) = {
6274            let mut s = handle.state.lock().unwrap();
6275            (std::mem::take(&mut s.pending), s.done, s.truncated)
6276        };
6277
6278        assert_eq!(matches.len(), 1);
6279        assert!(done);
6280        assert!(truncated);
6281    }
6282
6283    /// Producers and consumers must be able to interleave without
6284    /// blocking each other longer than a `mem::take` swap. This test
6285    /// drives writes from a worker thread while the main thread drains;
6286    /// it asserts the consumer eventually sees every match. With a
6287    /// per-chunk dispatch model an analogous test would deadlock or
6288    /// drop matches; with the pull model it converges.
6289    #[test]
6290    fn search_handle_concurrent_producer_consumer() {
6291        let handle = Arc::new(SearchHandleState::new());
6292        let producer = Arc::clone(&handle);
6293        let writer = std::thread::spawn(move || {
6294            for line in 1..=200 {
6295                let mut s = producer.state.lock().unwrap();
6296                s.pending.push(dummy_match(line));
6297                s.total_seen += 1;
6298            }
6299            producer.state.lock().unwrap().done = true;
6300        });
6301
6302        let mut drained: Vec<GrepMatch> = Vec::new();
6303        loop {
6304            let (mut batch, done) = {
6305                let mut s = handle.state.lock().unwrap();
6306                (std::mem::take(&mut s.pending), s.done)
6307            };
6308            drained.append(&mut batch);
6309            if done {
6310                let mut tail = handle.state.lock().unwrap();
6311                drained.append(&mut std::mem::take(&mut tail.pending));
6312                break;
6313            }
6314            std::thread::yield_now();
6315        }
6316        writer.join().unwrap();
6317        assert_eq!(drained.len(), 200);
6318    }
6319}