Skip to main content

fresh_core/
api.rs

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