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