Skip to main content

rpi_extensions/
editor_text.rs

1//! Editor text injection.
2//!
3//! Lets an extension place text into the interactive editor (the prompt input
4//! box) instead of sending it straight to the model. Mirrors
5//! [`ExtensionStatusMailbox`](crate::ExtensionStatusMailbox) and
6//! [`UiDialogMailbox`](crate::UiDialogMailbox): the plugin writes through the
7//! `SetEditorText` runtime action, and the TUI — the only consumer — polls
8//! [`EditorTextMailbox::take_pending`] once per tick.
9//!
10//! This exists for voice input: a transcription is a *draft*, not a message.
11//! Pasting it into the editor lets the user fix a mis-recognized word before it
12//! becomes part of the conversation, and submitting from the editor renders the
13//! user bubble through the normal path.
14//!
15//! ## Why a queue and not a plain setter
16//!
17//! Writes must not be lost when the TUI is busy rendering, and a headless run
18//! has no editor at all. An unbounded queue of pending edits (each an
19//! `append`/`replace` operation) keeps the plugin's call non-blocking and
20//! lossless; a headless host simply never drains it.
21
22use std::collections::VecDeque;
23use std::sync::{Arc, Mutex};
24
25/// How a pending edit combines with what the editor already holds.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum EditorTextMode {
28    /// Replace the whole draft.
29    Replace,
30    /// Append to the end of the existing draft (separated by a space).
31    Append,
32}
33
34/// One pending edit to apply to the editor.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct EditorTextEdit {
37    pub text: String,
38    pub mode: EditorTextMode,
39    /// When true the TUI should start its auto-send countdown for this edit:
40    /// the text is a draft the user may correct, but it is sent automatically
41    /// once the countdown elapses untouched.
42    pub auto_send_ms: Option<u64>,
43}
44
45impl EditorTextEdit {
46    /// Parse the `SetEditorText` action payload:
47    /// `{"text":"...", "mode":"append"|"replace", "autoSendMs": 3000}`.
48    pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
49        let text = args
50            .get("text")
51            .and_then(serde_json::Value::as_str)
52            .ok_or("set_editor_text requires a string `text`")?;
53        let mode = match args
54            .get("mode")
55            .and_then(serde_json::Value::as_str)
56            .unwrap_or("replace")
57            .to_ascii_lowercase()
58            .as_str()
59        {
60            "" | "replace" | "set" => EditorTextMode::Replace,
61            "append" => EditorTextMode::Append,
62            other => return Err(format!("unknown editor text mode `{other}`")),
63        };
64        // `0` (or an absent/negative value) disables auto-send.
65        let auto_send_ms = args
66            .get("autoSendMs")
67            .and_then(serde_json::Value::as_u64)
68            .filter(|ms| *ms > 0);
69        Ok(Self {
70            text: text.to_string(),
71            mode,
72            auto_send_ms,
73        })
74    }
75}
76
77/// Shared, cloneable handle to the pending-edit queue.
78#[derive(Clone, Default)]
79pub struct EditorTextMailbox {
80    pending: Arc<Mutex<VecDeque<EditorTextEdit>>>,
81}
82
83impl EditorTextMailbox {
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Queue an edit. Never blocks and never fails (a poisoned lock, which would
89    /// mean a panicking reader, just drops the write).
90    pub fn push(&self, edit: EditorTextEdit) {
91        if let Ok(mut pending) = self.pending.lock() {
92            pending.push_back(edit);
93        }
94    }
95
96    /// Handle the `SetEditorText` runtime action payload.
97    pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
98        let edit = EditorTextEdit::from_json(&args)?;
99        let len = edit.text.chars().count();
100        self.push(edit);
101        Ok(serde_json::json!({ "ok": true, "chars": len }))
102    }
103
104    /// Remove and return the next queued edit, or `None` when idle.
105    pub fn take_pending(&self) -> Option<EditorTextEdit> {
106        self.pending.lock().ok()?.pop_front()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn push_and_take_round_trip_fifo() {
116        let mailbox = EditorTextMailbox::new();
117        assert!(mailbox.take_pending().is_none());
118        mailbox.push(EditorTextEdit {
119            text: "first".into(),
120            mode: EditorTextMode::Replace,
121            auto_send_ms: None,
122        });
123        mailbox.push(EditorTextEdit {
124            text: "second".into(),
125            mode: EditorTextMode::Append,
126            auto_send_ms: Some(3000),
127        });
128        let a = mailbox.take_pending().expect("first");
129        assert_eq!(a.text, "first");
130        assert_eq!(a.mode, EditorTextMode::Replace);
131        let b = mailbox.take_pending().expect("second");
132        assert_eq!(b.text, "second");
133        assert_eq!(b.mode, EditorTextMode::Append);
134        assert_eq!(b.auto_send_ms, Some(3000));
135        assert!(mailbox.take_pending().is_none());
136    }
137
138    #[test]
139    fn handle_defaults_to_replace_without_auto_send() {
140        let mailbox = EditorTextMailbox::new();
141        let out = mailbox
142            .handle(serde_json::json!({"text": "hello"}))
143            .expect("ok");
144        assert_eq!(out["ok"], serde_json::json!(true));
145        assert_eq!(out["chars"], serde_json::json!(5));
146        let edit = mailbox.take_pending().unwrap();
147        assert_eq!(edit.mode, EditorTextMode::Replace);
148        assert_eq!(edit.auto_send_ms, None);
149    }
150
151    #[test]
152    fn handle_parses_mode_and_auto_send() {
153        let mailbox = EditorTextMailbox::new();
154        mailbox
155            .handle(serde_json::json!({
156                "text": "draft", "mode": "append", "autoSendMs": 2500
157            }))
158            .expect("ok");
159        let edit = mailbox.take_pending().unwrap();
160        assert_eq!(edit.mode, EditorTextMode::Append);
161        assert_eq!(edit.auto_send_ms, Some(2500));
162    }
163
164    #[test]
165    fn handle_rejects_missing_text_and_unknown_mode() {
166        let mailbox = EditorTextMailbox::new();
167        assert!(mailbox.handle(serde_json::json!({})).is_err());
168        assert!(mailbox
169            .handle(serde_json::json!({"text": "x", "mode": "sideways"}))
170            .is_err());
171        // A rejected payload must not enqueue anything.
172        assert!(mailbox.take_pending().is_none());
173    }
174
175    #[test]
176    fn zero_auto_send_means_manual_only() {
177        let mailbox = EditorTextMailbox::new();
178        mailbox
179            .handle(serde_json::json!({"text": "x", "autoSendMs": 0}))
180            .expect("ok");
181        assert_eq!(mailbox.take_pending().unwrap().auto_send_ms, None);
182    }
183}