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