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    /// Add virtual text (inline text that doesn't exist in the buffer)
2403    /// Used for color swatches, type hints, parameter hints, etc.
2404    AddVirtualText {
2405        buffer_id: BufferId,
2406        virtual_text_id: String,
2407        position: usize,
2408        text: String,
2409        color: (u8, u8, u8),
2410        use_bg: bool, // true = use color as background, false = use as foreground
2411        before: bool, // true = before char, false = after char
2412    },
2413
2414    /// Add virtual text with full styling — fg/bg can be RGB or theme
2415    /// keys (resolved at render time so theme changes apply live).
2416    /// This is the richer form of `AddVirtualText` that lets plugins
2417    /// produce themed labels (flash jump, type hints with semantic
2418    /// colours, …) without hard-coding RGB values.
2419    AddVirtualTextStyled {
2420        buffer_id: BufferId,
2421        virtual_text_id: String,
2422        position: usize,
2423        text: String,
2424        fg: Option<OverlayColorSpec>,
2425        bg: Option<OverlayColorSpec>,
2426        bold: bool,
2427        italic: bool,
2428        before: bool,
2429    },
2430
2431    /// Remove a virtual text by ID
2432    RemoveVirtualText {
2433        buffer_id: BufferId,
2434        virtual_text_id: String,
2435    },
2436
2437    /// Remove virtual texts whose ID starts with the given prefix
2438    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
2439
2440    /// Clear all virtual texts from a buffer
2441    ClearVirtualTexts { buffer_id: BufferId },
2442
2443    /// Add a virtual LINE (full line above/below a position)
2444    /// Used for git blame headers, code coverage, inline documentation, etc.
2445    /// These lines do NOT show line numbers in the gutter.
2446    AddVirtualLine {
2447        buffer_id: BufferId,
2448        /// Byte position to anchor the line to
2449        position: usize,
2450        /// Full line content to display
2451        text: String,
2452        /// Foreground color — RGB tuple or theme key string (e.g.
2453        /// `"editor.line_number_fg"`).  Resolved at render time so the line
2454        /// follows theme changes.
2455        fg_color: Option<OverlayColorSpec>,
2456        /// Background color — RGB tuple or theme key string.  None =
2457        /// transparent (inherits from underlying viewport background).
2458        bg_color: Option<OverlayColorSpec>,
2459        /// true = above the line containing position, false = below
2460        above: bool,
2461        /// Namespace for bulk removal (e.g., "git-blame")
2462        namespace: String,
2463        /// Priority for ordering multiple lines at same position (higher = later)
2464        priority: i32,
2465        /// Optional gutter glyph rendered in the line-number column on
2466        /// the first visual row of this virtual line. Used by diff
2467        /// plugins to put a "-" directly on the deletion line itself
2468        /// instead of the source line that follows it.
2469        gutter_glyph: Option<String>,
2470        /// Color for `gutter_glyph` (RGB or theme key). Falls back to
2471        /// `theme.line_number_fg` when `None`.
2472        gutter_color: Option<OverlayColorSpec>,
2473        /// Per-range modifier overlays applied on top of the base fg/bg.
2474        /// Offsets are byte offsets within `text`, not buffer bytes.
2475        /// Used e.g. by live-diff to bold + underline removed words on
2476        /// a deletion virtual line.
2477        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2478        text_overlays: Vec<VirtualLineTextOverlay>,
2479    },
2480
2481    /// Clear all virtual texts in a namespace
2482    /// This is the primary way to remove a plugin's virtual lines before updating them.
2483    ClearVirtualTextNamespace {
2484        buffer_id: BufferId,
2485        namespace: String,
2486    },
2487
2488    /// Add a conceal range that hides or replaces a byte range during rendering.
2489    /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc.
2490    AddConceal {
2491        buffer_id: BufferId,
2492        /// Namespace for bulk removal (shared with overlay namespace system)
2493        namespace: OverlayNamespace,
2494        /// Byte range to conceal
2495        start: usize,
2496        end: usize,
2497        /// Optional replacement text to show instead. None = hide completely.
2498        replacement: Option<String>,
2499    },
2500
2501    /// Clear all conceal ranges in a namespace
2502    ClearConcealNamespace {
2503        buffer_id: BufferId,
2504        namespace: OverlayNamespace,
2505    },
2506
2507    /// Remove all conceal ranges that overlap with a byte range
2508    /// Used for targeted invalidation when content in a range changes
2509    ClearConcealsInRange {
2510        buffer_id: BufferId,
2511        start: usize,
2512        end: usize,
2513    },
2514
2515    /// Add a collapsed fold range. Hides the byte range
2516    /// `[start, end)` from rendering — the line containing `start - 1`
2517    /// (the fold's "header") stays visible while the lines covered by
2518    /// the range are skipped. Used by plugins that want to expose
2519    /// outline-style collapse without rebuilding buffer content.
2520    AddFold {
2521        buffer_id: BufferId,
2522        start: usize,
2523        end: usize,
2524        /// Optional placeholder text to show on the header line
2525        /// (currently unused by the renderer; reserved for future use).
2526        placeholder: Option<String>,
2527    },
2528
2529    /// Clear every collapsed fold range on the buffer.
2530    ClearFolds { buffer_id: BufferId },
2531
2532    /// Publish a set of fold ranges on the buffer in the same shape
2533    /// LSP `textDocument/foldingRange` populates. The ranges are
2534    /// stored as **toggleable** — the standard `toggle_fold` keybinding
2535    /// finds them via `state.folding_ranges` and collapses/expands on
2536    /// demand. Unlike `AddFold`, this does not pre-collapse anything.
2537    ///
2538    /// Designed for plugins that derive structural folds from buffer
2539    /// content (e.g. git-log's per-file / per-hunk diff structure)
2540    /// without driving an LSP. Replacing call replaces the prior set.
2541    SetFoldingRanges {
2542        buffer_id: BufferId,
2543        #[ts(type = "any")]
2544        ranges: Vec<lsp_types::FoldingRange>,
2545    },
2546
2547    /// Add a soft break point for marker-based line wrapping.
2548    /// The break is stored as a marker that auto-adjusts on buffer edits,
2549    /// eliminating the flicker caused by async view_transform round-trips.
2550    AddSoftBreak {
2551        buffer_id: BufferId,
2552        /// Namespace for bulk removal (shared with overlay namespace system)
2553        namespace: OverlayNamespace,
2554        /// Byte offset where the break should be injected
2555        position: usize,
2556        /// Number of hanging indent spaces after the break
2557        indent: u16,
2558    },
2559
2560    /// Clear all soft breaks in a namespace
2561    ClearSoftBreakNamespace {
2562        buffer_id: BufferId,
2563        namespace: OverlayNamespace,
2564    },
2565
2566    /// Remove all soft breaks that fall within a byte range
2567    ClearSoftBreaksInRange {
2568        buffer_id: BufferId,
2569        start: usize,
2570        end: usize,
2571    },
2572
2573    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
2574    RefreshLines { buffer_id: BufferId },
2575
2576    /// Refresh lines for ALL buffers (clear entire seen_lines cache)
2577    /// Sent when a plugin registers for the lines_changed hook to handle the race
2578    /// where render marks lines as "seen" before the plugin has registered.
2579    RefreshAllLines,
2580
2581    /// Sentinel sent by the plugin thread after a hook has been fully processed.
2582    /// Used by the render loop to wait deterministically for plugin responses
2583    /// (e.g., conceal commands from `lines_changed`) instead of polling.
2584    HookCompleted { hook_name: String },
2585
2586    /// Set a line indicator in the gutter's indicator column
2587    /// Used for git gutter, breakpoints, bookmarks, etc.
2588    SetLineIndicator {
2589        buffer_id: BufferId,
2590        /// Line number (0-indexed)
2591        line: usize,
2592        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
2593        namespace: String,
2594        /// Symbol to display (e.g., "│", "●", "★")
2595        symbol: String,
2596        /// Color as RGB tuple
2597        color: (u8, u8, u8),
2598        /// Priority for display when multiple indicators exist (higher wins)
2599        priority: i32,
2600    },
2601
2602    /// Batch set line indicators in the gutter's indicator column
2603    /// Optimized for setting many lines with the same namespace/symbol/color/priority
2604    SetLineIndicators {
2605        buffer_id: BufferId,
2606        /// Line numbers (0-indexed)
2607        lines: Vec<usize>,
2608        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
2609        namespace: String,
2610        /// Symbol to display (e.g., "│", "●", "★")
2611        symbol: String,
2612        /// Color as RGB tuple
2613        color: (u8, u8, u8),
2614        /// Priority for display when multiple indicators exist (higher wins)
2615        priority: i32,
2616    },
2617
2618    /// Clear all line indicators for a specific namespace
2619    ClearLineIndicators {
2620        buffer_id: BufferId,
2621        /// Namespace to clear (e.g., "git-gutter")
2622        namespace: String,
2623    },
2624
2625    /// Set file explorer decorations for a namespace
2626    SetFileExplorerDecorations {
2627        /// Namespace for grouping (e.g., "git-status")
2628        namespace: String,
2629        /// Decorations to apply
2630        decorations: Vec<FileExplorerDecoration>,
2631    },
2632
2633    /// Clear file explorer decorations for a namespace
2634    ClearFileExplorerDecorations {
2635        /// Namespace to clear (e.g., "git-status")
2636        namespace: String,
2637    },
2638
2639    /// Open a file at a specific line and column
2640    /// Line and column are 1-indexed to match git grep output
2641    OpenFileAtLocation {
2642        path: PathBuf,
2643        line: Option<usize>,   // 1-indexed, None = go to start
2644        column: Option<usize>, // 1-indexed, None = go to line start
2645    },
2646
2647    /// Open a file in a specific split at a given line and column
2648    /// Line and column are 1-indexed to match git grep output
2649    OpenFileInSplit {
2650        split_id: usize,
2651        path: PathBuf,
2652        line: Option<usize>,   // 1-indexed, None = go to start
2653        column: Option<usize>, // 1-indexed, None = go to line start
2654    },
2655
2656    /// Start a prompt (minibuffer) with a custom type identifier
2657    /// This allows plugins to create interactive prompts
2658    StartPrompt {
2659        label: String,
2660        prompt_type: String, // e.g., "git-grep", "git-find-file"
2661        /// When true, the prompt renders as a centred floating
2662        /// overlay rather than a bottom-row minibuffer. Used for
2663        /// Live Grep (issue #1796). Defaults to false at the wire
2664        /// level via `#[serde(default)]`.
2665        #[serde(default)]
2666        floating_overlay: bool,
2667    },
2668
2669    /// Start a prompt with pre-filled initial value
2670    StartPromptWithInitial {
2671        label: String,
2672        prompt_type: String,
2673        initial_value: String,
2674        /// See `StartPrompt::floating_overlay`.
2675        #[serde(default)]
2676        floating_overlay: bool,
2677    },
2678
2679    /// Start an async prompt that returns result via callback
2680    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
2681    StartPromptAsync {
2682        label: String,
2683        initial_value: String,
2684        callback_id: JsCallbackId,
2685    },
2686
2687    /// Request the next keypress for the calling plugin.
2688    ///
2689    /// The editor enqueues `callback_id` and resolves it with a
2690    /// `KeyEventPayload` JSON value the next time a key arrives in
2691    /// `Editor::handle_key`. Multiple pending requests are FIFO.
2692    /// While at least one request is pending, the next key is consumed
2693    /// by the resolution and does not propagate to mode bindings or
2694    /// other dispatch — this is the primitive that lets a plugin run a
2695    /// short input loop (flash labels, vi find-char, replace-char,
2696    /// etc.) without binding every printable key in `defineMode`.
2697    AwaitNextKey { callback_id: JsCallbackId },
2698
2699    /// Begin or end "key capture" mode for the calling plugin.
2700    ///
2701    /// Without this, a plugin running a `getNextKey()` loop has a
2702    /// race: keys typed by the user (or pasted, or auto-repeated)
2703    /// can arrive between two consecutive `getNextKey()` calls while
2704    /// the plugin is still mid-redraw, and would otherwise fall
2705    /// through to the editor's normal dispatch (inserting into the
2706    /// buffer, etc.).
2707    ///
2708    /// While capture is active, every key arriving in
2709    /// `Editor::handle_key` (after terminal-input dispatch) is
2710    /// either resolved against a pending `AwaitNextKey` callback
2711    /// (existing behaviour) or, if no callback is pending, *buffered*
2712    /// in a FIFO queue.  When the next `AwaitNextKey` is processed,
2713    /// the queue is drained first.  This gives plugins lossless,
2714    /// in-order delivery of every key the user typed regardless of
2715    /// timing.
2716    ///
2717    /// `EndKeyCapture` clears any unconsumed buffered keys; they do
2718    /// NOT replay into the editor's normal dispatch path (that would
2719    /// be surprising — the user's intent was for the plugin to
2720    /// consume them).
2721    SetKeyCaptureActive { active: bool },
2722
2723    /// Update the suggestions list for the current prompt
2724    /// Uses the editor's Suggestion type
2725    SetPromptSuggestions { suggestions: Vec<Suggestion> },
2726
2727    /// When enabled, navigating suggestions updates the prompt input text
2728    SetPromptInputSync { sync: bool },
2729
2730    /// Set the title shown in a floating-overlay prompt's frame
2731    /// header (issue #1796) as styled segments. Each segment carries
2732    /// optional `OverlayOptions`, so plugins can theme keybinding
2733    /// hints with `fg: "ui.help_key_fg"`, separators with
2734    /// `fg: "ui.popup_border_fg"`, etc. An empty vec clears the
2735    /// title and falls back to the prompt-type default. Has no
2736    /// visible effect on non-overlay prompts.
2737    SetPromptTitle { title: Vec<StyledText> },
2738
2739    /// Plugin-supplied footer chrome rendered along the bottom
2740    /// row of the floating-overlay's results pane (Primitive #2
2741    /// chrome region in
2742    /// `docs/internal/orchestrator-sessions-design.md`). Orchestrator
2743    /// uses this for hotkey-hint rows. Empty vec clears the
2744    /// footer. Has no visible effect on non-overlay prompts.
2745    SetPromptFooter { footer: Vec<StyledText> },
2746
2747    /// Plugin-supplied toolbar for the floating-overlay prompt's header
2748    /// band, as a `WidgetSpec` (a `Row`/`Col` of `Toggle`s/`Button`s). Unlike
2749    /// `SetPromptTitle` (styled text), these are real widgets: they render
2750    /// with the theme's toggle/button styling and are clickable (each carries
2751    /// a stable `key`; the host maps a click to the
2752    /// `live_grep_toggle_<key>`-style action). `None`/absent leaves the
2753    /// styled-text title in place. Has no visible effect on non-overlay
2754    /// prompts.
2755    SetPromptToolbar { spec: Option<WidgetSpec> },
2756
2757    /// Short status text shown right-aligned on the floating-overlay prompt's
2758    /// input row, just left of the `selected / total` count (e.g.
2759    /// "Searching…", "No matches"). Empty clears it. No effect on non-overlay
2760    /// prompts.
2761    SetPromptStatus { status: String },
2762
2763    /// Flip a toolbar toggle in the floating-overlay prompt by its widget
2764    /// `key`. The host owns the toggle's checked state: it updates the spec in
2765    /// place and emits a `widget_event` so the plugin can react (re-run its
2766    /// search, etc.). Lets a plugin's Alt+… shortcut funnel through the same
2767    /// host path as a click or Space on the focused toggle.
2768    ToggleOverlayToolbarWidget { key: String },
2769
2770    /// Override the currently-highlighted suggestion row in the
2771    /// open prompt. Clamped to the suggestion list's bounds; out-
2772    /// of-range indices snap to the last row. No-op when there is
2773    /// no open prompt or the list is empty. The renderer scrolls
2774    /// the selection into view on the next frame.
2775    SetPromptSelectedIndex { index: u32 },
2776
2777    /// Add a menu item to an existing menu
2778    /// Add a menu item to an existing menu
2779    AddMenuItem {
2780        menu_label: String,
2781        item: MenuItem,
2782        position: MenuPosition,
2783    },
2784
2785    /// Add a new top-level menu
2786    AddMenu { menu: Menu, position: MenuPosition },
2787
2788    /// Remove a menu item from a menu
2789    RemoveMenuItem {
2790        menu_label: String,
2791        item_label: String,
2792    },
2793
2794    /// Remove a top-level menu
2795    RemoveMenu { menu_label: String },
2796
2797    /// Create a new virtual buffer (not backed by a file)
2798    CreateVirtualBuffer {
2799        /// Display name (e.g., "*Diagnostics*")
2800        name: String,
2801        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
2802        mode: String,
2803        /// Whether the buffer is read-only
2804        read_only: bool,
2805    },
2806
2807    /// Create a virtual buffer and set its content in one operation
2808    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
2809    /// because it doesn't require tracking the buffer ID
2810    CreateVirtualBufferWithContent {
2811        /// Display name (e.g., "*Diagnostics*")
2812        name: String,
2813        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
2814        mode: String,
2815        /// Whether the buffer is read-only
2816        read_only: bool,
2817        /// Entries with text and embedded properties
2818        entries: Vec<TextPropertyEntry>,
2819        /// Whether to show line numbers in the gutter
2820        show_line_numbers: bool,
2821        /// Whether to show cursors in the buffer
2822        show_cursors: bool,
2823        /// Whether editing is disabled (blocks editing commands)
2824        editing_disabled: bool,
2825        /// Whether this buffer should be hidden from tabs (for composite source buffers)
2826        hidden_from_tabs: bool,
2827        /// Optional request ID for async response
2828        request_id: Option<u64>,
2829    },
2830
2831    /// Create a virtual buffer in a horizontal split
2832    /// Opens the buffer in a new pane below the current one
2833    CreateVirtualBufferInSplit {
2834        /// Display name (e.g., "*Diagnostics*")
2835        name: String,
2836        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
2837        mode: String,
2838        /// Whether the buffer is read-only
2839        read_only: bool,
2840        /// Entries with text and embedded properties
2841        entries: Vec<TextPropertyEntry>,
2842        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
2843        ratio: f32,
2844        /// Split direction ("horizontal" or "vertical"), default horizontal
2845        direction: Option<String>,
2846        /// Optional panel ID for idempotent operations (if panel exists, update content)
2847        panel_id: Option<String>,
2848        /// Whether to show line numbers in the buffer (default true)
2849        show_line_numbers: bool,
2850        /// Whether to show cursors in the buffer (default true)
2851        show_cursors: bool,
2852        /// Whether editing is disabled for this buffer (default false)
2853        editing_disabled: bool,
2854        /// Whether line wrapping is enabled for this split (None = use global setting)
2855        line_wrap: Option<bool>,
2856        /// Place the new buffer before (left/top of) the existing content (default: false/after)
2857        before: bool,
2858        /// Optional split role tag. When `Some("utility_dock")`, the
2859        /// dispatcher routes the buffer to the existing dock leaf if
2860        /// one exists; otherwise it seeds a new dock leaf with the
2861        /// requested direction/ratio.
2862        role: Option<String>,
2863        /// Optional request ID for async response (if set, editor will send back buffer ID)
2864        request_id: Option<u64>,
2865    },
2866
2867    /// Set the content of a virtual buffer with text properties
2868    SetVirtualBufferContent {
2869        buffer_id: BufferId,
2870        /// Entries with text and embedded properties
2871        entries: Vec<TextPropertyEntry>,
2872    },
2873
2874    /// Get text properties at the cursor position in a buffer
2875    GetTextPropertiesAtCursor { buffer_id: BufferId },
2876
2877    /// Create a buffer group: multiple panels appearing as one tab.
2878    /// Each panel is a real buffer with its own scrollbar and viewport.
2879    CreateBufferGroup {
2880        /// Display name (shown in tab bar)
2881        name: String,
2882        /// Mode for keybindings
2883        mode: String,
2884        /// Layout tree as JSON string (parsed by the handler)
2885        layout_json: String,
2886        /// Optional request ID for async response
2887        request_id: Option<u64>,
2888    },
2889
2890    /// Set the content of a panel within a buffer group.
2891    SetPanelContent {
2892        /// Group ID
2893        group_id: usize,
2894        /// Panel name (e.g., "tree", "picker")
2895        panel_name: String,
2896        /// Content entries
2897        entries: Vec<TextPropertyEntry>,
2898    },
2899
2900    /// Close a buffer group (closes all panels and splits)
2901    CloseBufferGroup { group_id: usize },
2902
2903    /// Focus a specific panel within a buffer group
2904    FocusPanel { group_id: usize, panel_name: String },
2905
2906    /// Define a buffer mode with keybindings
2907    DefineMode {
2908        name: String,
2909        bindings: Vec<(String, String)>, // (key_string, command_name)
2910        read_only: bool,
2911        /// When true, unbound character keys dispatch as `mode_text_input:<char>`.
2912        allow_text_input: bool,
2913        /// When true, keys not bound by this mode fall through to the Normal
2914        /// context (motion, selection, copy) instead of being dropped.
2915        inherit_normal_bindings: bool,
2916        /// Name of the plugin that defined this mode (for attribution)
2917        plugin_name: Option<String>,
2918    },
2919
2920    /// Switch the current split to display a buffer
2921    ShowBuffer { buffer_id: BufferId },
2922
2923    /// Start a frame-buffer animation over a given screen region. The `id`
2924    /// is allocated on the plugin side so the JS call can return it
2925    /// synchronously; the editor uses it verbatim.
2926    StartAnimationArea {
2927        id: u64,
2928        rect: AnimationRect,
2929        kind: PluginAnimationKind,
2930    },
2931
2932    /// Start an animation over the on-screen Rect currently occupied by a
2933    /// virtual buffer. If the buffer is not visible, the editor ignores
2934    /// the command.
2935    StartAnimationVirtualBuffer {
2936        id: u64,
2937        buffer_id: BufferId,
2938        kind: PluginAnimationKind,
2939    },
2940
2941    /// Cancel an animation by the ID returned from `animateArea` /
2942    /// `animateVirtualBuffer`. No-op if the ID is unknown or already done.
2943    CancelAnimation { id: u64 },
2944
2945    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
2946    CreateVirtualBufferInExistingSplit {
2947        /// Display name (e.g., "*Commit Details*")
2948        name: String,
2949        /// Mode name for buffer-local keybindings
2950        mode: String,
2951        /// Whether the buffer is read-only
2952        read_only: bool,
2953        /// Entries with text and embedded properties
2954        entries: Vec<TextPropertyEntry>,
2955        /// Target split ID where the buffer should be displayed
2956        split_id: SplitId,
2957        /// Whether to show line numbers in the buffer (default true)
2958        show_line_numbers: bool,
2959        /// Whether to show cursors in the buffer (default true)
2960        show_cursors: bool,
2961        /// Whether editing is disabled for this buffer (default false)
2962        editing_disabled: bool,
2963        /// Whether line wrapping is enabled for this split (None = use global setting)
2964        line_wrap: Option<bool>,
2965        /// Optional request ID for async response
2966        request_id: Option<u64>,
2967    },
2968
2969    /// Close a buffer and remove it from all splits
2970    CloseBuffer { buffer_id: BufferId },
2971
2972    /// Close all buffers in the split except the specified one
2973    CloseOtherBuffersInSplit {
2974        buffer_id: BufferId,
2975        split_id: SplitId,
2976    },
2977
2978    /// Close all buffers in the split
2979    CloseAllBuffersInSplit { split_id: SplitId },
2980
2981    /// Close all buffers to the right of the specified buffer in the split
2982    CloseBuffersToRightInSplit {
2983        buffer_id: BufferId,
2984        split_id: SplitId,
2985    },
2986
2987    /// Close all buffers to the left of the specified buffer in the split
2988    CloseBuffersToLeftInSplit {
2989        buffer_id: BufferId,
2990        split_id: SplitId,
2991    },
2992
2993    /// Move the active tab one position to the left within its split
2994    MoveTabLeft,
2995
2996    /// Move the active tab one position to the right within its split
2997    MoveTabRight,
2998
2999    /// Create a composite buffer that displays multiple source buffers
3000    /// Used for side-by-side diff, unified diff, and 3-way merge views
3001    CreateCompositeBuffer {
3002        /// Display name (shown in tab bar)
3003        name: String,
3004        /// Mode name for keybindings (e.g., "diff-view")
3005        mode: String,
3006        /// Layout configuration
3007        layout: CompositeLayoutConfig,
3008        /// Source pane configurations
3009        sources: Vec<CompositeSourceConfig>,
3010        /// Diff hunks for line alignment (optional)
3011        hunks: Option<Vec<CompositeHunk>>,
3012        /// When set, first render scrolls to center this hunk (0-indexed)
3013        initial_focus_hunk: Option<usize>,
3014        /// Request ID for async response
3015        request_id: Option<u64>,
3016    },
3017
3018    /// Update alignment for a composite buffer (e.g., after source edit)
3019    UpdateCompositeAlignment {
3020        buffer_id: BufferId,
3021        hunks: Vec<CompositeHunk>,
3022    },
3023
3024    /// Close a composite buffer
3025    CloseCompositeBuffer { buffer_id: BufferId },
3026
3027    /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
3028    ///
3029    /// Creates `CompositeViewState` for any visible composite buffer that doesn't
3030    /// have one, and syncs viewport dimensions from split layout. This ensures
3031    /// subsequent commands can read/modify view state that is normally created
3032    /// lazily during the render cycle.
3033    FlushLayout,
3034
3035    /// Navigate to the next hunk in a composite buffer
3036    CompositeNextHunk { buffer_id: BufferId },
3037
3038    /// Navigate to the previous hunk in a composite buffer
3039    CompositePrevHunk { buffer_id: BufferId },
3040
3041    /// Focus a specific split
3042    FocusSplit { split_id: SplitId },
3043
3044    /// Set the buffer displayed in a specific split
3045    SetSplitBuffer {
3046        split_id: SplitId,
3047        buffer_id: BufferId,
3048    },
3049
3050    /// Set the scroll position of a specific split
3051    SetSplitScroll { split_id: SplitId, top_byte: usize },
3052
3053    /// Request syntax highlights for a buffer range
3054    RequestHighlights {
3055        buffer_id: BufferId,
3056        range: Range<usize>,
3057        request_id: u64,
3058    },
3059
3060    /// Close a split (if not the last one)
3061    CloseSplit { split_id: SplitId },
3062
3063    /// Set the ratio of a split container
3064    SetSplitRatio {
3065        split_id: SplitId,
3066        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
3067        ratio: f32,
3068    },
3069
3070    /// Set a label on a leaf split (e.g., "sidebar")
3071    SetSplitLabel { split_id: SplitId, label: String },
3072
3073    /// Remove a label from a split
3074    ClearSplitLabel { split_id: SplitId },
3075
3076    /// Find a split by its label (async)
3077    GetSplitByLabel { label: String, request_id: u64 },
3078
3079    /// Distribute splits evenly - make all given splits equal size
3080    DistributeSplitsEvenly {
3081        /// Split IDs to distribute evenly
3082        split_ids: Vec<SplitId>,
3083    },
3084
3085    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
3086    SetBufferCursor {
3087        buffer_id: BufferId,
3088        /// Byte offset position for the cursor
3089        position: usize,
3090    },
3091
3092    /// Toggle whether the editor draws a native caret for this buffer.
3093    ///
3094    /// Buffer-group panel buffers default to `show_cursors = false`, which not
3095    /// only hides the caret but also blocks all movement actions in
3096    /// `action_to_events`. Plugins that want native cursor motion in a panel
3097    /// buffer (e.g. for magit-style row navigation) flip this to `true` after
3098    /// `createBufferGroup` returns.
3099    SetBufferShowCursors { buffer_id: BufferId, show: bool },
3100
3101    /// Send an arbitrary LSP request and return the raw JSON response
3102    SendLspRequest {
3103        language: String,
3104        method: String,
3105        #[ts(type = "any")]
3106        params: Option<JsonValue>,
3107        request_id: u64,
3108    },
3109
3110    /// Set the internal clipboard content
3111    SetClipboard { text: String },
3112
3113    /// Delete the current selection in the active buffer
3114    /// This deletes all selected text across all cursors
3115    DeleteSelection,
3116
3117    /// Set or unset a custom context
3118    /// Custom contexts are plugin-defined states that can be used to control command visibility
3119    /// For example, "config-editor" context could make config editor commands available
3120    SetContext {
3121        /// Context name (e.g., "config-editor")
3122        name: String,
3123        /// Whether the context is active
3124        active: bool,
3125    },
3126
3127    /// Set the hunks for the Review Diff tool
3128    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
3129
3130    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
3131    /// Used by vi mode plugin to run motions and calculate cursor ranges
3132    ExecuteAction {
3133        /// Action name (e.g., "move_word_right", "move_line_end")
3134        action_name: String,
3135    },
3136
3137    /// Execute multiple actions in sequence, each with an optional repeat count
3138    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
3139    /// All actions execute atomically with no plugin roundtrips between them
3140    ExecuteActions {
3141        /// List of actions to execute in sequence
3142        actions: Vec<ActionSpec>,
3143    },
3144
3145    /// Get text from a buffer range (for yank operations)
3146    GetBufferText {
3147        /// Buffer ID
3148        buffer_id: BufferId,
3149        /// Start byte offset
3150        start: usize,
3151        /// End byte offset
3152        end: usize,
3153        /// Request ID for async response
3154        request_id: u64,
3155    },
3156
3157    /// Get byte offset of the start of a line (async)
3158    /// Line is 0-indexed (0 = first line)
3159    GetLineStartPosition {
3160        /// Buffer ID (0 for active buffer)
3161        buffer_id: BufferId,
3162        /// Line number (0-indexed)
3163        line: u32,
3164        /// Request ID for async response
3165        request_id: u64,
3166    },
3167
3168    /// Get byte offset of the end of a line (async)
3169    /// Line is 0-indexed (0 = first line)
3170    /// Returns the byte offset after the last character of the line (before newline)
3171    GetLineEndPosition {
3172        /// Buffer ID (0 for active buffer)
3173        buffer_id: BufferId,
3174        /// Line number (0-indexed)
3175        line: u32,
3176        /// Request ID for async response
3177        request_id: u64,
3178    },
3179
3180    /// Get the total number of lines in a buffer (async)
3181    GetBufferLineCount {
3182        /// Buffer ID (0 for active buffer)
3183        buffer_id: BufferId,
3184        /// Request ID for async response
3185        request_id: u64,
3186    },
3187
3188    /// Open `path` as a regular buffer in forced large-file (file-backed)
3189    /// mode regardless of file size. Designed for buffers whose backing
3190    /// file will grow under them (e.g. a temp file fed by `spawnProcess`
3191    /// with `stdoutTo`). Resolves with the new buffer's id.
3192    ///
3193    /// Pair with `RefreshBufferFromDisk` to grow the buffer as the file
3194    /// is written.
3195    OpenFileStreaming {
3196        /// Path to open. May not yet exist or may be empty.
3197        path: PathBuf,
3198        /// Request ID for async response (the buffer_id).
3199        request_id: u64,
3200    },
3201
3202    /// Re-stat the file backing `buffer_id` and extend the buffer if
3203    /// the file has grown. No-op if the buffer has no file path or the
3204    /// file didn't grow. Resolves with the new total byte length.
3205    RefreshBufferFromDisk {
3206        buffer_id: BufferId,
3207        /// Request ID for async response.
3208        request_id: u64,
3209    },
3210
3211    /// Re-point a buffer-group's panel at a different buffer id.
3212    /// Used by streaming plugins (e.g. git-log) to swap one
3213    /// file-backed buffer for another when the user navigates to a
3214    /// new commit, without rebuilding the group layout. Both
3215    /// `group.panel_buffers[panel_name]` and the corresponding
3216    /// `SplitViewState.active_buffer` are updated; layout is marked
3217    /// dirty for the next render.
3218    ///
3219    /// Resolves with `true` on success, `false` if the group or panel
3220    /// is missing.
3221    SetBufferGroupPanelBuffer {
3222        group_id: usize,
3223        panel_name: String,
3224        buffer_id: BufferId,
3225        request_id: u64,
3226    },
3227
3228    /// Scroll a split to center a specific line in the viewport
3229    /// Line is 0-indexed (0 = first line)
3230    ScrollToLineCenter {
3231        /// Split ID to scroll
3232        split_id: SplitId,
3233        /// Buffer ID containing the line
3234        buffer_id: BufferId,
3235        /// Line number to center (0-indexed)
3236        line: usize,
3237    },
3238
3239    /// Scroll any split/panel that displays `buffer_id` so the given
3240    /// line is visible in the viewport. Unlike `ScrollToLineCenter` this
3241    /// does not require a split id — it walks all splits (including
3242    /// inner panels of a buffer group) and updates every viewport that
3243    /// shows this buffer. Line is 0-indexed.
3244    ScrollBufferToLine {
3245        /// Buffer ID to scroll
3246        buffer_id: BufferId,
3247        /// Line number to bring into view (0-indexed)
3248        line: usize,
3249    },
3250
3251    /// Set the global editor mode (for modal editing like vi mode)
3252    /// When set, the mode's keybindings take precedence over normal editing
3253    SetEditorMode {
3254        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
3255        mode: Option<String>,
3256    },
3257
3258    /// Show an action popup with buttons for user interaction
3259    /// When the user selects an action, the ActionPopupResult hook is fired
3260    ShowActionPopup {
3261        /// Unique identifier for the popup (used in ActionPopupResult)
3262        popup_id: String,
3263        /// Title text for the popup
3264        title: String,
3265        /// Body message (supports basic formatting)
3266        message: String,
3267        /// Action buttons to display
3268        actions: Vec<ActionPopupAction>,
3269    },
3270
3271    /// Contribute (or replace, or clear) a set of menu rows for the
3272    /// LSP-Servers popup (the popup opened by clicking the LSP
3273    /// indicator). Each plugin owns its own slice keyed by
3274    /// `plugin_id`; passing an empty `items` clears that slice.
3275    ///
3276    /// Rationale: previously plugins reacting to `lsp_status_clicked`
3277    /// pushed their own separate action popup via `ShowActionPopup`,
3278    /// which stacked over the built-in LSP-Servers popup and created
3279    /// the UX conflict in PR #1941. This command lets plugins
3280    /// contribute rows that merge into the existing popup instead.
3281    /// Selecting a contributed row fires `action_popup_result` with
3282    /// `popup_id = "lsp_status"` and `action_id =
3283    /// "{plugin_id}|{id}"`.
3284    SetLspMenuContributions {
3285        /// Stable plugin identifier used both as the namespace for
3286        /// this slice of contributions and as the prefix of the
3287        /// resulting `action_popup_result.action_id`.
3288        plugin_id: String,
3289        /// Language whose LSP-Servers popup should display these
3290        /// rows (e.g. "rust", "python").
3291        language: String,
3292        /// The rows to install. Empty clears any previous
3293        /// contribution from this `plugin_id` for this `language`.
3294        items: Vec<LspMenuItem>,
3295    },
3296
3297    /// Disable LSP for a specific language and persist to config
3298    DisableLspForLanguage {
3299        /// The language to disable LSP for (e.g., "python", "rust")
3300        language: String,
3301    },
3302
3303    /// Restart LSP server for a specific language
3304    RestartLspForLanguage {
3305        /// The language to restart LSP for (e.g., "python", "rust")
3306        language: String,
3307    },
3308
3309    /// Set the workspace root URI for a specific language's LSP server
3310    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
3311    /// If the LSP is already running, it will be restarted with the new root
3312    SetLspRootUri {
3313        /// The language to set root URI for (e.g., "csharp", "rust")
3314        language: String,
3315        /// The root URI (file:// URL format)
3316        uri: String,
3317    },
3318
3319    /// Create a scroll sync group for anchor-based synchronized scrolling
3320    /// Used for side-by-side diff views where two panes need to scroll together
3321    /// The plugin provides the group ID (must be unique per plugin)
3322    CreateScrollSyncGroup {
3323        /// Plugin-assigned group ID
3324        group_id: u32,
3325        /// The left (primary) split - scroll position is tracked in this split's line space
3326        left_split: SplitId,
3327        /// The right (secondary) split - position is derived from anchors
3328        right_split: SplitId,
3329    },
3330
3331    /// Set sync anchors for a scroll sync group
3332    /// Anchors map corresponding line numbers between left and right buffers
3333    SetScrollSyncAnchors {
3334        /// The group ID returned by CreateScrollSyncGroup
3335        group_id: u32,
3336        /// List of (left_line, right_line) pairs marking corresponding positions
3337        anchors: Vec<(usize, usize)>,
3338    },
3339
3340    /// Remove a scroll sync group
3341    RemoveScrollSyncGroup {
3342        /// The group ID returned by CreateScrollSyncGroup
3343        group_id: u32,
3344    },
3345
3346    /// Save a buffer to a specific file path
3347    /// Used by :w filename command to save unnamed buffers or save-as
3348    SaveBufferToPath {
3349        /// Buffer ID to save
3350        buffer_id: BufferId,
3351        /// Path to save to
3352        path: PathBuf,
3353    },
3354
3355    /// Load a plugin from a file path
3356    /// The plugin will be initialized and start receiving events
3357    LoadPlugin {
3358        /// Path to the plugin file (.ts or .js)
3359        path: PathBuf,
3360        /// Callback ID for async response (success/failure)
3361        callback_id: JsCallbackId,
3362    },
3363
3364    /// Unload a plugin by name
3365    /// The plugin will stop receiving events and be removed from memory
3366    UnloadPlugin {
3367        /// Plugin name (as registered)
3368        name: String,
3369        /// Callback ID for async response (success/failure)
3370        callback_id: JsCallbackId,
3371    },
3372
3373    /// Reload a plugin by name (unload + load)
3374    /// Useful for development when plugin code changes
3375    ReloadPlugin {
3376        /// Plugin name (as registered)
3377        name: String,
3378        /// Callback ID for async response (success/failure)
3379        callback_id: JsCallbackId,
3380    },
3381
3382    /// List all loaded plugins
3383    /// Returns plugin info (name, path, enabled) for all loaded plugins
3384    ListPlugins {
3385        /// Callback ID for async response (JSON array of plugin info)
3386        callback_id: JsCallbackId,
3387    },
3388
3389    /// Reload the theme registry from disk
3390    /// Call this after installing a theme package or saving a new theme.
3391    /// If `apply_theme` is set, apply that theme immediately after reloading.
3392    ReloadThemes { apply_theme: Option<String> },
3393
3394    /// Register a TextMate grammar file for a language
3395    /// The grammar will be added to pending_grammars until ReloadGrammars is called
3396    RegisterGrammar {
3397        /// Language identifier (e.g., "elixir", "zig")
3398        language: String,
3399        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
3400        grammar_path: String,
3401        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
3402        extensions: Vec<String>,
3403    },
3404
3405    /// Register language configuration (comment prefix, indentation, formatter)
3406    /// This is applied immediately to the runtime config
3407    RegisterLanguageConfig {
3408        /// Language identifier (e.g., "elixir")
3409        language: String,
3410        /// Language configuration
3411        config: LanguagePackConfig,
3412    },
3413
3414    /// Register an LSP server for a language
3415    /// This is applied immediately to the LSP manager and runtime config
3416    RegisterLspServer {
3417        /// Language identifier (e.g., "elixir")
3418        language: String,
3419        /// LSP server configuration
3420        config: LspServerPackConfig,
3421    },
3422
3423    /// Reload the grammar registry to apply registered grammars (async)
3424    /// Call this after registering one or more grammars to rebuild the syntax set.
3425    /// The callback is resolved when the background grammar build completes.
3426    ReloadGrammars { callback_id: JsCallbackId },
3427
3428    // ==================== Terminal Commands ====================
3429    /// Create a new terminal in a split (async, returns TerminalResult)
3430    /// This spawns a PTY-backed terminal that plugins can write to and read from.
3431    CreateTerminal {
3432        /// Working directory for the terminal (defaults to editor cwd)
3433        cwd: Option<String>,
3434        /// Split direction ("horizontal" or "vertical"), default vertical
3435        direction: Option<String>,
3436        /// Split ratio (0.0 to 1.0), default 0.5
3437        ratio: Option<f32>,
3438        /// Whether to focus the new terminal split (default true)
3439        focus: Option<bool>,
3440        /// Whether this terminal survives editor restarts. When false, the
3441        /// terminal is excluded from workspace serialization and its backing
3442        /// file is kept unique-per-spawn so no scrollback from a prior run
3443        /// leaks in. Plugin-created terminals default to `false` since they
3444        /// are typically one-off tool UIs (rebuilds, exec shells, etc.).
3445        persistent: bool,
3446        /// Optional session id to attach the new terminal buffer to.
3447        /// `None` (default) attaches to the active session at creation
3448        /// time — the historical behaviour. `Some(id)` lets Orchestrator
3449        /// (and any plugin spawning agents in worktrees) attach the
3450        /// terminal to its target session without diving first; the
3451        /// terminal's split is created in that session's stashed split
3452        /// tree, and the buffer is added to the target session's
3453        /// `Session.buffers` membership rather than the active one's.
3454        /// Falls back to active session if the id is unknown.
3455        #[serde(default)]
3456        window_id: Option<WindowId>,
3457        /// Argv to spawn directly in the PTY in lieu of the host's
3458        /// configured shell. See `CreateTerminalOptions::command` for
3459        /// the full semantics — `None` keeps the shell-and-type
3460        /// behaviour, `Some(argv)` runs `argv` as the PTY child.
3461        #[serde(default)]
3462        command: Option<Vec<String>>,
3463        /// Tab title override. Defaults to `command[0]` (when
3464        /// `command` is set) or `"Terminal N"` (when it isn't).
3465        /// See `CreateTerminalOptions::title`.
3466        #[serde(default)]
3467        title: Option<String>,
3468        /// Callback ID for async response
3469        request_id: u64,
3470    },
3471
3472    /// Send input data to a terminal by its terminal ID
3473    SendTerminalInput {
3474        /// The terminal ID (from TerminalResult)
3475        terminal_id: TerminalId,
3476        /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
3477        data: String,
3478    },
3479
3480    /// Close a terminal by its terminal ID
3481    CloseTerminal {
3482        /// The terminal ID to close
3483        terminal_id: TerminalId,
3484    },
3485
3486    /// Send `signal` to every process group tracked by the
3487    /// window `id`. `signal` is one of `"SIGTERM"` / `"SIGKILL"`
3488    /// / `"SIGINT"` / `"SIGHUP"`; the window's authority
3489    /// determines the actual delivery mechanism (local
3490    /// `kill(-pgid, …)` on host, `docker exec kill …` for
3491    /// container authorities, SSH agent for remote ones —
3492    /// see `app/window/process_group.rs`). Idempotent across
3493    /// already-exited groups: callers can retry safely.
3494    SignalWindow { id: WindowId, signal: String },
3495
3496    /// Project-wide grep search (async)
3497    /// Searches all project files via FileSystem trait, respecting .gitignore.
3498    /// For open buffers with dirty edits, searches the buffer's piece tree.
3499    GrepProject {
3500        /// Search pattern (literal string)
3501        pattern: String,
3502        /// Whether the pattern is a fixed string (true) or regex (false)
3503        fixed_string: bool,
3504        /// Whether the search is case-sensitive
3505        case_sensitive: bool,
3506        /// Maximum number of results to return
3507        max_results: usize,
3508        /// Whether to match whole words only
3509        whole_words: bool,
3510        /// Callback ID for async response
3511        callback_id: JsCallbackId,
3512    },
3513
3514    /// Project-wide streaming search using a pull-based handle.
3515    ///
3516    /// The plugin allocates `handle_id` and registers an `Arc<SearchHandleState>`
3517    /// in the shared `SearchHandleRegistry` before sending this command. The
3518    /// editor's searcher tasks look up the same entry and write matches
3519    /// directly into its `pending` vec — no per-chunk JS dispatch. The plugin
3520    /// drains state via `editor._searchHandleTake(handle_id)` at its own pace.
3521    BeginSearch {
3522        /// Search pattern
3523        pattern: String,
3524        /// Whether the pattern is a fixed string (true) or regex (false)
3525        fixed_string: bool,
3526        /// Whether the search is case-sensitive
3527        case_sensitive: bool,
3528        /// Maximum number of results before the search self-truncates
3529        max_results: usize,
3530        /// Whether to match whole words only
3531        whole_words: bool,
3532        /// Handle ID — key into the shared `SearchHandleRegistry`
3533        handle_id: u64,
3534    },
3535
3536    /// Replace matches in a buffer (async)
3537    /// Opens the file if not already open, applies edits through the buffer model,
3538    /// groups as a single undo action, and saves via FileSystem trait.
3539    ReplaceInBuffer {
3540        /// File path to edit (will open if not already in a buffer)
3541        file_path: PathBuf,
3542        /// Matches to replace, each is (byte_offset, length)
3543        matches: Vec<(usize, usize)>,
3544        /// Replacement text
3545        replacement: String,
3546        /// Callback ID for async response
3547        callback_id: JsCallbackId,
3548    },
3549
3550    /// Install a new authority.
3551    ///
3552    /// Authority is opaque to core. The payload is a tagged JSON object
3553    /// (filesystem kind + spawner kind + terminal wrapper + display
3554    /// label) that `fresh-editor` deserializes into its concrete
3555    /// `AuthorityPayload` type. Using `serde_json::Value` here keeps
3556    /// fresh-core from growing backend-specific knowledge; see
3557    /// `crates/fresh-editor/src/services/authority/mod.rs` for the
3558    /// canonical schema.
3559    ///
3560    /// Fire-and-forget: the transition piggy-backs on the existing
3561    /// editor restart flow, so the plugin that sent this command will
3562    /// be re-loaded as part of the restart. Any follow-up work the
3563    /// plugin wants to do after the switch belongs in its post-restart
3564    /// init code, not in a callback here.
3565    SetAuthority {
3566        #[ts(type = "unknown")]
3567        payload: JsonValue,
3568    },
3569
3570    /// Restore the default local authority. Same semantics as
3571    /// `SetAuthority` with a local payload — triggers an editor
3572    /// restart.
3573    ClearAuthority,
3574
3575    /// Activate an environment: set the live env provider's recipe (an
3576    /// activation shell `snippet` run in `dir`). Re-evaluated on demand on the
3577    /// active backend and applied to every spawn — no authority rebuild. Only
3578    /// honored when the workspace is Trusted (it runs repo-controlled code).
3579    SetEnv {
3580        snippet: String,
3581        #[serde(default)]
3582        dir: Option<String>,
3583    },
3584
3585    /// Deactivate the environment — clear the live provider so spawns use the
3586    /// inherited environment again.
3587    ClearEnv,
3588
3589    /// Override the Remote Indicator's displayed state for the rest
3590    /// of the current editor session (until a restart, or until the
3591    /// plugin sends another override / `ClearRemoteIndicatorState`).
3592    ///
3593    /// The derived state — computed from the active authority's
3594    /// connection info — keeps running underneath and is what the
3595    /// indicator shows whenever an override is not in effect.
3596    /// Plugins use this to surface lifecycle states that have no
3597    /// authority-level truth yet (e.g. "Connecting" during
3598    /// `devcontainer up`, "FailedAttach" after a non-zero exit).
3599    ///
3600    /// `state` is a tagged enum keyed by `kind`:
3601    ///   - `{ "kind": "local" }`
3602    ///   - `{ "kind": "connecting", "label": "..." }`
3603    ///   - `{ "kind": "connected", "label": "..." }`
3604    ///   - `{ "kind": "failed_attach", "error": "..." }`
3605    ///   - `{ "kind": "disconnected", "label": "..." }`
3606    ///
3607    /// The exact schema lives in
3608    /// `crates/fresh-editor/src/view/ui/status_bar.rs`; fresh-core
3609    /// takes it opaquely so new variants can land without touching
3610    /// core plumbing.
3611    SetRemoteIndicatorState {
3612        #[ts(type = "unknown")]
3613        state: JsonValue,
3614    },
3615
3616    /// Drop any active Remote Indicator override and fall back to
3617    /// the authority-derived state. Safe to call without a prior
3618    /// `SetRemoteIndicatorState`.
3619    ClearRemoteIndicatorState,
3620
3621    /// Spawn a process on the host, regardless of the currently
3622    /// installed authority.
3623    ///
3624    /// Intended for plugin internals that must run host-side work
3625    /// (e.g. `devcontainer up`) before installing an authority that
3626    /// would otherwise route the spawn elsewhere. Behaves like
3627    /// `SpawnProcess` but always uses `LocalProcessSpawner`.
3628    ///
3629    /// The TS-side handle exposes `.kill()` on the returned
3630    /// `ProcessHandle`, serviced by `KillHostProcess` below — this
3631    /// lets callers abort a long-running host spawn (e.g.
3632    /// `devcontainer up`) via a user action like "Cancel Startup".
3633    SpawnHostProcess {
3634        command: String,
3635        args: Vec<String>,
3636        cwd: Option<String>,
3637        callback_id: JsCallbackId,
3638    },
3639
3640    /// Cancel a host-side process previously started via
3641    /// `SpawnHostProcess`. `process_id` is the callback id returned
3642    /// by `spawnHostProcess` (the TS handle stores it and forwards
3643    /// when the caller invokes `.kill()`).
3644    ///
3645    /// No-op when the id is unknown — the process may have already
3646    /// exited, or the caller may hold a stale handle. SIGKILL on
3647    /// Unix per `tokio::process::Child::start_kill`; children of the
3648    /// killed process may leak (see Q-C2 in
3649    /// `DEVCONTAINER_SPEC_GAP_PLAN.md`).
3650    KillHostProcess { process_id: u64 },
3651
3652    /// Mount a declarative widget panel inside an existing virtual
3653    /// buffer. The host renders the `WidgetSpec` and writes the
3654    /// resulting text-property entries into the buffer. The
3655    /// `panel_id` is plugin-allocated (any unique u64 for that
3656    /// plugin) and is used to address the panel for later
3657    /// `UpdateWidgetPanel` / `UnmountWidgetPanel` calls.
3658    ///
3659    /// See `docs/internal/plugin-widget-library-design.md`.
3660    MountWidgetPanel {
3661        panel_id: u64,
3662        buffer_id: BufferId,
3663        spec: WidgetSpec,
3664    },
3665
3666    /// Replace the spec of a previously-mounted widget panel.
3667    /// The reconciler diffs against the previous spec and applies
3668    /// the minimum mutation; widget instance state is preserved on
3669    /// nodes whose `key` matches.
3670    UpdateWidgetPanel { panel_id: u64, spec: WidgetSpec },
3671
3672    /// Tear down a widget panel. Subsequent `UpdateWidgetPanel`
3673    /// calls for the same `panel_id` are no-ops.
3674    UnmountWidgetPanel { panel_id: u64 },
3675
3676    /// Route a keystroke / nav action to the panel's currently
3677    /// focused widget. The plugin's `defineMode` bindings dispatch
3678    /// here for keys that should be handled by the widget layer
3679    /// (Tab cycle, Enter to activate, Up/Down to navigate a List,
3680    /// Backspace / arrows / printable input to edit a TextInput).
3681    /// See `WidgetAction` for the action shapes.
3682    WidgetCommand { panel_id: u64, action: WidgetAction },
3683
3684    /// Apply a targeted mutation to a mounted widget panel
3685    /// without re-transmitting the full spec. The IPC fast path
3686    /// for hot-path updates (typing, selection moves, partial
3687    /// list refreshes). See `WidgetMutation` for the shapes.
3688    WidgetMutate {
3689        panel_id: u64,
3690        mutation: WidgetMutation,
3691    },
3692
3693    /// Mount a declarative widget panel as a centered floating
3694    /// overlay rather than into a virtual buffer. `width_pct` and
3695    /// `height_pct` size the overlay rect relative to the terminal
3696    /// (clamped 1..=100). Only one floating widget panel may be
3697    /// mounted at a time; a second `MountFloatingWidget` replaces
3698    /// any existing one.
3699    MountFloatingWidget {
3700        panel_id: u64,
3701        spec: WidgetSpec,
3702        width_pct: u8,
3703        height_pct: u8,
3704    },
3705
3706    /// Replace the spec of the currently-mounted floating widget
3707    /// panel. No-op when no floating panel is mounted, or when the
3708    /// `panel_id` doesn't match the mounted one.
3709    UpdateFloatingWidget { panel_id: u64, spec: WidgetSpec },
3710
3711    /// Tear down the floating widget panel. No-op when no floating
3712    /// panel is mounted, or when the `panel_id` doesn't match.
3713    UnmountFloatingWidget { panel_id: u64 },
3714}
3715
3716impl PluginCommand {
3717    /// Extract the enum variant name from the Debug representation.
3718    pub fn debug_variant_name(&self) -> String {
3719        let dbg = format!("{:?}", self);
3720        dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
3721    }
3722}
3723
3724// =============================================================================
3725// Language Pack Configuration Types
3726// =============================================================================
3727
3728/// Language configuration for language packs
3729///
3730/// This is a simplified version of the full LanguageConfig, containing only
3731/// the fields that can be set via the plugin API.
3732#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
3733#[serde(rename_all = "camelCase")]
3734#[ts(export)]
3735pub struct LanguagePackConfig {
3736    /// Comment prefix for line comments (e.g., "//" or "#")
3737    #[serde(default)]
3738    pub comment_prefix: Option<String>,
3739
3740    /// Block comment start marker (e.g., slash-star)
3741    #[serde(default)]
3742    pub block_comment_start: Option<String>,
3743
3744    /// Block comment end marker (e.g., star-slash)
3745    #[serde(default)]
3746    pub block_comment_end: Option<String>,
3747
3748    /// Whether to use tabs instead of spaces for indentation
3749    #[serde(default)]
3750    pub use_tabs: Option<bool>,
3751
3752    /// Tab size (number of spaces per tab level)
3753    #[serde(default)]
3754    pub tab_size: Option<usize>,
3755
3756    /// Whether auto-indent is enabled
3757    #[serde(default)]
3758    pub auto_indent: Option<bool>,
3759
3760    /// Whether to show whitespace tab indicators (→) for this language
3761    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
3762    #[serde(default)]
3763    pub show_whitespace_tabs: Option<bool>,
3764
3765    /// Formatter configuration
3766    #[serde(default)]
3767    pub formatter: Option<FormatterPackConfig>,
3768}
3769
3770/// Formatter configuration for language packs
3771#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3772#[serde(rename_all = "camelCase")]
3773#[ts(export)]
3774pub struct FormatterPackConfig {
3775    /// Command to run (e.g., "prettier", "rustfmt")
3776    pub command: String,
3777
3778    /// Arguments to pass to the formatter
3779    #[serde(default)]
3780    pub args: Vec<String>,
3781}
3782
3783/// Process resource limits for LSP servers
3784#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3785#[serde(rename_all = "camelCase")]
3786#[ts(export)]
3787pub struct ProcessLimitsPackConfig {
3788    /// Maximum memory usage as percentage of total system memory (null = no limit)
3789    #[serde(default)]
3790    pub max_memory_percent: Option<u32>,
3791
3792    /// Maximum CPU usage as percentage of total CPU (null = no limit)
3793    #[serde(default)]
3794    pub max_cpu_percent: Option<u32>,
3795
3796    /// Enable resource limiting
3797    #[serde(default)]
3798    pub enabled: Option<bool>,
3799}
3800
3801/// LSP server configuration for language packs
3802#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3803#[serde(rename_all = "camelCase")]
3804#[ts(export)]
3805pub struct LspServerPackConfig {
3806    /// Command to start the LSP server
3807    pub command: String,
3808
3809    /// Arguments to pass to the command
3810    #[serde(default)]
3811    pub args: Vec<String>,
3812
3813    /// Whether to auto-start the server when a matching file is opened
3814    #[serde(default)]
3815    pub auto_start: Option<bool>,
3816
3817    /// LSP initialization options
3818    #[serde(default)]
3819    #[ts(type = "Record<string, unknown> | null")]
3820    pub initialization_options: Option<JsonValue>,
3821
3822    /// Process resource limits (memory and CPU)
3823    #[serde(default)]
3824    pub process_limits: Option<ProcessLimitsPackConfig>,
3825}
3826
3827/// Hunk status for Review Diff
3828#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
3829#[ts(export)]
3830pub enum HunkStatus {
3831    Pending,
3832    Staged,
3833    Discarded,
3834}
3835
3836/// A high-level hunk directive for the Review Diff tool
3837#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3838#[ts(export)]
3839pub struct ReviewHunk {
3840    pub id: String,
3841    pub file: String,
3842    pub context_header: String,
3843    pub status: HunkStatus,
3844    /// 0-indexed line range in the base (HEAD) version
3845    pub base_range: Option<(usize, usize)>,
3846    /// 0-indexed line range in the modified (Working) version
3847    pub modified_range: Option<(usize, usize)>,
3848}
3849
3850/// Action button for action popups
3851#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3852#[serde(deny_unknown_fields)]
3853#[ts(export, rename = "TsActionPopupAction")]
3854pub struct ActionPopupAction {
3855    /// Unique action identifier (returned in ActionPopupResult)
3856    pub id: String,
3857    /// Display text for the button (can include command hints)
3858    pub label: String,
3859}
3860
3861/// Plugin-contributed row in the LSP-Servers popup.
3862/// See `PluginCommand::SetLspMenuContributions`.
3863#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3864#[serde(deny_unknown_fields)]
3865#[ts(export, rename = "TsLspMenuItem")]
3866pub struct LspMenuItem {
3867    /// Stable identifier used as the `action_id` in the resulting
3868    /// `action_popup_result` event (prefixed by `{plugin_id}|`).
3869    pub id: String,
3870    /// Display label shown in the popup row.
3871    pub label: String,
3872}
3873
3874/// Options for showActionPopup
3875#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3876#[serde(deny_unknown_fields)]
3877#[ts(export)]
3878pub struct ActionPopupOptions {
3879    /// Unique identifier for the popup (used in ActionPopupResult)
3880    pub id: String,
3881    /// Title text for the popup
3882    pub title: String,
3883    /// Body message (supports basic formatting)
3884    pub message: String,
3885    /// Action buttons to display
3886    pub actions: Vec<ActionPopupAction>,
3887}
3888
3889/// Syntax highlight span for a buffer range
3890#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3891#[ts(export)]
3892pub struct TsHighlightSpan {
3893    pub start: u32,
3894    pub end: u32,
3895    #[ts(type = "[number, number, number]")]
3896    pub color: (u8, u8, u8),
3897    pub bold: bool,
3898    pub italic: bool,
3899}
3900
3901/// Result from spawning a process with spawnProcess
3902#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3903#[ts(export)]
3904pub struct SpawnResult {
3905    /// Complete stdout as string
3906    pub stdout: String,
3907    /// Complete stderr as string
3908    pub stderr: String,
3909    /// Process exit code (0 usually means success, -1 if killed)
3910    pub exit_code: i32,
3911}
3912
3913/// Result from spawning a background process
3914#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3915#[ts(export)]
3916pub struct BackgroundProcessResult {
3917    /// Unique process ID for later reference
3918    #[ts(type = "number")]
3919    pub process_id: u64,
3920    /// Process exit code (0 usually means success, -1 if killed)
3921    /// Only present when the process has exited
3922    pub exit_code: i32,
3923}
3924
3925/// A single match from project-wide grep
3926#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3927#[serde(rename_all = "camelCase")]
3928#[ts(export, rename_all = "camelCase")]
3929pub struct GrepMatch {
3930    /// Absolute file path
3931    pub file: String,
3932    /// Buffer ID if the file is open (0 if not)
3933    #[ts(type = "number")]
3934    pub buffer_id: usize,
3935    /// Byte offset of match start in the file/buffer content
3936    #[ts(type = "number")]
3937    pub byte_offset: usize,
3938    /// Match length in bytes
3939    #[ts(type = "number")]
3940    pub length: usize,
3941    /// 1-indexed line number
3942    #[ts(type = "number")]
3943    pub line: usize,
3944    /// 1-indexed column number
3945    #[ts(type = "number")]
3946    pub column: usize,
3947    /// The matched line content (for display)
3948    pub context: String,
3949}
3950
3951/// Per-call result from `SearchHandle.take()` — the matches accumulated since
3952/// the previous call plus terminal-state flags.
3953#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3954#[serde(rename_all = "camelCase")]
3955#[ts(export, rename_all = "camelCase")]
3956pub struct SearchTakeResult {
3957    /// Matches discovered since the previous take()
3958    pub matches: Vec<GrepMatch>,
3959    /// Whether the producer has finished (no more matches will arrive)
3960    pub done: bool,
3961    /// Total number of matches the producer has emitted across all batches
3962    /// (including ones already drained on prior take() calls)
3963    #[ts(type = "number")]
3964    pub total_seen: usize,
3965    /// Whether the producer stopped early because it hit `maxResults`
3966    pub truncated: bool,
3967    /// Producer error, if any (e.g., invalid regex). When set, `done` is also true.
3968    #[ts(optional, type = "string | null")]
3969    pub error: Option<String>,
3970}
3971
3972/// Inner state of a streaming search, written by the host's parallel
3973/// searchers and drained by the plugin via `SearchHandle.take()`. The plugin
3974/// observes deltas (`mem::take` on `pending`) at its own cadence; producers
3975/// write at full speed without per-chunk dispatches.
3976#[derive(Debug, Default)]
3977pub struct SearchState {
3978    /// Matches accumulated since the consumer's last drain
3979    pub pending: Vec<GrepMatch>,
3980    /// Total matches the producer has emitted across the search's lifetime
3981    pub total_seen: usize,
3982    /// Set when the producer stopped early due to hitting max_results
3983    pub truncated: bool,
3984    /// Set when the producer is fully done — no more writes will occur
3985    pub done: bool,
3986    /// Producer error, if any (final state)
3987    pub error: Option<String>,
3988}
3989
3990/// A search handle's shared state plus its cancellation flag. Owned by an
3991/// `Arc` so producers (host searcher tasks) and consumers (the JS plugin via
3992/// the registry) can both reference it.
3993#[derive(Debug)]
3994pub struct SearchHandleState {
3995    pub state: std::sync::Mutex<SearchState>,
3996    pub cancel: std::sync::atomic::AtomicBool,
3997}
3998
3999impl SearchHandleState {
4000    pub fn new() -> Self {
4001        Self {
4002            state: std::sync::Mutex::new(SearchState::default()),
4003            cancel: std::sync::atomic::AtomicBool::new(false),
4004        }
4005    }
4006}
4007
4008impl Default for SearchHandleState {
4009    fn default() -> Self {
4010        Self::new()
4011    }
4012}
4013
4014/// Registry mapping a handle ID to its shared `SearchHandleState`. Shared
4015/// between the JS thread (where `JsEditorApi` registers handles and serves
4016/// `take()`/`cancel()`) and the editor thread (where the host's searcher
4017/// tasks write into the same state).
4018pub type SearchHandleRegistry = Arc<std::sync::Mutex<HashMap<u64, Arc<SearchHandleState>>>>;
4019
4020/// Result from replacing matches in a buffer
4021#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4022#[serde(rename_all = "camelCase")]
4023#[ts(export, rename_all = "camelCase")]
4024pub struct ReplaceResult {
4025    /// Number of replacements made
4026    #[ts(type = "number")]
4027    pub replacements: usize,
4028    /// Buffer ID of the edited buffer
4029    #[ts(type = "number")]
4030    pub buffer_id: usize,
4031}
4032
4033/// Entry for virtual buffer content with optional text properties (JS API version)
4034#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4035#[serde(deny_unknown_fields, rename_all = "camelCase")]
4036#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
4037pub struct JsTextPropertyEntry {
4038    /// Text content for this entry
4039    pub text: String,
4040    /// Optional properties attached to this text (e.g., file path, line number)
4041    #[serde(default)]
4042    #[ts(optional, type = "Record<string, unknown>")]
4043    pub properties: Option<HashMap<String, JsonValue>>,
4044    /// Optional whole-entry styling
4045    #[serde(default)]
4046    #[ts(optional, type = "Partial<OverlayOptions>")]
4047    pub style: Option<OverlayOptions>,
4048    /// Optional sub-range styling within this entry
4049    #[serde(default)]
4050    #[ts(optional)]
4051    pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
4052    /// See `TextPropertyEntry::pad_to_chars`.
4053    #[serde(default)]
4054    #[ts(optional)]
4055    pub pad_to_chars: Option<u32>,
4056    /// See `TextPropertyEntry::truncate_to_chars`.
4057    #[serde(default)]
4058    #[ts(optional)]
4059    pub truncate_to_chars: Option<u32>,
4060    /// See `TextPropertyEntry::segments`.
4061    #[serde(default)]
4062    #[ts(optional)]
4063    pub segments: Option<Vec<crate::text_property::StyledSegment>>,
4064}
4065
4066/// Directory entry returned by readDir
4067#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4068#[ts(export)]
4069pub struct DirEntry {
4070    /// File/directory name
4071    pub name: String,
4072    /// True if this is a file
4073    pub is_file: bool,
4074    /// True if this is a directory
4075    pub is_dir: bool,
4076}
4077
4078/// Position in a document (line and character)
4079#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4080#[ts(export)]
4081pub struct JsPosition {
4082    /// Zero-indexed line number
4083    pub line: u32,
4084    /// Zero-indexed character offset
4085    pub character: u32,
4086}
4087
4088/// Range in a document (start and end positions)
4089#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4090#[ts(export)]
4091pub struct JsRange {
4092    /// Start position
4093    pub start: JsPosition,
4094    /// End position
4095    pub end: JsPosition,
4096}
4097
4098/// Diagnostic from LSP
4099#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4100#[ts(export)]
4101pub struct JsDiagnostic {
4102    /// Document URI
4103    pub uri: String,
4104    /// Diagnostic message
4105    pub message: String,
4106    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
4107    pub severity: Option<u8>,
4108    /// Range in the document
4109    pub range: JsRange,
4110    /// Source of the diagnostic (e.g., "typescript", "eslint")
4111    #[ts(optional)]
4112    pub source: Option<String>,
4113}
4114
4115/// Options for createVirtualBuffer
4116#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4117#[serde(deny_unknown_fields)]
4118#[ts(export)]
4119pub struct CreateVirtualBufferOptions {
4120    /// Buffer name (displayed in tabs/title)
4121    pub name: String,
4122    /// Mode for keybindings (e.g., "git-log", "search-results")
4123    #[serde(default)]
4124    #[ts(optional)]
4125    pub mode: Option<String>,
4126    /// Whether buffer is read-only (default: false)
4127    #[serde(default, rename = "readOnly")]
4128    #[ts(optional, rename = "readOnly")]
4129    pub read_only: Option<bool>,
4130    /// Show line numbers in gutter (default: false)
4131    #[serde(default, rename = "showLineNumbers")]
4132    #[ts(optional, rename = "showLineNumbers")]
4133    pub show_line_numbers: Option<bool>,
4134    /// Show cursor (default: true)
4135    #[serde(default, rename = "showCursors")]
4136    #[ts(optional, rename = "showCursors")]
4137    pub show_cursors: Option<bool>,
4138    /// Disable text editing (default: false)
4139    #[serde(default, rename = "editingDisabled")]
4140    #[ts(optional, rename = "editingDisabled")]
4141    pub editing_disabled: Option<bool>,
4142    /// Hide from tab bar (default: false)
4143    #[serde(default, rename = "hiddenFromTabs")]
4144    #[ts(optional, rename = "hiddenFromTabs")]
4145    pub hidden_from_tabs: Option<bool>,
4146    /// Initial content entries with optional properties
4147    #[serde(default)]
4148    #[ts(optional)]
4149    pub entries: Option<Vec<JsTextPropertyEntry>>,
4150}
4151
4152/// Options for createVirtualBufferInSplit
4153#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4154#[serde(deny_unknown_fields)]
4155#[ts(export)]
4156pub struct CreateVirtualBufferInSplitOptions {
4157    /// Buffer name (displayed in tabs/title)
4158    pub name: String,
4159    /// Mode for keybindings (e.g., "git-log", "search-results")
4160    #[serde(default)]
4161    #[ts(optional)]
4162    pub mode: Option<String>,
4163    /// Whether buffer is read-only (default: false)
4164    #[serde(default, rename = "readOnly")]
4165    #[ts(optional, rename = "readOnly")]
4166    pub read_only: Option<bool>,
4167    /// Split ratio 0.0-1.0 (default: 0.5)
4168    #[serde(default)]
4169    #[ts(optional)]
4170    pub ratio: Option<f32>,
4171    /// Split direction: "horizontal" or "vertical"
4172    #[serde(default)]
4173    #[ts(optional)]
4174    pub direction: Option<String>,
4175    /// Panel ID to split from
4176    #[serde(default, rename = "panelId")]
4177    #[ts(optional, rename = "panelId")]
4178    pub panel_id: Option<String>,
4179    /// Show line numbers in gutter (default: true)
4180    #[serde(default, rename = "showLineNumbers")]
4181    #[ts(optional, rename = "showLineNumbers")]
4182    pub show_line_numbers: Option<bool>,
4183    /// Show cursor (default: true)
4184    #[serde(default, rename = "showCursors")]
4185    #[ts(optional, rename = "showCursors")]
4186    pub show_cursors: Option<bool>,
4187    /// Disable text editing (default: false)
4188    #[serde(default, rename = "editingDisabled")]
4189    #[ts(optional, rename = "editingDisabled")]
4190    pub editing_disabled: Option<bool>,
4191    /// Enable line wrapping
4192    #[serde(default, rename = "lineWrap")]
4193    #[ts(optional, rename = "lineWrap")]
4194    pub line_wrap: Option<bool>,
4195    /// Place the new buffer before (left/top of) the existing content (default: false)
4196    #[serde(default)]
4197    #[ts(optional)]
4198    pub before: Option<bool>,
4199    /// Initial content entries with optional properties
4200    #[serde(default)]
4201    #[ts(optional)]
4202    pub entries: Option<Vec<JsTextPropertyEntry>>,
4203    /// Split role tag. When set to `"utility_dock"`, the dispatcher
4204    /// routes this buffer to the existing dock leaf if one exists,
4205    /// instead of creating a new split. See
4206    /// `docs/internal/tui-editor-layout-design.md` Section 2.
4207    #[serde(default)]
4208    #[ts(optional)]
4209    pub role: Option<String>,
4210}
4211
4212/// Options for createVirtualBufferInExistingSplit
4213#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4214#[serde(deny_unknown_fields)]
4215#[ts(export)]
4216pub struct CreateVirtualBufferInExistingSplitOptions {
4217    /// Buffer name (displayed in tabs/title)
4218    pub name: String,
4219    /// Target split ID (required)
4220    #[serde(rename = "splitId")]
4221    #[ts(rename = "splitId")]
4222    pub split_id: usize,
4223    /// Mode for keybindings (e.g., "git-log", "search-results")
4224    #[serde(default)]
4225    #[ts(optional)]
4226    pub mode: Option<String>,
4227    /// Whether buffer is read-only (default: false)
4228    #[serde(default, rename = "readOnly")]
4229    #[ts(optional, rename = "readOnly")]
4230    pub read_only: Option<bool>,
4231    /// Show line numbers in gutter (default: true)
4232    #[serde(default, rename = "showLineNumbers")]
4233    #[ts(optional, rename = "showLineNumbers")]
4234    pub show_line_numbers: Option<bool>,
4235    /// Show cursor (default: true)
4236    #[serde(default, rename = "showCursors")]
4237    #[ts(optional, rename = "showCursors")]
4238    pub show_cursors: Option<bool>,
4239    /// Disable text editing (default: false)
4240    #[serde(default, rename = "editingDisabled")]
4241    #[ts(optional, rename = "editingDisabled")]
4242    pub editing_disabled: Option<bool>,
4243    /// Enable line wrapping
4244    #[serde(default, rename = "lineWrap")]
4245    #[ts(optional, rename = "lineWrap")]
4246    pub line_wrap: Option<bool>,
4247    /// Initial content entries with optional properties
4248    #[serde(default)]
4249    #[ts(optional)]
4250    pub entries: Option<Vec<JsTextPropertyEntry>>,
4251}
4252
4253/// Options for createTerminal
4254#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4255#[serde(deny_unknown_fields)]
4256#[ts(export)]
4257pub struct CreateTerminalOptions {
4258    /// Working directory for the terminal (defaults to editor cwd)
4259    #[serde(default)]
4260    #[ts(optional)]
4261    pub cwd: Option<String>,
4262    /// Split direction: "horizontal" or "vertical" (default: "vertical")
4263    #[serde(default)]
4264    #[ts(optional)]
4265    pub direction: Option<String>,
4266    /// Split ratio 0.0-1.0 (default: 0.5)
4267    #[serde(default)]
4268    #[ts(optional)]
4269    pub ratio: Option<f32>,
4270    /// Whether to focus the new terminal split (default: true)
4271    #[serde(default)]
4272    #[ts(optional)]
4273    pub focus: Option<bool>,
4274    /// Whether this terminal is part of the user's persisted workspace.
4275    /// Defaults to `false` for plugin-created terminals — they are typically
4276    /// one-off tool UIs (rebuilds, exec shells, build output) and should
4277    /// start with empty scrollback on each invocation. Set to `true` only
4278    /// when the plugin owns a terminal that the user should see restored
4279    /// across editor restarts.
4280    #[serde(default)]
4281    #[ts(optional)]
4282    pub persistent: Option<bool>,
4283    /// Optional session id to attach the new terminal buffer to.
4284    /// Defaults to the active session at creation time. Setting this
4285    /// lets Orchestrator and similar plugins spawn a terminal *into* an
4286    /// inactive session (e.g. an agent in a worktree the user hasn't
4287    /// dived into yet). The terminal's split is created in that
4288    /// session's stashed split tree; the buffer is attached to the
4289    /// target session's membership set rather than the active one's.
4290    #[serde(default, rename = "windowId")]
4291    #[ts(optional, rename = "windowId")]
4292    pub window_id: Option<WindowId>,
4293    /// Argv to spawn directly inside the PTY instead of the host's
4294    /// configured shell. `None` (default) keeps the historical
4295    /// behaviour: spawn the user's shell and let the caller type into
4296    /// it via `sendTerminalInput`. `Some([cmd, ...args])` runs that
4297    /// exact command as the PTY child — no shell middleman, so the
4298    /// process exits cleanly when the agent does and the
4299    /// terminal-buffer's `terminal_exit` plugin hook reflects the
4300    /// agent's real exit status. Used by Orchestrator so a session
4301    /// with agent `python3` is just python3 in the PTY rather than
4302    /// bash-running-python3-as-a-subshell-command.
4303    #[serde(default)]
4304    #[ts(optional)]
4305    pub command: Option<Vec<String>>,
4306    /// Tab title for the terminal buffer. Defaults to `command[0]`
4307    /// (when `command` is set) or `"Terminal N"` (the historical
4308    /// auto-numbered title). If another terminal in the same window
4309    /// already uses the requested title, the host appends `" (k)"`
4310    /// to disambiguate. Empty string is treated the same as `None`.
4311    #[serde(default)]
4312    #[ts(optional)]
4313    pub title: Option<String>,
4314}
4315
4316/// Options for `createWindowWithTerminal` — the atomic
4317/// "spawn a new editor session that hosts an agent terminal"
4318/// entry point used by Orchestrator. Bundles window creation,
4319/// dive, and terminal spawn so the new window is born with the
4320/// terminal as its seed buffer (no transient `[No Name]` tab,
4321/// no race between create-window and create-terminal completing).
4322#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4323#[serde(deny_unknown_fields, rename_all = "camelCase")]
4324#[ts(export, rename_all = "camelCase")]
4325pub struct CreateWindowWithTerminalOptions {
4326    /// Absolute path to the new session's worktree / project
4327    /// root. Relative paths are rejected (logged, no window
4328    /// created).
4329    pub root: String,
4330    /// Human-readable label for the new session. When empty,
4331    /// defaults to the basename of `root`.
4332    #[serde(default)]
4333    pub label: String,
4334    /// Working directory for the spawned terminal. Defaults to
4335    /// `root` when omitted.
4336    #[serde(default)]
4337    #[ts(optional)]
4338    pub cwd: Option<String>,
4339    /// Argv to spawn directly inside the PTY. `None` keeps the
4340    /// shell-and-type behaviour; `Some([cmd, ...args])` runs the
4341    /// command as the PTY child (used by Orchestrator so the
4342    /// agent process is the PTY's direct child).
4343    #[serde(default)]
4344    #[ts(optional)]
4345    pub command: Option<Vec<String>>,
4346    /// Tab title override. Defaults to `command[0]`'s basename
4347    /// when `command` is set, or "Terminal N" otherwise.
4348    #[serde(default)]
4349    #[ts(optional)]
4350    pub title: Option<String>,
4351}
4352
4353/// Result of `createWindowWithTerminal` — the ids of the new
4354/// window plus the terminal seeded into its split layout.
4355#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4356#[serde(rename_all = "camelCase")]
4357#[ts(export, rename_all = "camelCase")]
4358pub struct SessionWithTerminalResult {
4359    /// The new window's id.
4360    #[ts(type = "number")]
4361    pub window_id: u64,
4362    /// The seeded terminal's id (for `sendTerminalInput`, etc.).
4363    #[ts(type = "number")]
4364    pub terminal_id: u64,
4365    /// The seeded terminal buffer's id.
4366    #[ts(type = "number")]
4367    pub buffer_id: u64,
4368}
4369
4370/// Result of getTextPropertiesAtCursor - array of property objects
4371///
4372/// Each element contains the properties from a text property span that overlaps
4373/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
4374#[derive(Debug, Clone, Serialize, TS)]
4375#[ts(export, type = "Array<Record<string, unknown>>")]
4376pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
4377
4378// Implement FromJs for option types using rquickjs_serde
4379#[cfg(feature = "plugins")]
4380mod fromjs_impls {
4381    use super::*;
4382    use rquickjs::{Ctx, FromJs, Value};
4383
4384    // All types that deserialize from a JS value via rquickjs_serde follow
4385    // the same 8-line pattern differing only in the type name. This macro
4386    // expands that pattern once so adding a new plugin-API type costs one line
4387    // here instead of a copy-pasted block.
4388    macro_rules! impl_from_js_via_serde {
4389        ($($T:ty),+ $(,)?) => {
4390            $(
4391                impl<'js> FromJs<'js> for $T {
4392                    fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
4393                        rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
4394                            from: "object",
4395                            to: stringify!($T),
4396                            message: Some(e.to_string()),
4397                        })
4398                    }
4399                }
4400            )+
4401        };
4402    }
4403
4404    impl_from_js_via_serde!(
4405        JsTextPropertyEntry,
4406        CreateVirtualBufferOptions,
4407        CreateVirtualBufferInSplitOptions,
4408        CreateVirtualBufferInExistingSplitOptions,
4409        ActionSpec,
4410        ActionPopupAction,
4411        ActionPopupOptions,
4412        LspMenuItem,
4413        ViewTokenWire,
4414        ViewTokenStyle,
4415        LayoutHints,
4416        CompositeHunk,
4417        LanguagePackConfig,
4418        LspServerPackConfig,
4419        ProcessLimitsPackConfig,
4420        CreateTerminalOptions,
4421        CreateWindowWithTerminalOptions,
4422    );
4423
4424    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
4425        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
4426            rquickjs_serde::to_value(ctx.clone(), &self.0)
4427                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
4428        }
4429    }
4430
4431    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
4432        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
4433            // Two-step deserialization: rquickjs_serde cannot handle the nested
4434            // enums in this struct directly, so go via serde_json as an intermediary.
4435            let json: serde_json::Value =
4436                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
4437                    from: "object",
4438                    to: "CreateCompositeBufferOptions (json)",
4439                    message: Some(e.to_string()),
4440                })?;
4441            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
4442                from: "json",
4443                to: "CreateCompositeBufferOptions",
4444                message: Some(e.to_string()),
4445            })
4446        }
4447    }
4448
4449    // ── Tests for FromJs / IntoJs impls ────────────────────────────────────
4450    //
4451    // Each impl is a one-liner that delegates to `rquickjs_serde`. A mutant
4452    // that replaces the body with `Ok(Default::default())` drops the
4453    // decoded payload on the floor. Every test below asserts a
4454    // non-defaultable field value, so the mutant cannot pass.
4455    //
4456    // Note: many of the target structs do not implement `Default`, making
4457    // those mutants unviable (they fail to compile) — cargo-mutants still
4458    // lists them as candidates. The tests below serve double-duty as
4459    // behavioural regression protection for the JS → Rust conversion layer.
4460    #[cfg(test)]
4461    mod tests {
4462        use super::*;
4463        use rquickjs::{Context, Runtime};
4464
4465        /// Run a closure within a fresh QuickJS context so that `FromJs`
4466        /// impls can be exercised end-to-end.
4467        fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
4468            let rt = Runtime::new().expect("create rquickjs runtime");
4469            let ctx = Context::full(&rt).expect("create rquickjs context");
4470            ctx.with(f)
4471        }
4472
4473        /// Evaluate a JS object literal and decode it as `T` via `FromJs`.
4474        fn eval_as<T>(src: &str) -> T
4475        where
4476            for<'js> T: rquickjs::FromJs<'js>,
4477        {
4478            with_js(|ctx| {
4479                let value: Value = ctx
4480                    .eval::<Value, _>(src.as_bytes())
4481                    .expect("eval JS source");
4482                T::from_js(&ctx, value).expect("from_js decode")
4483            })
4484        }
4485
4486        #[test]
4487        fn js_text_property_entry_decodes_text_and_properties() {
4488            let got: JsTextPropertyEntry =
4489                eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
4490            assert_eq!(got.text, "hello");
4491            let props = got.properties.expect("properties present");
4492            assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
4493        }
4494
4495        #[test]
4496        fn create_virtual_buffer_options_decodes_name() {
4497            let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
4498            assert_eq!(got.name, "logs");
4499            assert_eq!(got.read_only, Some(true));
4500        }
4501
4502        #[test]
4503        fn create_virtual_buffer_in_split_options_decodes_ratio() {
4504            let got: CreateVirtualBufferInSplitOptions =
4505                eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
4506            assert_eq!(got.name, "diag");
4507            assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
4508            assert_eq!(got.direction.as_deref(), Some("horizontal"));
4509        }
4510
4511        #[test]
4512        fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
4513            let got: CreateVirtualBufferInExistingSplitOptions =
4514                eval_as("({name: 'n', splitId: 7})");
4515            assert_eq!(got.name, "n");
4516            assert_eq!(got.split_id, 7);
4517        }
4518
4519        #[test]
4520        fn create_terminal_options_decodes_cwd_and_focus() {
4521            let got: CreateTerminalOptions =
4522                eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
4523            assert_eq!(got.cwd.as_deref(), Some("/tmp"));
4524            assert_eq!(got.direction.as_deref(), Some("vertical"));
4525            assert_eq!(got.focus, Some(false));
4526        }
4527
4528        #[test]
4529        fn action_spec_decodes_action_and_count() {
4530            let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
4531            assert_eq!(got.action, "move_word_right");
4532            assert_eq!(got.count, 5);
4533        }
4534
4535        #[test]
4536        fn action_popup_action_decodes_id_and_label() {
4537            let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
4538            assert_eq!(got.id, "ok");
4539            assert_eq!(got.label, "OK");
4540        }
4541
4542        #[test]
4543        fn action_popup_options_decodes_actions_list() {
4544            let got: ActionPopupOptions = eval_as(
4545                "({id: 'p', title: 't', message: 'm', \
4546                   actions: [{id: 'ok', label: 'OK'}]})",
4547            );
4548            assert_eq!(got.id, "p");
4549            assert_eq!(got.title, "t");
4550            assert_eq!(got.message, "m");
4551            assert_eq!(got.actions.len(), 1);
4552            assert_eq!(got.actions[0].id, "ok");
4553        }
4554
4555        #[test]
4556        fn view_token_wire_decodes_offset_and_kind() {
4557            // Using `Newline` (a unit variant) avoids the tuple-variant
4558            // wire-format ambiguity in rquickjs_serde while still exercising
4559            // the `FromJs` impl end-to-end.
4560            let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
4561            assert_eq!(got.source_offset, Some(42));
4562            assert!(matches!(got.kind, ViewTokenWireKind::Newline));
4563        }
4564
4565        #[test]
4566        fn view_token_style_decodes_boolean_flags() {
4567            // `fg`/`bg` are `Option<TokenColor>` (untagged: RGB array or
4568            // named string). rquickjs_serde struggles with the untagged
4569            // variant from a plain JS array, so we pin down the boolean
4570            // flags — enough to prove the body actually ran.
4571            let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
4572            assert!(got.bold);
4573            assert!(got.italic);
4574            assert!(got.fg.is_none());
4575        }
4576
4577        #[test]
4578        fn layout_hints_decodes_compose_width() {
4579            let got: LayoutHints = eval_as("({composeWidth: 120})");
4580            assert_eq!(got.compose_width, Some(120));
4581            assert!(got.column_guides.is_none());
4582        }
4583
4584        #[test]
4585        fn create_composite_buffer_options_decodes_name_and_sources() {
4586            let got: CreateCompositeBufferOptions = eval_as(
4587                "({name: 'diff', mode: 'm', \
4588                   layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
4589                   sources: [{bufferId: 3, label: 'OLD'}]})",
4590            );
4591            assert_eq!(got.name, "diff");
4592            assert_eq!(got.layout.layout_type, "side-by-side");
4593            assert_eq!(got.sources.len(), 1);
4594            assert_eq!(got.sources[0].buffer_id, 3);
4595            assert_eq!(got.sources[0].label, "OLD");
4596        }
4597
4598        #[test]
4599        fn composite_hunk_decodes_all_fields() {
4600            let got: CompositeHunk =
4601                eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
4602            assert_eq!(got.old_start, 1);
4603            assert_eq!(got.old_count, 2);
4604            assert_eq!(got.new_start, 3);
4605            assert_eq!(got.new_count, 4);
4606        }
4607
4608        #[test]
4609        fn language_pack_config_decodes_comment_prefix_and_tab_size() {
4610            let got: LanguagePackConfig =
4611                eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
4612            assert_eq!(got.comment_prefix.as_deref(), Some("//"));
4613            assert_eq!(got.tab_size, Some(7));
4614            assert_eq!(got.use_tabs, Some(true));
4615        }
4616
4617        #[test]
4618        fn lsp_server_pack_config_decodes_command_and_args() {
4619            let got: LspServerPackConfig =
4620                eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
4621            assert_eq!(got.command, "rust-analyzer");
4622            assert_eq!(got.args, vec!["--log".to_string()]);
4623            assert_eq!(got.auto_start, Some(true));
4624        }
4625
4626        #[test]
4627        fn process_limits_pack_config_decodes_percentages() {
4628            let got: ProcessLimitsPackConfig =
4629                eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
4630            assert_eq!(got.max_memory_percent, Some(75));
4631            assert_eq!(got.max_cpu_percent, Some(50));
4632            assert_eq!(got.enabled, Some(true));
4633        }
4634
4635        /// `TextPropertiesAtCursor::into_js` must serialise the inner vector
4636        /// into a JS array whose length matches the payload. A mutant that
4637        /// returns a default (`undefined` / empty) value would fail either
4638        /// the array check or the length check.
4639        #[test]
4640        fn text_properties_at_cursor_into_js_preserves_length() {
4641            use rquickjs::IntoJs;
4642            with_js(|ctx| {
4643                let mut entry = std::collections::HashMap::new();
4644                entry.insert("k".to_string(), serde_json::json!("v"));
4645                let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
4646
4647                let v = payload.into_js(&ctx).expect("into_js");
4648                let arr = v.as_array().expect("expected JS array");
4649                assert_eq!(arr.len(), 2);
4650            });
4651        }
4652    }
4653}
4654
4655/// Plugin API context - provides safe access to editor functionality
4656pub struct PluginApi {
4657    /// Hook registry (shared with editor)
4658    hooks: Arc<RwLock<HookRegistry>>,
4659
4660    /// Command registry (shared with editor)
4661    commands: Arc<RwLock<CommandRegistry>>,
4662
4663    /// Command queue for sending commands to editor
4664    command_sender: std::sync::mpsc::Sender<PluginCommand>,
4665
4666    /// Snapshot of editor state (read-only for plugins)
4667    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4668}
4669
4670impl PluginApi {
4671    /// Create a new plugin API context
4672    pub fn new(
4673        hooks: Arc<RwLock<HookRegistry>>,
4674        commands: Arc<RwLock<CommandRegistry>>,
4675        command_sender: std::sync::mpsc::Sender<PluginCommand>,
4676        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4677    ) -> Self {
4678        Self {
4679            hooks,
4680            commands,
4681            command_sender,
4682            state_snapshot,
4683        }
4684    }
4685
4686    /// Register a hook callback
4687    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
4688        let mut hooks = self.hooks.write().unwrap();
4689        hooks.add_hook(hook_name, callback);
4690    }
4691
4692    /// Remove all hooks for a specific name
4693    pub fn unregister_hooks(&self, hook_name: &str) {
4694        let mut hooks = self.hooks.write().unwrap();
4695        hooks.remove_hooks(hook_name);
4696    }
4697
4698    /// Register a command
4699    pub fn register_command(&self, command: Command) {
4700        let commands = self.commands.read().unwrap();
4701        commands.register(command);
4702    }
4703
4704    /// Unregister a command by name
4705    pub fn unregister_command(&self, name: &str) {
4706        let commands = self.commands.read().unwrap();
4707        commands.unregister(name);
4708    }
4709
4710    /// Send a command to the editor (async/non-blocking)
4711    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
4712        self.command_sender
4713            .send(command)
4714            .map_err(|e| format!("Failed to send command: {}", e))
4715    }
4716
4717    /// Insert text at a position in a buffer
4718    pub fn insert_text(
4719        &self,
4720        buffer_id: BufferId,
4721        position: usize,
4722        text: String,
4723    ) -> Result<(), String> {
4724        self.send_command(PluginCommand::InsertText {
4725            buffer_id,
4726            position,
4727            text,
4728        })
4729    }
4730
4731    /// Delete a range of text from a buffer
4732    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
4733        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
4734    }
4735
4736    /// Add an overlay (decoration) to a buffer
4737    /// Add an overlay to a buffer with styling options
4738    ///
4739    /// Returns an opaque handle that can be used to remove the overlay later.
4740    ///
4741    /// Colors can be specified as RGB arrays or theme key strings.
4742    /// Theme keys are resolved at render time, so overlays update with theme changes.
4743    pub fn add_overlay(
4744        &self,
4745        buffer_id: BufferId,
4746        namespace: Option<String>,
4747        range: Range<usize>,
4748        options: OverlayOptions,
4749    ) -> Result<(), String> {
4750        self.send_command(PluginCommand::AddOverlay {
4751            buffer_id,
4752            namespace: namespace.map(OverlayNamespace::from_string),
4753            range,
4754            options,
4755        })
4756    }
4757
4758    /// Remove an overlay from a buffer by its handle
4759    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
4760        self.send_command(PluginCommand::RemoveOverlay {
4761            buffer_id,
4762            handle: OverlayHandle::from_string(handle),
4763        })
4764    }
4765
4766    /// Clear all overlays in a namespace from a buffer
4767    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
4768        self.send_command(PluginCommand::ClearNamespace {
4769            buffer_id,
4770            namespace: OverlayNamespace::from_string(namespace),
4771        })
4772    }
4773
4774    /// Clear all overlays that overlap with a byte range
4775    /// Used for targeted invalidation when content changes
4776    pub fn clear_overlays_in_range(
4777        &self,
4778        buffer_id: BufferId,
4779        start: usize,
4780        end: usize,
4781    ) -> Result<(), String> {
4782        self.send_command(PluginCommand::ClearOverlaysInRange {
4783            buffer_id,
4784            start,
4785            end,
4786        })
4787    }
4788
4789    /// Set the status message
4790    pub fn set_status(&self, message: String) -> Result<(), String> {
4791        self.send_command(PluginCommand::SetStatus { message })
4792    }
4793
4794    /// Open a file at a specific line and column (1-indexed)
4795    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
4796    pub fn open_file_at_location(
4797        &self,
4798        path: PathBuf,
4799        line: Option<usize>,
4800        column: Option<usize>,
4801    ) -> Result<(), String> {
4802        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
4803    }
4804
4805    /// Open a file in a specific split at a line and column
4806    ///
4807    /// Similar to open_file_at_location but targets a specific split pane.
4808    /// The split_id is the ID of the split pane to open the file in.
4809    pub fn open_file_in_split(
4810        &self,
4811        split_id: usize,
4812        path: PathBuf,
4813        line: Option<usize>,
4814        column: Option<usize>,
4815    ) -> Result<(), String> {
4816        self.send_command(PluginCommand::OpenFileInSplit {
4817            split_id,
4818            path,
4819            line,
4820            column,
4821        })
4822    }
4823
4824    /// Start a prompt (minibuffer) with a custom type identifier
4825    /// The prompt_type is used to filter hooks in plugin code
4826    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
4827        self.send_command(PluginCommand::StartPrompt {
4828            label,
4829            prompt_type,
4830            floating_overlay: false,
4831        })
4832    }
4833
4834    /// Set the suggestions for the current prompt
4835    /// This updates the prompt's autocomplete/selection list
4836    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
4837        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
4838    }
4839
4840    /// Enable/disable syncing prompt input text when navigating suggestions
4841    pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
4842        self.send_command(PluginCommand::SetPromptInputSync { sync })
4843    }
4844
4845    /// Set the floating-overlay prompt's title (issue #1796) as
4846    /// styled segments. An empty vec clears the title and falls
4847    /// back to the prompt-type default.
4848    pub fn set_prompt_title(&self, title: Vec<StyledText>) -> Result<(), String> {
4849        self.send_command(PluginCommand::SetPromptTitle { title })
4850    }
4851
4852    /// Set the floating-overlay prompt's footer chrome row.
4853    /// Plugins use this for hotkey hints / footer banners along
4854    /// the bottom of the results pane. Empty vec clears.
4855    pub fn set_prompt_footer(&self, footer: Vec<StyledText>) -> Result<(), String> {
4856        self.send_command(PluginCommand::SetPromptFooter { footer })
4857    }
4858
4859    /// Set the floating-overlay prompt's toolbar as a `WidgetSpec` (real,
4860    /// clickable `Toggle`/`Button` widgets). `None` clears it.
4861    pub fn set_prompt_toolbar(&self, spec: Option<WidgetSpec>) -> Result<(), String> {
4862        self.send_command(PluginCommand::SetPromptToolbar { spec })
4863    }
4864
4865    /// Set the floating-overlay prompt's input-row status text. Empty clears.
4866    pub fn set_prompt_status(&self, status: String) -> Result<(), String> {
4867        self.send_command(PluginCommand::SetPromptStatus { status })
4868    }
4869
4870    /// Override the currently-highlighted suggestion row in the
4871    /// open prompt. Useful when re-opening a picker and wanting
4872    /// the previously-active entry to come up pre-selected
4873    /// (e.g. Orchestrator highlighting the active session). The
4874    /// editor clamps `index` to the list's bounds.
4875    pub fn set_prompt_selected_index(&self, index: u32) -> Result<(), String> {
4876        self.send_command(PluginCommand::SetPromptSelectedIndex { index })
4877    }
4878
4879    /// Add a menu item to an existing menu
4880    pub fn add_menu_item(
4881        &self,
4882        menu_label: String,
4883        item: MenuItem,
4884        position: MenuPosition,
4885    ) -> Result<(), String> {
4886        self.send_command(PluginCommand::AddMenuItem {
4887            menu_label,
4888            item,
4889            position,
4890        })
4891    }
4892
4893    /// Add a new top-level menu
4894    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
4895        self.send_command(PluginCommand::AddMenu { menu, position })
4896    }
4897
4898    /// Remove a menu item from a menu
4899    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
4900        self.send_command(PluginCommand::RemoveMenuItem {
4901            menu_label,
4902            item_label,
4903        })
4904    }
4905
4906    /// Remove a top-level menu
4907    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
4908        self.send_command(PluginCommand::RemoveMenu { menu_label })
4909    }
4910
4911    // === Virtual Buffer Methods ===
4912
4913    /// Create a new virtual buffer (not backed by a file)
4914    ///
4915    /// Virtual buffers are used for special displays like diagnostic lists,
4916    /// search results, etc. They have their own mode for keybindings.
4917    pub fn create_virtual_buffer(
4918        &self,
4919        name: String,
4920        mode: String,
4921        read_only: bool,
4922    ) -> Result<(), String> {
4923        self.send_command(PluginCommand::CreateVirtualBuffer {
4924            name,
4925            mode,
4926            read_only,
4927        })
4928    }
4929
4930    /// Create a virtual buffer and set its content in one operation
4931    ///
4932    /// This is the preferred way to create virtual buffers since it doesn't
4933    /// require tracking the buffer ID. The buffer is created and populated
4934    /// atomically.
4935    pub fn create_virtual_buffer_with_content(
4936        &self,
4937        name: String,
4938        mode: String,
4939        read_only: bool,
4940        entries: Vec<TextPropertyEntry>,
4941    ) -> Result<(), String> {
4942        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
4943            name,
4944            mode,
4945            read_only,
4946            entries,
4947            show_line_numbers: true,
4948            show_cursors: true,
4949            editing_disabled: false,
4950            hidden_from_tabs: false,
4951            request_id: None,
4952        })
4953    }
4954
4955    /// Set the content of a virtual buffer with text properties
4956    ///
4957    /// Each entry contains text and metadata properties (e.g., source location).
4958    pub fn set_virtual_buffer_content(
4959        &self,
4960        buffer_id: BufferId,
4961        entries: Vec<TextPropertyEntry>,
4962    ) -> Result<(), String> {
4963        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
4964    }
4965
4966    /// Get text properties at cursor position in a buffer
4967    ///
4968    /// This triggers a command that will make properties available to plugins.
4969    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
4970        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
4971    }
4972
4973    /// Define a buffer mode with keybindings
4974    ///
4975    /// Bindings are specified as (key_string, command_name) pairs.
4976    pub fn define_mode(
4977        &self,
4978        name: String,
4979        bindings: Vec<(String, String)>,
4980        read_only: bool,
4981        allow_text_input: bool,
4982    ) -> Result<(), String> {
4983        self.send_command(PluginCommand::DefineMode {
4984            name,
4985            bindings,
4986            read_only,
4987            allow_text_input,
4988            inherit_normal_bindings: false,
4989            plugin_name: None,
4990        })
4991    }
4992
4993    /// Switch the current split to display a buffer
4994    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
4995        self.send_command(PluginCommand::ShowBuffer { buffer_id })
4996    }
4997
4998    /// Set the scroll position of a specific split
4999    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
5000        self.send_command(PluginCommand::SetSplitScroll {
5001            split_id: SplitId(split_id),
5002            top_byte,
5003        })
5004    }
5005
5006    /// Request syntax highlights for a buffer range
5007    pub fn get_highlights(
5008        &self,
5009        buffer_id: BufferId,
5010        range: Range<usize>,
5011        request_id: u64,
5012    ) -> Result<(), String> {
5013        self.send_command(PluginCommand::RequestHighlights {
5014            buffer_id,
5015            range,
5016            request_id,
5017        })
5018    }
5019
5020    // === Query Methods ===
5021
5022    /// Get the currently active buffer ID
5023    pub fn get_active_buffer_id(&self) -> BufferId {
5024        let snapshot = self.state_snapshot.read().unwrap();
5025        snapshot.active_buffer_id
5026    }
5027
5028    /// Get the currently active split ID
5029    pub fn get_active_split_id(&self) -> usize {
5030        let snapshot = self.state_snapshot.read().unwrap();
5031        snapshot.active_split_id
5032    }
5033
5034    /// Get information about a specific buffer
5035    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
5036        let snapshot = self.state_snapshot.read().unwrap();
5037        snapshot.buffers.get(&buffer_id).cloned()
5038    }
5039
5040    /// Get all buffer IDs
5041    pub fn list_buffers(&self) -> Vec<BufferInfo> {
5042        let snapshot = self.state_snapshot.read().unwrap();
5043        snapshot.buffers.values().cloned().collect()
5044    }
5045
5046    /// Get primary cursor information for the active buffer
5047    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
5048        let snapshot = self.state_snapshot.read().unwrap();
5049        snapshot.primary_cursor.clone()
5050    }
5051
5052    /// Get all cursor information for the active buffer
5053    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
5054        let snapshot = self.state_snapshot.read().unwrap();
5055        snapshot.all_cursors.clone()
5056    }
5057
5058    /// Get viewport information for the active buffer
5059    pub fn get_viewport(&self) -> Option<ViewportInfo> {
5060        let snapshot = self.state_snapshot.read().unwrap();
5061        snapshot.viewport.clone()
5062    }
5063
5064    /// Get total terminal dimensions.
5065    pub fn get_screen_size(&self) -> ScreenSize {
5066        let snapshot = self.state_snapshot.read().unwrap();
5067        ScreenSize {
5068            width: snapshot.terminal_width,
5069            height: snapshot.terminal_height,
5070        }
5071    }
5072
5073    /// Get access to the state snapshot Arc (for internal use)
5074    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
5075        Arc::clone(&self.state_snapshot)
5076    }
5077}
5078
5079impl Clone for PluginApi {
5080    fn clone(&self) -> Self {
5081        Self {
5082            hooks: Arc::clone(&self.hooks),
5083            commands: Arc::clone(&self.commands),
5084            command_sender: self.command_sender.clone(),
5085            state_snapshot: Arc::clone(&self.state_snapshot),
5086        }
5087    }
5088}
5089
5090// ============================================================================
5091// Pluggable Completion Service — TypeScript Plugin API Types
5092// ============================================================================
5093//
5094// These types are the bridge between the Rust `CompletionService` and
5095// TypeScript plugins that want to provide completion candidates.  They are
5096// serialised to/from JSON via serde and generate TypeScript definitions via
5097// ts-rs so that the plugin API stays in sync automatically.
5098
5099/// A completion candidate produced by a TypeScript plugin provider.
5100///
5101/// This mirrors `CompletionCandidate` in the Rust `completion::provider`
5102/// module but uses serde-friendly primitives for the JS ↔ Rust boundary.
5103#[derive(Debug, Clone, Serialize, Deserialize, TS)]
5104#[serde(rename_all = "camelCase", deny_unknown_fields)]
5105#[ts(export, rename_all = "camelCase")]
5106pub struct TsCompletionCandidate {
5107    /// Display text shown in the completion popup.
5108    pub label: String,
5109
5110    /// Text to insert when accepted. Falls back to `label` if omitted.
5111    #[serde(skip_serializing_if = "Option::is_none")]
5112    pub insert_text: Option<String>,
5113
5114    /// Short detail string shown next to the label.
5115    #[serde(skip_serializing_if = "Option::is_none")]
5116    pub detail: Option<String>,
5117
5118    /// Single-character icon hint (e.g. `"λ"`, `"v"`).
5119    #[serde(skip_serializing_if = "Option::is_none")]
5120    pub icon: Option<String>,
5121
5122    /// Provider-assigned relevance score (higher = better).
5123    #[serde(default)]
5124    pub score: i64,
5125
5126    /// Whether `insert_text` uses LSP snippet syntax (`$0`, `${1:ph}`, …).
5127    #[serde(default)]
5128    pub is_snippet: bool,
5129
5130    /// Opaque data carried through to the `completionAccepted` hook.
5131    #[serde(skip_serializing_if = "Option::is_none")]
5132    pub provider_data: Option<String>,
5133}
5134
5135/// Context sent to a TypeScript plugin's `provideCompletions` handler.
5136///
5137/// Plugins receive this as a read-only snapshot so they never need direct
5138/// buffer access (which would be unsafe for huge files).
5139#[derive(Debug, Clone, Serialize, Deserialize, TS)]
5140#[serde(rename_all = "camelCase")]
5141#[ts(export, rename_all = "camelCase")]
5142pub struct TsCompletionContext {
5143    /// The word prefix typed so far.
5144    pub prefix: String,
5145
5146    /// Byte offset of the cursor.
5147    pub cursor_byte: usize,
5148
5149    /// Byte offset of the word start (for replacement range).
5150    pub word_start_byte: usize,
5151
5152    /// Total buffer size in bytes.
5153    pub buffer_len: usize,
5154
5155    /// Whether the buffer is a lazily-loaded huge file.
5156    pub is_large_file: bool,
5157
5158    /// A text excerpt around the cursor (the contents of the safe scan window).
5159    /// Plugins should search only this string, not request the full buffer.
5160    pub text_around_cursor: String,
5161
5162    /// Byte offset within `text_around_cursor` that corresponds to the cursor.
5163    pub cursor_offset_in_text: usize,
5164
5165    /// File language id (e.g. `"rust"`, `"typescript"`), if known.
5166    #[serde(skip_serializing_if = "Option::is_none")]
5167    pub language_id: Option<String>,
5168}
5169
5170/// Registration payload sent by a plugin to register a completion provider.
5171#[derive(Debug, Clone, Serialize, Deserialize, TS)]
5172#[serde(rename_all = "camelCase", deny_unknown_fields)]
5173#[ts(export, rename_all = "camelCase")]
5174pub struct TsCompletionProviderRegistration {
5175    /// Unique id for this provider (e.g., `"my-snippets"`).
5176    pub id: String,
5177
5178    /// Human-readable name shown in status/debug UI.
5179    pub display_name: String,
5180
5181    /// Priority tier (lower = higher priority). Convention:
5182    /// 0 = LSP, 10 = ctags, 20 = buffer words, 30 = dabbrev, 50 = plugin.
5183    #[serde(default = "default_plugin_provider_priority")]
5184    pub priority: u32,
5185
5186    /// Optional list of language ids this provider is active for.
5187    /// If empty/omitted, the provider is active for all languages.
5188    #[serde(default)]
5189    pub language_ids: Vec<String>,
5190}
5191
5192fn default_plugin_provider_priority() -> u32 {
5193    50
5194}
5195
5196#[cfg(test)]
5197mod tests {
5198    use super::*;
5199    use std::path::Path;
5200
5201    #[test]
5202    fn test_plugin_api_creation() {
5203        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5204        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5205        let (tx, _rx) = std::sync::mpsc::channel();
5206        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5207
5208        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5209
5210        // Should not panic
5211        let _clone = api.clone();
5212    }
5213
5214    #[test]
5215    fn test_register_hook() {
5216        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5217        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5218        let (tx, _rx) = std::sync::mpsc::channel();
5219        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5220
5221        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
5222
5223        api.register_hook("test-hook", Box::new(|_| true));
5224
5225        let hook_registry = hooks.read().unwrap();
5226        assert_eq!(hook_registry.hook_count("test-hook"), 1);
5227    }
5228
5229    #[test]
5230    fn test_send_command() {
5231        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5232        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5233        let (tx, rx) = std::sync::mpsc::channel();
5234        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5235
5236        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5237
5238        let result = api.insert_text(BufferId(1), 0, "test".to_string());
5239        assert!(result.is_ok());
5240
5241        // Verify command was sent
5242        let received = rx.try_recv();
5243        assert!(received.is_ok());
5244
5245        match received.unwrap() {
5246            PluginCommand::InsertText {
5247                buffer_id,
5248                position,
5249                text,
5250            } => {
5251                assert_eq!(buffer_id.0, 1);
5252                assert_eq!(position, 0);
5253                assert_eq!(text, "test");
5254            }
5255            _ => panic!("Wrong command type"),
5256        }
5257    }
5258
5259    #[test]
5260    fn test_add_overlay_command() {
5261        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5262        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5263        let (tx, rx) = std::sync::mpsc::channel();
5264        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5265
5266        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5267
5268        let result = api.add_overlay(
5269            BufferId(1),
5270            Some("test-overlay".to_string()),
5271            0..10,
5272            OverlayOptions {
5273                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
5274                bg: None,
5275                underline: true,
5276                bold: false,
5277                italic: false,
5278                strikethrough: false,
5279                extend_to_line_end: false,
5280                fg_on_collision_only: false,
5281                url: None,
5282            },
5283        );
5284        assert!(result.is_ok());
5285
5286        let received = rx.try_recv().unwrap();
5287        match received {
5288            PluginCommand::AddOverlay {
5289                buffer_id,
5290                namespace,
5291                range,
5292                options,
5293            } => {
5294                assert_eq!(buffer_id.0, 1);
5295                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
5296                assert_eq!(range, 0..10);
5297                assert!(matches!(
5298                    options.fg,
5299                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
5300                ));
5301                assert!(options.bg.is_none());
5302                assert!(options.underline);
5303                assert!(!options.bold);
5304                assert!(!options.italic);
5305                assert!(!options.extend_to_line_end);
5306            }
5307            _ => panic!("Wrong command type"),
5308        }
5309    }
5310
5311    #[test]
5312    fn test_set_status_command() {
5313        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5314        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5315        let (tx, rx) = std::sync::mpsc::channel();
5316        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5317
5318        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5319
5320        let result = api.set_status("Test status".to_string());
5321        assert!(result.is_ok());
5322
5323        let received = rx.try_recv().unwrap();
5324        match received {
5325            PluginCommand::SetStatus { message } => {
5326                assert_eq!(message, "Test status");
5327            }
5328            _ => panic!("Wrong command type"),
5329        }
5330    }
5331
5332    #[test]
5333    fn test_get_active_buffer_id() {
5334        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5335        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5336        let (tx, _rx) = std::sync::mpsc::channel();
5337        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5338
5339        // Set active buffer to 5
5340        {
5341            let mut snapshot = state_snapshot.write().unwrap();
5342            snapshot.active_buffer_id = BufferId(5);
5343        }
5344
5345        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5346
5347        let active_id = api.get_active_buffer_id();
5348        assert_eq!(active_id.0, 5);
5349    }
5350
5351    #[test]
5352    fn test_get_buffer_info() {
5353        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5354        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5355        let (tx, _rx) = std::sync::mpsc::channel();
5356        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5357
5358        // Add buffer info
5359        {
5360            let mut snapshot = state_snapshot.write().unwrap();
5361            let buffer_info = BufferInfo {
5362                id: BufferId(1),
5363                path: Some(std::path::PathBuf::from("/test/file.txt")),
5364                modified: true,
5365                length: 100,
5366                is_virtual: false,
5367                view_mode: "source".to_string(),
5368                is_composing_in_any_split: false,
5369                compose_width: None,
5370                language: "text".to_string(),
5371                is_preview: false,
5372                splits: Vec::new(),
5373            };
5374            snapshot.buffers.insert(BufferId(1), buffer_info);
5375        }
5376
5377        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5378
5379        let info = api.get_buffer_info(BufferId(1));
5380        assert!(info.is_some());
5381        let info = info.unwrap();
5382        assert_eq!(info.id.0, 1);
5383        assert_eq!(
5384            info.path.as_ref().unwrap().to_str().unwrap(),
5385            "/test/file.txt"
5386        );
5387        assert!(info.modified);
5388        assert_eq!(info.length, 100);
5389
5390        // Non-existent buffer
5391        let no_info = api.get_buffer_info(BufferId(999));
5392        assert!(no_info.is_none());
5393    }
5394
5395    #[test]
5396    fn test_list_buffers() {
5397        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5398        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5399        let (tx, _rx) = std::sync::mpsc::channel();
5400        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5401
5402        // Add multiple buffers
5403        {
5404            let mut snapshot = state_snapshot.write().unwrap();
5405            snapshot.buffers.insert(
5406                BufferId(1),
5407                BufferInfo {
5408                    id: BufferId(1),
5409                    path: Some(std::path::PathBuf::from("/file1.txt")),
5410                    modified: false,
5411                    length: 50,
5412                    is_virtual: false,
5413                    view_mode: "source".to_string(),
5414                    is_composing_in_any_split: false,
5415                    compose_width: None,
5416                    language: "text".to_string(),
5417                    is_preview: false,
5418                    splits: Vec::new(),
5419                },
5420            );
5421            snapshot.buffers.insert(
5422                BufferId(2),
5423                BufferInfo {
5424                    id: BufferId(2),
5425                    path: Some(std::path::PathBuf::from("/file2.txt")),
5426                    modified: true,
5427                    length: 100,
5428                    is_virtual: false,
5429                    view_mode: "source".to_string(),
5430                    is_composing_in_any_split: false,
5431                    compose_width: None,
5432                    language: "text".to_string(),
5433                    is_preview: false,
5434                    splits: Vec::new(),
5435                },
5436            );
5437            snapshot.buffers.insert(
5438                BufferId(3),
5439                BufferInfo {
5440                    id: BufferId(3),
5441                    path: None,
5442                    modified: false,
5443                    length: 0,
5444                    is_virtual: true,
5445                    view_mode: "source".to_string(),
5446                    is_composing_in_any_split: false,
5447                    compose_width: None,
5448                    language: "text".to_string(),
5449                    is_preview: false,
5450                    splits: Vec::new(),
5451                },
5452            );
5453        }
5454
5455        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5456
5457        let buffers = api.list_buffers();
5458        assert_eq!(buffers.len(), 3);
5459
5460        // Verify all buffers are present
5461        assert!(buffers.iter().any(|b| b.id.0 == 1));
5462        assert!(buffers.iter().any(|b| b.id.0 == 2));
5463        assert!(buffers.iter().any(|b| b.id.0 == 3));
5464    }
5465
5466    #[test]
5467    fn test_get_primary_cursor() {
5468        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5469        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5470        let (tx, _rx) = std::sync::mpsc::channel();
5471        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5472
5473        // Add cursor info
5474        {
5475            let mut snapshot = state_snapshot.write().unwrap();
5476            snapshot.primary_cursor = Some(CursorInfo {
5477                position: 42,
5478                selection: Some(10..42),
5479                line: Some(3),
5480            });
5481        }
5482
5483        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5484
5485        let cursor = api.get_primary_cursor();
5486        assert!(cursor.is_some());
5487        let cursor = cursor.unwrap();
5488        assert_eq!(cursor.position, 42);
5489        assert_eq!(cursor.selection, Some(10..42));
5490    }
5491
5492    #[test]
5493    fn test_get_all_cursors() {
5494        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5495        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5496        let (tx, _rx) = std::sync::mpsc::channel();
5497        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5498
5499        // Add multiple cursors
5500        {
5501            let mut snapshot = state_snapshot.write().unwrap();
5502            snapshot.all_cursors = vec![
5503                CursorInfo {
5504                    position: 10,
5505                    selection: None,
5506                    line: Some(0),
5507                },
5508                CursorInfo {
5509                    position: 20,
5510                    selection: Some(15..20),
5511                    line: Some(1),
5512                },
5513                CursorInfo {
5514                    position: 30,
5515                    selection: Some(25..30),
5516                    line: Some(2),
5517                },
5518            ];
5519        }
5520
5521        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5522
5523        let cursors = api.get_all_cursors();
5524        assert_eq!(cursors.len(), 3);
5525        assert_eq!(cursors[0].position, 10);
5526        assert_eq!(cursors[0].selection, None);
5527        assert_eq!(cursors[1].position, 20);
5528        assert_eq!(cursors[1].selection, Some(15..20));
5529        assert_eq!(cursors[2].position, 30);
5530        assert_eq!(cursors[2].selection, Some(25..30));
5531    }
5532
5533    #[test]
5534    fn test_get_viewport() {
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 viewport info
5541        {
5542            let mut snapshot = state_snapshot.write().unwrap();
5543            snapshot.viewport = Some(ViewportInfo {
5544                top_byte: 100,
5545                top_line: Some(5),
5546                left_column: 5,
5547                width: 80,
5548                height: 24,
5549            });
5550        }
5551
5552        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5553
5554        let viewport = api.get_viewport();
5555        assert!(viewport.is_some());
5556        let viewport = viewport.unwrap();
5557        assert_eq!(viewport.top_byte, 100);
5558        assert_eq!(viewport.left_column, 5);
5559        assert_eq!(viewport.width, 80);
5560        assert_eq!(viewport.height, 24);
5561    }
5562
5563    #[test]
5564    fn test_composite_buffer_options_rejects_unknown_fields() {
5565        // Valid JSON with correct field names
5566        let valid_json = r#"{
5567            "name": "test",
5568            "mode": "diff",
5569            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
5570            "sources": [{"bufferId": 1, "label": "old"}]
5571        }"#;
5572        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
5573        assert!(
5574            result.is_ok(),
5575            "Valid JSON should parse: {:?}",
5576            result.err()
5577        );
5578
5579        // Invalid JSON with unknown field (buffer_id instead of bufferId)
5580        let invalid_json = r#"{
5581            "name": "test",
5582            "mode": "diff",
5583            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
5584            "sources": [{"buffer_id": 1, "label": "old"}]
5585        }"#;
5586        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
5587        assert!(
5588            result.is_err(),
5589            "JSON with unknown field should fail to parse"
5590        );
5591        let err = result.unwrap_err().to_string();
5592        assert!(
5593            err.contains("unknown field") || err.contains("buffer_id"),
5594            "Error should mention unknown field: {}",
5595            err
5596        );
5597    }
5598
5599    #[test]
5600    fn test_composite_hunk_rejects_unknown_fields() {
5601        // Valid JSON with correct field names
5602        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
5603        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
5604        assert!(
5605            result.is_ok(),
5606            "Valid JSON should parse: {:?}",
5607            result.err()
5608        );
5609
5610        // Invalid JSON with unknown field (old_start instead of oldStart)
5611        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
5612        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
5613        assert!(
5614            result.is_err(),
5615            "JSON with unknown field should fail to parse"
5616        );
5617        let err = result.unwrap_err().to_string();
5618        assert!(
5619            err.contains("unknown field") || err.contains("old_start"),
5620            "Error should mention unknown field: {}",
5621            err
5622        );
5623    }
5624
5625    #[test]
5626    fn test_plugin_response_line_end_position() {
5627        let response = PluginResponse::LineEndPosition {
5628            request_id: 42,
5629            position: Some(100),
5630        };
5631        let json = serde_json::to_string(&response).unwrap();
5632        assert!(json.contains("LineEndPosition"));
5633        assert!(json.contains("42"));
5634        assert!(json.contains("100"));
5635
5636        // Test None case
5637        let response_none = PluginResponse::LineEndPosition {
5638            request_id: 1,
5639            position: None,
5640        };
5641        let json_none = serde_json::to_string(&response_none).unwrap();
5642        assert!(json_none.contains("null"));
5643    }
5644
5645    #[test]
5646    fn test_plugin_response_buffer_line_count() {
5647        let response = PluginResponse::BufferLineCount {
5648            request_id: 99,
5649            count: Some(500),
5650        };
5651        let json = serde_json::to_string(&response).unwrap();
5652        assert!(json.contains("BufferLineCount"));
5653        assert!(json.contains("99"));
5654        assert!(json.contains("500"));
5655    }
5656
5657    #[test]
5658    fn test_plugin_command_get_line_end_position() {
5659        let command = PluginCommand::GetLineEndPosition {
5660            buffer_id: BufferId(1),
5661            line: 10,
5662            request_id: 123,
5663        };
5664        let json = serde_json::to_string(&command).unwrap();
5665        assert!(json.contains("GetLineEndPosition"));
5666        assert!(json.contains("10"));
5667    }
5668
5669    #[test]
5670    fn test_plugin_command_get_buffer_line_count() {
5671        let command = PluginCommand::GetBufferLineCount {
5672            buffer_id: BufferId(0),
5673            request_id: 456,
5674        };
5675        let json = serde_json::to_string(&command).unwrap();
5676        assert!(json.contains("GetBufferLineCount"));
5677        assert!(json.contains("456"));
5678    }
5679
5680    #[test]
5681    fn test_plugin_command_scroll_to_line_center() {
5682        let command = PluginCommand::ScrollToLineCenter {
5683            split_id: SplitId(1),
5684            buffer_id: BufferId(2),
5685            line: 50,
5686        };
5687        let json = serde_json::to_string(&command).unwrap();
5688        assert!(json.contains("ScrollToLineCenter"));
5689        assert!(json.contains("50"));
5690    }
5691
5692    /// `JsCallbackId` round-trips through `u64` via `new` / `as_u64` / `From`
5693    /// and renders as its underlying integer via `Display`.
5694    #[test]
5695    fn js_callback_id_conversions_and_display() {
5696        for raw in [0u64, 1, 42, u64::MAX] {
5697            let id = JsCallbackId::new(raw);
5698            assert_eq!(id.as_u64(), raw);
5699            assert_eq!(u64::from(id), raw);
5700            assert_eq!(JsCallbackId::from(raw), id);
5701            assert_eq!(id.to_string(), raw.to_string());
5702        }
5703    }
5704
5705    /// Serde `default = ...` helpers fire when the field is omitted and are
5706    /// overridden by explicit values. One test per struct pins each helper
5707    /// to its documented default.
5708    #[test]
5709    fn serde_defaults_fire_when_fields_are_omitted() {
5710        // default_action_count → 1
5711        let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
5712        assert_eq!(spec.count, 1);
5713        let spec: ActionSpec =
5714            serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
5715        assert_eq!(spec.count, 5);
5716
5717        // default_true → showSeparator = true
5718        let layout: CompositeLayoutConfig =
5719            serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
5720        assert!(layout.show_separator);
5721        let layout: CompositeLayoutConfig =
5722            serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
5723        assert!(!layout.show_separator);
5724
5725        // default_plugin_provider_priority → 50
5726        let reg: TsCompletionProviderRegistration =
5727            serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
5728        assert_eq!(reg.priority, 50);
5729        let reg: TsCompletionProviderRegistration =
5730            serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
5731        assert_eq!(reg.priority, 3);
5732    }
5733
5734    // ── Behavioural tests added to kill the mutants reported by cargo-mutants ──
5735    //
5736    // These tests pin down observable behaviour for tiny methods whose bodies
5737    // were replaceable with a constant (e.g. `()`, `Ok(())`, `None`, or a
5738    // default value) without any existing test noticing.
5739
5740    /// Helper: build a minimal `Command` with a given name.
5741    fn mk_cmd(name: &str) -> Command {
5742        Command {
5743            name: name.to_string(),
5744            description: String::new(),
5745            action_name: String::new(),
5746            plugin_name: String::new(),
5747            custom_contexts: Vec::new(),
5748            terminal_bypass: false,
5749        }
5750    }
5751
5752    /// `CommandRegistry::register` appends new commands and replaces any
5753    /// existing entry with the same name; `unregister` removes exactly the
5754    /// matching entry and is a no-op for unknown names.
5755    ///
5756    /// Kills: replace register with `()`; `!= → ==` in register;
5757    ///        replace unregister with `()`; `!= → ==` in unregister.
5758    #[test]
5759    fn command_registry_register_and_unregister_semantics() {
5760        let r = CommandRegistry::new();
5761
5762        r.register(mk_cmd("a"));
5763        r.register(mk_cmd("b"));
5764        assert_eq!(r.commands.read().unwrap().len(), 2);
5765
5766        // Re-registering "a" must keep "b" (retain filters by `!=`); the
5767        // `== → !=` mutant would drop "b" and leave two copies of "a".
5768        r.register(mk_cmd("a"));
5769        let names: Vec<String> = r
5770            .commands
5771            .read()
5772            .unwrap()
5773            .iter()
5774            .map(|c| c.name.clone())
5775            .collect();
5776        assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
5777
5778        // Unregister must remove exactly "a" and preserve "b"; the `== → !=`
5779        // mutant would keep "a" and drop "b".
5780        r.unregister("a");
5781        let names: Vec<String> = r
5782            .commands
5783            .read()
5784            .unwrap()
5785            .iter()
5786            .map(|c| c.name.clone())
5787            .collect();
5788        assert_eq!(names, vec!["b".to_string()]);
5789
5790        // Unregistering an unknown name is a no-op.
5791        r.unregister("nope");
5792        assert_eq!(r.commands.read().unwrap().len(), 1);
5793    }
5794
5795    /// `OverlayColorSpec::as_rgb` returns the exact stored tuple for the RGB
5796    /// variant and `None` for the theme-key variant; `as_theme_key` is the
5797    /// dual. Uses a triple with no zero or one components and a theme key
5798    /// that is neither empty nor `"xyzzy"` to kill every constant-return
5799    /// mutant reported by cargo-mutants at once.
5800    #[test]
5801    fn overlay_color_spec_accessors_are_variant_specific() {
5802        let rgb = OverlayColorSpec::rgb(12, 34, 56);
5803        assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
5804        assert_eq!(rgb.as_theme_key(), None);
5805
5806        let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
5807        assert_eq!(tk.as_rgb(), None);
5808        assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
5809    }
5810
5811    /// `PluginCommand::debug_variant_name` returns the actual variant name
5812    /// derived from the `Debug` impl, not an empty or hard-coded string.
5813    #[test]
5814    fn plugin_command_debug_variant_name_returns_real_variant() {
5815        let c = PluginCommand::SetStatus {
5816            message: "hi".into(),
5817        };
5818        assert_eq!(c.debug_variant_name(), "SetStatus");
5819
5820        let c2 = PluginCommand::InsertText {
5821            buffer_id: BufferId(1),
5822            position: 0,
5823            text: String::new(),
5824        };
5825        assert_eq!(c2.debug_variant_name(), "InsertText");
5826    }
5827
5828    // ── PluginApi dispatch / mutation tests ────────────────────────────────
5829    //
5830    // Each `PluginApi` method is a one-liner that either pushes a
5831    // `PluginCommand` onto the channel or mutates a shared registry. The
5832    // mutants replace the body with `Ok(())` / `()`, i.e. the side effect
5833    // disappears. One assertion per method ties the side effect down.
5834
5835    type MkApi = (
5836        PluginApi,
5837        std::sync::mpsc::Receiver<PluginCommand>,
5838        Arc<RwLock<HookRegistry>>,
5839        Arc<RwLock<CommandRegistry>>,
5840        Arc<RwLock<EditorStateSnapshot>>,
5841    );
5842
5843    fn mk_api() -> MkApi {
5844        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5845        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5846        let (tx, rx) = std::sync::mpsc::channel();
5847        let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5848        let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
5849        (api, rx, hooks, commands, snap)
5850    }
5851
5852    /// `unregister_hooks` must actually clear hooks registered under the
5853    /// same name; replacing the body with `()` leaves the count at 1.
5854    #[test]
5855    fn plugin_api_unregister_hooks_clears_registry() {
5856        let (api, _rx, hooks, _cmds, _snap) = mk_api();
5857        api.register_hook("h", Box::new(|_| true));
5858        assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
5859        api.unregister_hooks("h");
5860        assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
5861    }
5862
5863    /// `register_command` / `unregister_command` must actually write through
5864    /// to the shared `CommandRegistry`.
5865    #[test]
5866    fn plugin_api_register_and_unregister_command_write_through() {
5867        let (api, _rx, _hooks, cmds, _snap) = mk_api();
5868
5869        api.register_command(mk_cmd("x"));
5870        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
5871
5872        api.unregister_command("x");
5873        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
5874    }
5875
5876    /// Macro: assert that calling `$call` on a fresh `PluginApi` produces
5877    /// exactly one `PluginCommand` matching `$pattern` with the additional
5878    /// invariants in `$guard`.
5879    macro_rules! assert_dispatches {
5880        ($call:expr, $pattern:pat $(if $guard:expr)?) => {{
5881            let (api, rx, _h, _c, _s) = mk_api();
5882            let _ = $call(&api);
5883            match rx.try_recv().expect("no command sent") {
5884                $pattern $(if $guard)? => {}
5885                other => panic!("unexpected command variant: {:?}", other),
5886            }
5887        }};
5888    }
5889
5890    /// Every simple `send_command`-based method on `PluginApi` translates
5891    /// its arguments into the documented `PluginCommand` variant with the
5892    /// expected fields.
5893    #[test]
5894    fn plugin_api_send_command_methods_dispatch_correctly() {
5895        // delete_range
5896        assert_dispatches!(
5897            |a: &PluginApi| a.delete_range(BufferId(7), 3..9),
5898            PluginCommand::DeleteRange { buffer_id, range }
5899                if buffer_id == BufferId(7) && range == (3..9)
5900        );
5901
5902        // remove_overlay
5903        assert_dispatches!(
5904            |a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
5905            PluginCommand::RemoveOverlay { buffer_id, handle }
5906                if buffer_id == BufferId(2) && handle.as_str() == "h-1"
5907        );
5908
5909        // clear_namespace
5910        assert_dispatches!(
5911            |a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
5912            PluginCommand::ClearNamespace { buffer_id, namespace }
5913                if buffer_id == BufferId(3) && namespace.as_str() == "diag"
5914        );
5915
5916        // clear_overlays_in_range
5917        assert_dispatches!(
5918            |a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
5919            PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
5920                if buffer_id == BufferId(4) && start == 10 && end == 20
5921        );
5922
5923        // open_file_at_location
5924        assert_dispatches!(
5925            |a: &PluginApi| a.open_file_at_location(
5926                PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
5927            ),
5928            PluginCommand::OpenFileAtLocation { path, line, column }
5929                if path == Path::new("/tmp/x.rs")
5930                    && line == Some(4)
5931                    && column == Some(8)
5932        );
5933
5934        // open_file_in_split
5935        assert_dispatches!(
5936            |a: &PluginApi| a.open_file_in_split(
5937                2, PathBuf::from("/tmp/y.rs"), Some(5), None
5938            ),
5939            PluginCommand::OpenFileInSplit { split_id, path, line, column }
5940                if split_id == 2
5941                    && path == Path::new("/tmp/y.rs")
5942                    && line == Some(5)
5943                    && column.is_none()
5944        );
5945
5946        // start_prompt
5947        assert_dispatches!(
5948            |a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
5949            PluginCommand::StartPrompt { label, prompt_type, floating_overlay }
5950                if label == "label" && prompt_type == "cmd" && !floating_overlay
5951        );
5952
5953        // set_prompt_suggestions
5954        assert_dispatches!(
5955            |a: &PluginApi| a.set_prompt_suggestions(vec![
5956                Suggestion::new("one".into()),
5957                Suggestion::new("two".into()),
5958            ]),
5959            PluginCommand::SetPromptSuggestions { suggestions }
5960                if suggestions.len() == 2
5961                    && suggestions[0].text == "one"
5962                    && suggestions[1].text == "two"
5963        );
5964
5965        // set_prompt_input_sync
5966        assert_dispatches!(
5967            |a: &PluginApi| a.set_prompt_input_sync(true),
5968            PluginCommand::SetPromptInputSync { sync } if sync
5969        );
5970        assert_dispatches!(
5971            |a: &PluginApi| a.set_prompt_input_sync(false),
5972            PluginCommand::SetPromptInputSync { sync } if !sync
5973        );
5974
5975        // add_menu_item
5976        assert_dispatches!(
5977            |a: &PluginApi| a.add_menu_item(
5978                "File".into(),
5979                MenuItem::Label { info: "info".into() },
5980                MenuPosition::Bottom,
5981            ),
5982            PluginCommand::AddMenuItem { menu_label, item, position }
5983                if menu_label == "File"
5984                    && matches!(item, MenuItem::Label { ref info } if info == "info")
5985                    && matches!(position, MenuPosition::Bottom)
5986        );
5987
5988        // add_menu
5989        assert_dispatches!(
5990            |a: &PluginApi| a.add_menu(
5991                Menu {
5992                    id: None,
5993                    label: "Help".into(),
5994                    items: vec![],
5995                    when: None,
5996                },
5997                MenuPosition::After("Edit".into()),
5998            ),
5999            PluginCommand::AddMenu { menu, position }
6000                if menu.label == "Help"
6001                    && matches!(position, MenuPosition::After(ref s) if s == "Edit")
6002        );
6003
6004        // remove_menu_item
6005        assert_dispatches!(
6006            |a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
6007            PluginCommand::RemoveMenuItem { menu_label, item_label }
6008                if menu_label == "File" && item_label == "Open"
6009        );
6010
6011        // remove_menu
6012        assert_dispatches!(
6013            |a: &PluginApi| a.remove_menu("File".into()),
6014            PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
6015        );
6016
6017        // create_virtual_buffer
6018        assert_dispatches!(
6019            |a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
6020            PluginCommand::CreateVirtualBuffer { name, mode, read_only }
6021                if name == "buf" && mode == "mode" && read_only
6022        );
6023
6024        // create_virtual_buffer_with_content
6025        assert_dispatches!(
6026            |a: &PluginApi| a.create_virtual_buffer_with_content(
6027                "n".into(), "m".into(), false, vec![]
6028            ),
6029            PluginCommand::CreateVirtualBufferWithContent {
6030                name, mode, read_only, show_line_numbers, show_cursors,
6031                editing_disabled, hidden_from_tabs, request_id, ..
6032            }
6033                if name == "n" && mode == "m" && !read_only
6034                    && show_line_numbers && show_cursors
6035                    && !editing_disabled && !hidden_from_tabs
6036                    && request_id.is_none()
6037        );
6038
6039        // set_virtual_buffer_content
6040        assert_dispatches!(
6041            |a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
6042            PluginCommand::SetVirtualBufferContent { buffer_id, entries }
6043                if buffer_id == BufferId(9) && entries.is_empty()
6044        );
6045
6046        // get_text_properties_at_cursor
6047        assert_dispatches!(
6048            |a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
6049            PluginCommand::GetTextPropertiesAtCursor { buffer_id }
6050                if buffer_id == BufferId(11)
6051        );
6052
6053        // define_mode
6054        assert_dispatches!(
6055            |a: &PluginApi| a.define_mode(
6056                "m".into(),
6057                vec![("j".into(), "move_down".into())],
6058                true,
6059                false,
6060            ),
6061            PluginCommand::DefineMode {
6062                name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
6063            }
6064                if name == "m"
6065                    && bindings.len() == 1
6066                    && bindings[0].0 == "j"
6067                    && bindings[0].1 == "move_down"
6068                    && read_only
6069                    && !allow_text_input
6070                    && !inherit_normal_bindings
6071                    && plugin_name.is_none()
6072        );
6073
6074        // show_buffer
6075        assert_dispatches!(
6076            |a: &PluginApi| a.show_buffer(BufferId(77)),
6077            PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
6078        );
6079
6080        // set_split_scroll
6081        assert_dispatches!(
6082            |a: &PluginApi| a.set_split_scroll(5, 128),
6083            PluginCommand::SetSplitScroll { split_id, top_byte }
6084                if split_id == SplitId(5) && top_byte == 128
6085        );
6086
6087        // get_highlights
6088        assert_dispatches!(
6089            |a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
6090            PluginCommand::RequestHighlights { buffer_id, range, request_id }
6091                if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
6092        );
6093    }
6094
6095    /// `get_active_split_id` reads the snapshot verbatim; a non-{0,1}
6096    /// sentinel value kills both the `0` and `1` constant-return mutants.
6097    #[test]
6098    fn plugin_api_get_active_split_id_reads_snapshot() {
6099        let (api, _rx, _h, _c, snap) = mk_api();
6100        snap.write().unwrap().active_split_id = 42;
6101        assert_eq!(api.get_active_split_id(), 42);
6102    }
6103
6104    /// `state_snapshot_handle` returns a clone of the same `Arc`, not a
6105    /// freshly-defaulted snapshot. A distinguishing field value on the
6106    /// original state proves that the handle sees it.
6107    #[test]
6108    fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
6109        let (api, _rx, _h, _c, snap) = mk_api();
6110        snap.write().unwrap().active_buffer_id = BufferId(42);
6111
6112        let h = api.state_snapshot_handle();
6113        assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
6114        assert!(Arc::ptr_eq(&h, &snap));
6115    }
6116
6117    /// `KillHostProcess` survives a round-trip through serde: the
6118    /// `process_id` field stays identified by name and the variant
6119    /// retains its tag shape. If a future contributor renames the
6120    /// field or splits it into a tuple, the plugin-runtime TS side
6121    /// (which hand-builds the command JSON for the dispatcher) would
6122    /// silently break — this test pins the wire format.
6123    #[test]
6124    fn plugin_command_kill_host_process_serde_round_trip() {
6125        let cmd = PluginCommand::KillHostProcess { process_id: 1234 };
6126        let json = serde_json::to_value(&cmd).unwrap();
6127        assert_eq!(json["KillHostProcess"]["process_id"], 1234);
6128        let decoded: PluginCommand = serde_json::from_value(json).unwrap();
6129        match decoded {
6130            PluginCommand::KillHostProcess { process_id } => assert_eq!(process_id, 1234),
6131            other => panic!("expected KillHostProcess, got {:?}", other),
6132        }
6133    }
6134
6135    // ==================== SearchHandle behavior ====================
6136
6137    fn dummy_match(line: usize) -> GrepMatch {
6138        GrepMatch {
6139            file: "fixture.rs".to_string(),
6140            buffer_id: 0,
6141            byte_offset: 0,
6142            length: 4,
6143            line,
6144            column: 1,
6145            context: "match".to_string(),
6146        }
6147    }
6148
6149    /// Pull-based handle batches matches between drains: a producer that
6150    /// pushes N matches across multiple writes hands them to the consumer
6151    /// in a single take(), and a follow-up take() with no new writes
6152    /// returns an empty batch — proving the architectural property the
6153    /// new API was built around (no per-chunk dispatch).
6154    #[test]
6155    fn search_handle_batches_between_takes() {
6156        let handle = Arc::new(SearchHandleState::new());
6157
6158        // Three independent writer batches simulate three searcher tasks
6159        // pushing into the shared state.
6160        for chunk in [vec![dummy_match(1), dummy_match(2)], vec![dummy_match(3)]] {
6161            let count = chunk.len();
6162            let mut state = handle.state.lock().unwrap();
6163            state.pending.extend(chunk);
6164            state.total_seen += count;
6165        }
6166
6167        // First take drains everything written so far.
6168        let drained: Vec<_> = {
6169            let mut s = handle.state.lock().unwrap();
6170            std::mem::take(&mut s.pending)
6171        };
6172        assert_eq!(drained.len(), 3);
6173        assert_eq!(handle.state.lock().unwrap().total_seen, 3);
6174
6175        // Second take with no producer activity yields an empty batch.
6176        let empty: Vec<_> = {
6177            let mut s = handle.state.lock().unwrap();
6178            std::mem::take(&mut s.pending)
6179        };
6180        assert!(empty.is_empty());
6181    }
6182
6183    /// `cancel` is a one-way latch visible to producers and consumers.
6184    /// Setting it does not implicitly mark `done` — completion is the
6185    /// producer's responsibility — but a producer observing the flag
6186    /// should stop pushing.
6187    #[test]
6188    fn search_handle_cancel_is_observable() {
6189        let handle = Arc::new(SearchHandleState::new());
6190        assert!(!handle.cancel.load(std::sync::atomic::Ordering::Relaxed));
6191
6192        handle
6193            .cancel
6194            .store(true, std::sync::atomic::Ordering::Relaxed);
6195
6196        assert!(handle.cancel.load(std::sync::atomic::Ordering::Relaxed));
6197        assert!(!handle.state.lock().unwrap().done);
6198    }
6199
6200    /// The terminal state transition: producers flip `done = true` once
6201    /// no more matches will arrive, with `truncated` reflecting whether
6202    /// the search hit `max_results`. Consumers learn the search is
6203    /// finished from the same `take()` that drains the final batch.
6204    #[test]
6205    fn search_handle_done_transition_is_visible_to_consumer() {
6206        let handle = Arc::new(SearchHandleState::new());
6207
6208        // Producer pushes a final batch, then marks done.
6209        {
6210            let mut s = handle.state.lock().unwrap();
6211            s.pending.push(dummy_match(7));
6212            s.total_seen += 1;
6213            s.truncated = true;
6214            s.done = true;
6215        }
6216
6217        let (matches, done, truncated) = {
6218            let mut s = handle.state.lock().unwrap();
6219            (std::mem::take(&mut s.pending), s.done, s.truncated)
6220        };
6221
6222        assert_eq!(matches.len(), 1);
6223        assert!(done);
6224        assert!(truncated);
6225    }
6226
6227    /// Producers and consumers must be able to interleave without
6228    /// blocking each other longer than a `mem::take` swap. This test
6229    /// drives writes from a worker thread while the main thread drains;
6230    /// it asserts the consumer eventually sees every match. With a
6231    /// per-chunk dispatch model an analogous test would deadlock or
6232    /// drop matches; with the pull model it converges.
6233    #[test]
6234    fn search_handle_concurrent_producer_consumer() {
6235        let handle = Arc::new(SearchHandleState::new());
6236        let producer = Arc::clone(&handle);
6237        let writer = std::thread::spawn(move || {
6238            for line in 1..=200 {
6239                let mut s = producer.state.lock().unwrap();
6240                s.pending.push(dummy_match(line));
6241                s.total_seen += 1;
6242            }
6243            producer.state.lock().unwrap().done = true;
6244        });
6245
6246        let mut drained: Vec<GrepMatch> = Vec::new();
6247        loop {
6248            let (mut batch, done) = {
6249                let mut s = handle.state.lock().unwrap();
6250                (std::mem::take(&mut s.pending), s.done)
6251            };
6252            drained.append(&mut batch);
6253            if done {
6254                let mut tail = handle.state.lock().unwrap();
6255                drained.append(&mut std::mem::take(&mut tail.pending));
6256                break;
6257            }
6258            std::thread::yield_now();
6259        }
6260        writer.join().unwrap();
6261        assert_eq!(drained.len(), 200);
6262    }
6263}