Skip to main content

rmux_core/
terminal_passthrough.rs

1use std::sync::Arc;
2
3/// Maximum payload size retained for one terminal graphics passthrough event.
4pub(crate) const MAX_TERMINAL_PASSTHROUGH_PAYLOAD_BYTES: usize = 8 * 1024 * 1024;
5
6/// Opaque terminal command that must be forwarded to a capable outer terminal.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct TerminalPassthrough {
9    kind: TerminalPassthroughKind,
10    cursor_x: u32,
11    cursor_y: u32,
12    palette_index: Option<TerminalPaletteIndex>,
13    payload: Arc<[u8]>,
14}
15
16/// Supported terminal passthrough protocol families.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TerminalPassthroughKind {
19    /// Opaque tmux DCS passthrough payload, already framed for the outer terminal.
20    Raw,
21    /// OSC 52 clipboard payload emitted by a pane program.
22    Clipboard,
23    /// OSC 4 palette query relayed to the attached outer terminal.
24    PaletteQuery,
25    /// Kitty terminal graphics protocol, encoded as an APC payload.
26    KittyGraphics,
27    /// SIXEL graphics protocol, encoded as a DCS payload.
28    Sixel,
29}
30
31/// A terminal palette index accepted by OSC 4.
32///
33/// OSC 4 addresses the 256-entry terminal palette. Keeping the bound in a
34/// type prevents arbitrary OSC bodies from being reflected through the outer
35/// terminal query path.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct TerminalPaletteIndex(u8);
38
39impl TerminalPaletteIndex {
40    /// Parses one strict ASCII-decimal palette index in the inclusive 0..=255
41    /// range.
42    #[must_use]
43    pub fn parse(value: &str) -> Option<Self> {
44        if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
45            return None;
46        }
47        value.parse::<u8>().ok().map(Self)
48    }
49
50    /// Returns the numeric palette index.
51    #[must_use]
52    pub const fn get(self) -> u8 {
53        self.0
54    }
55}
56
57impl From<u8> for TerminalPaletteIndex {
58    fn from(value: u8) -> Self {
59        Self(value)
60    }
61}
62
63impl TerminalPassthrough {
64    /// Creates an opaque passthrough event at a pane-local cursor position.
65    #[must_use]
66    pub fn raw(cursor_x: u32, cursor_y: u32, payload: impl Into<Vec<u8>>) -> Self {
67        Self {
68            kind: TerminalPassthroughKind::Raw,
69            cursor_x,
70            cursor_y,
71            palette_index: None,
72            payload: Arc::from(payload.into()),
73        }
74    }
75
76    /// Creates an OSC 52 clipboard passthrough event.
77    #[must_use]
78    pub fn clipboard(payload: impl Into<Vec<u8>>) -> Self {
79        Self {
80            kind: TerminalPassthroughKind::Clipboard,
81            cursor_x: 0,
82            cursor_y: 0,
83            palette_index: None,
84            payload: Arc::from(payload.into()),
85        }
86    }
87
88    /// Creates a bounded OSC 4 query for one palette index.
89    ///
90    /// tmux 3.7b canonicalizes both BEL- and ST-terminated pane queries to an
91    /// ST-terminated sequence before sending them to the outer terminal.
92    #[must_use]
93    pub fn palette_query(index: TerminalPaletteIndex) -> Self {
94        let payload = format!("\x1b]4;{};?\x1b\\", index.get()).into_bytes();
95        Self {
96            kind: TerminalPassthroughKind::PaletteQuery,
97            cursor_x: 0,
98            cursor_y: 0,
99            palette_index: Some(index),
100            payload: Arc::from(payload),
101        }
102    }
103
104    /// Creates a Kitty graphics passthrough event at a pane-local cursor position.
105    #[must_use]
106    pub fn kitty_graphics(cursor_x: u32, cursor_y: u32, payload: impl Into<Vec<u8>>) -> Self {
107        Self {
108            kind: TerminalPassthroughKind::KittyGraphics,
109            cursor_x,
110            cursor_y,
111            palette_index: None,
112            payload: Arc::from(payload.into()),
113        }
114    }
115
116    /// Creates a SIXEL passthrough event at a pane-local cursor position.
117    #[must_use]
118    pub fn sixel(cursor_x: u32, cursor_y: u32, payload: impl Into<Vec<u8>>) -> Self {
119        Self {
120            kind: TerminalPassthroughKind::Sixel,
121            cursor_x,
122            cursor_y,
123            palette_index: None,
124            payload: Arc::from(payload.into()),
125        }
126    }
127
128    /// Returns the passthrough protocol family.
129    #[must_use]
130    pub const fn kind(&self) -> TerminalPassthroughKind {
131        self.kind
132    }
133
134    /// Returns the pane-local cursor column captured when the sequence arrived.
135    #[must_use]
136    pub const fn cursor_x(&self) -> u32 {
137        self.cursor_x
138    }
139
140    /// Returns the pane-local cursor row captured when the sequence arrived.
141    #[must_use]
142    pub const fn cursor_y(&self) -> u32 {
143        self.cursor_y
144    }
145
146    /// Returns the opaque protocol payload without escape framing.
147    #[must_use]
148    pub fn payload(&self) -> &[u8] {
149        &self.payload
150    }
151
152    /// Returns the queried palette index for typed OSC 4 query events.
153    #[must_use]
154    pub const fn palette_query_index(&self) -> Option<TerminalPaletteIndex> {
155        self.palette_index
156    }
157
158    /// Renders the passthrough as an outer-terminal escape sequence.
159    #[must_use]
160    pub fn render_sequence(&self) -> Vec<u8> {
161        match self.kind {
162            TerminalPassthroughKind::Raw => self.payload.to_vec(),
163            TerminalPassthroughKind::Clipboard => self.payload.to_vec(),
164            TerminalPassthroughKind::PaletteQuery => self.payload.to_vec(),
165            TerminalPassthroughKind::KittyGraphics => {
166                let mut sequence = Vec::with_capacity(self.payload.len() + 4);
167                sequence.extend_from_slice(b"\x1b_");
168                sequence.extend_from_slice(&self.payload);
169                sequence.extend_from_slice(b"\x1b\\");
170                sequence
171            }
172            TerminalPassthroughKind::Sixel => {
173                let mut sequence = Vec::with_capacity(self.payload.len() + 4);
174                sequence.extend_from_slice(b"\x1bP");
175                sequence.extend_from_slice(&self.payload);
176                sequence.extend_from_slice(b"\x1b\\");
177                sequence
178            }
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::{TerminalPaletteIndex, TerminalPassthrough};
186
187    #[test]
188    fn renders_kitty_apc_sequence() {
189        let passthrough = TerminalPassthrough::kitty_graphics(0, 0, b"Gf=100;AAAA".to_vec());
190
191        assert_eq!(passthrough.render_sequence(), b"\x1b_Gf=100;AAAA\x1b\\");
192    }
193
194    #[test]
195    fn renders_raw_sequence_verbatim() {
196        let passthrough = TerminalPassthrough::raw(0, 0, b"\x1b]52;c;QQ==\x1b\\".to_vec());
197
198        assert_eq!(passthrough.render_sequence(), b"\x1b]52;c;QQ==\x1b\\");
199    }
200
201    #[test]
202    fn renders_clipboard_sequence_verbatim() {
203        let passthrough = TerminalPassthrough::clipboard(b"\x1b]52;c;QQ==\x07".to_vec());
204
205        assert_eq!(passthrough.render_sequence(), b"\x1b]52;c;QQ==\x07");
206    }
207
208    #[test]
209    fn palette_query_is_bounded_typed_and_canonical() {
210        assert_eq!(
211            TerminalPaletteIndex::parse("0").map(TerminalPaletteIndex::get),
212            Some(0)
213        );
214        assert_eq!(
215            TerminalPaletteIndex::parse("255").map(TerminalPaletteIndex::get),
216            Some(255)
217        );
218        assert_eq!(TerminalPaletteIndex::parse("256"), None);
219        assert_eq!(TerminalPaletteIndex::parse("-1"), None);
220
221        let query = TerminalPassthrough::palette_query(TerminalPaletteIndex::from(255));
222        assert_eq!(query.render_sequence(), b"\x1b]4;255;?\x1b\\");
223        assert_eq!(
224            query.palette_query_index(),
225            Some(TerminalPaletteIndex::from(255))
226        );
227    }
228
229    #[test]
230    fn renders_sixel_dcs_sequence() {
231        let passthrough = TerminalPassthrough::sixel(0, 0, b"q#0!10~".to_vec());
232
233        assert_eq!(passthrough.render_sequence(), b"\x1bPq#0!10~\x1b\\");
234    }
235}