Skip to main content

par_term_scripting/
protocol.rs

1//! JSON protocol types for communication between the terminal and script subprocesses.
2//!
3//! Scripts read [`ScriptEvent`] objects from stdin (one JSON object per line) and write
4//! [`ScriptCommand`] objects to stdout (one JSON object per line).
5//!
6//! # Security Model
7//!
8//! ## Trust Assumptions
9//!
10//! Scripts are user-configured subprocesses launched from `ScriptConfig` entries in
11//! `~/.config/par-term/config.yaml`. The script binary is implicitly trusted (it was
12//! placed there by the user). However, this trust must be bounded because:
13//!
14//! 1. **Supply-chain attacks**: A malicious package could replace a trusted script
15//!    with one that emits dangerous command payloads.
16//! 2. **Injection through event data**: Malicious terminal sequences could produce
17//!    events whose payloads are forwarded to the script, which could reflect them
18//!    back in commands (terminal injection risk).
19//! 3. **Compromised scripts**: A script may be modified after initial deployment.
20//!
21//! ## Command Categories
22//!
23//! Script commands fall into three security categories:
24//!
25//! ### Safe Commands (no permission required)
26//! - `Log`: Write to the script's output buffer (UI only)
27//! - `SetPanel` / `ClearPanel`: Display markdown content in a panel
28//! - `Notify`: Show a desktop notification
29//! - `SetBadge`: Set the tab badge text
30//! - `SetVariable`: Set a user variable
31//! - `SetWidget`: Plugin-only status-bar text; a tab *script* sending it gets
32//!   an error line (see the variant docs below)
33//!
34//! ### Restricted Commands (require permission flags)
35//! These commands require explicit opt-in via `ScriptConfig` permission fields:
36//! - `WriteText`: Inject text into the PTY (requires `allow_write_text: true`)
37//!   - Must strip VT/ANSI escape sequences before writing
38//!   - Subject to rate limiting
39//!   - Queued for user confirmation unless `prompt_before_write_text: false`
40//!     (see [`crate::confirm`]) — stripping escapes leaves the printable text
41//!     and newline that make up a command line
42//! - `RunCommand`: Spawn an external process (requires `allow_run_command: true`)
43//!   - Must check against `check_command_denylist()` from par-term-config
44//!   - Must use shell tokenization (not `/bin/sh -c`) to prevent metacharacter injection
45//!   - Subject to rate limiting
46//! - `ChangeConfig`: Modify terminal configuration (requires `allow_change_config: true`)
47//!   - Must validate config keys against an allowlist
48//!
49//! ## Implementation Status
50//!
51//! All commands are implemented:
52//! - `Log`, `SetPanel`, `ClearPanel`: Safe, always allowed
53//! - `Notify`, `SetBadge`, `SetVariable`: Safe, always allowed
54//! - `WriteText`: Requires `allow_write_text`, rate-limited, VT sequences stripped,
55//!   and confirmed by the user unless `prompt_before_write_text` is off
56//! - `RunCommand`: Requires `allow_run_command`, rate-limited, denylist-checked,
57//!   tokenised without shell invocation
58//! - `ChangeConfig`: Requires `allow_change_config`, allowlisted keys only
59//!
60//! ## Dispatcher Responsibility
61//!
62//! The command dispatcher in `src/app/window_manager/scripting/mod.rs` is responsible for:
63//! 1. Checking `command.requires_permission()` before executing restricted commands
64//! 2. Verifying the corresponding `ScriptConfig.allow_*` flag is set
65//! 3. Applying rate limits, denylists, and input sanitization
66//! 4. Routing `WriteText` through the confirmation dialog when it is not
67//!    pre-approved
68//!
69//! See `par-term-scripting/SECURITY.md` for the complete security model.
70
71use serde::{Deserialize, Serialize};
72use std::collections::HashMap;
73
74/// Event kind for [`ScriptEventData::PluginActionInvoked`]: a user invoked a
75/// plugin-contributed palette action.
76pub const PLUGIN_ACTION_INVOKED_KIND: &str = "plugin_action_invoked";
77
78/// Event kind for [`ScriptEventData::OverlayEvent`]: a focused overlay's
79/// widget reported a semantic interaction (click / text_changed / select).
80pub const OVERLAY_EVENT_KIND: &str = "overlay_event";
81
82/// Event kind for [`ScriptEventData::ThemeChanged`]: the window's active
83/// theme changed, or a subscribed plugin is being greeted with the current
84/// one on (re)spawn. App-sourced: delivered by the plugin host's
85/// `sync_theme`, never by a terminal observer.
86pub const THEME_CHANGED_KIND: &str = "theme_changed";
87
88/// An event sent from the terminal to a script subprocess (via stdin).
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub struct ScriptEvent {
91    /// Event kind name (e.g., "bell_rang", "cwd_changed", "command_complete").
92    pub kind: String,
93    /// Event-specific payload.
94    pub data: ScriptEventData,
95}
96
97/// Event-specific payload data.
98///
99/// Tagged with `data_type` so the JSON includes a discriminant field for Python scripts
100/// to easily dispatch on.
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102#[serde(tag = "data_type")]
103pub enum ScriptEventData {
104    /// Empty payload for events that carry no additional data (e.g., BellRang).
105    Empty {},
106
107    /// The current working directory changed.
108    CwdChanged {
109        /// New working directory path.
110        cwd: String,
111    },
112
113    /// A command completed execution.
114    CommandComplete {
115        /// The command that completed.
116        command: String,
117        /// Exit code, if available.
118        exit_code: Option<i32>,
119    },
120
121    /// The terminal title changed.
122    TitleChanged {
123        /// New terminal title.
124        title: String,
125    },
126
127    /// The terminal size changed.
128    SizeChanged {
129        /// Number of columns.
130        cols: usize,
131        /// Number of rows.
132        rows: usize,
133    },
134
135    /// A user variable changed.
136    VariableChanged {
137        /// Variable name.
138        name: String,
139        /// New value.
140        value: String,
141        /// Previous value, if any.
142        old_value: Option<String>,
143    },
144
145    /// An environment variable changed.
146    EnvironmentChanged {
147        /// Environment variable key.
148        key: String,
149        /// New value.
150        value: String,
151        /// Previous value, if any.
152        old_value: Option<String>,
153    },
154
155    /// The badge text changed.
156    BadgeChanged {
157        /// New badge text, or None if cleared.
158        text: Option<String>,
159    },
160
161    /// A trigger pattern was matched.
162    TriggerMatched {
163        /// The trigger pattern that matched.
164        pattern: String,
165        /// The text that matched.
166        matched_text: String,
167        /// Line number where the match occurred.
168        line: usize,
169    },
170
171    /// A semantic zone event occurred.
172    ZoneEvent {
173        /// Zone identifier.
174        zone_id: u64,
175        /// Type of zone.
176        zone_type: String,
177        /// Event type (e.g., "enter", "exit").
178        event: String,
179    },
180
181    /// A plugin-contributed palette action was invoked.
182    ///
183    /// Tab scripts never receive this variant: it is written directly to the
184    /// contributing plugin process's stdin when the host invokes one of its
185    /// actions, and is never routed through the observer fan-out that
186    /// carries other events to scripts.
187    PluginActionInvoked {
188        /// Manifest id of the invoked action.
189        action: String,
190    },
191
192    /// Fallback for unmapped events. Carries arbitrary key-value fields.
193    Generic {
194        /// Arbitrary event fields.
195        fields: HashMap<String, serde_json::Value>,
196    },
197
198    /// A semantic event from a focused interactive overlay's own widget
199    /// (overlay kind only). Written directly to the overlay process's
200    /// stdin when the host's renderer reports a widget interaction —
201    /// never raw keys (design: input & focus routing).
202    OverlayEvent {
203        /// Overlay id the widget belongs to.
204        overlay: String,
205        /// The widget's scene id.
206        widget: String,
207        /// What happened on the widget.
208        event: OverlayWidgetEvent,
209    },
210
211    /// The window's active theme changed, or a subscribed plugin is being
212    /// greeted with the current one on (re)spawn. App-sourced — see
213    /// [`THEME_CHANGED_KIND`].
214    ThemeChanged {
215        /// Resolved theme name (e.g., "Dracula").
216        theme: String,
217        /// Every theme color as `#rrggbb`, keyed by Theme field name.
218        tokens: std::collections::BTreeMap<String, String>,
219    },
220}
221
222/// What a focused overlay widget reports to the plugin process.
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224#[serde(tag = "type")]
225pub enum OverlayWidgetEvent {
226    /// A button was clicked.
227    Click {},
228    /// A text input's value changed.
229    TextChanged {
230        /// Current value of the input.
231        value: String,
232    },
233    /// A list row was selected.
234    Select {
235        /// Index of the selected row.
236        index: usize,
237    },
238}
239
240/// A command sent from a script subprocess to the terminal (via stdout).
241///
242/// Tagged with `type` for easy JSON dispatch.
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
244#[serde(tag = "type")]
245pub enum ScriptCommand {
246    /// Write text to the PTY.
247    WriteText {
248        /// Text to write.
249        text: String,
250    },
251
252    /// Show a desktop notification.
253    Notify {
254        /// Notification title.
255        title: String,
256        /// Notification body.
257        body: String,
258    },
259
260    /// Set the tab badge text.
261    SetBadge {
262        /// Badge text to display.
263        text: String,
264    },
265
266    /// Set a user variable.
267    SetVariable {
268        /// Variable name.
269        name: String,
270        /// Variable value.
271        value: String,
272    },
273
274    /// Execute a shell command.
275    RunCommand {
276        /// Command to execute.
277        command: String,
278    },
279
280    /// Change a configuration value.
281    ChangeConfig {
282        /// Configuration key.
283        key: String,
284        /// New value.
285        value: serde_json::Value,
286    },
287
288    /// Log a message.
289    Log {
290        /// Log level (e.g., "info", "warn", "error", "debug").
291        level: String,
292        /// Log message.
293        message: String,
294    },
295
296    /// Set a markdown panel.
297    SetPanel {
298        /// Panel title.
299        title: String,
300        /// Markdown content.
301        content: String,
302    },
303
304    /// Clear the markdown panel.
305    ClearPanel {},
306
307    /// Upsert a plugin-owned overlay surface (overlay kind only). Idempotent
308    /// by id: same id replaces the whole overlay. Phase 1 is display-only —
309    /// the host forces `interactive` off pending the manifest capability.
310    SetOverlay {
311        /// Overlay id; unique within the plugin.
312        id: String,
313        /// Position: a named anchor or a free rect (fractions of window).
314        position: OverlayPosition,
315        /// Size as fractions of the window (0.0–1.0).
316        size: OverlaySize,
317        /// Opacity (0.0–1.0, default 1.0).
318        #[serde(default = "overlay_default_opacity")]
319        opacity: f32,
320        /// Interactive request; forced off without the manifest capability.
321        #[serde(default)]
322        interactive: bool,
323        /// Scene to render (declarative tree).
324        content: OverlayScene,
325    },
326
327    /// Clear a plugin-owned overlay by id (overlay kind only).
328    ClearOverlay {
329        /// Overlay id to remove.
330        id: String,
331    },
332
333    /// Set the text of a plugin-provided status-bar widget.
334    ///
335    /// Display-only and plugin-sourced: no permission flag exists for it
336    /// because a widget's text is strictly less powerful than `SetBadge`
337    /// (which scripts already send unrestricted). A tab *script* emitting
338    /// this command gets an error line in its output pane — the plugin host
339    /// is the only consumer.
340    SetWidget {
341        /// Widget text to display (last write wins; empty text hides the widget).
342        text: String,
343    },
344}
345
346fn overlay_default_opacity() -> f32 {
347    1.0
348}
349
350/// Where a plugin overlay sits: a named anchor or a free rect as window
351/// fractions. Free rects are clamped on-screen by the host.
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
353#[serde(rename_all = "kebab-case", untagged)]
354pub enum OverlayPosition {
355    /// One of nine named positions (corners, edge midpoints, center) or an
356    /// edge strip (`top-strip`, `bottom-strip`, `left-strip`, `right-strip`).
357    Anchor(OverlayAnchor),
358    /// Free rect: x/y/width/height as fractions of the window (0.0–1.0).
359    Free {
360        #[serde(default)]
361        x: f32,
362        #[serde(default)]
363        y: f32,
364    },
365}
366
367/// Named overlay anchors (kebab-case on the wire).
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
369#[serde(rename_all = "kebab-case")]
370pub enum OverlayAnchor {
371    TopLeft,
372    Top,
373    TopRight,
374    Left,
375    Center,
376    Right,
377    BottomLeft,
378    Bottom,
379    BottomRight,
380    /// Top edge strip spanning the window width.
381    TopStrip,
382    /// Bottom edge strip spanning the window width.
383    BottomStrip,
384    /// Left edge strip spanning the window height.
385    LeftStrip,
386    /// Right edge strip spanning the window height.
387    RightStrip,
388}
389
390/// Overlay size as fractions of the window, clamped by the host (half the
391/// window per axis).
392#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
393pub struct OverlaySize {
394    /// Width fraction (0.0–1.0).
395    pub w: f32,
396    /// Height fraction (0.0–1.0).
397    pub h: f32,
398}
399
400/// A declarative scene tree the host renders through egui. Phase 1
401/// vocabulary: text, row, markdown; phase 2 adds the interactive
402/// button / text_input / list (rendered only while the overlay may
403/// focus — see `PluginOverlay::interactive`). No images (deferred by
404/// design).
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
406#[serde(rename_all = "snake_case", tag = "type")]
407pub enum OverlayScene {
408    /// Plain text.
409    Text { text: String },
410    /// Children laid out horizontally.
411    Row { children: Vec<OverlayScene> },
412    /// Markdown block.
413    Markdown { text: String },
414    /// Clickable button (interactive vocabulary, O3).
415    Button {
416        /// Scene-unique widget id the click event names.
417        id: String,
418        /// Button label.
419        label: String,
420    },
421    /// Single-line text input (interactive vocabulary, O3).
422    TextInput {
423        /// Scene-unique widget id events name.
424        id: String,
425        /// Current value; full-scene replace semantics (the plugin owns
426        /// state).
427        value: String,
428        /// Placeholder shown when empty.
429        #[serde(default)]
430        placeholder: String,
431    },
432    /// Selectable row list (interactive vocabulary, O3).
433    List {
434        /// Scene-unique widget id events name.
435        id: String,
436        /// Row labels.
437        items: Vec<String>,
438        /// Currently selected row index, if any.
439        #[serde(default)]
440        selected: Option<usize>,
441    },
442}
443
444/// The host-side stored form of a live overlay. `interactive` is true only
445/// when the request asked for it AND the plugin's manifest carries the
446/// `overlay.interactive` capability (phase 2) — the ingest path forces it
447/// off otherwise.
448#[derive(Debug, Clone, PartialEq)]
449pub struct PluginOverlay {
450    /// Overlay id (unique within the plugin).
451    pub id: String,
452    /// Anchor or free-rect position.
453    pub position: OverlayPosition,
454    /// Size as window fractions.
455    pub size: OverlaySize,
456    /// Opacity (0.0–1.0).
457    pub opacity: f32,
458    /// Whether the overlay may take focus and render interactive widgets.
459    pub interactive: bool,
460    /// Scene to render.
461    pub content: OverlayScene,
462}
463
464/// Strip VT/ANSI escape sequences from text before PTY injection.
465///
466/// Removes CSI (`ESC[`), OSC (`ESC]`), DCS (`ESC P`), APC (`ESC _`),
467/// PM (`ESC ^`), SOS (`ESC X`) sequences, and bare two-byte `ESC x`
468/// sequences. Printable characters and newlines are passed through.
469///
470/// This is required for safe `WriteText` dispatch: a script must not be
471/// able to embed control sequences that reposition the cursor, exfiltrate
472/// data, or otherwise corrupt the terminal state.
473pub fn strip_vt_sequences(text: &str) -> String {
474    let mut result = String::with_capacity(text.len());
475    let mut chars = text.chars().peekable();
476
477    while let Some(c) = chars.next() {
478        if c != '\x1b' {
479            result.push(c);
480            continue;
481        }
482        // ESC seen — classify and skip the sequence
483        match chars.peek().copied() {
484            Some('[') => {
485                // CSI: ESC [ ... <final-byte>
486                chars.next(); // consume '['
487                while let Some(&ch) = chars.peek() {
488                    chars.next();
489                    if ch.is_ascii_alphabetic() || ch == '@' || ch == '`' {
490                        break;
491                    }
492                }
493            }
494            Some(']') => {
495                // OSC: ESC ] ... BEL or ST (ESC \)
496                chars.next(); // consume ']'
497                while let Some(ch) = chars.next() {
498                    if ch == '\x07' {
499                        break;
500                    }
501                    if ch == '\x1b' && chars.peek() == Some(&'\\') {
502                        chars.next();
503                        break;
504                    }
505                }
506            }
507            Some('P') | Some('_') | Some('^') | Some('X') => {
508                // DCS / APC / PM / SOS: ESC <type> ... ST (ESC \)
509                chars.next(); // consume the type byte
510                while let Some(ch) = chars.next() {
511                    if ch == '\x1b' && chars.peek() == Some(&'\\') {
512                        chars.next();
513                        break;
514                    }
515                }
516            }
517            Some('(') | Some(')') | Some('*') | Some('+') => {
518                // Character-set designation: ESC ( x — skip two bytes
519                chars.next();
520                chars.next();
521            }
522            Some(_) => {
523                // Generic two-byte ESC sequence — skip one byte
524                chars.next();
525            }
526            None => {}
527        }
528    }
529    result
530}
531
532impl ScriptCommand {
533    /// Returns `true` if this command requires explicit permission in the script config.
534    ///
535    /// Commands that return `true` must have their corresponding `allow_*` flag set
536    /// in `ScriptConfig` before the dispatcher will execute them.
537    ///
538    /// # Security Classification
539    ///
540    /// | Command | Requires Permission | Risk Level |
541    /// |---------|--------------------| -----------|
542    /// | `Log` | No | Low (UI output only) |
543    /// | `SetPanel` / `ClearPanel` | No | Low (UI display only) |
544    /// | `Notify` | No | Low (desktop notification) |
545    /// | `SetBadge` | No | Low (tab badge display) |
546    /// | `SetVariable` | No | Low (user variable storage) |
547    /// | `WriteText` | **Yes** | High (PTY injection, command execution) |
548    /// | `RunCommand` | **Yes** | Critical (arbitrary process spawn) |
549    /// | `ChangeConfig` | **Yes** | High (config modification) |
550    pub fn requires_permission(&self) -> bool {
551        matches!(
552            self,
553            ScriptCommand::RunCommand { .. }
554                | ScriptCommand::WriteText { .. }
555                | ScriptCommand::ChangeConfig { .. }
556        )
557    }
558
559    /// Returns the name of the permission flag required to execute this command.
560    ///
561    /// Returns `None` for commands that don't require permission.
562    /// The returned string corresponds to a field in `ScriptConfig`:
563    /// - `"allow_run_command"` for `RunCommand`
564    /// - `"allow_write_text"` for `WriteText`
565    /// - `"allow_change_config"` for `ChangeConfig`
566    pub fn permission_flag_name(&self) -> Option<&'static str> {
567        match self {
568            ScriptCommand::RunCommand { .. } => Some("allow_run_command"),
569            ScriptCommand::WriteText { .. } => Some("allow_write_text"),
570            ScriptCommand::ChangeConfig { .. } => Some("allow_change_config"),
571            _ => None,
572        }
573    }
574
575    /// Returns `true` if this command can safely be executed without rate limiting.
576    ///
577    /// Commands that may be emitted frequently (like `Log`) should not be rate-limited
578    /// to avoid dropping important debug output. High-impact commands (`WriteText`,
579    /// `RunCommand`) must be rate-limited to prevent abuse.
580    pub fn is_rate_limited(&self) -> bool {
581        matches!(
582            self,
583            ScriptCommand::RunCommand { .. } | ScriptCommand::WriteText { .. }
584        )
585    }
586
587    /// Returns a human-readable name for this command type (for logging/errors).
588    pub fn command_name(&self) -> &'static str {
589        match self {
590            ScriptCommand::WriteText { .. } => "WriteText",
591            ScriptCommand::Notify { .. } => "Notify",
592            ScriptCommand::SetBadge { .. } => "SetBadge",
593            ScriptCommand::SetVariable { .. } => "SetVariable",
594            ScriptCommand::RunCommand { .. } => "RunCommand",
595            ScriptCommand::ChangeConfig { .. } => "ChangeConfig",
596            ScriptCommand::Log { .. } => "Log",
597            ScriptCommand::SetPanel { .. } => "SetPanel",
598            ScriptCommand::ClearPanel {} => "ClearPanel",
599            ScriptCommand::SetOverlay { .. } => "SetOverlay",
600            ScriptCommand::ClearOverlay { .. } => "ClearOverlay",
601            ScriptCommand::SetWidget { .. } => "SetWidget",
602        }
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn set_overlay_round_trips_through_serde() {
612        let line = r##"{"type":"SetOverlay","id":"hud","position":"top-right","size":{"w":0.2,"h":0.1},"opacity":0.8,"content":{"type":"markdown","text":"# hello"}}"##;
613        let cmd: ScriptCommand = serde_json::from_str(line).expect("parse SetOverlay line");
614        match &cmd {
615            ScriptCommand::SetOverlay {
616                id,
617                position,
618                size,
619                opacity,
620                interactive,
621                content,
622            } => {
623                assert_eq!(id, "hud");
624                assert_eq!(position, &OverlayPosition::Anchor(OverlayAnchor::TopRight));
625                assert_eq!((size.w, size.h), (0.2, 0.1));
626                assert_eq!(*opacity, 0.8);
627                assert!(!interactive, "interactive defaults off");
628                assert_eq!(
629                    content,
630                    &OverlayScene::Markdown {
631                        text: "# hello".to_string()
632                    }
633                );
634            }
635            other => panic!("wrong variant: {other:?}"),
636        }
637        let re = serde_json::to_string(&cmd).expect("serialize SetOverlay");
638        let back: ScriptCommand = serde_json::from_str(&re).expect("round-trip");
639        assert_eq!(back, cmd);
640    }
641
642    #[test]
643    fn clear_overlay_parses() {
644        let line = r#"{"type":"ClearOverlay","id":"hud"}"#;
645        let cmd: ScriptCommand = serde_json::from_str(line).expect("parse ClearOverlay line");
646        assert_eq!(
647            cmd,
648            ScriptCommand::ClearOverlay {
649                id: "hud".to_string()
650            }
651        );
652    }
653
654    #[test]
655    fn set_overlay_free_rect_position_parses() {
656        let line = r#"{"type":"SetOverlay","id":"f","position":{"x":0.1,"y":0.2},"size":{"w":0.3,"h":0.3},"content":{"type":"text","text":"t"}}"#;
657        let cmd: ScriptCommand = serde_json::from_str(line).expect("parse free-rect SetOverlay");
658        match cmd {
659            ScriptCommand::SetOverlay { position, .. } => {
660                assert_eq!(position, OverlayPosition::Free { x: 0.1, y: 0.2 });
661            }
662            other => panic!("wrong variant: {other:?}"),
663        }
664    }
665
666    #[test]
667    fn set_widget_round_trips_through_serde() {
668        let line = r#"{"type":"SetWidget","text":"🕒 14:32"}"#;
669        let cmd: ScriptCommand = serde_json::from_str(line).expect("parse SetWidget line");
670        assert_eq!(
671            cmd,
672            ScriptCommand::SetWidget {
673                text: "🕒 14:32".into()
674            }
675        );
676        let re = serde_json::to_string(&cmd).expect("serialize SetWidget");
677        let back: ScriptCommand = serde_json::from_str(&re).expect("round-trip");
678        assert_eq!(back, cmd);
679    }
680
681    #[test]
682    fn set_widget_requires_no_permission_and_is_not_rate_limited() {
683        let cmd = ScriptCommand::SetWidget {
684            text: String::new(),
685        };
686        assert!(!cmd.requires_permission());
687        assert!(cmd.permission_flag_name().is_none());
688        assert!(!cmd.is_rate_limited());
689        assert_eq!(cmd.command_name(), "SetWidget");
690    }
691
692    #[test]
693    fn interactive_scene_nodes_round_trip_through_serde() {
694        let scene = OverlayScene::Row {
695            children: vec![
696                OverlayScene::Button {
697                    id: "deploy".to_string(),
698                    label: "Deploy".to_string(),
699                },
700                OverlayScene::TextInput {
701                    id: "filter".to_string(),
702                    value: "par".to_string(),
703                    placeholder: "filter…".to_string(),
704                },
705                OverlayScene::List {
706                    id: "jobs".to_string(),
707                    items: vec!["one".to_string(), "two".to_string()],
708                    selected: Some(1),
709                },
710            ],
711        };
712        let json = serde_json::to_string(&scene).expect("serialize scene");
713        let back: OverlayScene = serde_json::from_str(&json).expect("parse scene back");
714        assert_eq!(back, scene);
715
716        // Wire shapes stay snake_case with placeholder/selected optional.
717        assert!(json.contains(r#""type":"button""#), "got: {json}");
718        assert!(json.contains(r#""type":"text_input""#), "got: {json}");
719        assert!(json.contains(r#""type":"list""#), "got: {json}");
720        let no_optional: OverlayScene =
721            serde_json::from_str(r#"{"type":"text_input","id":"f","value":""}"#)
722                .expect("optional fields default");
723        assert_eq!(
724            no_optional,
725            OverlayScene::TextInput {
726                id: "f".to_string(),
727                value: String::new(),
728                placeholder: String::new(),
729            }
730        );
731    }
732
733    #[test]
734    fn overlay_event_round_trips_through_serde() {
735        for data in [
736            ScriptEventData::OverlayEvent {
737                overlay: "hud".to_string(),
738                widget: "deploy".to_string(),
739                event: OverlayWidgetEvent::Click {},
740            },
741            ScriptEventData::OverlayEvent {
742                overlay: "hud".to_string(),
743                widget: "filter".to_string(),
744                event: OverlayWidgetEvent::TextChanged {
745                    value: "par".to_string(),
746                },
747            },
748            ScriptEventData::OverlayEvent {
749                overlay: "hud".to_string(),
750                widget: "jobs".to_string(),
751                event: OverlayWidgetEvent::Select { index: 2 },
752            },
753        ] {
754            let json = serde_json::to_string(&data).expect("serialize event data");
755            let back: ScriptEventData = serde_json::from_str(&json).expect("parse back");
756            assert_eq!(back, data);
757        }
758        // One exact wire shape: nested tag + flat fields, plugin-parseable.
759        let json = serde_json::to_string(&ScriptEventData::OverlayEvent {
760            overlay: "hud".to_string(),
761            widget: "deploy".to_string(),
762            event: OverlayWidgetEvent::Click {},
763        })
764        .expect("serialize");
765        assert_eq!(
766            json,
767            r#"{"data_type":"OverlayEvent","overlay":"hud","widget":"deploy","event":{"type":"Click"}}"#
768        );
769    }
770
771    #[test]
772    fn plugin_action_invoked_round_trips_through_serde() {
773        let data = ScriptEventData::PluginActionInvoked {
774            action: "greet".to_string(),
775        };
776        let json = serde_json::to_string(&data).expect("serialize event data");
777        assert_eq!(
778            json, r#"{"data_type":"PluginActionInvoked","action":"greet"}"#,
779            "the wire shape plugin processes parse must stay exact"
780        );
781        let back: ScriptEventData = serde_json::from_str(&json).expect("parse event data back");
782        assert_eq!(back, data);
783
784        let event = ScriptEvent {
785            kind: PLUGIN_ACTION_INVOKED_KIND.to_string(),
786            data,
787        };
788        let line = serde_json::to_string(&event).expect("serialize event");
789        assert_eq!(
790            line,
791            r#"{"kind":"plugin_action_invoked","data":{"data_type":"PluginActionInvoked","action":"greet"}}"#
792        );
793        let parsed: ScriptEvent = serde_json::from_str(&line).expect("parse event line back");
794        assert_eq!(parsed, event);
795    }
796}