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