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