Skip to main content

synaps_cli/extensions/
settings_editor.rs

1//! Plugin custom-editor JSON-RPC payloads (Path B Phase 4).
2//!
3//! When a user opens a plugin-declared settings field whose `editor` is
4//! `"custom"`, Synaps and the plugin exchange these typed messages over
5//! the existing JSON-RPC channel:
6//!
7//! ```text
8//! synaps → plugin   settings.editor.open      { category, field }
9//! plugin → synaps   settings.editor.render    { rows, cursor?, footer? }   (notification, repeated)
10//! synaps → plugin   settings.editor.key       { key }                      (per keypress)
11//! plugin → synaps   settings.editor.commit    { value }                    (when user accepts)
12//! ```
13//!
14//! The render notification is a server-push that may be emitted multiple
15//! times as the plugin's editor state evolves; consumers should debounce
16//! at the UI layer to avoid flicker.
17//!
18//! Wire shape mirrors `extensions::commands::CommandOutputEvent`. This
19//! module owns *only* the typed contracts and a small parser; the
20//! settings UI glue (overlay rendering, key dispatch, debounce) lives in
21//! `chatui/settings/`.
22
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26pub const METHOD_OPEN: &str = "settings.editor.open";
27pub const METHOD_RENDER: &str = "settings.editor.render";
28pub const METHOD_KEY: &str = "settings.editor.key";
29pub const METHOD_COMMIT: &str = "settings.editor.commit";
30pub const METHOD_CLOSE: &str = "settings.editor.close";
31
32/// `settings.editor.open` — synaps → plugin request when the user opens
33/// a custom editor for `(category, field)`.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct SettingsEditorOpenParams {
36    pub category: String,
37    pub field: String,
38}
39
40/// `settings.editor.key` — synaps → plugin notification on each
41/// keypress while a custom editor is focused. `key` is the string form
42/// produced by `crossterm::event::KeyEvent` (e.g. `"Down"`, `"Enter"`,
43/// `"Esc"`, `"Char(' ')"` — the exact lexicon is documented alongside
44/// the keybind subsystem).
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct SettingsEditorKeyParams {
47    pub key: String,
48}
49
50/// `settings.editor.commit` — plugin → synaps notification when the user
51/// accepts a value. Synaps writes `value` to the plugin's config
52/// namespace at `(category, field)` and closes the overlay.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct SettingsEditorCommitParams {
55    pub value: Value,
56}
57
58/// `settings.editor.close` — either side may emit this to dismiss the
59/// overlay (e.g. plugin-side cancellation).
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
61pub struct SettingsEditorCloseParams {
62    /// Optional reason string surfaced to the user as a transient note.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub reason: Option<String>,
65}
66
67/// `settings.editor.render` — plugin → synaps notification carrying the
68/// current visual state of the editor body.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct SettingsEditorRenderParams {
71    pub rows: Vec<SettingsEditorRow>,
72    /// Index into `rows` of the currently-highlighted entry. `None`
73    /// means no row is selected (e.g. a free-text editor).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub cursor: Option<usize>,
76    /// Bottom hint line (e.g. `"↓ Up/Down  Enter to select  Esc to cancel"`).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub footer: Option<String>,
79}
80
81/// A single row in the custom editor body.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct SettingsEditorRow {
84    pub label: String,
85    /// Short marker glyph, e.g. `"✓"`, `" "`, `"→"`. The UI renders this
86    /// in a fixed-width gutter so columns align across rows.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub marker: Option<String>,
89    /// `true` when the row reacts to Enter. Non-selectable rows are
90    /// rendered dimmed and skipped by cursor navigation.
91    #[serde(default = "default_true")]
92    pub selectable: bool,
93    /// Plugin-side opaque payload echoed back via `settings.editor.commit`
94    /// when the user accepts this row. Synaps does not interpret it.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub data: Option<Value>,
97}
98
99fn default_true() -> bool {
100    true
101}
102
103/// Errors returned when parsing a JSON-RPC frame whose method belongs
104/// to the `settings.editor.*` family but whose params don't match the
105/// expected shape.
106#[derive(Debug, thiserror::Error)]
107pub enum SettingsEditorParseError {
108    #[error("unknown settings.editor method: {0}")]
109    UnknownMethod(String),
110    #[error("invalid params for {method}: {source}")]
111    InvalidParams {
112        method: &'static str,
113        #[source]
114        source: serde_json::Error,
115    },
116}
117
118/// Normalised view of a single inbound frame. Distinguishes the four
119/// notifications the core can receive from the plugin side. (The
120/// `open`/`key` requests originate from the core and are typed via the
121/// individual params structs above.)
122#[derive(Debug, Clone, PartialEq)]
123pub enum InboundSettingsEditorFrame {
124    Render(SettingsEditorRenderParams),
125    Commit(SettingsEditorCommitParams),
126    Close(SettingsEditorCloseParams),
127}
128
129/// Parse the params object of a `settings.editor.*` notification.
130pub fn parse_inbound(
131    method: &str,
132    params: Value,
133) -> Result<InboundSettingsEditorFrame, SettingsEditorParseError> {
134    match method {
135        METHOD_RENDER => serde_json::from_value(params)
136            .map(InboundSettingsEditorFrame::Render)
137            .map_err(|source| SettingsEditorParseError::InvalidParams {
138                method: METHOD_RENDER,
139                source,
140            }),
141        METHOD_COMMIT => serde_json::from_value(params)
142            .map(InboundSettingsEditorFrame::Commit)
143            .map_err(|source| SettingsEditorParseError::InvalidParams {
144                method: METHOD_COMMIT,
145                source,
146            }),
147        METHOD_CLOSE => serde_json::from_value(params)
148            .map(InboundSettingsEditorFrame::Close)
149            .map_err(|source| SettingsEditorParseError::InvalidParams {
150                method: METHOD_CLOSE,
151                source,
152            }),
153        other => Err(SettingsEditorParseError::UnknownMethod(other.to_string())),
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use serde_json::json;
161
162    #[test]
163    fn open_params_round_trip() {
164        let p = SettingsEditorOpenParams {
165            category: "capture".to_string(),
166            field: "model_path".to_string(),
167        };
168        let v = serde_json::to_value(&p).unwrap();
169        assert_eq!(v, json!({"category":"capture","field":"model_path"}));
170        let back: SettingsEditorOpenParams = serde_json::from_value(v).unwrap();
171        assert_eq!(back, p);
172    }
173
174    #[test]
175    fn render_params_parse_full_example() {
176        let v = json!({
177            "rows": [
178                { "label": "tiny.en (75 MB)", "marker": "✓", "selectable": true,  "data": "/abs/path/.bin" },
179                { "label": "base (142 MB)",   "marker": " ", "selectable": true,  "data": "download:base" },
180                { "label": "(separator)",                      "selectable": false }
181            ],
182            "cursor": 2,
183            "footer": "Up/Down  Enter to select"
184        });
185        let frame = parse_inbound(METHOD_RENDER, v).unwrap();
186        match frame {
187            InboundSettingsEditorFrame::Render(r) => {
188                assert_eq!(r.rows.len(), 3);
189                assert_eq!(r.rows[0].marker.as_deref(), Some("✓"));
190                assert!(r.rows[0].selectable);
191                assert_eq!(r.rows[0].data.as_ref().unwrap(), &json!("/abs/path/.bin"));
192                assert!(!r.rows[2].selectable);
193                assert_eq!(r.cursor, Some(2));
194                assert_eq!(r.footer.as_deref(), Some("Up/Down  Enter to select"));
195            }
196            _ => panic!("expected render frame"),
197        }
198    }
199
200    #[test]
201    fn render_row_selectable_defaults_to_true() {
202        let v = json!({ "rows": [ { "label": "x" } ] });
203        let frame = parse_inbound(METHOD_RENDER, v).unwrap();
204        match frame {
205            InboundSettingsEditorFrame::Render(r) => {
206                assert!(r.rows[0].selectable);
207                assert!(r.rows[0].marker.is_none());
208                assert!(r.rows[0].data.is_none());
209            }
210            _ => panic!(),
211        }
212    }
213
214    #[test]
215    fn commit_params_carry_arbitrary_value() {
216        let v = json!({ "value": { "path": "/x", "id": 7 } });
217        let frame = parse_inbound(METHOD_COMMIT, v).unwrap();
218        match frame {
219            InboundSettingsEditorFrame::Commit(c) => {
220                assert_eq!(c.value["path"], "/x");
221                assert_eq!(c.value["id"], 7);
222            }
223            _ => panic!(),
224        }
225    }
226
227    #[test]
228    fn close_params_optional_reason() {
229        let frame = parse_inbound(METHOD_CLOSE, json!({})).unwrap();
230        match frame {
231            InboundSettingsEditorFrame::Close(c) => assert!(c.reason.is_none()),
232            _ => panic!(),
233        }
234        let frame = parse_inbound(METHOD_CLOSE, json!({"reason":"cancelled"})).unwrap();
235        match frame {
236            InboundSettingsEditorFrame::Close(c) => assert_eq!(c.reason.as_deref(), Some("cancelled")),
237            _ => panic!(),
238        }
239    }
240
241    #[test]
242    fn unknown_method_rejected() {
243        let err = parse_inbound("settings.editor.bogus", json!({})).unwrap_err();
244        assert!(matches!(err, SettingsEditorParseError::UnknownMethod(_)));
245    }
246
247    #[test]
248    fn invalid_render_params_rejected() {
249        // `rows` must be an array.
250        let err = parse_inbound(METHOD_RENDER, json!({"rows": "nope"})).unwrap_err();
251        assert!(matches!(
252            err,
253            SettingsEditorParseError::InvalidParams {
254                method: METHOD_RENDER,
255                ..
256            }
257        ));
258    }
259
260    #[test]
261    fn key_params_round_trip() {
262        let p = SettingsEditorKeyParams { key: "Down".into() };
263        let v = serde_json::to_value(&p).unwrap();
264        assert_eq!(v, json!({"key":"Down"}));
265    }
266}