Skip to main content

rmux_core/
terminal_passthrough.rs

1use std::sync::Arc;
2
3use crate::input::InputEndType;
4
5/// Maximum payload size retained for one terminal graphics passthrough event.
6pub(crate) const MAX_TERMINAL_PASSTHROUGH_PAYLOAD_BYTES: usize = 8 * 1024 * 1024;
7
8/// Opaque terminal command that must be forwarded to a capable outer terminal.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct TerminalPassthrough {
11    kind: TerminalPassthroughKind,
12    cursor_x: u32,
13    cursor_y: u32,
14    palette_index: Option<TerminalPaletteIndex>,
15    clipboard_query: Option<TerminalClipboardQuery>,
16    payload: Arc<[u8]>,
17}
18
19/// Supported terminal passthrough protocol families.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum TerminalPassthroughKind {
22    /// Opaque tmux DCS passthrough payload, already framed for the outer terminal.
23    Raw,
24    /// OSC 52 clipboard payload emitted by a pane program.
25    Clipboard,
26    /// OSC 4 palette query relayed to the attached outer terminal.
27    PaletteQuery,
28    /// Kitty terminal graphics protocol, encoded as an APC payload.
29    KittyGraphics,
30    /// SIXEL graphics protocol, encoded as a DCS payload.
31    Sixel,
32}
33
34/// A terminal palette index accepted by OSC 4.
35///
36/// OSC 4 addresses the 256-entry terminal palette. Keeping the bound in a
37/// type prevents arbitrary OSC bodies from being reflected through the outer
38/// terminal query path.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct TerminalPaletteIndex(u8);
41
42/// A bounded pane-originated OSC 52 clipboard query.
43///
44/// The selector is reduced to the first tmux-supported selector byte. Invalid
45/// selector bytes are discarded instead of being reflected to a terminal.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct TerminalClipboardQuery {
48    selection: Option<u8>,
49    terminator: InputEndType,
50}
51
52impl TerminalClipboardQuery {
53    const VALID_SELECTIONS: &'static [u8] = b"cpqs01234567";
54
55    /// Creates a typed query from an OSC 52 selection field and terminator.
56    #[must_use]
57    pub fn new(selection: &str, terminator: InputEndType) -> Self {
58        Self {
59            selection: selection
60                .bytes()
61                .find(|byte| Self::VALID_SELECTIONS.contains(byte)),
62            terminator,
63        }
64    }
65
66    /// Returns the first valid tmux clipboard selector, if any.
67    #[must_use]
68    pub const fn selection(self) -> Option<u8> {
69        self.selection
70    }
71
72    /// Returns the pane query's original OSC terminator.
73    #[must_use]
74    pub const fn terminator(self) -> InputEndType {
75        self.terminator
76    }
77}
78
79impl TerminalPaletteIndex {
80    /// Parses one strict ASCII-decimal palette index in the inclusive 0..=255
81    /// range.
82    #[must_use]
83    pub fn parse(value: &str) -> Option<Self> {
84        if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
85            return None;
86        }
87        value.parse::<u8>().ok().map(Self)
88    }
89
90    /// Returns the numeric palette index.
91    #[must_use]
92    pub const fn get(self) -> u8 {
93        self.0
94    }
95}
96
97impl From<u8> for TerminalPaletteIndex {
98    fn from(value: u8) -> Self {
99        Self(value)
100    }
101}
102
103impl TerminalPassthrough {
104    /// Creates an opaque passthrough event at a pane-local cursor position.
105    #[must_use]
106    pub fn raw(cursor_x: u32, cursor_y: u32, payload: impl Into<Vec<u8>>) -> Self {
107        Self {
108            kind: TerminalPassthroughKind::Raw,
109            cursor_x,
110            cursor_y,
111            palette_index: None,
112            clipboard_query: None,
113            payload: Arc::from(payload.into()),
114        }
115    }
116
117    /// Creates an OSC 52 clipboard passthrough event.
118    #[must_use]
119    pub fn clipboard(payload: impl Into<Vec<u8>>) -> Self {
120        Self {
121            kind: TerminalPassthroughKind::Clipboard,
122            cursor_x: 0,
123            cursor_y: 0,
124            palette_index: None,
125            clipboard_query: None,
126            payload: Arc::from(payload.into()),
127        }
128    }
129
130    /// Creates a typed OSC 52 clipboard query event while retaining its
131    /// original framed payload for inspection.
132    #[must_use]
133    pub fn clipboard_query(query: TerminalClipboardQuery, payload: impl Into<Vec<u8>>) -> Self {
134        Self {
135            // Preserve the public protocol-family classification: adding a
136            // new enum variant would break exhaustive downstream matches in
137            // a patch release. The additive typed metadata distinguishes a
138            // query from an OSC 52 write internally.
139            kind: TerminalPassthroughKind::Clipboard,
140            cursor_x: 0,
141            cursor_y: 0,
142            palette_index: None,
143            clipboard_query: Some(query),
144            payload: Arc::from(payload.into()),
145        }
146    }
147
148    /// Creates a bounded OSC 4 query for one palette index.
149    ///
150    /// tmux 3.7b canonicalizes both BEL- and ST-terminated pane queries to an
151    /// ST-terminated sequence before sending them to the outer terminal.
152    #[must_use]
153    pub fn palette_query(index: TerminalPaletteIndex) -> Self {
154        let payload = format!("\x1b]4;{};?\x1b\\", index.get()).into_bytes();
155        Self {
156            kind: TerminalPassthroughKind::PaletteQuery,
157            cursor_x: 0,
158            cursor_y: 0,
159            palette_index: Some(index),
160            clipboard_query: None,
161            payload: Arc::from(payload),
162        }
163    }
164
165    /// Creates a Kitty graphics passthrough event at a pane-local cursor position.
166    #[must_use]
167    pub fn kitty_graphics(cursor_x: u32, cursor_y: u32, payload: impl Into<Vec<u8>>) -> Self {
168        Self {
169            kind: TerminalPassthroughKind::KittyGraphics,
170            cursor_x,
171            cursor_y,
172            palette_index: None,
173            clipboard_query: None,
174            payload: Arc::from(payload.into()),
175        }
176    }
177
178    /// Creates a SIXEL passthrough event at a pane-local cursor position.
179    #[must_use]
180    pub fn sixel(cursor_x: u32, cursor_y: u32, payload: impl Into<Vec<u8>>) -> Self {
181        Self {
182            kind: TerminalPassthroughKind::Sixel,
183            cursor_x,
184            cursor_y,
185            palette_index: None,
186            clipboard_query: None,
187            payload: Arc::from(payload.into()),
188        }
189    }
190
191    /// Returns the passthrough protocol family.
192    #[must_use]
193    pub const fn kind(&self) -> TerminalPassthroughKind {
194        self.kind
195    }
196
197    /// Returns the pane-local cursor column captured when the sequence arrived.
198    #[must_use]
199    pub const fn cursor_x(&self) -> u32 {
200        self.cursor_x
201    }
202
203    /// Returns the pane-local cursor row captured when the sequence arrived.
204    #[must_use]
205    pub const fn cursor_y(&self) -> u32 {
206        self.cursor_y
207    }
208
209    /// Returns the opaque protocol payload without escape framing.
210    #[must_use]
211    pub fn payload(&self) -> &[u8] {
212        &self.payload
213    }
214
215    /// Returns the queried palette index for typed OSC 4 query events.
216    #[must_use]
217    pub const fn palette_query_index(&self) -> Option<TerminalPaletteIndex> {
218        self.palette_index
219    }
220
221    /// Returns typed OSC 52 query metadata for clipboard-query events.
222    #[must_use]
223    pub const fn clipboard_query_metadata(&self) -> Option<TerminalClipboardQuery> {
224        self.clipboard_query
225    }
226
227    /// Renders the passthrough as an outer-terminal escape sequence.
228    #[must_use]
229    pub fn render_sequence(&self) -> Vec<u8> {
230        // Clipboard queries are server-correlated and must never fall
231        // through the generic outer-terminal passthrough path.
232        if self.clipboard_query.is_some() {
233            return Vec::new();
234        }
235        match self.kind {
236            TerminalPassthroughKind::Raw => self.payload.to_vec(),
237            TerminalPassthroughKind::Clipboard => self.payload.to_vec(),
238            TerminalPassthroughKind::PaletteQuery => self.payload.to_vec(),
239            TerminalPassthroughKind::KittyGraphics => {
240                let mut sequence = Vec::with_capacity(self.payload.len() + 4);
241                sequence.extend_from_slice(b"\x1b_");
242                sequence.extend_from_slice(&self.payload);
243                sequence.extend_from_slice(b"\x1b\\");
244                sequence
245            }
246            TerminalPassthroughKind::Sixel => {
247                let mut sequence = Vec::with_capacity(self.payload.len() + 4);
248                sequence.extend_from_slice(b"\x1bP");
249                sequence.extend_from_slice(&self.payload);
250                sequence.extend_from_slice(b"\x1b\\");
251                sequence
252            }
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::{TerminalClipboardQuery, TerminalPaletteIndex, TerminalPassthrough};
260    use crate::input::InputEndType;
261
262    #[test]
263    fn renders_kitty_apc_sequence() {
264        let passthrough = TerminalPassthrough::kitty_graphics(0, 0, b"Gf=100;AAAA".to_vec());
265
266        assert_eq!(passthrough.render_sequence(), b"\x1b_Gf=100;AAAA\x1b\\");
267    }
268
269    #[test]
270    fn renders_raw_sequence_verbatim() {
271        let passthrough = TerminalPassthrough::raw(0, 0, b"\x1b]52;c;QQ==\x1b\\".to_vec());
272
273        assert_eq!(passthrough.render_sequence(), b"\x1b]52;c;QQ==\x1b\\");
274    }
275
276    #[test]
277    fn renders_clipboard_sequence_verbatim() {
278        let passthrough = TerminalPassthrough::clipboard(b"\x1b]52;c;QQ==\x07".to_vec());
279
280        assert_eq!(passthrough.render_sequence(), b"\x1b]52;c;QQ==\x07");
281    }
282
283    #[test]
284    fn clipboard_query_is_typed_bounded_and_never_generically_rendered() {
285        let query = TerminalClipboardQuery::new("zzpc", InputEndType::St);
286        assert_eq!(query.selection(), Some(b'p'));
287        assert_eq!(query.terminator(), InputEndType::St);
288
289        let passthrough =
290            TerminalPassthrough::clipboard_query(query, b"\x1b]52;zzpc;?\x1b\\".to_vec());
291        assert_eq!(
292            passthrough.kind(),
293            super::TerminalPassthroughKind::Clipboard
294        );
295        assert_eq!(passthrough.clipboard_query_metadata(), Some(query));
296        assert_eq!(passthrough.payload(), b"\x1b]52;zzpc;?\x1b\\");
297        assert!(passthrough.render_sequence().is_empty());
298
299        let invalid = TerminalClipboardQuery::new("xyz", InputEndType::Bel);
300        assert_eq!(invalid.selection(), None);
301    }
302
303    #[test]
304    fn palette_query_is_bounded_typed_and_canonical() {
305        assert_eq!(
306            TerminalPaletteIndex::parse("0").map(TerminalPaletteIndex::get),
307            Some(0)
308        );
309        assert_eq!(
310            TerminalPaletteIndex::parse("255").map(TerminalPaletteIndex::get),
311            Some(255)
312        );
313        assert_eq!(TerminalPaletteIndex::parse("256"), None);
314        assert_eq!(TerminalPaletteIndex::parse("-1"), None);
315
316        let query = TerminalPassthrough::palette_query(TerminalPaletteIndex::from(255));
317        assert_eq!(query.render_sequence(), b"\x1b]4;255;?\x1b\\");
318        assert_eq!(
319            query.palette_query_index(),
320            Some(TerminalPaletteIndex::from(255))
321        );
322    }
323
324    #[test]
325    fn renders_sixel_dcs_sequence() {
326        let passthrough = TerminalPassthrough::sixel(0, 0, b"q#0!10~".to_vec());
327
328        assert_eq!(passthrough.render_sequence(), b"\x1bPq#0!10~\x1b\\");
329    }
330}