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