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 /// Add virtual text (inline text that doesn't exist in the buffer)
2403 /// Used for color swatches, type hints, parameter hints, etc.
2404 AddVirtualText {
2405 buffer_id: BufferId,
2406 virtual_text_id: String,
2407 position: usize,
2408 text: String,
2409 color: (u8, u8, u8),
2410 use_bg: bool, // true = use color as background, false = use as foreground
2411 before: bool, // true = before char, false = after char
2412 },
2413
2414 /// Add virtual text with full styling — fg/bg can be RGB or theme
2415 /// keys (resolved at render time so theme changes apply live).
2416 /// This is the richer form of `AddVirtualText` that lets plugins
2417 /// produce themed labels (flash jump, type hints with semantic
2418 /// colours, …) without hard-coding RGB values.
2419 AddVirtualTextStyled {
2420 buffer_id: BufferId,
2421 virtual_text_id: String,
2422 position: usize,
2423 text: String,
2424 fg: Option<OverlayColorSpec>,
2425 bg: Option<OverlayColorSpec>,
2426 bold: bool,
2427 italic: bool,
2428 before: bool,
2429 },
2430
2431 /// Remove a virtual text by ID
2432 RemoveVirtualText {
2433 buffer_id: BufferId,
2434 virtual_text_id: String,
2435 },
2436
2437 /// Remove virtual texts whose ID starts with the given prefix
2438 RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
2439
2440 /// Clear all virtual texts from a buffer
2441 ClearVirtualTexts { buffer_id: BufferId },
2442
2443 /// Add a virtual LINE (full line above/below a position)
2444 /// Used for git blame headers, code coverage, inline documentation, etc.
2445 /// These lines do NOT show line numbers in the gutter.
2446 AddVirtualLine {
2447 buffer_id: BufferId,
2448 /// Byte position to anchor the line to
2449 position: usize,
2450 /// Full line content to display
2451 text: String,
2452 /// Foreground color — RGB tuple or theme key string (e.g.
2453 /// `"editor.line_number_fg"`). Resolved at render time so the line
2454 /// follows theme changes.
2455 fg_color: Option<OverlayColorSpec>,
2456 /// Background color — RGB tuple or theme key string. None =
2457 /// transparent (inherits from underlying viewport background).
2458 bg_color: Option<OverlayColorSpec>,
2459 /// true = above the line containing position, false = below
2460 above: bool,
2461 /// Namespace for bulk removal (e.g., "git-blame")
2462 namespace: String,
2463 /// Priority for ordering multiple lines at same position (higher = later)
2464 priority: i32,
2465 /// Optional gutter glyph rendered in the line-number column on
2466 /// the first visual row of this virtual line. Used by diff
2467 /// plugins to put a "-" directly on the deletion line itself
2468 /// instead of the source line that follows it.
2469 gutter_glyph: Option<String>,
2470 /// Color for `gutter_glyph` (RGB or theme key). Falls back to
2471 /// `theme.line_number_fg` when `None`.
2472 gutter_color: Option<OverlayColorSpec>,
2473 /// Per-range modifier overlays applied on top of the base fg/bg.
2474 /// Offsets are byte offsets within `text`, not buffer bytes.
2475 /// Used e.g. by live-diff to bold + underline removed words on
2476 /// a deletion virtual line.
2477 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2478 text_overlays: Vec<VirtualLineTextOverlay>,
2479 },
2480
2481 /// Clear all virtual texts in a namespace
2482 /// This is the primary way to remove a plugin's virtual lines before updating them.
2483 ClearVirtualTextNamespace {
2484 buffer_id: BufferId,
2485 namespace: String,
2486 },
2487
2488 /// Add a conceal range that hides or replaces a byte range during rendering.
2489 /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc.
2490 AddConceal {
2491 buffer_id: BufferId,
2492 /// Namespace for bulk removal (shared with overlay namespace system)
2493 namespace: OverlayNamespace,
2494 /// Byte range to conceal
2495 start: usize,
2496 end: usize,
2497 /// Optional replacement text to show instead. None = hide completely.
2498 replacement: Option<String>,
2499 },
2500
2501 /// Clear all conceal ranges in a namespace
2502 ClearConcealNamespace {
2503 buffer_id: BufferId,
2504 namespace: OverlayNamespace,
2505 },
2506
2507 /// Remove all conceal ranges that overlap with a byte range
2508 /// Used for targeted invalidation when content in a range changes
2509 ClearConcealsInRange {
2510 buffer_id: BufferId,
2511 start: usize,
2512 end: usize,
2513 },
2514
2515 /// Add a collapsed fold range. Hides the byte range
2516 /// `[start, end)` from rendering — the line containing `start - 1`
2517 /// (the fold's "header") stays visible while the lines covered by
2518 /// the range are skipped. Used by plugins that want to expose
2519 /// outline-style collapse without rebuilding buffer content.
2520 AddFold {
2521 buffer_id: BufferId,
2522 start: usize,
2523 end: usize,
2524 /// Optional placeholder text to show on the header line
2525 /// (currently unused by the renderer; reserved for future use).
2526 placeholder: Option<String>,
2527 },
2528
2529 /// Clear every collapsed fold range on the buffer.
2530 ClearFolds { buffer_id: BufferId },
2531
2532 /// Publish a set of fold ranges on the buffer in the same shape
2533 /// LSP `textDocument/foldingRange` populates. The ranges are
2534 /// stored as **toggleable** — the standard `toggle_fold` keybinding
2535 /// finds them via `state.folding_ranges` and collapses/expands on
2536 /// demand. Unlike `AddFold`, this does not pre-collapse anything.
2537 ///
2538 /// Designed for plugins that derive structural folds from buffer
2539 /// content (e.g. git-log's per-file / per-hunk diff structure)
2540 /// without driving an LSP. Replacing call replaces the prior set.
2541 SetFoldingRanges {
2542 buffer_id: BufferId,
2543 #[ts(type = "any")]
2544 ranges: Vec<lsp_types::FoldingRange>,
2545 },
2546
2547 /// Add a soft break point for marker-based line wrapping.
2548 /// The break is stored as a marker that auto-adjusts on buffer edits,
2549 /// eliminating the flicker caused by async view_transform round-trips.
2550 AddSoftBreak {
2551 buffer_id: BufferId,
2552 /// Namespace for bulk removal (shared with overlay namespace system)
2553 namespace: OverlayNamespace,
2554 /// Byte offset where the break should be injected
2555 position: usize,
2556 /// Number of hanging indent spaces after the break
2557 indent: u16,
2558 },
2559
2560 /// Clear all soft breaks in a namespace
2561 ClearSoftBreakNamespace {
2562 buffer_id: BufferId,
2563 namespace: OverlayNamespace,
2564 },
2565
2566 /// Remove all soft breaks that fall within a byte range
2567 ClearSoftBreaksInRange {
2568 buffer_id: BufferId,
2569 start: usize,
2570 end: usize,
2571 },
2572
2573 /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
2574 RefreshLines { buffer_id: BufferId },
2575
2576 /// Refresh lines for ALL buffers (clear entire seen_lines cache)
2577 /// Sent when a plugin registers for the lines_changed hook to handle the race
2578 /// where render marks lines as "seen" before the plugin has registered.
2579 RefreshAllLines,
2580
2581 /// Sentinel sent by the plugin thread after a hook has been fully processed.
2582 /// Used by the render loop to wait deterministically for plugin responses
2583 /// (e.g., conceal commands from `lines_changed`) instead of polling.
2584 HookCompleted { hook_name: String },
2585
2586 /// Set a line indicator in the gutter's indicator column
2587 /// Used for git gutter, breakpoints, bookmarks, etc.
2588 SetLineIndicator {
2589 buffer_id: BufferId,
2590 /// Line number (0-indexed)
2591 line: usize,
2592 /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
2593 namespace: String,
2594 /// Symbol to display (e.g., "│", "●", "★")
2595 symbol: String,
2596 /// Color as RGB tuple
2597 color: (u8, u8, u8),
2598 /// Priority for display when multiple indicators exist (higher wins)
2599 priority: i32,
2600 },
2601
2602 /// Batch set line indicators in the gutter's indicator column
2603 /// Optimized for setting many lines with the same namespace/symbol/color/priority
2604 SetLineIndicators {
2605 buffer_id: BufferId,
2606 /// Line numbers (0-indexed)
2607 lines: Vec<usize>,
2608 /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
2609 namespace: String,
2610 /// Symbol to display (e.g., "│", "●", "★")
2611 symbol: String,
2612 /// Color as RGB tuple
2613 color: (u8, u8, u8),
2614 /// Priority for display when multiple indicators exist (higher wins)
2615 priority: i32,
2616 },
2617
2618 /// Clear all line indicators for a specific namespace
2619 ClearLineIndicators {
2620 buffer_id: BufferId,
2621 /// Namespace to clear (e.g., "git-gutter")
2622 namespace: String,
2623 },
2624
2625 /// Set file explorer decorations for a namespace
2626 SetFileExplorerDecorations {
2627 /// Namespace for grouping (e.g., "git-status")
2628 namespace: String,
2629 /// Decorations to apply
2630 decorations: Vec<FileExplorerDecoration>,
2631 },
2632
2633 /// Clear file explorer decorations for a namespace
2634 ClearFileExplorerDecorations {
2635 /// Namespace to clear (e.g., "git-status")
2636 namespace: String,
2637 },
2638
2639 /// Open a file at a specific line and column
2640 /// Line and column are 1-indexed to match git grep output
2641 OpenFileAtLocation {
2642 path: PathBuf,
2643 line: Option<usize>, // 1-indexed, None = go to start
2644 column: Option<usize>, // 1-indexed, None = go to line start
2645 },
2646
2647 /// Open a file in a specific split at a given line and column
2648 /// Line and column are 1-indexed to match git grep output
2649 OpenFileInSplit {
2650 split_id: usize,
2651 path: PathBuf,
2652 line: Option<usize>, // 1-indexed, None = go to start
2653 column: Option<usize>, // 1-indexed, None = go to line start
2654 },
2655
2656 /// Start a prompt (minibuffer) with a custom type identifier
2657 /// This allows plugins to create interactive prompts
2658 StartPrompt {
2659 label: String,
2660 prompt_type: String, // e.g., "git-grep", "git-find-file"
2661 /// When true, the prompt renders as a centred floating
2662 /// overlay rather than a bottom-row minibuffer. Used for
2663 /// Live Grep (issue #1796). Defaults to false at the wire
2664 /// level via `#[serde(default)]`.
2665 #[serde(default)]
2666 floating_overlay: bool,
2667 },
2668
2669 /// Start a prompt with pre-filled initial value
2670 StartPromptWithInitial {
2671 label: String,
2672 prompt_type: String,
2673 initial_value: String,
2674 /// See `StartPrompt::floating_overlay`.
2675 #[serde(default)]
2676 floating_overlay: bool,
2677 },
2678
2679 /// Start an async prompt that returns result via callback
2680 /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
2681 StartPromptAsync {
2682 label: String,
2683 initial_value: String,
2684 callback_id: JsCallbackId,
2685 },
2686
2687 /// Request the next keypress for the calling plugin.
2688 ///
2689 /// The editor enqueues `callback_id` and resolves it with a
2690 /// `KeyEventPayload` JSON value the next time a key arrives in
2691 /// `Editor::handle_key`. Multiple pending requests are FIFO.
2692 /// While at least one request is pending, the next key is consumed
2693 /// by the resolution and does not propagate to mode bindings or
2694 /// other dispatch — this is the primitive that lets a plugin run a
2695 /// short input loop (flash labels, vi find-char, replace-char,
2696 /// etc.) without binding every printable key in `defineMode`.
2697 AwaitNextKey { callback_id: JsCallbackId },
2698
2699 /// Begin or end "key capture" mode for the calling plugin.
2700 ///
2701 /// Without this, a plugin running a `getNextKey()` loop has a
2702 /// race: keys typed by the user (or pasted, or auto-repeated)
2703 /// can arrive between two consecutive `getNextKey()` calls while
2704 /// the plugin is still mid-redraw, and would otherwise fall
2705 /// through to the editor's normal dispatch (inserting into the
2706 /// buffer, etc.).
2707 ///
2708 /// While capture is active, every key arriving in
2709 /// `Editor::handle_key` (after terminal-input dispatch) is
2710 /// either resolved against a pending `AwaitNextKey` callback
2711 /// (existing behaviour) or, if no callback is pending, *buffered*
2712 /// in a FIFO queue. When the next `AwaitNextKey` is processed,
2713 /// the queue is drained first. This gives plugins lossless,
2714 /// in-order delivery of every key the user typed regardless of
2715 /// timing.
2716 ///
2717 /// `EndKeyCapture` clears any unconsumed buffered keys; they do
2718 /// NOT replay into the editor's normal dispatch path (that would
2719 /// be surprising — the user's intent was for the plugin to
2720 /// consume them).
2721 SetKeyCaptureActive { active: bool },
2722
2723 /// Update the suggestions list for the current prompt
2724 /// Uses the editor's Suggestion type
2725 SetPromptSuggestions { suggestions: Vec<Suggestion> },
2726
2727 /// When enabled, navigating suggestions updates the prompt input text
2728 SetPromptInputSync { sync: bool },
2729
2730 /// Set the title shown in a floating-overlay prompt's frame
2731 /// header (issue #1796) as styled segments. Each segment carries
2732 /// optional `OverlayOptions`, so plugins can theme keybinding
2733 /// hints with `fg: "ui.help_key_fg"`, separators with
2734 /// `fg: "ui.popup_border_fg"`, etc. An empty vec clears the
2735 /// title and falls back to the prompt-type default. Has no
2736 /// visible effect on non-overlay prompts.
2737 SetPromptTitle { title: Vec<StyledText> },
2738
2739 /// Plugin-supplied footer chrome rendered along the bottom
2740 /// row of the floating-overlay's results pane (Primitive #2
2741 /// chrome region in
2742 /// `docs/internal/orchestrator-sessions-design.md`). Orchestrator
2743 /// uses this for hotkey-hint rows. Empty vec clears the
2744 /// footer. Has no visible effect on non-overlay prompts.
2745 SetPromptFooter { footer: Vec<StyledText> },
2746
2747 /// Plugin-supplied toolbar for the floating-overlay prompt's header
2748 /// band, as a `WidgetSpec` (a `Row`/`Col` of `Toggle`s/`Button`s). Unlike
2749 /// `SetPromptTitle` (styled text), these are real widgets: they render
2750 /// with the theme's toggle/button styling and are clickable (each carries
2751 /// a stable `key`; the host maps a click to the
2752 /// `live_grep_toggle_<key>`-style action). `None`/absent leaves the
2753 /// styled-text title in place. Has no visible effect on non-overlay
2754 /// prompts.
2755 SetPromptToolbar { spec: Option<WidgetSpec> },
2756
2757 /// Short status text shown right-aligned on the floating-overlay prompt's
2758 /// input row, just left of the `selected / total` count (e.g.
2759 /// "Searching…", "No matches"). Empty clears it. No effect on non-overlay
2760 /// prompts.
2761 SetPromptStatus { status: String },
2762
2763 /// Flip a toolbar toggle in the floating-overlay prompt by its widget
2764 /// `key`. The host owns the toggle's checked state: it updates the spec in
2765 /// place and emits a `widget_event` so the plugin can react (re-run its
2766 /// search, etc.). Lets a plugin's Alt+… shortcut funnel through the same
2767 /// host path as a click or Space on the focused toggle.
2768 ToggleOverlayToolbarWidget { key: String },
2769
2770 /// Override the currently-highlighted suggestion row in the
2771 /// open prompt. Clamped to the suggestion list's bounds; out-
2772 /// of-range indices snap to the last row. No-op when there is
2773 /// no open prompt or the list is empty. The renderer scrolls
2774 /// the selection into view on the next frame.
2775 SetPromptSelectedIndex { index: u32 },
2776
2777 /// Add a menu item to an existing menu
2778 /// Add a menu item to an existing menu
2779 AddMenuItem {
2780 menu_label: String,
2781 item: MenuItem,
2782 position: MenuPosition,
2783 },
2784
2785 /// Add a new top-level menu
2786 AddMenu { menu: Menu, position: MenuPosition },
2787
2788 /// Remove a menu item from a menu
2789 RemoveMenuItem {
2790 menu_label: String,
2791 item_label: String,
2792 },
2793
2794 /// Remove a top-level menu
2795 RemoveMenu { menu_label: String },
2796
2797 /// Create a new virtual buffer (not backed by a file)
2798 CreateVirtualBuffer {
2799 /// Display name (e.g., "*Diagnostics*")
2800 name: String,
2801 /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
2802 mode: String,
2803 /// Whether the buffer is read-only
2804 read_only: bool,
2805 },
2806
2807 /// Create a virtual buffer and set its content in one operation
2808 /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
2809 /// because it doesn't require tracking the buffer ID
2810 CreateVirtualBufferWithContent {
2811 /// Display name (e.g., "*Diagnostics*")
2812 name: String,
2813 /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
2814 mode: String,
2815 /// Whether the buffer is read-only
2816 read_only: bool,
2817 /// Entries with text and embedded properties
2818 entries: Vec<TextPropertyEntry>,
2819 /// Whether to show line numbers in the gutter
2820 show_line_numbers: bool,
2821 /// Whether to show cursors in the buffer
2822 show_cursors: bool,
2823 /// Whether editing is disabled (blocks editing commands)
2824 editing_disabled: bool,
2825 /// Whether this buffer should be hidden from tabs (for composite source buffers)
2826 hidden_from_tabs: bool,
2827 /// Optional request ID for async response
2828 request_id: Option<u64>,
2829 },
2830
2831 /// Create a virtual buffer in a horizontal split
2832 /// Opens the buffer in a new pane below the current one
2833 CreateVirtualBufferInSplit {
2834 /// Display name (e.g., "*Diagnostics*")
2835 name: String,
2836 /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
2837 mode: String,
2838 /// Whether the buffer is read-only
2839 read_only: bool,
2840 /// Entries with text and embedded properties
2841 entries: Vec<TextPropertyEntry>,
2842 /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
2843 ratio: f32,
2844 /// Split direction ("horizontal" or "vertical"), default horizontal
2845 direction: Option<String>,
2846 /// Optional panel ID for idempotent operations (if panel exists, update content)
2847 panel_id: Option<String>,
2848 /// Whether to show line numbers in the buffer (default true)
2849 show_line_numbers: bool,
2850 /// Whether to show cursors in the buffer (default true)
2851 show_cursors: bool,
2852 /// Whether editing is disabled for this buffer (default false)
2853 editing_disabled: bool,
2854 /// Whether line wrapping is enabled for this split (None = use global setting)
2855 line_wrap: Option<bool>,
2856 /// Place the new buffer before (left/top of) the existing content (default: false/after)
2857 before: bool,
2858 /// Optional split role tag. When `Some("utility_dock")`, the
2859 /// dispatcher routes the buffer to the existing dock leaf if
2860 /// one exists; otherwise it seeds a new dock leaf with the
2861 /// requested direction/ratio.
2862 role: Option<String>,
2863 /// Optional request ID for async response (if set, editor will send back buffer ID)
2864 request_id: Option<u64>,
2865 },
2866
2867 /// Set the content of a virtual buffer with text properties
2868 SetVirtualBufferContent {
2869 buffer_id: BufferId,
2870 /// Entries with text and embedded properties
2871 entries: Vec<TextPropertyEntry>,
2872 },
2873
2874 /// Get text properties at the cursor position in a buffer
2875 GetTextPropertiesAtCursor { buffer_id: BufferId },
2876
2877 /// Create a buffer group: multiple panels appearing as one tab.
2878 /// Each panel is a real buffer with its own scrollbar and viewport.
2879 CreateBufferGroup {
2880 /// Display name (shown in tab bar)
2881 name: String,
2882 /// Mode for keybindings
2883 mode: String,
2884 /// Layout tree as JSON string (parsed by the handler)
2885 layout_json: String,
2886 /// Optional request ID for async response
2887 request_id: Option<u64>,
2888 },
2889
2890 /// Set the content of a panel within a buffer group.
2891 SetPanelContent {
2892 /// Group ID
2893 group_id: usize,
2894 /// Panel name (e.g., "tree", "picker")
2895 panel_name: String,
2896 /// Content entries
2897 entries: Vec<TextPropertyEntry>,
2898 },
2899
2900 /// Close a buffer group (closes all panels and splits)
2901 CloseBufferGroup { group_id: usize },
2902
2903 /// Focus a specific panel within a buffer group
2904 FocusPanel { group_id: usize, panel_name: String },
2905
2906 /// Define a buffer mode with keybindings
2907 DefineMode {
2908 name: String,
2909 bindings: Vec<(String, String)>, // (key_string, command_name)
2910 read_only: bool,
2911 /// When true, unbound character keys dispatch as `mode_text_input:<char>`.
2912 allow_text_input: bool,
2913 /// When true, keys not bound by this mode fall through to the Normal
2914 /// context (motion, selection, copy) instead of being dropped.
2915 inherit_normal_bindings: bool,
2916 /// Name of the plugin that defined this mode (for attribution)
2917 plugin_name: Option<String>,
2918 },
2919
2920 /// Switch the current split to display a buffer
2921 ShowBuffer { buffer_id: BufferId },
2922
2923 /// Start a frame-buffer animation over a given screen region. The `id`
2924 /// is allocated on the plugin side so the JS call can return it
2925 /// synchronously; the editor uses it verbatim.
2926 StartAnimationArea {
2927 id: u64,
2928 rect: AnimationRect,
2929 kind: PluginAnimationKind,
2930 },
2931
2932 /// Start an animation over the on-screen Rect currently occupied by a
2933 /// virtual buffer. If the buffer is not visible, the editor ignores
2934 /// the command.
2935 StartAnimationVirtualBuffer {
2936 id: u64,
2937 buffer_id: BufferId,
2938 kind: PluginAnimationKind,
2939 },
2940
2941 /// Cancel an animation by the ID returned from `animateArea` /
2942 /// `animateVirtualBuffer`. No-op if the ID is unknown or already done.
2943 CancelAnimation { id: u64 },
2944
2945 /// Create a virtual buffer in an existing split (replaces current buffer in that split)
2946 CreateVirtualBufferInExistingSplit {
2947 /// Display name (e.g., "*Commit Details*")
2948 name: String,
2949 /// Mode name for buffer-local keybindings
2950 mode: String,
2951 /// Whether the buffer is read-only
2952 read_only: bool,
2953 /// Entries with text and embedded properties
2954 entries: Vec<TextPropertyEntry>,
2955 /// Target split ID where the buffer should be displayed
2956 split_id: SplitId,
2957 /// Whether to show line numbers in the buffer (default true)
2958 show_line_numbers: bool,
2959 /// Whether to show cursors in the buffer (default true)
2960 show_cursors: bool,
2961 /// Whether editing is disabled for this buffer (default false)
2962 editing_disabled: bool,
2963 /// Whether line wrapping is enabled for this split (None = use global setting)
2964 line_wrap: Option<bool>,
2965 /// Optional request ID for async response
2966 request_id: Option<u64>,
2967 },
2968
2969 /// Close a buffer and remove it from all splits
2970 CloseBuffer { buffer_id: BufferId },
2971
2972 /// Close all buffers in the split except the specified one
2973 CloseOtherBuffersInSplit {
2974 buffer_id: BufferId,
2975 split_id: SplitId,
2976 },
2977
2978 /// Close all buffers in the split
2979 CloseAllBuffersInSplit { split_id: SplitId },
2980
2981 /// Close all buffers to the right of the specified buffer in the split
2982 CloseBuffersToRightInSplit {
2983 buffer_id: BufferId,
2984 split_id: SplitId,
2985 },
2986
2987 /// Close all buffers to the left of the specified buffer in the split
2988 CloseBuffersToLeftInSplit {
2989 buffer_id: BufferId,
2990 split_id: SplitId,
2991 },
2992
2993 /// Move the active tab one position to the left within its split
2994 MoveTabLeft,
2995
2996 /// Move the active tab one position to the right within its split
2997 MoveTabRight,
2998
2999 /// Create a composite buffer that displays multiple source buffers
3000 /// Used for side-by-side diff, unified diff, and 3-way merge views
3001 CreateCompositeBuffer {
3002 /// Display name (shown in tab bar)
3003 name: String,
3004 /// Mode name for keybindings (e.g., "diff-view")
3005 mode: String,
3006 /// Layout configuration
3007 layout: CompositeLayoutConfig,
3008 /// Source pane configurations
3009 sources: Vec<CompositeSourceConfig>,
3010 /// Diff hunks for line alignment (optional)
3011 hunks: Option<Vec<CompositeHunk>>,
3012 /// When set, first render scrolls to center this hunk (0-indexed)
3013 initial_focus_hunk: Option<usize>,
3014 /// Request ID for async response
3015 request_id: Option<u64>,
3016 },
3017
3018 /// Update alignment for a composite buffer (e.g., after source edit)
3019 UpdateCompositeAlignment {
3020 buffer_id: BufferId,
3021 hunks: Vec<CompositeHunk>,
3022 },
3023
3024 /// Close a composite buffer
3025 CloseCompositeBuffer { buffer_id: BufferId },
3026
3027 /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
3028 ///
3029 /// Creates `CompositeViewState` for any visible composite buffer that doesn't
3030 /// have one, and syncs viewport dimensions from split layout. This ensures
3031 /// subsequent commands can read/modify view state that is normally created
3032 /// lazily during the render cycle.
3033 FlushLayout,
3034
3035 /// Navigate to the next hunk in a composite buffer
3036 CompositeNextHunk { buffer_id: BufferId },
3037
3038 /// Navigate to the previous hunk in a composite buffer
3039 CompositePrevHunk { buffer_id: BufferId },
3040
3041 /// Focus a specific split
3042 FocusSplit { split_id: SplitId },
3043
3044 /// Set the buffer displayed in a specific split
3045 SetSplitBuffer {
3046 split_id: SplitId,
3047 buffer_id: BufferId,
3048 },
3049
3050 /// Set the scroll position of a specific split
3051 SetSplitScroll { split_id: SplitId, top_byte: usize },
3052
3053 /// Request syntax highlights for a buffer range
3054 RequestHighlights {
3055 buffer_id: BufferId,
3056 range: Range<usize>,
3057 request_id: u64,
3058 },
3059
3060 /// Close a split (if not the last one)
3061 CloseSplit { split_id: SplitId },
3062
3063 /// Set the ratio of a split container
3064 SetSplitRatio {
3065 split_id: SplitId,
3066 /// Ratio between 0.0 and 1.0 (0.5 = equal split)
3067 ratio: f32,
3068 },
3069
3070 /// Set a label on a leaf split (e.g., "sidebar")
3071 SetSplitLabel { split_id: SplitId, label: String },
3072
3073 /// Remove a label from a split
3074 ClearSplitLabel { split_id: SplitId },
3075
3076 /// Find a split by its label (async)
3077 GetSplitByLabel { label: String, request_id: u64 },
3078
3079 /// Distribute splits evenly - make all given splits equal size
3080 DistributeSplitsEvenly {
3081 /// Split IDs to distribute evenly
3082 split_ids: Vec<SplitId>,
3083 },
3084
3085 /// Set cursor position in a buffer (also scrolls viewport to show cursor)
3086 SetBufferCursor {
3087 buffer_id: BufferId,
3088 /// Byte offset position for the cursor
3089 position: usize,
3090 },
3091
3092 /// Toggle whether the editor draws a native caret for this buffer.
3093 ///
3094 /// Buffer-group panel buffers default to `show_cursors = false`, which not
3095 /// only hides the caret but also blocks all movement actions in
3096 /// `action_to_events`. Plugins that want native cursor motion in a panel
3097 /// buffer (e.g. for magit-style row navigation) flip this to `true` after
3098 /// `createBufferGroup` returns.
3099 SetBufferShowCursors { buffer_id: BufferId, show: bool },
3100
3101 /// Send an arbitrary LSP request and return the raw JSON response
3102 SendLspRequest {
3103 language: String,
3104 method: String,
3105 #[ts(type = "any")]
3106 params: Option<JsonValue>,
3107 request_id: u64,
3108 },
3109
3110 /// Set the internal clipboard content
3111 SetClipboard { text: String },
3112
3113 /// Delete the current selection in the active buffer
3114 /// This deletes all selected text across all cursors
3115 DeleteSelection,
3116
3117 /// Set or unset a custom context
3118 /// Custom contexts are plugin-defined states that can be used to control command visibility
3119 /// For example, "config-editor" context could make config editor commands available
3120 SetContext {
3121 /// Context name (e.g., "config-editor")
3122 name: String,
3123 /// Whether the context is active
3124 active: bool,
3125 },
3126
3127 /// Set the hunks for the Review Diff tool
3128 SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
3129
3130 /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
3131 /// Used by vi mode plugin to run motions and calculate cursor ranges
3132 ExecuteAction {
3133 /// Action name (e.g., "move_word_right", "move_line_end")
3134 action_name: String,
3135 },
3136
3137 /// Execute multiple actions in sequence, each with an optional repeat count
3138 /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
3139 /// All actions execute atomically with no plugin roundtrips between them
3140 ExecuteActions {
3141 /// List of actions to execute in sequence
3142 actions: Vec<ActionSpec>,
3143 },
3144
3145 /// Get text from a buffer range (for yank operations)
3146 GetBufferText {
3147 /// Buffer ID
3148 buffer_id: BufferId,
3149 /// Start byte offset
3150 start: usize,
3151 /// End byte offset
3152 end: usize,
3153 /// Request ID for async response
3154 request_id: u64,
3155 },
3156
3157 /// Get byte offset of the start of a line (async)
3158 /// Line is 0-indexed (0 = first line)
3159 GetLineStartPosition {
3160 /// Buffer ID (0 for active buffer)
3161 buffer_id: BufferId,
3162 /// Line number (0-indexed)
3163 line: u32,
3164 /// Request ID for async response
3165 request_id: u64,
3166 },
3167
3168 /// Get byte offset of the end of a line (async)
3169 /// Line is 0-indexed (0 = first line)
3170 /// Returns the byte offset after the last character of the line (before newline)
3171 GetLineEndPosition {
3172 /// Buffer ID (0 for active buffer)
3173 buffer_id: BufferId,
3174 /// Line number (0-indexed)
3175 line: u32,
3176 /// Request ID for async response
3177 request_id: u64,
3178 },
3179
3180 /// Get the total number of lines in a buffer (async)
3181 GetBufferLineCount {
3182 /// Buffer ID (0 for active buffer)
3183 buffer_id: BufferId,
3184 /// Request ID for async response
3185 request_id: u64,
3186 },
3187
3188 /// Open `path` as a regular buffer in forced large-file (file-backed)
3189 /// mode regardless of file size. Designed for buffers whose backing
3190 /// file will grow under them (e.g. a temp file fed by `spawnProcess`
3191 /// with `stdoutTo`). Resolves with the new buffer's id.
3192 ///
3193 /// Pair with `RefreshBufferFromDisk` to grow the buffer as the file
3194 /// is written.
3195 OpenFileStreaming {
3196 /// Path to open. May not yet exist or may be empty.
3197 path: PathBuf,
3198 /// Request ID for async response (the buffer_id).
3199 request_id: u64,
3200 },
3201
3202 /// Re-stat the file backing `buffer_id` and extend the buffer if
3203 /// the file has grown. No-op if the buffer has no file path or the
3204 /// file didn't grow. Resolves with the new total byte length.
3205 RefreshBufferFromDisk {
3206 buffer_id: BufferId,
3207 /// Request ID for async response.
3208 request_id: u64,
3209 },
3210
3211 /// Re-point a buffer-group's panel at a different buffer id.
3212 /// Used by streaming plugins (e.g. git-log) to swap one
3213 /// file-backed buffer for another when the user navigates to a
3214 /// new commit, without rebuilding the group layout. Both
3215 /// `group.panel_buffers[panel_name]` and the corresponding
3216 /// `SplitViewState.active_buffer` are updated; layout is marked
3217 /// dirty for the next render.
3218 ///
3219 /// Resolves with `true` on success, `false` if the group or panel
3220 /// is missing.
3221 SetBufferGroupPanelBuffer {
3222 group_id: usize,
3223 panel_name: String,
3224 buffer_id: BufferId,
3225 request_id: u64,
3226 },
3227
3228 /// Scroll a split to center a specific line in the viewport
3229 /// Line is 0-indexed (0 = first line)
3230 ScrollToLineCenter {
3231 /// Split ID to scroll
3232 split_id: SplitId,
3233 /// Buffer ID containing the line
3234 buffer_id: BufferId,
3235 /// Line number to center (0-indexed)
3236 line: usize,
3237 },
3238
3239 /// Scroll any split/panel that displays `buffer_id` so the given
3240 /// line is visible in the viewport. Unlike `ScrollToLineCenter` this
3241 /// does not require a split id — it walks all splits (including
3242 /// inner panels of a buffer group) and updates every viewport that
3243 /// shows this buffer. Line is 0-indexed.
3244 ScrollBufferToLine {
3245 /// Buffer ID to scroll
3246 buffer_id: BufferId,
3247 /// Line number to bring into view (0-indexed)
3248 line: usize,
3249 },
3250
3251 /// Set the global editor mode (for modal editing like vi mode)
3252 /// When set, the mode's keybindings take precedence over normal editing
3253 SetEditorMode {
3254 /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
3255 mode: Option<String>,
3256 },
3257
3258 /// Show an action popup with buttons for user interaction
3259 /// When the user selects an action, the ActionPopupResult hook is fired
3260 ShowActionPopup {
3261 /// Unique identifier for the popup (used in ActionPopupResult)
3262 popup_id: String,
3263 /// Title text for the popup
3264 title: String,
3265 /// Body message (supports basic formatting)
3266 message: String,
3267 /// Action buttons to display
3268 actions: Vec<ActionPopupAction>,
3269 },
3270
3271 /// Contribute (or replace, or clear) a set of menu rows for the
3272 /// LSP-Servers popup (the popup opened by clicking the LSP
3273 /// indicator). Each plugin owns its own slice keyed by
3274 /// `plugin_id`; passing an empty `items` clears that slice.
3275 ///
3276 /// Rationale: previously plugins reacting to `lsp_status_clicked`
3277 /// pushed their own separate action popup via `ShowActionPopup`,
3278 /// which stacked over the built-in LSP-Servers popup and created
3279 /// the UX conflict in PR #1941. This command lets plugins
3280 /// contribute rows that merge into the existing popup instead.
3281 /// Selecting a contributed row fires `action_popup_result` with
3282 /// `popup_id = "lsp_status"` and `action_id =
3283 /// "{plugin_id}|{id}"`.
3284 SetLspMenuContributions {
3285 /// Stable plugin identifier used both as the namespace for
3286 /// this slice of contributions and as the prefix of the
3287 /// resulting `action_popup_result.action_id`.
3288 plugin_id: String,
3289 /// Language whose LSP-Servers popup should display these
3290 /// rows (e.g. "rust", "python").
3291 language: String,
3292 /// The rows to install. Empty clears any previous
3293 /// contribution from this `plugin_id` for this `language`.
3294 items: Vec<LspMenuItem>,
3295 },
3296
3297 /// Disable LSP for a specific language and persist to config
3298 DisableLspForLanguage {
3299 /// The language to disable LSP for (e.g., "python", "rust")
3300 language: String,
3301 },
3302
3303 /// Restart LSP server for a specific language
3304 RestartLspForLanguage {
3305 /// The language to restart LSP for (e.g., "python", "rust")
3306 language: String,
3307 },
3308
3309 /// Set the workspace root URI for a specific language's LSP server
3310 /// This allows plugins to specify project roots (e.g., directory containing .csproj)
3311 /// If the LSP is already running, it will be restarted with the new root
3312 SetLspRootUri {
3313 /// The language to set root URI for (e.g., "csharp", "rust")
3314 language: String,
3315 /// The root URI (file:// URL format)
3316 uri: String,
3317 },
3318
3319 /// Create a scroll sync group for anchor-based synchronized scrolling
3320 /// Used for side-by-side diff views where two panes need to scroll together
3321 /// The plugin provides the group ID (must be unique per plugin)
3322 CreateScrollSyncGroup {
3323 /// Plugin-assigned group ID
3324 group_id: u32,
3325 /// The left (primary) split - scroll position is tracked in this split's line space
3326 left_split: SplitId,
3327 /// The right (secondary) split - position is derived from anchors
3328 right_split: SplitId,
3329 },
3330
3331 /// Set sync anchors for a scroll sync group
3332 /// Anchors map corresponding line numbers between left and right buffers
3333 SetScrollSyncAnchors {
3334 /// The group ID returned by CreateScrollSyncGroup
3335 group_id: u32,
3336 /// List of (left_line, right_line) pairs marking corresponding positions
3337 anchors: Vec<(usize, usize)>,
3338 },
3339
3340 /// Remove a scroll sync group
3341 RemoveScrollSyncGroup {
3342 /// The group ID returned by CreateScrollSyncGroup
3343 group_id: u32,
3344 },
3345
3346 /// Save a buffer to a specific file path
3347 /// Used by :w filename command to save unnamed buffers or save-as
3348 SaveBufferToPath {
3349 /// Buffer ID to save
3350 buffer_id: BufferId,
3351 /// Path to save to
3352 path: PathBuf,
3353 },
3354
3355 /// Load a plugin from a file path
3356 /// The plugin will be initialized and start receiving events
3357 LoadPlugin {
3358 /// Path to the plugin file (.ts or .js)
3359 path: PathBuf,
3360 /// Callback ID for async response (success/failure)
3361 callback_id: JsCallbackId,
3362 },
3363
3364 /// Unload a plugin by name
3365 /// The plugin will stop receiving events and be removed from memory
3366 UnloadPlugin {
3367 /// Plugin name (as registered)
3368 name: String,
3369 /// Callback ID for async response (success/failure)
3370 callback_id: JsCallbackId,
3371 },
3372
3373 /// Reload a plugin by name (unload + load)
3374 /// Useful for development when plugin code changes
3375 ReloadPlugin {
3376 /// Plugin name (as registered)
3377 name: String,
3378 /// Callback ID for async response (success/failure)
3379 callback_id: JsCallbackId,
3380 },
3381
3382 /// List all loaded plugins
3383 /// Returns plugin info (name, path, enabled) for all loaded plugins
3384 ListPlugins {
3385 /// Callback ID for async response (JSON array of plugin info)
3386 callback_id: JsCallbackId,
3387 },
3388
3389 /// Reload the theme registry from disk
3390 /// Call this after installing a theme package or saving a new theme.
3391 /// If `apply_theme` is set, apply that theme immediately after reloading.
3392 ReloadThemes { apply_theme: Option<String> },
3393
3394 /// Register a TextMate grammar file for a language
3395 /// The grammar will be added to pending_grammars until ReloadGrammars is called
3396 RegisterGrammar {
3397 /// Language identifier (e.g., "elixir", "zig")
3398 language: String,
3399 /// Path to the grammar file (.sublime-syntax or .tmLanguage)
3400 grammar_path: String,
3401 /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
3402 extensions: Vec<String>,
3403 },
3404
3405 /// Register language configuration (comment prefix, indentation, formatter)
3406 /// This is applied immediately to the runtime config
3407 RegisterLanguageConfig {
3408 /// Language identifier (e.g., "elixir")
3409 language: String,
3410 /// Language configuration
3411 config: LanguagePackConfig,
3412 },
3413
3414 /// Register an LSP server for a language
3415 /// This is applied immediately to the LSP manager and runtime config
3416 RegisterLspServer {
3417 /// Language identifier (e.g., "elixir")
3418 language: String,
3419 /// LSP server configuration
3420 config: LspServerPackConfig,
3421 },
3422
3423 /// Reload the grammar registry to apply registered grammars (async)
3424 /// Call this after registering one or more grammars to rebuild the syntax set.
3425 /// The callback is resolved when the background grammar build completes.
3426 ReloadGrammars { callback_id: JsCallbackId },
3427
3428 // ==================== Terminal Commands ====================
3429 /// Create a new terminal in a split (async, returns TerminalResult)
3430 /// This spawns a PTY-backed terminal that plugins can write to and read from.
3431 CreateTerminal {
3432 /// Working directory for the terminal (defaults to editor cwd)
3433 cwd: Option<String>,
3434 /// Split direction ("horizontal" or "vertical"), default vertical
3435 direction: Option<String>,
3436 /// Split ratio (0.0 to 1.0), default 0.5
3437 ratio: Option<f32>,
3438 /// Whether to focus the new terminal split (default true)
3439 focus: Option<bool>,
3440 /// Whether this terminal survives editor restarts. When false, the
3441 /// terminal is excluded from workspace serialization and its backing
3442 /// file is kept unique-per-spawn so no scrollback from a prior run
3443 /// leaks in. Plugin-created terminals default to `false` since they
3444 /// are typically one-off tool UIs (rebuilds, exec shells, etc.).
3445 persistent: bool,
3446 /// Optional session id to attach the new terminal buffer to.
3447 /// `None` (default) attaches to the active session at creation
3448 /// time — the historical behaviour. `Some(id)` lets Orchestrator
3449 /// (and any plugin spawning agents in worktrees) attach the
3450 /// terminal to its target session without diving first; the
3451 /// terminal's split is created in that session's stashed split
3452 /// tree, and the buffer is added to the target session's
3453 /// `Session.buffers` membership rather than the active one's.
3454 /// Falls back to active session if the id is unknown.
3455 #[serde(default)]
3456 window_id: Option<WindowId>,
3457 /// Argv to spawn directly in the PTY in lieu of the host's
3458 /// configured shell. See `CreateTerminalOptions::command` for
3459 /// the full semantics — `None` keeps the shell-and-type
3460 /// behaviour, `Some(argv)` runs `argv` as the PTY child.
3461 #[serde(default)]
3462 command: Option<Vec<String>>,
3463 /// Tab title override. Defaults to `command[0]` (when
3464 /// `command` is set) or `"Terminal N"` (when it isn't).
3465 /// See `CreateTerminalOptions::title`.
3466 #[serde(default)]
3467 title: Option<String>,
3468 /// Callback ID for async response
3469 request_id: u64,
3470 },
3471
3472 /// Send input data to a terminal by its terminal ID
3473 SendTerminalInput {
3474 /// The terminal ID (from TerminalResult)
3475 terminal_id: TerminalId,
3476 /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
3477 data: String,
3478 },
3479
3480 /// Close a terminal by its terminal ID
3481 CloseTerminal {
3482 /// The terminal ID to close
3483 terminal_id: TerminalId,
3484 },
3485
3486 /// Send `signal` to every process group tracked by the
3487 /// window `id`. `signal` is one of `"SIGTERM"` / `"SIGKILL"`
3488 /// / `"SIGINT"` / `"SIGHUP"`; the window's authority
3489 /// determines the actual delivery mechanism (local
3490 /// `kill(-pgid, …)` on host, `docker exec kill …` for
3491 /// container authorities, SSH agent for remote ones —
3492 /// see `app/window/process_group.rs`). Idempotent across
3493 /// already-exited groups: callers can retry safely.
3494 SignalWindow { id: WindowId, signal: String },
3495
3496 /// Project-wide grep search (async)
3497 /// Searches all project files via FileSystem trait, respecting .gitignore.
3498 /// For open buffers with dirty edits, searches the buffer's piece tree.
3499 GrepProject {
3500 /// Search pattern (literal string)
3501 pattern: String,
3502 /// Whether the pattern is a fixed string (true) or regex (false)
3503 fixed_string: bool,
3504 /// Whether the search is case-sensitive
3505 case_sensitive: bool,
3506 /// Maximum number of results to return
3507 max_results: usize,
3508 /// Whether to match whole words only
3509 whole_words: bool,
3510 /// Callback ID for async response
3511 callback_id: JsCallbackId,
3512 },
3513
3514 /// Project-wide streaming search using a pull-based handle.
3515 ///
3516 /// The plugin allocates `handle_id` and registers an `Arc<SearchHandleState>`
3517 /// in the shared `SearchHandleRegistry` before sending this command. The
3518 /// editor's searcher tasks look up the same entry and write matches
3519 /// directly into its `pending` vec — no per-chunk JS dispatch. The plugin
3520 /// drains state via `editor._searchHandleTake(handle_id)` at its own pace.
3521 BeginSearch {
3522 /// Search pattern
3523 pattern: String,
3524 /// Whether the pattern is a fixed string (true) or regex (false)
3525 fixed_string: bool,
3526 /// Whether the search is case-sensitive
3527 case_sensitive: bool,
3528 /// Maximum number of results before the search self-truncates
3529 max_results: usize,
3530 /// Whether to match whole words only
3531 whole_words: bool,
3532 /// Handle ID — key into the shared `SearchHandleRegistry`
3533 handle_id: u64,
3534 },
3535
3536 /// Replace matches in a buffer (async)
3537 /// Opens the file if not already open, applies edits through the buffer model,
3538 /// groups as a single undo action, and saves via FileSystem trait.
3539 ReplaceInBuffer {
3540 /// File path to edit (will open if not already in a buffer)
3541 file_path: PathBuf,
3542 /// Matches to replace, each is (byte_offset, length)
3543 matches: Vec<(usize, usize)>,
3544 /// Replacement text
3545 replacement: String,
3546 /// Callback ID for async response
3547 callback_id: JsCallbackId,
3548 },
3549
3550 /// Install a new authority.
3551 ///
3552 /// Authority is opaque to core. The payload is a tagged JSON object
3553 /// (filesystem kind + spawner kind + terminal wrapper + display
3554 /// label) that `fresh-editor` deserializes into its concrete
3555 /// `AuthorityPayload` type. Using `serde_json::Value` here keeps
3556 /// fresh-core from growing backend-specific knowledge; see
3557 /// `crates/fresh-editor/src/services/authority/mod.rs` for the
3558 /// canonical schema.
3559 ///
3560 /// Fire-and-forget: the transition piggy-backs on the existing
3561 /// editor restart flow, so the plugin that sent this command will
3562 /// be re-loaded as part of the restart. Any follow-up work the
3563 /// plugin wants to do after the switch belongs in its post-restart
3564 /// init code, not in a callback here.
3565 SetAuthority {
3566 #[ts(type = "unknown")]
3567 payload: JsonValue,
3568 },
3569
3570 /// Restore the default local authority. Same semantics as
3571 /// `SetAuthority` with a local payload — triggers an editor
3572 /// restart.
3573 ClearAuthority,
3574
3575 /// Activate an environment: set the live env provider's recipe (an
3576 /// activation shell `snippet` run in `dir`). Re-evaluated on demand on the
3577 /// active backend and applied to every spawn — no authority rebuild. Only
3578 /// honored when the workspace is Trusted (it runs repo-controlled code).
3579 SetEnv {
3580 snippet: String,
3581 #[serde(default)]
3582 dir: Option<String>,
3583 },
3584
3585 /// Deactivate the environment — clear the live provider so spawns use the
3586 /// inherited environment again.
3587 ClearEnv,
3588
3589 /// Override the Remote Indicator's displayed state for the rest
3590 /// of the current editor session (until a restart, or until the
3591 /// plugin sends another override / `ClearRemoteIndicatorState`).
3592 ///
3593 /// The derived state — computed from the active authority's
3594 /// connection info — keeps running underneath and is what the
3595 /// indicator shows whenever an override is not in effect.
3596 /// Plugins use this to surface lifecycle states that have no
3597 /// authority-level truth yet (e.g. "Connecting" during
3598 /// `devcontainer up`, "FailedAttach" after a non-zero exit).
3599 ///
3600 /// `state` is a tagged enum keyed by `kind`:
3601 /// - `{ "kind": "local" }`
3602 /// - `{ "kind": "connecting", "label": "..." }`
3603 /// - `{ "kind": "connected", "label": "..." }`
3604 /// - `{ "kind": "failed_attach", "error": "..." }`
3605 /// - `{ "kind": "disconnected", "label": "..." }`
3606 ///
3607 /// The exact schema lives in
3608 /// `crates/fresh-editor/src/view/ui/status_bar.rs`; fresh-core
3609 /// takes it opaquely so new variants can land without touching
3610 /// core plumbing.
3611 SetRemoteIndicatorState {
3612 #[ts(type = "unknown")]
3613 state: JsonValue,
3614 },
3615
3616 /// Drop any active Remote Indicator override and fall back to
3617 /// the authority-derived state. Safe to call without a prior
3618 /// `SetRemoteIndicatorState`.
3619 ClearRemoteIndicatorState,
3620
3621 /// Spawn a process on the host, regardless of the currently
3622 /// installed authority.
3623 ///
3624 /// Intended for plugin internals that must run host-side work
3625 /// (e.g. `devcontainer up`) before installing an authority that
3626 /// would otherwise route the spawn elsewhere. Behaves like
3627 /// `SpawnProcess` but always uses `LocalProcessSpawner`.
3628 ///
3629 /// The TS-side handle exposes `.kill()` on the returned
3630 /// `ProcessHandle`, serviced by `KillHostProcess` below — this
3631 /// lets callers abort a long-running host spawn (e.g.
3632 /// `devcontainer up`) via a user action like "Cancel Startup".
3633 SpawnHostProcess {
3634 command: String,
3635 args: Vec<String>,
3636 cwd: Option<String>,
3637 callback_id: JsCallbackId,
3638 },
3639
3640 /// Cancel a host-side process previously started via
3641 /// `SpawnHostProcess`. `process_id` is the callback id returned
3642 /// by `spawnHostProcess` (the TS handle stores it and forwards
3643 /// when the caller invokes `.kill()`).
3644 ///
3645 /// No-op when the id is unknown — the process may have already
3646 /// exited, or the caller may hold a stale handle. SIGKILL on
3647 /// Unix per `tokio::process::Child::start_kill`; children of the
3648 /// killed process may leak (see Q-C2 in
3649 /// `DEVCONTAINER_SPEC_GAP_PLAN.md`).
3650 KillHostProcess { process_id: u64 },
3651
3652 /// Mount a declarative widget panel inside an existing virtual
3653 /// buffer. The host renders the `WidgetSpec` and writes the
3654 /// resulting text-property entries into the buffer. The
3655 /// `panel_id` is plugin-allocated (any unique u64 for that
3656 /// plugin) and is used to address the panel for later
3657 /// `UpdateWidgetPanel` / `UnmountWidgetPanel` calls.
3658 ///
3659 /// See `docs/internal/plugin-widget-library-design.md`.
3660 MountWidgetPanel {
3661 panel_id: u64,
3662 buffer_id: BufferId,
3663 spec: WidgetSpec,
3664 },
3665
3666 /// Replace the spec of a previously-mounted widget panel.
3667 /// The reconciler diffs against the previous spec and applies
3668 /// the minimum mutation; widget instance state is preserved on
3669 /// nodes whose `key` matches.
3670 UpdateWidgetPanel { panel_id: u64, spec: WidgetSpec },
3671
3672 /// Tear down a widget panel. Subsequent `UpdateWidgetPanel`
3673 /// calls for the same `panel_id` are no-ops.
3674 UnmountWidgetPanel { panel_id: u64 },
3675
3676 /// Route a keystroke / nav action to the panel's currently
3677 /// focused widget. The plugin's `defineMode` bindings dispatch
3678 /// here for keys that should be handled by the widget layer
3679 /// (Tab cycle, Enter to activate, Up/Down to navigate a List,
3680 /// Backspace / arrows / printable input to edit a TextInput).
3681 /// See `WidgetAction` for the action shapes.
3682 WidgetCommand { panel_id: u64, action: WidgetAction },
3683
3684 /// Apply a targeted mutation to a mounted widget panel
3685 /// without re-transmitting the full spec. The IPC fast path
3686 /// for hot-path updates (typing, selection moves, partial
3687 /// list refreshes). See `WidgetMutation` for the shapes.
3688 WidgetMutate {
3689 panel_id: u64,
3690 mutation: WidgetMutation,
3691 },
3692
3693 /// Mount a declarative widget panel as a centered floating
3694 /// overlay rather than into a virtual buffer. `width_pct` and
3695 /// `height_pct` size the overlay rect relative to the terminal
3696 /// (clamped 1..=100). Only one floating widget panel may be
3697 /// mounted at a time; a second `MountFloatingWidget` replaces
3698 /// any existing one.
3699 MountFloatingWidget {
3700 panel_id: u64,
3701 spec: WidgetSpec,
3702 width_pct: u8,
3703 height_pct: u8,
3704 },
3705
3706 /// Replace the spec of the currently-mounted floating widget
3707 /// panel. No-op when no floating panel is mounted, or when the
3708 /// `panel_id` doesn't match the mounted one.
3709 UpdateFloatingWidget { panel_id: u64, spec: WidgetSpec },
3710
3711 /// Tear down the floating widget panel. No-op when no floating
3712 /// panel is mounted, or when the `panel_id` doesn't match.
3713 UnmountFloatingWidget { panel_id: u64 },
3714}
3715
3716impl PluginCommand {
3717 /// Extract the enum variant name from the Debug representation.
3718 pub fn debug_variant_name(&self) -> String {
3719 let dbg = format!("{:?}", self);
3720 dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
3721 }
3722}
3723
3724// =============================================================================
3725// Language Pack Configuration Types
3726// =============================================================================
3727
3728/// Language configuration for language packs
3729///
3730/// This is a simplified version of the full LanguageConfig, containing only
3731/// the fields that can be set via the plugin API.
3732#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
3733#[serde(rename_all = "camelCase")]
3734#[ts(export)]
3735pub struct LanguagePackConfig {
3736 /// Comment prefix for line comments (e.g., "//" or "#")
3737 #[serde(default)]
3738 pub comment_prefix: Option<String>,
3739
3740 /// Block comment start marker (e.g., slash-star)
3741 #[serde(default)]
3742 pub block_comment_start: Option<String>,
3743
3744 /// Block comment end marker (e.g., star-slash)
3745 #[serde(default)]
3746 pub block_comment_end: Option<String>,
3747
3748 /// Whether to use tabs instead of spaces for indentation
3749 #[serde(default)]
3750 pub use_tabs: Option<bool>,
3751
3752 /// Tab size (number of spaces per tab level)
3753 #[serde(default)]
3754 pub tab_size: Option<usize>,
3755
3756 /// Whether auto-indent is enabled
3757 #[serde(default)]
3758 pub auto_indent: Option<bool>,
3759
3760 /// Whether to show whitespace tab indicators (→) for this language
3761 /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
3762 #[serde(default)]
3763 pub show_whitespace_tabs: Option<bool>,
3764
3765 /// Formatter configuration
3766 #[serde(default)]
3767 pub formatter: Option<FormatterPackConfig>,
3768}
3769
3770/// Formatter configuration for language packs
3771#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3772#[serde(rename_all = "camelCase")]
3773#[ts(export)]
3774pub struct FormatterPackConfig {
3775 /// Command to run (e.g., "prettier", "rustfmt")
3776 pub command: String,
3777
3778 /// Arguments to pass to the formatter
3779 #[serde(default)]
3780 pub args: Vec<String>,
3781}
3782
3783/// Process resource limits for LSP servers
3784#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3785#[serde(rename_all = "camelCase")]
3786#[ts(export)]
3787pub struct ProcessLimitsPackConfig {
3788 /// Maximum memory usage as percentage of total system memory (null = no limit)
3789 #[serde(default)]
3790 pub max_memory_percent: Option<u32>,
3791
3792 /// Maximum CPU usage as percentage of total CPU (null = no limit)
3793 #[serde(default)]
3794 pub max_cpu_percent: Option<u32>,
3795
3796 /// Enable resource limiting
3797 #[serde(default)]
3798 pub enabled: Option<bool>,
3799}
3800
3801/// LSP server configuration for language packs
3802#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3803#[serde(rename_all = "camelCase")]
3804#[ts(export)]
3805pub struct LspServerPackConfig {
3806 /// Command to start the LSP server
3807 pub command: String,
3808
3809 /// Arguments to pass to the command
3810 #[serde(default)]
3811 pub args: Vec<String>,
3812
3813 /// Whether to auto-start the server when a matching file is opened
3814 #[serde(default)]
3815 pub auto_start: Option<bool>,
3816
3817 /// LSP initialization options
3818 #[serde(default)]
3819 #[ts(type = "Record<string, unknown> | null")]
3820 pub initialization_options: Option<JsonValue>,
3821
3822 /// Process resource limits (memory and CPU)
3823 #[serde(default)]
3824 pub process_limits: Option<ProcessLimitsPackConfig>,
3825}
3826
3827/// Hunk status for Review Diff
3828#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
3829#[ts(export)]
3830pub enum HunkStatus {
3831 Pending,
3832 Staged,
3833 Discarded,
3834}
3835
3836/// A high-level hunk directive for the Review Diff tool
3837#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3838#[ts(export)]
3839pub struct ReviewHunk {
3840 pub id: String,
3841 pub file: String,
3842 pub context_header: String,
3843 pub status: HunkStatus,
3844 /// 0-indexed line range in the base (HEAD) version
3845 pub base_range: Option<(usize, usize)>,
3846 /// 0-indexed line range in the modified (Working) version
3847 pub modified_range: Option<(usize, usize)>,
3848}
3849
3850/// Action button for action popups
3851#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3852#[serde(deny_unknown_fields)]
3853#[ts(export, rename = "TsActionPopupAction")]
3854pub struct ActionPopupAction {
3855 /// Unique action identifier (returned in ActionPopupResult)
3856 pub id: String,
3857 /// Display text for the button (can include command hints)
3858 pub label: String,
3859}
3860
3861/// Plugin-contributed row in the LSP-Servers popup.
3862/// See `PluginCommand::SetLspMenuContributions`.
3863#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3864#[serde(deny_unknown_fields)]
3865#[ts(export, rename = "TsLspMenuItem")]
3866pub struct LspMenuItem {
3867 /// Stable identifier used as the `action_id` in the resulting
3868 /// `action_popup_result` event (prefixed by `{plugin_id}|`).
3869 pub id: String,
3870 /// Display label shown in the popup row.
3871 pub label: String,
3872}
3873
3874/// Options for showActionPopup
3875#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3876#[serde(deny_unknown_fields)]
3877#[ts(export)]
3878pub struct ActionPopupOptions {
3879 /// Unique identifier for the popup (used in ActionPopupResult)
3880 pub id: String,
3881 /// Title text for the popup
3882 pub title: String,
3883 /// Body message (supports basic formatting)
3884 pub message: String,
3885 /// Action buttons to display
3886 pub actions: Vec<ActionPopupAction>,
3887}
3888
3889/// Syntax highlight span for a buffer range
3890#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3891#[ts(export)]
3892pub struct TsHighlightSpan {
3893 pub start: u32,
3894 pub end: u32,
3895 #[ts(type = "[number, number, number]")]
3896 pub color: (u8, u8, u8),
3897 pub bold: bool,
3898 pub italic: bool,
3899}
3900
3901/// Result from spawning a process with spawnProcess
3902#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3903#[ts(export)]
3904pub struct SpawnResult {
3905 /// Complete stdout as string
3906 pub stdout: String,
3907 /// Complete stderr as string
3908 pub stderr: String,
3909 /// Process exit code (0 usually means success, -1 if killed)
3910 pub exit_code: i32,
3911}
3912
3913/// Result from spawning a background process
3914#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3915#[ts(export)]
3916pub struct BackgroundProcessResult {
3917 /// Unique process ID for later reference
3918 #[ts(type = "number")]
3919 pub process_id: u64,
3920 /// Process exit code (0 usually means success, -1 if killed)
3921 /// Only present when the process has exited
3922 pub exit_code: i32,
3923}
3924
3925/// A single match from project-wide grep
3926#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3927#[serde(rename_all = "camelCase")]
3928#[ts(export, rename_all = "camelCase")]
3929pub struct GrepMatch {
3930 /// Absolute file path
3931 pub file: String,
3932 /// Buffer ID if the file is open (0 if not)
3933 #[ts(type = "number")]
3934 pub buffer_id: usize,
3935 /// Byte offset of match start in the file/buffer content
3936 #[ts(type = "number")]
3937 pub byte_offset: usize,
3938 /// Match length in bytes
3939 #[ts(type = "number")]
3940 pub length: usize,
3941 /// 1-indexed line number
3942 #[ts(type = "number")]
3943 pub line: usize,
3944 /// 1-indexed column number
3945 #[ts(type = "number")]
3946 pub column: usize,
3947 /// The matched line content (for display)
3948 pub context: String,
3949}
3950
3951/// Per-call result from `SearchHandle.take()` — the matches accumulated since
3952/// the previous call plus terminal-state flags.
3953#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3954#[serde(rename_all = "camelCase")]
3955#[ts(export, rename_all = "camelCase")]
3956pub struct SearchTakeResult {
3957 /// Matches discovered since the previous take()
3958 pub matches: Vec<GrepMatch>,
3959 /// Whether the producer has finished (no more matches will arrive)
3960 pub done: bool,
3961 /// Total number of matches the producer has emitted across all batches
3962 /// (including ones already drained on prior take() calls)
3963 #[ts(type = "number")]
3964 pub total_seen: usize,
3965 /// Whether the producer stopped early because it hit `maxResults`
3966 pub truncated: bool,
3967 /// Producer error, if any (e.g., invalid regex). When set, `done` is also true.
3968 #[ts(optional, type = "string | null")]
3969 pub error: Option<String>,
3970}
3971
3972/// Inner state of a streaming search, written by the host's parallel
3973/// searchers and drained by the plugin via `SearchHandle.take()`. The plugin
3974/// observes deltas (`mem::take` on `pending`) at its own cadence; producers
3975/// write at full speed without per-chunk dispatches.
3976#[derive(Debug, Default)]
3977pub struct SearchState {
3978 /// Matches accumulated since the consumer's last drain
3979 pub pending: Vec<GrepMatch>,
3980 /// Total matches the producer has emitted across the search's lifetime
3981 pub total_seen: usize,
3982 /// Set when the producer stopped early due to hitting max_results
3983 pub truncated: bool,
3984 /// Set when the producer is fully done — no more writes will occur
3985 pub done: bool,
3986 /// Producer error, if any (final state)
3987 pub error: Option<String>,
3988}
3989
3990/// A search handle's shared state plus its cancellation flag. Owned by an
3991/// `Arc` so producers (host searcher tasks) and consumers (the JS plugin via
3992/// the registry) can both reference it.
3993#[derive(Debug)]
3994pub struct SearchHandleState {
3995 pub state: std::sync::Mutex<SearchState>,
3996 pub cancel: std::sync::atomic::AtomicBool,
3997}
3998
3999impl SearchHandleState {
4000 pub fn new() -> Self {
4001 Self {
4002 state: std::sync::Mutex::new(SearchState::default()),
4003 cancel: std::sync::atomic::AtomicBool::new(false),
4004 }
4005 }
4006}
4007
4008impl Default for SearchHandleState {
4009 fn default() -> Self {
4010 Self::new()
4011 }
4012}
4013
4014/// Registry mapping a handle ID to its shared `SearchHandleState`. Shared
4015/// between the JS thread (where `JsEditorApi` registers handles and serves
4016/// `take()`/`cancel()`) and the editor thread (where the host's searcher
4017/// tasks write into the same state).
4018pub type SearchHandleRegistry = Arc<std::sync::Mutex<HashMap<u64, Arc<SearchHandleState>>>>;
4019
4020/// Result from replacing matches in a buffer
4021#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4022#[serde(rename_all = "camelCase")]
4023#[ts(export, rename_all = "camelCase")]
4024pub struct ReplaceResult {
4025 /// Number of replacements made
4026 #[ts(type = "number")]
4027 pub replacements: usize,
4028 /// Buffer ID of the edited buffer
4029 #[ts(type = "number")]
4030 pub buffer_id: usize,
4031}
4032
4033/// Entry for virtual buffer content with optional text properties (JS API version)
4034#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4035#[serde(deny_unknown_fields, rename_all = "camelCase")]
4036#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
4037pub struct JsTextPropertyEntry {
4038 /// Text content for this entry
4039 pub text: String,
4040 /// Optional properties attached to this text (e.g., file path, line number)
4041 #[serde(default)]
4042 #[ts(optional, type = "Record<string, unknown>")]
4043 pub properties: Option<HashMap<String, JsonValue>>,
4044 /// Optional whole-entry styling
4045 #[serde(default)]
4046 #[ts(optional, type = "Partial<OverlayOptions>")]
4047 pub style: Option<OverlayOptions>,
4048 /// Optional sub-range styling within this entry
4049 #[serde(default)]
4050 #[ts(optional)]
4051 pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
4052 /// See `TextPropertyEntry::pad_to_chars`.
4053 #[serde(default)]
4054 #[ts(optional)]
4055 pub pad_to_chars: Option<u32>,
4056 /// See `TextPropertyEntry::truncate_to_chars`.
4057 #[serde(default)]
4058 #[ts(optional)]
4059 pub truncate_to_chars: Option<u32>,
4060 /// See `TextPropertyEntry::segments`.
4061 #[serde(default)]
4062 #[ts(optional)]
4063 pub segments: Option<Vec<crate::text_property::StyledSegment>>,
4064}
4065
4066/// Directory entry returned by readDir
4067#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4068#[ts(export)]
4069pub struct DirEntry {
4070 /// File/directory name
4071 pub name: String,
4072 /// True if this is a file
4073 pub is_file: bool,
4074 /// True if this is a directory
4075 pub is_dir: bool,
4076}
4077
4078/// Position in a document (line and character)
4079#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4080#[ts(export)]
4081pub struct JsPosition {
4082 /// Zero-indexed line number
4083 pub line: u32,
4084 /// Zero-indexed character offset
4085 pub character: u32,
4086}
4087
4088/// Range in a document (start and end positions)
4089#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4090#[ts(export)]
4091pub struct JsRange {
4092 /// Start position
4093 pub start: JsPosition,
4094 /// End position
4095 pub end: JsPosition,
4096}
4097
4098/// Diagnostic from LSP
4099#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4100#[ts(export)]
4101pub struct JsDiagnostic {
4102 /// Document URI
4103 pub uri: String,
4104 /// Diagnostic message
4105 pub message: String,
4106 /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
4107 pub severity: Option<u8>,
4108 /// Range in the document
4109 pub range: JsRange,
4110 /// Source of the diagnostic (e.g., "typescript", "eslint")
4111 #[ts(optional)]
4112 pub source: Option<String>,
4113}
4114
4115/// Options for createVirtualBuffer
4116#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4117#[serde(deny_unknown_fields)]
4118#[ts(export)]
4119pub struct CreateVirtualBufferOptions {
4120 /// Buffer name (displayed in tabs/title)
4121 pub name: String,
4122 /// Mode for keybindings (e.g., "git-log", "search-results")
4123 #[serde(default)]
4124 #[ts(optional)]
4125 pub mode: Option<String>,
4126 /// Whether buffer is read-only (default: false)
4127 #[serde(default, rename = "readOnly")]
4128 #[ts(optional, rename = "readOnly")]
4129 pub read_only: Option<bool>,
4130 /// Show line numbers in gutter (default: false)
4131 #[serde(default, rename = "showLineNumbers")]
4132 #[ts(optional, rename = "showLineNumbers")]
4133 pub show_line_numbers: Option<bool>,
4134 /// Show cursor (default: true)
4135 #[serde(default, rename = "showCursors")]
4136 #[ts(optional, rename = "showCursors")]
4137 pub show_cursors: Option<bool>,
4138 /// Disable text editing (default: false)
4139 #[serde(default, rename = "editingDisabled")]
4140 #[ts(optional, rename = "editingDisabled")]
4141 pub editing_disabled: Option<bool>,
4142 /// Hide from tab bar (default: false)
4143 #[serde(default, rename = "hiddenFromTabs")]
4144 #[ts(optional, rename = "hiddenFromTabs")]
4145 pub hidden_from_tabs: Option<bool>,
4146 /// Initial content entries with optional properties
4147 #[serde(default)]
4148 #[ts(optional)]
4149 pub entries: Option<Vec<JsTextPropertyEntry>>,
4150}
4151
4152/// Options for createVirtualBufferInSplit
4153#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4154#[serde(deny_unknown_fields)]
4155#[ts(export)]
4156pub struct CreateVirtualBufferInSplitOptions {
4157 /// Buffer name (displayed in tabs/title)
4158 pub name: String,
4159 /// Mode for keybindings (e.g., "git-log", "search-results")
4160 #[serde(default)]
4161 #[ts(optional)]
4162 pub mode: Option<String>,
4163 /// Whether buffer is read-only (default: false)
4164 #[serde(default, rename = "readOnly")]
4165 #[ts(optional, rename = "readOnly")]
4166 pub read_only: Option<bool>,
4167 /// Split ratio 0.0-1.0 (default: 0.5)
4168 #[serde(default)]
4169 #[ts(optional)]
4170 pub ratio: Option<f32>,
4171 /// Split direction: "horizontal" or "vertical"
4172 #[serde(default)]
4173 #[ts(optional)]
4174 pub direction: Option<String>,
4175 /// Panel ID to split from
4176 #[serde(default, rename = "panelId")]
4177 #[ts(optional, rename = "panelId")]
4178 pub panel_id: Option<String>,
4179 /// Show line numbers in gutter (default: true)
4180 #[serde(default, rename = "showLineNumbers")]
4181 #[ts(optional, rename = "showLineNumbers")]
4182 pub show_line_numbers: Option<bool>,
4183 /// Show cursor (default: true)
4184 #[serde(default, rename = "showCursors")]
4185 #[ts(optional, rename = "showCursors")]
4186 pub show_cursors: Option<bool>,
4187 /// Disable text editing (default: false)
4188 #[serde(default, rename = "editingDisabled")]
4189 #[ts(optional, rename = "editingDisabled")]
4190 pub editing_disabled: Option<bool>,
4191 /// Enable line wrapping
4192 #[serde(default, rename = "lineWrap")]
4193 #[ts(optional, rename = "lineWrap")]
4194 pub line_wrap: Option<bool>,
4195 /// Place the new buffer before (left/top of) the existing content (default: false)
4196 #[serde(default)]
4197 #[ts(optional)]
4198 pub before: Option<bool>,
4199 /// Initial content entries with optional properties
4200 #[serde(default)]
4201 #[ts(optional)]
4202 pub entries: Option<Vec<JsTextPropertyEntry>>,
4203 /// Split role tag. When set to `"utility_dock"`, the dispatcher
4204 /// routes this buffer to the existing dock leaf if one exists,
4205 /// instead of creating a new split. See
4206 /// `docs/internal/tui-editor-layout-design.md` Section 2.
4207 #[serde(default)]
4208 #[ts(optional)]
4209 pub role: Option<String>,
4210}
4211
4212/// Options for createVirtualBufferInExistingSplit
4213#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4214#[serde(deny_unknown_fields)]
4215#[ts(export)]
4216pub struct CreateVirtualBufferInExistingSplitOptions {
4217 /// Buffer name (displayed in tabs/title)
4218 pub name: String,
4219 /// Target split ID (required)
4220 #[serde(rename = "splitId")]
4221 #[ts(rename = "splitId")]
4222 pub split_id: usize,
4223 /// Mode for keybindings (e.g., "git-log", "search-results")
4224 #[serde(default)]
4225 #[ts(optional)]
4226 pub mode: Option<String>,
4227 /// Whether buffer is read-only (default: false)
4228 #[serde(default, rename = "readOnly")]
4229 #[ts(optional, rename = "readOnly")]
4230 pub read_only: Option<bool>,
4231 /// Show line numbers in gutter (default: true)
4232 #[serde(default, rename = "showLineNumbers")]
4233 #[ts(optional, rename = "showLineNumbers")]
4234 pub show_line_numbers: Option<bool>,
4235 /// Show cursor (default: true)
4236 #[serde(default, rename = "showCursors")]
4237 #[ts(optional, rename = "showCursors")]
4238 pub show_cursors: Option<bool>,
4239 /// Disable text editing (default: false)
4240 #[serde(default, rename = "editingDisabled")]
4241 #[ts(optional, rename = "editingDisabled")]
4242 pub editing_disabled: Option<bool>,
4243 /// Enable line wrapping
4244 #[serde(default, rename = "lineWrap")]
4245 #[ts(optional, rename = "lineWrap")]
4246 pub line_wrap: Option<bool>,
4247 /// Initial content entries with optional properties
4248 #[serde(default)]
4249 #[ts(optional)]
4250 pub entries: Option<Vec<JsTextPropertyEntry>>,
4251}
4252
4253/// Options for createTerminal
4254#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4255#[serde(deny_unknown_fields)]
4256#[ts(export)]
4257pub struct CreateTerminalOptions {
4258 /// Working directory for the terminal (defaults to editor cwd)
4259 #[serde(default)]
4260 #[ts(optional)]
4261 pub cwd: Option<String>,
4262 /// Split direction: "horizontal" or "vertical" (default: "vertical")
4263 #[serde(default)]
4264 #[ts(optional)]
4265 pub direction: Option<String>,
4266 /// Split ratio 0.0-1.0 (default: 0.5)
4267 #[serde(default)]
4268 #[ts(optional)]
4269 pub ratio: Option<f32>,
4270 /// Whether to focus the new terminal split (default: true)
4271 #[serde(default)]
4272 #[ts(optional)]
4273 pub focus: Option<bool>,
4274 /// Whether this terminal is part of the user's persisted workspace.
4275 /// Defaults to `false` for plugin-created terminals — they are typically
4276 /// one-off tool UIs (rebuilds, exec shells, build output) and should
4277 /// start with empty scrollback on each invocation. Set to `true` only
4278 /// when the plugin owns a terminal that the user should see restored
4279 /// across editor restarts.
4280 #[serde(default)]
4281 #[ts(optional)]
4282 pub persistent: Option<bool>,
4283 /// Optional session id to attach the new terminal buffer to.
4284 /// Defaults to the active session at creation time. Setting this
4285 /// lets Orchestrator and similar plugins spawn a terminal *into* an
4286 /// inactive session (e.g. an agent in a worktree the user hasn't
4287 /// dived into yet). The terminal's split is created in that
4288 /// session's stashed split tree; the buffer is attached to the
4289 /// target session's membership set rather than the active one's.
4290 #[serde(default, rename = "windowId")]
4291 #[ts(optional, rename = "windowId")]
4292 pub window_id: Option<WindowId>,
4293 /// Argv to spawn directly inside the PTY instead of the host's
4294 /// configured shell. `None` (default) keeps the historical
4295 /// behaviour: spawn the user's shell and let the caller type into
4296 /// it via `sendTerminalInput`. `Some([cmd, ...args])` runs that
4297 /// exact command as the PTY child — no shell middleman, so the
4298 /// process exits cleanly when the agent does and the
4299 /// terminal-buffer's `terminal_exit` plugin hook reflects the
4300 /// agent's real exit status. Used by Orchestrator so a session
4301 /// with agent `python3` is just python3 in the PTY rather than
4302 /// bash-running-python3-as-a-subshell-command.
4303 #[serde(default)]
4304 #[ts(optional)]
4305 pub command: Option<Vec<String>>,
4306 /// Tab title for the terminal buffer. Defaults to `command[0]`
4307 /// (when `command` is set) or `"Terminal N"` (the historical
4308 /// auto-numbered title). If another terminal in the same window
4309 /// already uses the requested title, the host appends `" (k)"`
4310 /// to disambiguate. Empty string is treated the same as `None`.
4311 #[serde(default)]
4312 #[ts(optional)]
4313 pub title: Option<String>,
4314}
4315
4316/// Options for `createWindowWithTerminal` — the atomic
4317/// "spawn a new editor session that hosts an agent terminal"
4318/// entry point used by Orchestrator. Bundles window creation,
4319/// dive, and terminal spawn so the new window is born with the
4320/// terminal as its seed buffer (no transient `[No Name]` tab,
4321/// no race between create-window and create-terminal completing).
4322#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4323#[serde(deny_unknown_fields, rename_all = "camelCase")]
4324#[ts(export, rename_all = "camelCase")]
4325pub struct CreateWindowWithTerminalOptions {
4326 /// Absolute path to the new session's worktree / project
4327 /// root. Relative paths are rejected (logged, no window
4328 /// created).
4329 pub root: String,
4330 /// Human-readable label for the new session. When empty,
4331 /// defaults to the basename of `root`.
4332 #[serde(default)]
4333 pub label: String,
4334 /// Working directory for the spawned terminal. Defaults to
4335 /// `root` when omitted.
4336 #[serde(default)]
4337 #[ts(optional)]
4338 pub cwd: Option<String>,
4339 /// Argv to spawn directly inside the PTY. `None` keeps the
4340 /// shell-and-type behaviour; `Some([cmd, ...args])` runs the
4341 /// command as the PTY child (used by Orchestrator so the
4342 /// agent process is the PTY's direct child).
4343 #[serde(default)]
4344 #[ts(optional)]
4345 pub command: Option<Vec<String>>,
4346 /// Tab title override. Defaults to `command[0]`'s basename
4347 /// when `command` is set, or "Terminal N" otherwise.
4348 #[serde(default)]
4349 #[ts(optional)]
4350 pub title: Option<String>,
4351}
4352
4353/// Result of `createWindowWithTerminal` — the ids of the new
4354/// window plus the terminal seeded into its split layout.
4355#[derive(Debug, Clone, Serialize, Deserialize, TS)]
4356#[serde(rename_all = "camelCase")]
4357#[ts(export, rename_all = "camelCase")]
4358pub struct SessionWithTerminalResult {
4359 /// The new window's id.
4360 #[ts(type = "number")]
4361 pub window_id: u64,
4362 /// The seeded terminal's id (for `sendTerminalInput`, etc.).
4363 #[ts(type = "number")]
4364 pub terminal_id: u64,
4365 /// The seeded terminal buffer's id.
4366 #[ts(type = "number")]
4367 pub buffer_id: u64,
4368}
4369
4370/// Result of getTextPropertiesAtCursor - array of property objects
4371///
4372/// Each element contains the properties from a text property span that overlaps
4373/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
4374#[derive(Debug, Clone, Serialize, TS)]
4375#[ts(export, type = "Array<Record<string, unknown>>")]
4376pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
4377
4378// Implement FromJs for option types using rquickjs_serde
4379#[cfg(feature = "plugins")]
4380mod fromjs_impls {
4381 use super::*;
4382 use rquickjs::{Ctx, FromJs, Value};
4383
4384 // All types that deserialize from a JS value via rquickjs_serde follow
4385 // the same 8-line pattern differing only in the type name. This macro
4386 // expands that pattern once so adding a new plugin-API type costs one line
4387 // here instead of a copy-pasted block.
4388 macro_rules! impl_from_js_via_serde {
4389 ($($T:ty),+ $(,)?) => {
4390 $(
4391 impl<'js> FromJs<'js> for $T {
4392 fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
4393 rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
4394 from: "object",
4395 to: stringify!($T),
4396 message: Some(e.to_string()),
4397 })
4398 }
4399 }
4400 )+
4401 };
4402 }
4403
4404 impl_from_js_via_serde!(
4405 JsTextPropertyEntry,
4406 CreateVirtualBufferOptions,
4407 CreateVirtualBufferInSplitOptions,
4408 CreateVirtualBufferInExistingSplitOptions,
4409 ActionSpec,
4410 ActionPopupAction,
4411 ActionPopupOptions,
4412 LspMenuItem,
4413 ViewTokenWire,
4414 ViewTokenStyle,
4415 LayoutHints,
4416 CompositeHunk,
4417 LanguagePackConfig,
4418 LspServerPackConfig,
4419 ProcessLimitsPackConfig,
4420 CreateTerminalOptions,
4421 CreateWindowWithTerminalOptions,
4422 );
4423
4424 impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
4425 fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
4426 rquickjs_serde::to_value(ctx.clone(), &self.0)
4427 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
4428 }
4429 }
4430
4431 impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
4432 fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
4433 // Two-step deserialization: rquickjs_serde cannot handle the nested
4434 // enums in this struct directly, so go via serde_json as an intermediary.
4435 let json: serde_json::Value =
4436 rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
4437 from: "object",
4438 to: "CreateCompositeBufferOptions (json)",
4439 message: Some(e.to_string()),
4440 })?;
4441 serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
4442 from: "json",
4443 to: "CreateCompositeBufferOptions",
4444 message: Some(e.to_string()),
4445 })
4446 }
4447 }
4448
4449 // ── Tests for FromJs / IntoJs impls ────────────────────────────────────
4450 //
4451 // Each impl is a one-liner that delegates to `rquickjs_serde`. A mutant
4452 // that replaces the body with `Ok(Default::default())` drops the
4453 // decoded payload on the floor. Every test below asserts a
4454 // non-defaultable field value, so the mutant cannot pass.
4455 //
4456 // Note: many of the target structs do not implement `Default`, making
4457 // those mutants unviable (they fail to compile) — cargo-mutants still
4458 // lists them as candidates. The tests below serve double-duty as
4459 // behavioural regression protection for the JS → Rust conversion layer.
4460 #[cfg(test)]
4461 mod tests {
4462 use super::*;
4463 use rquickjs::{Context, Runtime};
4464
4465 /// Run a closure within a fresh QuickJS context so that `FromJs`
4466 /// impls can be exercised end-to-end.
4467 fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
4468 let rt = Runtime::new().expect("create rquickjs runtime");
4469 let ctx = Context::full(&rt).expect("create rquickjs context");
4470 ctx.with(f)
4471 }
4472
4473 /// Evaluate a JS object literal and decode it as `T` via `FromJs`.
4474 fn eval_as<T>(src: &str) -> T
4475 where
4476 for<'js> T: rquickjs::FromJs<'js>,
4477 {
4478 with_js(|ctx| {
4479 let value: Value = ctx
4480 .eval::<Value, _>(src.as_bytes())
4481 .expect("eval JS source");
4482 T::from_js(&ctx, value).expect("from_js decode")
4483 })
4484 }
4485
4486 #[test]
4487 fn js_text_property_entry_decodes_text_and_properties() {
4488 let got: JsTextPropertyEntry =
4489 eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
4490 assert_eq!(got.text, "hello");
4491 let props = got.properties.expect("properties present");
4492 assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
4493 }
4494
4495 #[test]
4496 fn create_virtual_buffer_options_decodes_name() {
4497 let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
4498 assert_eq!(got.name, "logs");
4499 assert_eq!(got.read_only, Some(true));
4500 }
4501
4502 #[test]
4503 fn create_virtual_buffer_in_split_options_decodes_ratio() {
4504 let got: CreateVirtualBufferInSplitOptions =
4505 eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
4506 assert_eq!(got.name, "diag");
4507 assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
4508 assert_eq!(got.direction.as_deref(), Some("horizontal"));
4509 }
4510
4511 #[test]
4512 fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
4513 let got: CreateVirtualBufferInExistingSplitOptions =
4514 eval_as("({name: 'n', splitId: 7})");
4515 assert_eq!(got.name, "n");
4516 assert_eq!(got.split_id, 7);
4517 }
4518
4519 #[test]
4520 fn create_terminal_options_decodes_cwd_and_focus() {
4521 let got: CreateTerminalOptions =
4522 eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
4523 assert_eq!(got.cwd.as_deref(), Some("/tmp"));
4524 assert_eq!(got.direction.as_deref(), Some("vertical"));
4525 assert_eq!(got.focus, Some(false));
4526 }
4527
4528 #[test]
4529 fn action_spec_decodes_action_and_count() {
4530 let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
4531 assert_eq!(got.action, "move_word_right");
4532 assert_eq!(got.count, 5);
4533 }
4534
4535 #[test]
4536 fn action_popup_action_decodes_id_and_label() {
4537 let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
4538 assert_eq!(got.id, "ok");
4539 assert_eq!(got.label, "OK");
4540 }
4541
4542 #[test]
4543 fn action_popup_options_decodes_actions_list() {
4544 let got: ActionPopupOptions = eval_as(
4545 "({id: 'p', title: 't', message: 'm', \
4546 actions: [{id: 'ok', label: 'OK'}]})",
4547 );
4548 assert_eq!(got.id, "p");
4549 assert_eq!(got.title, "t");
4550 assert_eq!(got.message, "m");
4551 assert_eq!(got.actions.len(), 1);
4552 assert_eq!(got.actions[0].id, "ok");
4553 }
4554
4555 #[test]
4556 fn view_token_wire_decodes_offset_and_kind() {
4557 // Using `Newline` (a unit variant) avoids the tuple-variant
4558 // wire-format ambiguity in rquickjs_serde while still exercising
4559 // the `FromJs` impl end-to-end.
4560 let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
4561 assert_eq!(got.source_offset, Some(42));
4562 assert!(matches!(got.kind, ViewTokenWireKind::Newline));
4563 }
4564
4565 #[test]
4566 fn view_token_style_decodes_boolean_flags() {
4567 // `fg`/`bg` are `Option<TokenColor>` (untagged: RGB array or
4568 // named string). rquickjs_serde struggles with the untagged
4569 // variant from a plain JS array, so we pin down the boolean
4570 // flags — enough to prove the body actually ran.
4571 let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
4572 assert!(got.bold);
4573 assert!(got.italic);
4574 assert!(got.fg.is_none());
4575 }
4576
4577 #[test]
4578 fn layout_hints_decodes_compose_width() {
4579 let got: LayoutHints = eval_as("({composeWidth: 120})");
4580 assert_eq!(got.compose_width, Some(120));
4581 assert!(got.column_guides.is_none());
4582 }
4583
4584 #[test]
4585 fn create_composite_buffer_options_decodes_name_and_sources() {
4586 let got: CreateCompositeBufferOptions = eval_as(
4587 "({name: 'diff', mode: 'm', \
4588 layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
4589 sources: [{bufferId: 3, label: 'OLD'}]})",
4590 );
4591 assert_eq!(got.name, "diff");
4592 assert_eq!(got.layout.layout_type, "side-by-side");
4593 assert_eq!(got.sources.len(), 1);
4594 assert_eq!(got.sources[0].buffer_id, 3);
4595 assert_eq!(got.sources[0].label, "OLD");
4596 }
4597
4598 #[test]
4599 fn composite_hunk_decodes_all_fields() {
4600 let got: CompositeHunk =
4601 eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
4602 assert_eq!(got.old_start, 1);
4603 assert_eq!(got.old_count, 2);
4604 assert_eq!(got.new_start, 3);
4605 assert_eq!(got.new_count, 4);
4606 }
4607
4608 #[test]
4609 fn language_pack_config_decodes_comment_prefix_and_tab_size() {
4610 let got: LanguagePackConfig =
4611 eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
4612 assert_eq!(got.comment_prefix.as_deref(), Some("//"));
4613 assert_eq!(got.tab_size, Some(7));
4614 assert_eq!(got.use_tabs, Some(true));
4615 }
4616
4617 #[test]
4618 fn lsp_server_pack_config_decodes_command_and_args() {
4619 let got: LspServerPackConfig =
4620 eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
4621 assert_eq!(got.command, "rust-analyzer");
4622 assert_eq!(got.args, vec!["--log".to_string()]);
4623 assert_eq!(got.auto_start, Some(true));
4624 }
4625
4626 #[test]
4627 fn process_limits_pack_config_decodes_percentages() {
4628 let got: ProcessLimitsPackConfig =
4629 eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
4630 assert_eq!(got.max_memory_percent, Some(75));
4631 assert_eq!(got.max_cpu_percent, Some(50));
4632 assert_eq!(got.enabled, Some(true));
4633 }
4634
4635 /// `TextPropertiesAtCursor::into_js` must serialise the inner vector
4636 /// into a JS array whose length matches the payload. A mutant that
4637 /// returns a default (`undefined` / empty) value would fail either
4638 /// the array check or the length check.
4639 #[test]
4640 fn text_properties_at_cursor_into_js_preserves_length() {
4641 use rquickjs::IntoJs;
4642 with_js(|ctx| {
4643 let mut entry = std::collections::HashMap::new();
4644 entry.insert("k".to_string(), serde_json::json!("v"));
4645 let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
4646
4647 let v = payload.into_js(&ctx).expect("into_js");
4648 let arr = v.as_array().expect("expected JS array");
4649 assert_eq!(arr.len(), 2);
4650 });
4651 }
4652 }
4653}
4654
4655/// Plugin API context - provides safe access to editor functionality
4656pub struct PluginApi {
4657 /// Hook registry (shared with editor)
4658 hooks: Arc<RwLock<HookRegistry>>,
4659
4660 /// Command registry (shared with editor)
4661 commands: Arc<RwLock<CommandRegistry>>,
4662
4663 /// Command queue for sending commands to editor
4664 command_sender: std::sync::mpsc::Sender<PluginCommand>,
4665
4666 /// Snapshot of editor state (read-only for plugins)
4667 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4668}
4669
4670impl PluginApi {
4671 /// Create a new plugin API context
4672 pub fn new(
4673 hooks: Arc<RwLock<HookRegistry>>,
4674 commands: Arc<RwLock<CommandRegistry>>,
4675 command_sender: std::sync::mpsc::Sender<PluginCommand>,
4676 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4677 ) -> Self {
4678 Self {
4679 hooks,
4680 commands,
4681 command_sender,
4682 state_snapshot,
4683 }
4684 }
4685
4686 /// Register a hook callback
4687 pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
4688 let mut hooks = self.hooks.write().unwrap();
4689 hooks.add_hook(hook_name, callback);
4690 }
4691
4692 /// Remove all hooks for a specific name
4693 pub fn unregister_hooks(&self, hook_name: &str) {
4694 let mut hooks = self.hooks.write().unwrap();
4695 hooks.remove_hooks(hook_name);
4696 }
4697
4698 /// Register a command
4699 pub fn register_command(&self, command: Command) {
4700 let commands = self.commands.read().unwrap();
4701 commands.register(command);
4702 }
4703
4704 /// Unregister a command by name
4705 pub fn unregister_command(&self, name: &str) {
4706 let commands = self.commands.read().unwrap();
4707 commands.unregister(name);
4708 }
4709
4710 /// Send a command to the editor (async/non-blocking)
4711 pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
4712 self.command_sender
4713 .send(command)
4714 .map_err(|e| format!("Failed to send command: {}", e))
4715 }
4716
4717 /// Insert text at a position in a buffer
4718 pub fn insert_text(
4719 &self,
4720 buffer_id: BufferId,
4721 position: usize,
4722 text: String,
4723 ) -> Result<(), String> {
4724 self.send_command(PluginCommand::InsertText {
4725 buffer_id,
4726 position,
4727 text,
4728 })
4729 }
4730
4731 /// Delete a range of text from a buffer
4732 pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
4733 self.send_command(PluginCommand::DeleteRange { buffer_id, range })
4734 }
4735
4736 /// Add an overlay (decoration) to a buffer
4737 /// Add an overlay to a buffer with styling options
4738 ///
4739 /// Returns an opaque handle that can be used to remove the overlay later.
4740 ///
4741 /// Colors can be specified as RGB arrays or theme key strings.
4742 /// Theme keys are resolved at render time, so overlays update with theme changes.
4743 pub fn add_overlay(
4744 &self,
4745 buffer_id: BufferId,
4746 namespace: Option<String>,
4747 range: Range<usize>,
4748 options: OverlayOptions,
4749 ) -> Result<(), String> {
4750 self.send_command(PluginCommand::AddOverlay {
4751 buffer_id,
4752 namespace: namespace.map(OverlayNamespace::from_string),
4753 range,
4754 options,
4755 })
4756 }
4757
4758 /// Remove an overlay from a buffer by its handle
4759 pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
4760 self.send_command(PluginCommand::RemoveOverlay {
4761 buffer_id,
4762 handle: OverlayHandle::from_string(handle),
4763 })
4764 }
4765
4766 /// Clear all overlays in a namespace from a buffer
4767 pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
4768 self.send_command(PluginCommand::ClearNamespace {
4769 buffer_id,
4770 namespace: OverlayNamespace::from_string(namespace),
4771 })
4772 }
4773
4774 /// Clear all overlays that overlap with a byte range
4775 /// Used for targeted invalidation when content changes
4776 pub fn clear_overlays_in_range(
4777 &self,
4778 buffer_id: BufferId,
4779 start: usize,
4780 end: usize,
4781 ) -> Result<(), String> {
4782 self.send_command(PluginCommand::ClearOverlaysInRange {
4783 buffer_id,
4784 start,
4785 end,
4786 })
4787 }
4788
4789 /// Set the status message
4790 pub fn set_status(&self, message: String) -> Result<(), String> {
4791 self.send_command(PluginCommand::SetStatus { message })
4792 }
4793
4794 /// Open a file at a specific line and column (1-indexed)
4795 /// This is useful for jumping to locations from git grep, LSP definitions, etc.
4796 pub fn open_file_at_location(
4797 &self,
4798 path: PathBuf,
4799 line: Option<usize>,
4800 column: Option<usize>,
4801 ) -> Result<(), String> {
4802 self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
4803 }
4804
4805 /// Open a file in a specific split at a line and column
4806 ///
4807 /// Similar to open_file_at_location but targets a specific split pane.
4808 /// The split_id is the ID of the split pane to open the file in.
4809 pub fn open_file_in_split(
4810 &self,
4811 split_id: usize,
4812 path: PathBuf,
4813 line: Option<usize>,
4814 column: Option<usize>,
4815 ) -> Result<(), String> {
4816 self.send_command(PluginCommand::OpenFileInSplit {
4817 split_id,
4818 path,
4819 line,
4820 column,
4821 })
4822 }
4823
4824 /// Start a prompt (minibuffer) with a custom type identifier
4825 /// The prompt_type is used to filter hooks in plugin code
4826 pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
4827 self.send_command(PluginCommand::StartPrompt {
4828 label,
4829 prompt_type,
4830 floating_overlay: false,
4831 })
4832 }
4833
4834 /// Set the suggestions for the current prompt
4835 /// This updates the prompt's autocomplete/selection list
4836 pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
4837 self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
4838 }
4839
4840 /// Enable/disable syncing prompt input text when navigating suggestions
4841 pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
4842 self.send_command(PluginCommand::SetPromptInputSync { sync })
4843 }
4844
4845 /// Set the floating-overlay prompt's title (issue #1796) as
4846 /// styled segments. An empty vec clears the title and falls
4847 /// back to the prompt-type default.
4848 pub fn set_prompt_title(&self, title: Vec<StyledText>) -> Result<(), String> {
4849 self.send_command(PluginCommand::SetPromptTitle { title })
4850 }
4851
4852 /// Set the floating-overlay prompt's footer chrome row.
4853 /// Plugins use this for hotkey hints / footer banners along
4854 /// the bottom of the results pane. Empty vec clears.
4855 pub fn set_prompt_footer(&self, footer: Vec<StyledText>) -> Result<(), String> {
4856 self.send_command(PluginCommand::SetPromptFooter { footer })
4857 }
4858
4859 /// Set the floating-overlay prompt's toolbar as a `WidgetSpec` (real,
4860 /// clickable `Toggle`/`Button` widgets). `None` clears it.
4861 pub fn set_prompt_toolbar(&self, spec: Option<WidgetSpec>) -> Result<(), String> {
4862 self.send_command(PluginCommand::SetPromptToolbar { spec })
4863 }
4864
4865 /// Set the floating-overlay prompt's input-row status text. Empty clears.
4866 pub fn set_prompt_status(&self, status: String) -> Result<(), String> {
4867 self.send_command(PluginCommand::SetPromptStatus { status })
4868 }
4869
4870 /// Override the currently-highlighted suggestion row in the
4871 /// open prompt. Useful when re-opening a picker and wanting
4872 /// the previously-active entry to come up pre-selected
4873 /// (e.g. Orchestrator highlighting the active session). The
4874 /// editor clamps `index` to the list's bounds.
4875 pub fn set_prompt_selected_index(&self, index: u32) -> Result<(), String> {
4876 self.send_command(PluginCommand::SetPromptSelectedIndex { index })
4877 }
4878
4879 /// Add a menu item to an existing menu
4880 pub fn add_menu_item(
4881 &self,
4882 menu_label: String,
4883 item: MenuItem,
4884 position: MenuPosition,
4885 ) -> Result<(), String> {
4886 self.send_command(PluginCommand::AddMenuItem {
4887 menu_label,
4888 item,
4889 position,
4890 })
4891 }
4892
4893 /// Add a new top-level menu
4894 pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
4895 self.send_command(PluginCommand::AddMenu { menu, position })
4896 }
4897
4898 /// Remove a menu item from a menu
4899 pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
4900 self.send_command(PluginCommand::RemoveMenuItem {
4901 menu_label,
4902 item_label,
4903 })
4904 }
4905
4906 /// Remove a top-level menu
4907 pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
4908 self.send_command(PluginCommand::RemoveMenu { menu_label })
4909 }
4910
4911 // === Virtual Buffer Methods ===
4912
4913 /// Create a new virtual buffer (not backed by a file)
4914 ///
4915 /// Virtual buffers are used for special displays like diagnostic lists,
4916 /// search results, etc. They have their own mode for keybindings.
4917 pub fn create_virtual_buffer(
4918 &self,
4919 name: String,
4920 mode: String,
4921 read_only: bool,
4922 ) -> Result<(), String> {
4923 self.send_command(PluginCommand::CreateVirtualBuffer {
4924 name,
4925 mode,
4926 read_only,
4927 })
4928 }
4929
4930 /// Create a virtual buffer and set its content in one operation
4931 ///
4932 /// This is the preferred way to create virtual buffers since it doesn't
4933 /// require tracking the buffer ID. The buffer is created and populated
4934 /// atomically.
4935 pub fn create_virtual_buffer_with_content(
4936 &self,
4937 name: String,
4938 mode: String,
4939 read_only: bool,
4940 entries: Vec<TextPropertyEntry>,
4941 ) -> Result<(), String> {
4942 self.send_command(PluginCommand::CreateVirtualBufferWithContent {
4943 name,
4944 mode,
4945 read_only,
4946 entries,
4947 show_line_numbers: true,
4948 show_cursors: true,
4949 editing_disabled: false,
4950 hidden_from_tabs: false,
4951 request_id: None,
4952 })
4953 }
4954
4955 /// Set the content of a virtual buffer with text properties
4956 ///
4957 /// Each entry contains text and metadata properties (e.g., source location).
4958 pub fn set_virtual_buffer_content(
4959 &self,
4960 buffer_id: BufferId,
4961 entries: Vec<TextPropertyEntry>,
4962 ) -> Result<(), String> {
4963 self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
4964 }
4965
4966 /// Get text properties at cursor position in a buffer
4967 ///
4968 /// This triggers a command that will make properties available to plugins.
4969 pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
4970 self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
4971 }
4972
4973 /// Define a buffer mode with keybindings
4974 ///
4975 /// Bindings are specified as (key_string, command_name) pairs.
4976 pub fn define_mode(
4977 &self,
4978 name: String,
4979 bindings: Vec<(String, String)>,
4980 read_only: bool,
4981 allow_text_input: bool,
4982 ) -> Result<(), String> {
4983 self.send_command(PluginCommand::DefineMode {
4984 name,
4985 bindings,
4986 read_only,
4987 allow_text_input,
4988 inherit_normal_bindings: false,
4989 plugin_name: None,
4990 })
4991 }
4992
4993 /// Switch the current split to display a buffer
4994 pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
4995 self.send_command(PluginCommand::ShowBuffer { buffer_id })
4996 }
4997
4998 /// Set the scroll position of a specific split
4999 pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
5000 self.send_command(PluginCommand::SetSplitScroll {
5001 split_id: SplitId(split_id),
5002 top_byte,
5003 })
5004 }
5005
5006 /// Request syntax highlights for a buffer range
5007 pub fn get_highlights(
5008 &self,
5009 buffer_id: BufferId,
5010 range: Range<usize>,
5011 request_id: u64,
5012 ) -> Result<(), String> {
5013 self.send_command(PluginCommand::RequestHighlights {
5014 buffer_id,
5015 range,
5016 request_id,
5017 })
5018 }
5019
5020 // === Query Methods ===
5021
5022 /// Get the currently active buffer ID
5023 pub fn get_active_buffer_id(&self) -> BufferId {
5024 let snapshot = self.state_snapshot.read().unwrap();
5025 snapshot.active_buffer_id
5026 }
5027
5028 /// Get the currently active split ID
5029 pub fn get_active_split_id(&self) -> usize {
5030 let snapshot = self.state_snapshot.read().unwrap();
5031 snapshot.active_split_id
5032 }
5033
5034 /// Get information about a specific buffer
5035 pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
5036 let snapshot = self.state_snapshot.read().unwrap();
5037 snapshot.buffers.get(&buffer_id).cloned()
5038 }
5039
5040 /// Get all buffer IDs
5041 pub fn list_buffers(&self) -> Vec<BufferInfo> {
5042 let snapshot = self.state_snapshot.read().unwrap();
5043 snapshot.buffers.values().cloned().collect()
5044 }
5045
5046 /// Get primary cursor information for the active buffer
5047 pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
5048 let snapshot = self.state_snapshot.read().unwrap();
5049 snapshot.primary_cursor.clone()
5050 }
5051
5052 /// Get all cursor information for the active buffer
5053 pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
5054 let snapshot = self.state_snapshot.read().unwrap();
5055 snapshot.all_cursors.clone()
5056 }
5057
5058 /// Get viewport information for the active buffer
5059 pub fn get_viewport(&self) -> Option<ViewportInfo> {
5060 let snapshot = self.state_snapshot.read().unwrap();
5061 snapshot.viewport.clone()
5062 }
5063
5064 /// Get total terminal dimensions.
5065 pub fn get_screen_size(&self) -> ScreenSize {
5066 let snapshot = self.state_snapshot.read().unwrap();
5067 ScreenSize {
5068 width: snapshot.terminal_width,
5069 height: snapshot.terminal_height,
5070 }
5071 }
5072
5073 /// Get access to the state snapshot Arc (for internal use)
5074 pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
5075 Arc::clone(&self.state_snapshot)
5076 }
5077}
5078
5079impl Clone for PluginApi {
5080 fn clone(&self) -> Self {
5081 Self {
5082 hooks: Arc::clone(&self.hooks),
5083 commands: Arc::clone(&self.commands),
5084 command_sender: self.command_sender.clone(),
5085 state_snapshot: Arc::clone(&self.state_snapshot),
5086 }
5087 }
5088}
5089
5090// ============================================================================
5091// Pluggable Completion Service — TypeScript Plugin API Types
5092// ============================================================================
5093//
5094// These types are the bridge between the Rust `CompletionService` and
5095// TypeScript plugins that want to provide completion candidates. They are
5096// serialised to/from JSON via serde and generate TypeScript definitions via
5097// ts-rs so that the plugin API stays in sync automatically.
5098
5099/// A completion candidate produced by a TypeScript plugin provider.
5100///
5101/// This mirrors `CompletionCandidate` in the Rust `completion::provider`
5102/// module but uses serde-friendly primitives for the JS ↔ Rust boundary.
5103#[derive(Debug, Clone, Serialize, Deserialize, TS)]
5104#[serde(rename_all = "camelCase", deny_unknown_fields)]
5105#[ts(export, rename_all = "camelCase")]
5106pub struct TsCompletionCandidate {
5107 /// Display text shown in the completion popup.
5108 pub label: String,
5109
5110 /// Text to insert when accepted. Falls back to `label` if omitted.
5111 #[serde(skip_serializing_if = "Option::is_none")]
5112 pub insert_text: Option<String>,
5113
5114 /// Short detail string shown next to the label.
5115 #[serde(skip_serializing_if = "Option::is_none")]
5116 pub detail: Option<String>,
5117
5118 /// Single-character icon hint (e.g. `"λ"`, `"v"`).
5119 #[serde(skip_serializing_if = "Option::is_none")]
5120 pub icon: Option<String>,
5121
5122 /// Provider-assigned relevance score (higher = better).
5123 #[serde(default)]
5124 pub score: i64,
5125
5126 /// Whether `insert_text` uses LSP snippet syntax (`$0`, `${1:ph}`, …).
5127 #[serde(default)]
5128 pub is_snippet: bool,
5129
5130 /// Opaque data carried through to the `completionAccepted` hook.
5131 #[serde(skip_serializing_if = "Option::is_none")]
5132 pub provider_data: Option<String>,
5133}
5134
5135/// Context sent to a TypeScript plugin's `provideCompletions` handler.
5136///
5137/// Plugins receive this as a read-only snapshot so they never need direct
5138/// buffer access (which would be unsafe for huge files).
5139#[derive(Debug, Clone, Serialize, Deserialize, TS)]
5140#[serde(rename_all = "camelCase")]
5141#[ts(export, rename_all = "camelCase")]
5142pub struct TsCompletionContext {
5143 /// The word prefix typed so far.
5144 pub prefix: String,
5145
5146 /// Byte offset of the cursor.
5147 pub cursor_byte: usize,
5148
5149 /// Byte offset of the word start (for replacement range).
5150 pub word_start_byte: usize,
5151
5152 /// Total buffer size in bytes.
5153 pub buffer_len: usize,
5154
5155 /// Whether the buffer is a lazily-loaded huge file.
5156 pub is_large_file: bool,
5157
5158 /// A text excerpt around the cursor (the contents of the safe scan window).
5159 /// Plugins should search only this string, not request the full buffer.
5160 pub text_around_cursor: String,
5161
5162 /// Byte offset within `text_around_cursor` that corresponds to the cursor.
5163 pub cursor_offset_in_text: usize,
5164
5165 /// File language id (e.g. `"rust"`, `"typescript"`), if known.
5166 #[serde(skip_serializing_if = "Option::is_none")]
5167 pub language_id: Option<String>,
5168}
5169
5170/// Registration payload sent by a plugin to register a completion provider.
5171#[derive(Debug, Clone, Serialize, Deserialize, TS)]
5172#[serde(rename_all = "camelCase", deny_unknown_fields)]
5173#[ts(export, rename_all = "camelCase")]
5174pub struct TsCompletionProviderRegistration {
5175 /// Unique id for this provider (e.g., `"my-snippets"`).
5176 pub id: String,
5177
5178 /// Human-readable name shown in status/debug UI.
5179 pub display_name: String,
5180
5181 /// Priority tier (lower = higher priority). Convention:
5182 /// 0 = LSP, 10 = ctags, 20 = buffer words, 30 = dabbrev, 50 = plugin.
5183 #[serde(default = "default_plugin_provider_priority")]
5184 pub priority: u32,
5185
5186 /// Optional list of language ids this provider is active for.
5187 /// If empty/omitted, the provider is active for all languages.
5188 #[serde(default)]
5189 pub language_ids: Vec<String>,
5190}
5191
5192fn default_plugin_provider_priority() -> u32 {
5193 50
5194}
5195
5196#[cfg(test)]
5197mod tests {
5198 use super::*;
5199 use std::path::Path;
5200
5201 #[test]
5202 fn test_plugin_api_creation() {
5203 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5204 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5205 let (tx, _rx) = std::sync::mpsc::channel();
5206 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5207
5208 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5209
5210 // Should not panic
5211 let _clone = api.clone();
5212 }
5213
5214 #[test]
5215 fn test_register_hook() {
5216 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5217 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5218 let (tx, _rx) = std::sync::mpsc::channel();
5219 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5220
5221 let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
5222
5223 api.register_hook("test-hook", Box::new(|_| true));
5224
5225 let hook_registry = hooks.read().unwrap();
5226 assert_eq!(hook_registry.hook_count("test-hook"), 1);
5227 }
5228
5229 #[test]
5230 fn test_send_command() {
5231 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5232 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5233 let (tx, rx) = std::sync::mpsc::channel();
5234 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5235
5236 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5237
5238 let result = api.insert_text(BufferId(1), 0, "test".to_string());
5239 assert!(result.is_ok());
5240
5241 // Verify command was sent
5242 let received = rx.try_recv();
5243 assert!(received.is_ok());
5244
5245 match received.unwrap() {
5246 PluginCommand::InsertText {
5247 buffer_id,
5248 position,
5249 text,
5250 } => {
5251 assert_eq!(buffer_id.0, 1);
5252 assert_eq!(position, 0);
5253 assert_eq!(text, "test");
5254 }
5255 _ => panic!("Wrong command type"),
5256 }
5257 }
5258
5259 #[test]
5260 fn test_add_overlay_command() {
5261 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5262 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5263 let (tx, rx) = std::sync::mpsc::channel();
5264 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5265
5266 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5267
5268 let result = api.add_overlay(
5269 BufferId(1),
5270 Some("test-overlay".to_string()),
5271 0..10,
5272 OverlayOptions {
5273 fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
5274 bg: None,
5275 underline: true,
5276 bold: false,
5277 italic: false,
5278 strikethrough: false,
5279 extend_to_line_end: false,
5280 fg_on_collision_only: false,
5281 url: None,
5282 },
5283 );
5284 assert!(result.is_ok());
5285
5286 let received = rx.try_recv().unwrap();
5287 match received {
5288 PluginCommand::AddOverlay {
5289 buffer_id,
5290 namespace,
5291 range,
5292 options,
5293 } => {
5294 assert_eq!(buffer_id.0, 1);
5295 assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
5296 assert_eq!(range, 0..10);
5297 assert!(matches!(
5298 options.fg,
5299 Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
5300 ));
5301 assert!(options.bg.is_none());
5302 assert!(options.underline);
5303 assert!(!options.bold);
5304 assert!(!options.italic);
5305 assert!(!options.extend_to_line_end);
5306 }
5307 _ => panic!("Wrong command type"),
5308 }
5309 }
5310
5311 #[test]
5312 fn test_set_status_command() {
5313 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5314 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5315 let (tx, rx) = std::sync::mpsc::channel();
5316 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5317
5318 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5319
5320 let result = api.set_status("Test status".to_string());
5321 assert!(result.is_ok());
5322
5323 let received = rx.try_recv().unwrap();
5324 match received {
5325 PluginCommand::SetStatus { message } => {
5326 assert_eq!(message, "Test status");
5327 }
5328 _ => panic!("Wrong command type"),
5329 }
5330 }
5331
5332 #[test]
5333 fn test_get_active_buffer_id() {
5334 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5335 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5336 let (tx, _rx) = std::sync::mpsc::channel();
5337 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5338
5339 // Set active buffer to 5
5340 {
5341 let mut snapshot = state_snapshot.write().unwrap();
5342 snapshot.active_buffer_id = BufferId(5);
5343 }
5344
5345 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5346
5347 let active_id = api.get_active_buffer_id();
5348 assert_eq!(active_id.0, 5);
5349 }
5350
5351 #[test]
5352 fn test_get_buffer_info() {
5353 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5354 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5355 let (tx, _rx) = std::sync::mpsc::channel();
5356 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5357
5358 // Add buffer info
5359 {
5360 let mut snapshot = state_snapshot.write().unwrap();
5361 let buffer_info = BufferInfo {
5362 id: BufferId(1),
5363 path: Some(std::path::PathBuf::from("/test/file.txt")),
5364 modified: true,
5365 length: 100,
5366 is_virtual: false,
5367 view_mode: "source".to_string(),
5368 is_composing_in_any_split: false,
5369 compose_width: None,
5370 language: "text".to_string(),
5371 is_preview: false,
5372 splits: Vec::new(),
5373 };
5374 snapshot.buffers.insert(BufferId(1), buffer_info);
5375 }
5376
5377 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5378
5379 let info = api.get_buffer_info(BufferId(1));
5380 assert!(info.is_some());
5381 let info = info.unwrap();
5382 assert_eq!(info.id.0, 1);
5383 assert_eq!(
5384 info.path.as_ref().unwrap().to_str().unwrap(),
5385 "/test/file.txt"
5386 );
5387 assert!(info.modified);
5388 assert_eq!(info.length, 100);
5389
5390 // Non-existent buffer
5391 let no_info = api.get_buffer_info(BufferId(999));
5392 assert!(no_info.is_none());
5393 }
5394
5395 #[test]
5396 fn test_list_buffers() {
5397 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5398 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5399 let (tx, _rx) = std::sync::mpsc::channel();
5400 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5401
5402 // Add multiple buffers
5403 {
5404 let mut snapshot = state_snapshot.write().unwrap();
5405 snapshot.buffers.insert(
5406 BufferId(1),
5407 BufferInfo {
5408 id: BufferId(1),
5409 path: Some(std::path::PathBuf::from("/file1.txt")),
5410 modified: false,
5411 length: 50,
5412 is_virtual: false,
5413 view_mode: "source".to_string(),
5414 is_composing_in_any_split: false,
5415 compose_width: None,
5416 language: "text".to_string(),
5417 is_preview: false,
5418 splits: Vec::new(),
5419 },
5420 );
5421 snapshot.buffers.insert(
5422 BufferId(2),
5423 BufferInfo {
5424 id: BufferId(2),
5425 path: Some(std::path::PathBuf::from("/file2.txt")),
5426 modified: true,
5427 length: 100,
5428 is_virtual: false,
5429 view_mode: "source".to_string(),
5430 is_composing_in_any_split: false,
5431 compose_width: None,
5432 language: "text".to_string(),
5433 is_preview: false,
5434 splits: Vec::new(),
5435 },
5436 );
5437 snapshot.buffers.insert(
5438 BufferId(3),
5439 BufferInfo {
5440 id: BufferId(3),
5441 path: None,
5442 modified: false,
5443 length: 0,
5444 is_virtual: true,
5445 view_mode: "source".to_string(),
5446 is_composing_in_any_split: false,
5447 compose_width: None,
5448 language: "text".to_string(),
5449 is_preview: false,
5450 splits: Vec::new(),
5451 },
5452 );
5453 }
5454
5455 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5456
5457 let buffers = api.list_buffers();
5458 assert_eq!(buffers.len(), 3);
5459
5460 // Verify all buffers are present
5461 assert!(buffers.iter().any(|b| b.id.0 == 1));
5462 assert!(buffers.iter().any(|b| b.id.0 == 2));
5463 assert!(buffers.iter().any(|b| b.id.0 == 3));
5464 }
5465
5466 #[test]
5467 fn test_get_primary_cursor() {
5468 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5469 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5470 let (tx, _rx) = std::sync::mpsc::channel();
5471 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5472
5473 // Add cursor info
5474 {
5475 let mut snapshot = state_snapshot.write().unwrap();
5476 snapshot.primary_cursor = Some(CursorInfo {
5477 position: 42,
5478 selection: Some(10..42),
5479 line: Some(3),
5480 });
5481 }
5482
5483 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5484
5485 let cursor = api.get_primary_cursor();
5486 assert!(cursor.is_some());
5487 let cursor = cursor.unwrap();
5488 assert_eq!(cursor.position, 42);
5489 assert_eq!(cursor.selection, Some(10..42));
5490 }
5491
5492 #[test]
5493 fn test_get_all_cursors() {
5494 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5495 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5496 let (tx, _rx) = std::sync::mpsc::channel();
5497 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5498
5499 // Add multiple cursors
5500 {
5501 let mut snapshot = state_snapshot.write().unwrap();
5502 snapshot.all_cursors = vec![
5503 CursorInfo {
5504 position: 10,
5505 selection: None,
5506 line: Some(0),
5507 },
5508 CursorInfo {
5509 position: 20,
5510 selection: Some(15..20),
5511 line: Some(1),
5512 },
5513 CursorInfo {
5514 position: 30,
5515 selection: Some(25..30),
5516 line: Some(2),
5517 },
5518 ];
5519 }
5520
5521 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5522
5523 let cursors = api.get_all_cursors();
5524 assert_eq!(cursors.len(), 3);
5525 assert_eq!(cursors[0].position, 10);
5526 assert_eq!(cursors[0].selection, None);
5527 assert_eq!(cursors[1].position, 20);
5528 assert_eq!(cursors[1].selection, Some(15..20));
5529 assert_eq!(cursors[2].position, 30);
5530 assert_eq!(cursors[2].selection, Some(25..30));
5531 }
5532
5533 #[test]
5534 fn test_get_viewport() {
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 viewport info
5541 {
5542 let mut snapshot = state_snapshot.write().unwrap();
5543 snapshot.viewport = Some(ViewportInfo {
5544 top_byte: 100,
5545 top_line: Some(5),
5546 left_column: 5,
5547 width: 80,
5548 height: 24,
5549 });
5550 }
5551
5552 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
5553
5554 let viewport = api.get_viewport();
5555 assert!(viewport.is_some());
5556 let viewport = viewport.unwrap();
5557 assert_eq!(viewport.top_byte, 100);
5558 assert_eq!(viewport.left_column, 5);
5559 assert_eq!(viewport.width, 80);
5560 assert_eq!(viewport.height, 24);
5561 }
5562
5563 #[test]
5564 fn test_composite_buffer_options_rejects_unknown_fields() {
5565 // Valid JSON with correct field names
5566 let valid_json = r#"{
5567 "name": "test",
5568 "mode": "diff",
5569 "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
5570 "sources": [{"bufferId": 1, "label": "old"}]
5571 }"#;
5572 let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
5573 assert!(
5574 result.is_ok(),
5575 "Valid JSON should parse: {:?}",
5576 result.err()
5577 );
5578
5579 // Invalid JSON with unknown field (buffer_id instead of bufferId)
5580 let invalid_json = r#"{
5581 "name": "test",
5582 "mode": "diff",
5583 "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
5584 "sources": [{"buffer_id": 1, "label": "old"}]
5585 }"#;
5586 let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
5587 assert!(
5588 result.is_err(),
5589 "JSON with unknown field should fail to parse"
5590 );
5591 let err = result.unwrap_err().to_string();
5592 assert!(
5593 err.contains("unknown field") || err.contains("buffer_id"),
5594 "Error should mention unknown field: {}",
5595 err
5596 );
5597 }
5598
5599 #[test]
5600 fn test_composite_hunk_rejects_unknown_fields() {
5601 // Valid JSON with correct field names
5602 let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
5603 let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
5604 assert!(
5605 result.is_ok(),
5606 "Valid JSON should parse: {:?}",
5607 result.err()
5608 );
5609
5610 // Invalid JSON with unknown field (old_start instead of oldStart)
5611 let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
5612 let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
5613 assert!(
5614 result.is_err(),
5615 "JSON with unknown field should fail to parse"
5616 );
5617 let err = result.unwrap_err().to_string();
5618 assert!(
5619 err.contains("unknown field") || err.contains("old_start"),
5620 "Error should mention unknown field: {}",
5621 err
5622 );
5623 }
5624
5625 #[test]
5626 fn test_plugin_response_line_end_position() {
5627 let response = PluginResponse::LineEndPosition {
5628 request_id: 42,
5629 position: Some(100),
5630 };
5631 let json = serde_json::to_string(&response).unwrap();
5632 assert!(json.contains("LineEndPosition"));
5633 assert!(json.contains("42"));
5634 assert!(json.contains("100"));
5635
5636 // Test None case
5637 let response_none = PluginResponse::LineEndPosition {
5638 request_id: 1,
5639 position: None,
5640 };
5641 let json_none = serde_json::to_string(&response_none).unwrap();
5642 assert!(json_none.contains("null"));
5643 }
5644
5645 #[test]
5646 fn test_plugin_response_buffer_line_count() {
5647 let response = PluginResponse::BufferLineCount {
5648 request_id: 99,
5649 count: Some(500),
5650 };
5651 let json = serde_json::to_string(&response).unwrap();
5652 assert!(json.contains("BufferLineCount"));
5653 assert!(json.contains("99"));
5654 assert!(json.contains("500"));
5655 }
5656
5657 #[test]
5658 fn test_plugin_command_get_line_end_position() {
5659 let command = PluginCommand::GetLineEndPosition {
5660 buffer_id: BufferId(1),
5661 line: 10,
5662 request_id: 123,
5663 };
5664 let json = serde_json::to_string(&command).unwrap();
5665 assert!(json.contains("GetLineEndPosition"));
5666 assert!(json.contains("10"));
5667 }
5668
5669 #[test]
5670 fn test_plugin_command_get_buffer_line_count() {
5671 let command = PluginCommand::GetBufferLineCount {
5672 buffer_id: BufferId(0),
5673 request_id: 456,
5674 };
5675 let json = serde_json::to_string(&command).unwrap();
5676 assert!(json.contains("GetBufferLineCount"));
5677 assert!(json.contains("456"));
5678 }
5679
5680 #[test]
5681 fn test_plugin_command_scroll_to_line_center() {
5682 let command = PluginCommand::ScrollToLineCenter {
5683 split_id: SplitId(1),
5684 buffer_id: BufferId(2),
5685 line: 50,
5686 };
5687 let json = serde_json::to_string(&command).unwrap();
5688 assert!(json.contains("ScrollToLineCenter"));
5689 assert!(json.contains("50"));
5690 }
5691
5692 /// `JsCallbackId` round-trips through `u64` via `new` / `as_u64` / `From`
5693 /// and renders as its underlying integer via `Display`.
5694 #[test]
5695 fn js_callback_id_conversions_and_display() {
5696 for raw in [0u64, 1, 42, u64::MAX] {
5697 let id = JsCallbackId::new(raw);
5698 assert_eq!(id.as_u64(), raw);
5699 assert_eq!(u64::from(id), raw);
5700 assert_eq!(JsCallbackId::from(raw), id);
5701 assert_eq!(id.to_string(), raw.to_string());
5702 }
5703 }
5704
5705 /// Serde `default = ...` helpers fire when the field is omitted and are
5706 /// overridden by explicit values. One test per struct pins each helper
5707 /// to its documented default.
5708 #[test]
5709 fn serde_defaults_fire_when_fields_are_omitted() {
5710 // default_action_count → 1
5711 let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
5712 assert_eq!(spec.count, 1);
5713 let spec: ActionSpec =
5714 serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
5715 assert_eq!(spec.count, 5);
5716
5717 // default_true → showSeparator = true
5718 let layout: CompositeLayoutConfig =
5719 serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
5720 assert!(layout.show_separator);
5721 let layout: CompositeLayoutConfig =
5722 serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
5723 assert!(!layout.show_separator);
5724
5725 // default_plugin_provider_priority → 50
5726 let reg: TsCompletionProviderRegistration =
5727 serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
5728 assert_eq!(reg.priority, 50);
5729 let reg: TsCompletionProviderRegistration =
5730 serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
5731 assert_eq!(reg.priority, 3);
5732 }
5733
5734 // ── Behavioural tests added to kill the mutants reported by cargo-mutants ──
5735 //
5736 // These tests pin down observable behaviour for tiny methods whose bodies
5737 // were replaceable with a constant (e.g. `()`, `Ok(())`, `None`, or a
5738 // default value) without any existing test noticing.
5739
5740 /// Helper: build a minimal `Command` with a given name.
5741 fn mk_cmd(name: &str) -> Command {
5742 Command {
5743 name: name.to_string(),
5744 description: String::new(),
5745 action_name: String::new(),
5746 plugin_name: String::new(),
5747 custom_contexts: Vec::new(),
5748 terminal_bypass: false,
5749 }
5750 }
5751
5752 /// `CommandRegistry::register` appends new commands and replaces any
5753 /// existing entry with the same name; `unregister` removes exactly the
5754 /// matching entry and is a no-op for unknown names.
5755 ///
5756 /// Kills: replace register with `()`; `!= → ==` in register;
5757 /// replace unregister with `()`; `!= → ==` in unregister.
5758 #[test]
5759 fn command_registry_register_and_unregister_semantics() {
5760 let r = CommandRegistry::new();
5761
5762 r.register(mk_cmd("a"));
5763 r.register(mk_cmd("b"));
5764 assert_eq!(r.commands.read().unwrap().len(), 2);
5765
5766 // Re-registering "a" must keep "b" (retain filters by `!=`); the
5767 // `== → !=` mutant would drop "b" and leave two copies of "a".
5768 r.register(mk_cmd("a"));
5769 let names: Vec<String> = r
5770 .commands
5771 .read()
5772 .unwrap()
5773 .iter()
5774 .map(|c| c.name.clone())
5775 .collect();
5776 assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
5777
5778 // Unregister must remove exactly "a" and preserve "b"; the `== → !=`
5779 // mutant would keep "a" and drop "b".
5780 r.unregister("a");
5781 let names: Vec<String> = r
5782 .commands
5783 .read()
5784 .unwrap()
5785 .iter()
5786 .map(|c| c.name.clone())
5787 .collect();
5788 assert_eq!(names, vec!["b".to_string()]);
5789
5790 // Unregistering an unknown name is a no-op.
5791 r.unregister("nope");
5792 assert_eq!(r.commands.read().unwrap().len(), 1);
5793 }
5794
5795 /// `OverlayColorSpec::as_rgb` returns the exact stored tuple for the RGB
5796 /// variant and `None` for the theme-key variant; `as_theme_key` is the
5797 /// dual. Uses a triple with no zero or one components and a theme key
5798 /// that is neither empty nor `"xyzzy"` to kill every constant-return
5799 /// mutant reported by cargo-mutants at once.
5800 #[test]
5801 fn overlay_color_spec_accessors_are_variant_specific() {
5802 let rgb = OverlayColorSpec::rgb(12, 34, 56);
5803 assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
5804 assert_eq!(rgb.as_theme_key(), None);
5805
5806 let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
5807 assert_eq!(tk.as_rgb(), None);
5808 assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
5809 }
5810
5811 /// `PluginCommand::debug_variant_name` returns the actual variant name
5812 /// derived from the `Debug` impl, not an empty or hard-coded string.
5813 #[test]
5814 fn plugin_command_debug_variant_name_returns_real_variant() {
5815 let c = PluginCommand::SetStatus {
5816 message: "hi".into(),
5817 };
5818 assert_eq!(c.debug_variant_name(), "SetStatus");
5819
5820 let c2 = PluginCommand::InsertText {
5821 buffer_id: BufferId(1),
5822 position: 0,
5823 text: String::new(),
5824 };
5825 assert_eq!(c2.debug_variant_name(), "InsertText");
5826 }
5827
5828 // ── PluginApi dispatch / mutation tests ────────────────────────────────
5829 //
5830 // Each `PluginApi` method is a one-liner that either pushes a
5831 // `PluginCommand` onto the channel or mutates a shared registry. The
5832 // mutants replace the body with `Ok(())` / `()`, i.e. the side effect
5833 // disappears. One assertion per method ties the side effect down.
5834
5835 type MkApi = (
5836 PluginApi,
5837 std::sync::mpsc::Receiver<PluginCommand>,
5838 Arc<RwLock<HookRegistry>>,
5839 Arc<RwLock<CommandRegistry>>,
5840 Arc<RwLock<EditorStateSnapshot>>,
5841 );
5842
5843 fn mk_api() -> MkApi {
5844 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
5845 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
5846 let (tx, rx) = std::sync::mpsc::channel();
5847 let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5848 let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
5849 (api, rx, hooks, commands, snap)
5850 }
5851
5852 /// `unregister_hooks` must actually clear hooks registered under the
5853 /// same name; replacing the body with `()` leaves the count at 1.
5854 #[test]
5855 fn plugin_api_unregister_hooks_clears_registry() {
5856 let (api, _rx, hooks, _cmds, _snap) = mk_api();
5857 api.register_hook("h", Box::new(|_| true));
5858 assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
5859 api.unregister_hooks("h");
5860 assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
5861 }
5862
5863 /// `register_command` / `unregister_command` must actually write through
5864 /// to the shared `CommandRegistry`.
5865 #[test]
5866 fn plugin_api_register_and_unregister_command_write_through() {
5867 let (api, _rx, _hooks, cmds, _snap) = mk_api();
5868
5869 api.register_command(mk_cmd("x"));
5870 assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
5871
5872 api.unregister_command("x");
5873 assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
5874 }
5875
5876 /// Macro: assert that calling `$call` on a fresh `PluginApi` produces
5877 /// exactly one `PluginCommand` matching `$pattern` with the additional
5878 /// invariants in `$guard`.
5879 macro_rules! assert_dispatches {
5880 ($call:expr, $pattern:pat $(if $guard:expr)?) => {{
5881 let (api, rx, _h, _c, _s) = mk_api();
5882 let _ = $call(&api);
5883 match rx.try_recv().expect("no command sent") {
5884 $pattern $(if $guard)? => {}
5885 other => panic!("unexpected command variant: {:?}", other),
5886 }
5887 }};
5888 }
5889
5890 /// Every simple `send_command`-based method on `PluginApi` translates
5891 /// its arguments into the documented `PluginCommand` variant with the
5892 /// expected fields.
5893 #[test]
5894 fn plugin_api_send_command_methods_dispatch_correctly() {
5895 // delete_range
5896 assert_dispatches!(
5897 |a: &PluginApi| a.delete_range(BufferId(7), 3..9),
5898 PluginCommand::DeleteRange { buffer_id, range }
5899 if buffer_id == BufferId(7) && range == (3..9)
5900 );
5901
5902 // remove_overlay
5903 assert_dispatches!(
5904 |a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
5905 PluginCommand::RemoveOverlay { buffer_id, handle }
5906 if buffer_id == BufferId(2) && handle.as_str() == "h-1"
5907 );
5908
5909 // clear_namespace
5910 assert_dispatches!(
5911 |a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
5912 PluginCommand::ClearNamespace { buffer_id, namespace }
5913 if buffer_id == BufferId(3) && namespace.as_str() == "diag"
5914 );
5915
5916 // clear_overlays_in_range
5917 assert_dispatches!(
5918 |a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
5919 PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
5920 if buffer_id == BufferId(4) && start == 10 && end == 20
5921 );
5922
5923 // open_file_at_location
5924 assert_dispatches!(
5925 |a: &PluginApi| a.open_file_at_location(
5926 PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
5927 ),
5928 PluginCommand::OpenFileAtLocation { path, line, column }
5929 if path == Path::new("/tmp/x.rs")
5930 && line == Some(4)
5931 && column == Some(8)
5932 );
5933
5934 // open_file_in_split
5935 assert_dispatches!(
5936 |a: &PluginApi| a.open_file_in_split(
5937 2, PathBuf::from("/tmp/y.rs"), Some(5), None
5938 ),
5939 PluginCommand::OpenFileInSplit { split_id, path, line, column }
5940 if split_id == 2
5941 && path == Path::new("/tmp/y.rs")
5942 && line == Some(5)
5943 && column.is_none()
5944 );
5945
5946 // start_prompt
5947 assert_dispatches!(
5948 |a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
5949 PluginCommand::StartPrompt { label, prompt_type, floating_overlay }
5950 if label == "label" && prompt_type == "cmd" && !floating_overlay
5951 );
5952
5953 // set_prompt_suggestions
5954 assert_dispatches!(
5955 |a: &PluginApi| a.set_prompt_suggestions(vec![
5956 Suggestion::new("one".into()),
5957 Suggestion::new("two".into()),
5958 ]),
5959 PluginCommand::SetPromptSuggestions { suggestions }
5960 if suggestions.len() == 2
5961 && suggestions[0].text == "one"
5962 && suggestions[1].text == "two"
5963 );
5964
5965 // set_prompt_input_sync
5966 assert_dispatches!(
5967 |a: &PluginApi| a.set_prompt_input_sync(true),
5968 PluginCommand::SetPromptInputSync { sync } if sync
5969 );
5970 assert_dispatches!(
5971 |a: &PluginApi| a.set_prompt_input_sync(false),
5972 PluginCommand::SetPromptInputSync { sync } if !sync
5973 );
5974
5975 // add_menu_item
5976 assert_dispatches!(
5977 |a: &PluginApi| a.add_menu_item(
5978 "File".into(),
5979 MenuItem::Label { info: "info".into() },
5980 MenuPosition::Bottom,
5981 ),
5982 PluginCommand::AddMenuItem { menu_label, item, position }
5983 if menu_label == "File"
5984 && matches!(item, MenuItem::Label { ref info } if info == "info")
5985 && matches!(position, MenuPosition::Bottom)
5986 );
5987
5988 // add_menu
5989 assert_dispatches!(
5990 |a: &PluginApi| a.add_menu(
5991 Menu {
5992 id: None,
5993 label: "Help".into(),
5994 items: vec![],
5995 when: None,
5996 },
5997 MenuPosition::After("Edit".into()),
5998 ),
5999 PluginCommand::AddMenu { menu, position }
6000 if menu.label == "Help"
6001 && matches!(position, MenuPosition::After(ref s) if s == "Edit")
6002 );
6003
6004 // remove_menu_item
6005 assert_dispatches!(
6006 |a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
6007 PluginCommand::RemoveMenuItem { menu_label, item_label }
6008 if menu_label == "File" && item_label == "Open"
6009 );
6010
6011 // remove_menu
6012 assert_dispatches!(
6013 |a: &PluginApi| a.remove_menu("File".into()),
6014 PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
6015 );
6016
6017 // create_virtual_buffer
6018 assert_dispatches!(
6019 |a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
6020 PluginCommand::CreateVirtualBuffer { name, mode, read_only }
6021 if name == "buf" && mode == "mode" && read_only
6022 );
6023
6024 // create_virtual_buffer_with_content
6025 assert_dispatches!(
6026 |a: &PluginApi| a.create_virtual_buffer_with_content(
6027 "n".into(), "m".into(), false, vec![]
6028 ),
6029 PluginCommand::CreateVirtualBufferWithContent {
6030 name, mode, read_only, show_line_numbers, show_cursors,
6031 editing_disabled, hidden_from_tabs, request_id, ..
6032 }
6033 if name == "n" && mode == "m" && !read_only
6034 && show_line_numbers && show_cursors
6035 && !editing_disabled && !hidden_from_tabs
6036 && request_id.is_none()
6037 );
6038
6039 // set_virtual_buffer_content
6040 assert_dispatches!(
6041 |a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
6042 PluginCommand::SetVirtualBufferContent { buffer_id, entries }
6043 if buffer_id == BufferId(9) && entries.is_empty()
6044 );
6045
6046 // get_text_properties_at_cursor
6047 assert_dispatches!(
6048 |a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
6049 PluginCommand::GetTextPropertiesAtCursor { buffer_id }
6050 if buffer_id == BufferId(11)
6051 );
6052
6053 // define_mode
6054 assert_dispatches!(
6055 |a: &PluginApi| a.define_mode(
6056 "m".into(),
6057 vec![("j".into(), "move_down".into())],
6058 true,
6059 false,
6060 ),
6061 PluginCommand::DefineMode {
6062 name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
6063 }
6064 if name == "m"
6065 && bindings.len() == 1
6066 && bindings[0].0 == "j"
6067 && bindings[0].1 == "move_down"
6068 && read_only
6069 && !allow_text_input
6070 && !inherit_normal_bindings
6071 && plugin_name.is_none()
6072 );
6073
6074 // show_buffer
6075 assert_dispatches!(
6076 |a: &PluginApi| a.show_buffer(BufferId(77)),
6077 PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
6078 );
6079
6080 // set_split_scroll
6081 assert_dispatches!(
6082 |a: &PluginApi| a.set_split_scroll(5, 128),
6083 PluginCommand::SetSplitScroll { split_id, top_byte }
6084 if split_id == SplitId(5) && top_byte == 128
6085 );
6086
6087 // get_highlights
6088 assert_dispatches!(
6089 |a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
6090 PluginCommand::RequestHighlights { buffer_id, range, request_id }
6091 if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
6092 );
6093 }
6094
6095 /// `get_active_split_id` reads the snapshot verbatim; a non-{0,1}
6096 /// sentinel value kills both the `0` and `1` constant-return mutants.
6097 #[test]
6098 fn plugin_api_get_active_split_id_reads_snapshot() {
6099 let (api, _rx, _h, _c, snap) = mk_api();
6100 snap.write().unwrap().active_split_id = 42;
6101 assert_eq!(api.get_active_split_id(), 42);
6102 }
6103
6104 /// `state_snapshot_handle` returns a clone of the same `Arc`, not a
6105 /// freshly-defaulted snapshot. A distinguishing field value on the
6106 /// original state proves that the handle sees it.
6107 #[test]
6108 fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
6109 let (api, _rx, _h, _c, snap) = mk_api();
6110 snap.write().unwrap().active_buffer_id = BufferId(42);
6111
6112 let h = api.state_snapshot_handle();
6113 assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
6114 assert!(Arc::ptr_eq(&h, &snap));
6115 }
6116
6117 /// `KillHostProcess` survives a round-trip through serde: the
6118 /// `process_id` field stays identified by name and the variant
6119 /// retains its tag shape. If a future contributor renames the
6120 /// field or splits it into a tuple, the plugin-runtime TS side
6121 /// (which hand-builds the command JSON for the dispatcher) would
6122 /// silently break — this test pins the wire format.
6123 #[test]
6124 fn plugin_command_kill_host_process_serde_round_trip() {
6125 let cmd = PluginCommand::KillHostProcess { process_id: 1234 };
6126 let json = serde_json::to_value(&cmd).unwrap();
6127 assert_eq!(json["KillHostProcess"]["process_id"], 1234);
6128 let decoded: PluginCommand = serde_json::from_value(json).unwrap();
6129 match decoded {
6130 PluginCommand::KillHostProcess { process_id } => assert_eq!(process_id, 1234),
6131 other => panic!("expected KillHostProcess, got {:?}", other),
6132 }
6133 }
6134
6135 // ==================== SearchHandle behavior ====================
6136
6137 fn dummy_match(line: usize) -> GrepMatch {
6138 GrepMatch {
6139 file: "fixture.rs".to_string(),
6140 buffer_id: 0,
6141 byte_offset: 0,
6142 length: 4,
6143 line,
6144 column: 1,
6145 context: "match".to_string(),
6146 }
6147 }
6148
6149 /// Pull-based handle batches matches between drains: a producer that
6150 /// pushes N matches across multiple writes hands them to the consumer
6151 /// in a single take(), and a follow-up take() with no new writes
6152 /// returns an empty batch — proving the architectural property the
6153 /// new API was built around (no per-chunk dispatch).
6154 #[test]
6155 fn search_handle_batches_between_takes() {
6156 let handle = Arc::new(SearchHandleState::new());
6157
6158 // Three independent writer batches simulate three searcher tasks
6159 // pushing into the shared state.
6160 for chunk in [vec![dummy_match(1), dummy_match(2)], vec![dummy_match(3)]] {
6161 let count = chunk.len();
6162 let mut state = handle.state.lock().unwrap();
6163 state.pending.extend(chunk);
6164 state.total_seen += count;
6165 }
6166
6167 // First take drains everything written so far.
6168 let drained: Vec<_> = {
6169 let mut s = handle.state.lock().unwrap();
6170 std::mem::take(&mut s.pending)
6171 };
6172 assert_eq!(drained.len(), 3);
6173 assert_eq!(handle.state.lock().unwrap().total_seen, 3);
6174
6175 // Second take with no producer activity yields an empty batch.
6176 let empty: Vec<_> = {
6177 let mut s = handle.state.lock().unwrap();
6178 std::mem::take(&mut s.pending)
6179 };
6180 assert!(empty.is_empty());
6181 }
6182
6183 /// `cancel` is a one-way latch visible to producers and consumers.
6184 /// Setting it does not implicitly mark `done` — completion is the
6185 /// producer's responsibility — but a producer observing the flag
6186 /// should stop pushing.
6187 #[test]
6188 fn search_handle_cancel_is_observable() {
6189 let handle = Arc::new(SearchHandleState::new());
6190 assert!(!handle.cancel.load(std::sync::atomic::Ordering::Relaxed));
6191
6192 handle
6193 .cancel
6194 .store(true, std::sync::atomic::Ordering::Relaxed);
6195
6196 assert!(handle.cancel.load(std::sync::atomic::Ordering::Relaxed));
6197 assert!(!handle.state.lock().unwrap().done);
6198 }
6199
6200 /// The terminal state transition: producers flip `done = true` once
6201 /// no more matches will arrive, with `truncated` reflecting whether
6202 /// the search hit `max_results`. Consumers learn the search is
6203 /// finished from the same `take()` that drains the final batch.
6204 #[test]
6205 fn search_handle_done_transition_is_visible_to_consumer() {
6206 let handle = Arc::new(SearchHandleState::new());
6207
6208 // Producer pushes a final batch, then marks done.
6209 {
6210 let mut s = handle.state.lock().unwrap();
6211 s.pending.push(dummy_match(7));
6212 s.total_seen += 1;
6213 s.truncated = true;
6214 s.done = true;
6215 }
6216
6217 let (matches, done, truncated) = {
6218 let mut s = handle.state.lock().unwrap();
6219 (std::mem::take(&mut s.pending), s.done, s.truncated)
6220 };
6221
6222 assert_eq!(matches.len(), 1);
6223 assert!(done);
6224 assert!(truncated);
6225 }
6226
6227 /// Producers and consumers must be able to interleave without
6228 /// blocking each other longer than a `mem::take` swap. This test
6229 /// drives writes from a worker thread while the main thread drains;
6230 /// it asserts the consumer eventually sees every match. With a
6231 /// per-chunk dispatch model an analogous test would deadlock or
6232 /// drop matches; with the pull model it converges.
6233 #[test]
6234 fn search_handle_concurrent_producer_consumer() {
6235 let handle = Arc::new(SearchHandleState::new());
6236 let producer = Arc::clone(&handle);
6237 let writer = std::thread::spawn(move || {
6238 for line in 1..=200 {
6239 let mut s = producer.state.lock().unwrap();
6240 s.pending.push(dummy_match(line));
6241 s.total_seen += 1;
6242 }
6243 producer.state.lock().unwrap().done = true;
6244 });
6245
6246 let mut drained: Vec<GrepMatch> = Vec::new();
6247 loop {
6248 let (mut batch, done) = {
6249 let mut s = handle.state.lock().unwrap();
6250 (std::mem::take(&mut s.pending), s.done)
6251 };
6252 drained.append(&mut batch);
6253 if done {
6254 let mut tail = handle.state.lock().unwrap();
6255 drained.append(&mut std::mem::take(&mut tail.pending));
6256 break;
6257 }
6258 std::thread::yield_now();
6259 }
6260 writer.join().unwrap();
6261 assert_eq!(drained.len(), 200);
6262 }
6263}