Skip to main content

fresh_core/
api.rs

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