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