agentmux_srv/backend/
osc_extractor.rs1const MAX_PAYLOAD_BYTES: usize = 4096;
22
23#[derive(Debug, Clone, PartialEq)]
24enum State {
25 Idle,
26 AfterEsc,
27 InOsc,
28 InOscAfterEsc,
29}
30
31pub struct OscEvent {
33 pub payload: String,
35}
36
37pub struct OscExtractor {
40 state: State,
41 payload_buf: Vec<u8>,
42}
43
44impl OscExtractor {
45 pub fn new() -> Self {
46 OscExtractor {
47 state: State::Idle,
48 payload_buf: Vec::new(),
49 }
50 }
51
52 pub fn feed(&mut self, chunk: &[u8]) -> (Vec<u8>, Vec<OscEvent>) {
60 let mut out = Vec::with_capacity(chunk.len());
61 let mut events = Vec::new();
62
63 let mut i = 0;
64 while i < chunk.len() {
65 let byte = chunk[i];
66 i += 1;
67
68 match self.state {
69 State::Idle => {
70 if byte == 0x1b {
71 self.state = State::AfterEsc;
72 } else {
73 out.push(byte);
74 }
75 }
76 State::AfterEsc => {
77 if byte == 0x5d {
78 self.state = State::InOsc;
80 self.payload_buf.clear();
81 } else if byte == 0x1b {
82 out.push(0x1b);
84 } else {
85 out.push(0x1b);
87 out.push(byte);
88 self.state = State::Idle;
89 }
90 }
91 State::InOsc => {
92 if byte == 0x07 {
93 if let Some(ev) = self.complete_osc() {
95 events.push(ev);
96 }
97 self.state = State::Idle;
98 } else if byte == 0x1b {
99 self.state = State::InOscAfterEsc;
101 } else if self.payload_buf.len() < MAX_PAYLOAD_BYTES {
102 self.payload_buf.push(byte);
103 } else {
104 self.payload_buf.clear();
106 self.state = State::Idle;
107 continue;
108 }
109 }
110 State::InOscAfterEsc => {
111 if byte == 0x5c {
112 if let Some(ev) = self.complete_osc() {
114 events.push(ev);
115 }
116 self.state = State::Idle;
117 } else {
118 if self.payload_buf.len() < MAX_PAYLOAD_BYTES {
120 self.payload_buf.push(0x1b);
121 } else {
122 self.payload_buf.clear();
123 self.state = State::Idle;
124 continue;
125 }
126 self.state = State::InOsc;
127 if byte == 0x07 {
129 if let Some(ev) = self.complete_osc() {
130 events.push(ev);
131 }
132 self.state = State::Idle;
133 } else if byte == 0x1b {
134 self.state = State::InOscAfterEsc;
135 } else if self.payload_buf.len() < MAX_PAYLOAD_BYTES {
136 self.payload_buf.push(byte);
137 } else {
138 self.payload_buf.clear();
139 self.state = State::Idle;
140 }
141 }
142 }
143 }
144 }
145
146 (out, events)
147 }
148
149 fn complete_osc(&mut self) -> Option<OscEvent> {
150 let raw = std::mem::take(&mut self.payload_buf);
151 let s = String::from_utf8_lossy(&raw);
152
153 let semicolon = s.find(';')?;
155 let ps_str = &s[..semicolon];
156 let ps: u16 = ps_str.parse().ok()?;
157
158 if ps != 0 && ps != 2 {
160 return None;
161 }
162
163 let data = &s[semicolon + 1..];
164 let payload = normalise_title(data);
165 if payload.is_empty() {
166 return None;
167 }
168
169 Some(OscEvent { payload })
170 }
171}
172
173fn normalise_title(s: &str) -> String {
182 let stripped = if let Some(rest) = s.strip_prefix("claude - ") {
183 rest
184 } else if let Some(rest) = s.strip_prefix("Claude - ") {
185 rest
186 } else if let Some(rest) = s.strip_prefix("Claude: ") {
187 rest
188 } else if let Some(rest) = s.strip_prefix("Claude Code: ") {
189 rest
190 } else {
191 s
192 };
193
194 let trimmed = stripped.trim();
195 if trimmed.is_empty()
196 || trimmed.eq_ignore_ascii_case("claude")
197 || trimmed.eq_ignore_ascii_case("claude code")
198 {
199 return String::new();
200 }
201
202 trimmed.to_string()
203}
204
205#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn feed_str(ext: &mut OscExtractor, s: &str) -> (String, Vec<String>) {
214 let (cleaned, events) = ext.feed(s.as_bytes());
215 (
216 String::from_utf8_lossy(&cleaned).into_owned(),
217 events.into_iter().map(|e| e.payload).collect(),
218 )
219 }
220
221 #[test]
222 fn bel_terminator() {
223 let mut ext = OscExtractor::new();
224 let (cleaned, evs) = feed_str(&mut ext, "\x1b]0;claude - auth refactor\x07hello");
225 assert_eq!(cleaned, "hello");
226 assert_eq!(evs, ["auth refactor"]);
227 }
228
229 #[test]
230 fn st_terminator() {
231 let mut ext = OscExtractor::new();
232 let (cleaned, evs) = feed_str(&mut ext, "\x1b]0;Claude: editing auth.rs\x1b\\hello");
233 assert_eq!(cleaned, "hello");
234 assert_eq!(evs, ["editing auth.rs"]);
235 }
236
237 #[test]
238 fn osc2_handled() {
239 let mut ext = OscExtractor::new();
240 let (_, evs) = feed_str(&mut ext, "\x1b]2;Claude Code: summary\x07");
241 assert_eq!(evs, ["summary"]);
242 }
243
244 #[test]
245 fn non_title_osc_stripped_no_event() {
246 let mut ext = OscExtractor::new();
247 let (cleaned, evs) = feed_str(&mut ext, "\x1b]7;file:///home/user\x07text");
248 assert_eq!(cleaned, "text");
249 assert!(evs.is_empty());
250 }
251
252 #[test]
253 fn bare_startup_title_discarded() {
254 for title in &["claude", "CLAUDE", "Claude Code", "claude code", "CLAUDE CODE"] {
255 let input = format!("\x1b]0;{}\x07", title);
256 let mut ext = OscExtractor::new();
257 let (_, evs) = ext.feed(input.as_bytes());
258 assert!(evs.is_empty(), "expected discard for '{title}' but got event");
259 }
260 }
261
262 #[test]
263 fn cross_chunk_split_bel() {
264 let mut ext = OscExtractor::new();
265 let (_, evs1) = ext.feed(b"\x1b]0;claude - auth refactor");
267 assert!(evs1.is_empty());
268 let (cleaned, evs2) = ext.feed(b"\x07rest");
269 assert_eq!(evs2.iter().map(|e| e.payload.as_str()).collect::<Vec<_>>(), ["auth refactor"]);
270 assert_eq!(String::from_utf8_lossy(&cleaned), "rest");
271 }
272
273 #[test]
274 fn cross_chunk_split_at_every_byte() {
275 let input = b"\x1b]0;claude - topic\x07";
276 for split in 1..input.len() {
278 let mut ext = OscExtractor::new();
279 let (_, evs1) = ext.feed(&input[..split]);
280 let (_, evs2) = ext.feed(&input[split..]);
281 let all_evs: Vec<_> = evs1.iter().chain(evs2.iter()).map(|e| e.payload.as_str()).collect();
282 assert_eq!(all_evs, ["topic"], "split at byte {split} failed");
283 }
284 }
285
286 #[test]
287 fn buffer_overflow_guard() {
288 let mut ext = OscExtractor::new();
289 let big: Vec<u8> = std::iter::once(b'\x1b')
291 .chain(std::iter::once(b']'))
292 .chain(b"0;".iter().copied())
293 .chain(vec![b'x'; MAX_PAYLOAD_BYTES + 10])
294 .chain(std::iter::once(b'\x07'))
295 .collect();
296 let (_, evs) = ext.feed(&big);
297 assert!(evs.is_empty());
298 let (_, evs2) = ext.feed(b"\x1b]0;claude - recovery\x07");
300 assert_eq!(evs2.iter().map(|e| e.payload.as_str()).collect::<Vec<_>>(), ["recovery"]);
301 }
302
303 #[test]
304 fn non_utf8_replaced() {
305 let mut ext = OscExtractor::new();
306 let payload: Vec<u8> = b"\x1b]0;claude - topic\xff\x07".to_vec();
308 let (_, evs) = ext.feed(&payload);
309 assert_eq!(evs.len(), 1);
311 assert!(evs[0].payload.contains("topic"));
312 }
313
314 #[test]
315 fn passthrough_bytes_unchanged() {
316 let mut ext = OscExtractor::new();
317 let (cleaned, _) = ext.feed(b"hello world");
318 assert_eq!(cleaned, b"hello world");
319 }
320
321 #[test]
322 fn esc_not_followed_by_bracket_passed_through() {
323 let mut ext = OscExtractor::new();
324 let (cleaned, evs) = feed_str(&mut ext, "\x1b[32mgreen\x1b[0m");
325 assert_eq!(cleaned, "\x1b[32mgreen\x1b[0m");
327 assert!(evs.is_empty());
328 }
329}