Skip to main content

fresh_core/
api.rs

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