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