diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 19:23:13 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 19:23:13 +0300 |
| commit | 82468a0e03360219898ebf3735bdf559182009f8 (patch) | |
| tree | 0ee5213fe041a539f23e846f9148493528249512 /src/pane | |
| parent | 36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (diff) | |
feat: add manifest-based detection engine
Diffstat (limited to 'src/pane')
| -rw-r--r-- | src/pane/agent_detection.rs | 13 | ||||
| -rw-r--r-- | src/pane/osc.rs | 495 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 62 |
3 files changed, 567 insertions, 3 deletions
diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs index e6afd368..d891c284 100644 --- a/src/pane/agent_detection.rs +++ b/src/pane/agent_detection.rs @@ -512,11 +512,22 @@ pub(super) fn decide_screen_detection_publish( } } +#[allow(dead_code)] // shim for tests; detection_update_for_publish_with_osc is the real path pub(super) fn detection_update_for_publish( agent: Option<Agent>, content: &str, process_exited: bool, ) -> Option<crate::detect::AgentDetection> { + detection_update_for_publish_with_osc(agent, content, "", "", process_exited) +} + +pub(super) fn detection_update_for_publish_with_osc( + agent: Option<Agent>, + content: &str, + osc_title: &str, + osc_progress: &str, + process_exited: bool, +) -> Option<crate::detect::AgentDetection> { if process_exited { return Some(crate::detect::AgentDetection { state: AgentState::Idle, @@ -527,7 +538,7 @@ pub(super) fn detection_update_for_publish( }); } - let detection = crate::detect::detect_agent(agent, content); + let detection = crate::detect::detect_agent_with_osc(agent, content, osc_title, osc_progress); (!detection.skip_state_update).then_some(detection) } diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 23ab433a..e7b97a79 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -440,6 +440,263 @@ fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> { None } +/// Maximum retained string length for agent OSC title and progress payloads. +/// Title text is untrusted model output; cap it to bound memory and log size. +const AGENT_OSC_MAX_CHARS: usize = 256; + +/// Always-on tracker that retains the latest OSC 0/2 title and OSC 9 progress +/// payload emitted by the child process. Nothing here affects rendering; this +/// is pure passive capture for the detection engine (Stage C / Stage D). +/// +/// - `latest_title` — last OSC 0 or OSC 2 payload, sanitized. An empty +/// payload (e.g. `\x1b]0;\x07`) clears the stored value. +/// - `latest_progress` — last OSC 9 payload (the part after `9;`), stored +/// as-is after sanitization. E.g. `"4;3;"` or `"4;0;"`. +#[derive(Debug, Default)] +pub(super) struct AgentOscStateTracker { + state: Osc52ForwarderState, + body: Vec<u8>, + latest_title: Option<String>, + latest_progress: Option<String>, +} + +impl AgentOscStateTracker { + pub(super) fn observe(&mut self, bytes: &[u8]) { + for &byte in bytes { + match self.state { + Osc52ForwarderState::Ground => { + if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } + } + Osc52ForwarderState::Escape => { + if byte == b']' { + self.body.clear(); + self.state = Osc52ForwarderState::OscBody; + } else if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } else { + self.state = Osc52ForwarderState::Ground; + } + } + Osc52ForwarderState::OscBody => match byte { + 0x07 => { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } + 0x1b => self.state = Osc52ForwarderState::OscEscape, + _ => self.body.push(byte), + }, + Osc52ForwarderState::OscEscape => { + if byte == b'\\' { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } else { + self.body.push(0x1b); + self.body.push(byte); + self.state = Osc52ForwarderState::OscBody; + } + } + } + + if self.body.len() > 4096 { + self.body.clear(); + self.state = Osc52ForwarderState::Ground; + } + } + } + + fn finalize(&mut self) { + if let Some((command, payload)) = parse_agent_osc_body(&self.body) { + match command { + b"0" | b"2" => { + if payload.is_empty() { + self.latest_title = None; + } else { + self.latest_title = + Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS)); + } + } + b"9" => { + self.latest_progress = + Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS)); + } + _ => {} + } + } + self.body.clear(); + } + + /// Returns the latest retained OSC title, or `""` if none has been seen or + /// the last title was an empty clear. + #[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C + pub(super) fn latest_title(&self) -> &str { + self.latest_title.as_deref().unwrap_or("") + } + + /// Returns the latest retained OSC 9 progress payload, or `""` if none. + #[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C + pub(super) fn latest_progress(&self) -> &str { + self.latest_progress.as_deref().unwrap_or("") + } + + /// Drops the retained title and progress so a new foreground agent cannot + /// inherit OSC evidence emitted by a previous process. The in-flight parse + /// state is kept: a sequence spanning the agent change finalizes normally + /// and is attributed to the new agent. + pub(super) fn clear_retained(&mut self) { + self.latest_title = None; + self.latest_progress = None; + } +} + +/// Splits an OSC body at the first `;`, returning `(command, payload)`. +/// Returns `None` if there is no `;`. +fn parse_agent_osc_body(body: &[u8]) -> Option<(&[u8], &[u8])> { + let sep = body.iter().position(|&b| b == b';')?; + Some((&body[..sep], &body[sep + 1..])) +} + +fn sanitize_agent_osc_string(payload: &[u8], max_chars: usize) -> String { + let text = String::from_utf8_lossy(payload); + let mut out = String::new(); + for ch in text.chars().filter(|ch| !ch.is_control()).take(max_chars) { + out.push(ch); + } + out +} + +/// Reconstructs selected OSC sequences for local evidence capture while +/// debugging agent title/status behavior. This is intentionally passive: +/// nothing here affects terminal rendering or detection state. +#[derive(Debug)] +pub(super) struct OscDebugTracker { + enabled: bool, + state: Osc52ForwarderState, + body: Vec<u8>, + pending: Vec<OscDebugEvent>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct OscDebugEvent { + pub(super) command: String, + pub(super) payload: String, +} + +impl OscDebugTracker { + pub(super) fn from_env() -> Self { + Self { + enabled: osc_debug_enabled_from_env(), + state: Osc52ForwarderState::Ground, + body: Vec::new(), + pending: Vec::new(), + } + } + + pub(super) fn observe(&mut self, bytes: &[u8]) { + if !self.enabled { + return; + } + + for &byte in bytes { + match self.state { + Osc52ForwarderState::Ground => { + if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } + } + Osc52ForwarderState::Escape => { + if byte == b']' { + self.body.clear(); + self.state = Osc52ForwarderState::OscBody; + } else if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } else { + self.state = Osc52ForwarderState::Ground; + } + } + Osc52ForwarderState::OscBody => match byte { + 0x07 => { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } + 0x1b => self.state = Osc52ForwarderState::OscEscape, + _ => self.body.push(byte), + }, + Osc52ForwarderState::OscEscape => { + if byte == b'\\' { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } else { + self.body.push(0x1b); + self.body.push(byte); + self.state = Osc52ForwarderState::OscBody; + } + } + } + + if self.body.len() > 4096 { + self.body.clear(); + self.state = Osc52ForwarderState::Ground; + } + } + } + + fn finalize(&mut self) { + if let Some(event) = parse_osc_debug_event(&self.body) { + self.pending.push(event); + } + self.body.clear(); + } + + pub(super) fn drain_pending(&mut self) -> Vec<OscDebugEvent> { + std::mem::take(&mut self.pending) + } +} + +impl Default for OscDebugTracker { + fn default() -> Self { + Self::from_env() + } +} + +fn osc_debug_enabled_from_env() -> bool { + std::env::var("HERDR_DEBUG_OSC_EVIDENCE") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn parse_osc_debug_event(body: &[u8]) -> Option<OscDebugEvent> { + let separator = body.iter().position(|byte| *byte == b';')?; + let command = &body[..separator]; + let payload = &body[separator + 1..]; + if !matches!(command, b"0" | b"2" | b"9" | b"21337") { + return None; + } + Some(OscDebugEvent { + command: std::str::from_utf8(command).ok()?.to_string(), + payload: sanitized_osc_debug_payload(payload), + }) +} + +fn sanitized_osc_debug_payload(payload: &[u8]) -> String { + const MAX_CHARS: usize = 512; + let text = String::from_utf8_lossy(payload); + let mut sanitized = String::new(); + for ch in text.chars().filter(|ch| !ch.is_control()).take(MAX_CHARS) { + sanitized.push(ch); + } + if text.chars().count() > MAX_CHARS { + sanitized.push_str("..."); + } + sanitized +} + fn parse_file_uri_cwd(uri: &str) -> Option<PathBuf> { let rest = uri.strip_prefix("file://")?; let path = if rest.starts_with('/') { @@ -724,6 +981,15 @@ mod tests { events.into_iter().map(|event| event.event).collect() } + fn enabled_osc_debug_tracker() -> OscDebugTracker { + OscDebugTracker { + enabled: true, + state: Osc52ForwarderState::Ground, + body: Vec::new(), + pending: Vec::new(), + } + } + #[test] fn default_color_tracker_detects_split_osc_11_sequences() { let mut tracker = DefaultColorOscTracker::default(); @@ -766,6 +1032,235 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // AgentOscStateTracker tests + // ----------------------------------------------------------------------- + + #[test] + fn agent_osc_osc0_title_with_bel() { + let mut t = AgentOscStateTracker::default(); + t.observe("hello\x1b]0;braille title\x07world".as_bytes()); + assert_eq!(t.latest_title(), "braille title"); + assert_eq!(t.latest_progress(), ""); + } + + #[test] + fn agent_osc_osc2_title_with_st() { + let mut t = AgentOscStateTracker::default(); + t.observe("hello\x1b]2;static title\x1b\\world".as_bytes()); + assert_eq!(t.latest_title(), "static title"); + assert_eq!(t.latest_progress(), ""); + } + + #[test] + fn agent_osc_empty_osc0_clears_title() { + let mut t = AgentOscStateTracker::default(); + // First set a title. + t.observe(b"\x1b]0;some title\x07"); + assert_eq!(t.latest_title(), "some title"); + // Then clear it with an empty payload (Codex pattern). + t.observe(b"\x1b]0;\x07"); + assert_eq!(t.latest_title(), ""); + } + + #[test] + fn agent_osc_osc9_sets_progress_with_bel() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]9;4;3;\x07"); + assert_eq!(t.latest_progress(), "4;3;"); + assert_eq!(t.latest_title(), ""); + } + + #[test] + fn agent_osc_osc9_clear_progress_with_st() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]9;4;3;\x07"); + assert_eq!(t.latest_progress(), "4;3;"); + t.observe(b"\x1b]9;4;0;\x1b\\"); + assert_eq!(t.latest_progress(), "4;0;"); + } + + #[test] + fn agent_osc_split_sequence_across_chunks() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]9;4;3"); + assert_eq!(t.latest_progress(), ""); + t.observe(b";\x07"); + assert_eq!(t.latest_progress(), "4;3;"); + } + + #[test] + fn agent_osc_bel_and_st_terminators_both_work() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]0;title-bel\x07"); + assert_eq!(t.latest_title(), "title-bel"); + t.observe(b"\x1b]0;title-st\x1b\\"); + assert_eq!(t.latest_title(), "title-st"); + } + + #[test] + fn agent_osc_oversized_payload_is_discarded_and_recovers() { + let mut t = AgentOscStateTracker::default(); + // Set a title first. + t.observe(b"\x1b]0;before\x07"); + assert_eq!(t.latest_title(), "before"); + + // Feed an oversized OSC body (> 4096 bytes). + let mut oversized = Vec::from(b"\x1b]0;".as_slice()); + oversized.extend(std::iter::repeat_n(b'x', 4097)); + oversized.push(0x07); + t.observe(&oversized); + // The oversized body is dropped; the previously stored title is kept. + assert_eq!(t.latest_title(), "before"); + + // After recovery, subsequent valid sequences are captured normally. + t.observe(b"\x1b]0;after\x07"); + assert_eq!(t.latest_title(), "after"); + } + + #[test] + fn agent_osc_cap_length_is_respected() { + let mut t = AgentOscStateTracker::default(); + // Build a title of AGENT_OSC_MAX_CHARS + 50 ASCII chars. + let long_title: String = "a".repeat(AGENT_OSC_MAX_CHARS + 50); + let seq = format!("\x1b]0;{long_title}\x07"); + t.observe(seq.as_bytes()); + assert_eq!(t.latest_title().len(), AGENT_OSC_MAX_CHARS); + } + + #[test] + fn agent_osc_control_chars_stripped() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]0;before\x01after\x07"); + assert_eq!(t.latest_title(), "beforeafter"); + } + + #[test] + fn agent_osc_unrelated_osc_does_not_overwrite_title() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]0;my title\x07"); + // OSC 4 (palette color), OSC 52 (clipboard) — should not touch title/progress. + t.observe(b"\x1b]4;1;rgb:aa/bb/cc\x07"); + t.observe(b"\x1b]52;c;aGVsbG8=\x07"); + assert_eq!(t.latest_title(), "my title"); + assert_eq!(t.latest_progress(), ""); + } + + #[test] + fn agent_osc_interleaved_sequences() { + let mut t = AgentOscStateTracker::default(); + // OSC 0 title, then OSC 9 progress, then OSC 2 title update. + t.observe(b"\x1b]0;first\x07\x1b]9;4;3;\x07\x1b]2;second\x07"); + assert_eq!(t.latest_title(), "second"); + assert_eq!(t.latest_progress(), "4;3;"); + } + + #[test] + fn agent_osc_default_state_is_empty() { + let t = AgentOscStateTracker::default(); + assert_eq!(t.latest_title(), ""); + assert_eq!(t.latest_progress(), ""); + } + + // ----------------------------------------------------------------------- + // OscDebugTracker tests (existing) + // ----------------------------------------------------------------------- + + #[test] + fn osc_debug_tracker_detects_title_with_bel() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe("hello\x1b]0;✻ working title\x07world".as_bytes()); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "0".to_string(), + payload: "✻ working title".to_string(), + }] + ); + } + + #[test] + fn osc_debug_tracker_detects_title_with_st() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe("hello\x1b]2;static title\x1b\\world".as_bytes()); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "2".to_string(), + payload: "static title".to_string(), + }] + ); + } + + #[test] + fn osc_debug_tracker_detects_split_status_sequences() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe(b"\x1b]9;4;3"); + assert!(tracker.drain_pending().is_empty()); + tracker.observe(b"\x07\x1b]21337;status=working\x1b\\"); + + assert_eq!( + tracker.drain_pending(), + vec![ + OscDebugEvent { + command: "9".to_string(), + payload: "4;3".to_string(), + }, + OscDebugEvent { + command: "21337".to_string(), + payload: "status=working".to_string(), + }, + ] + ); + } + + #[test] + fn osc_debug_tracker_ignores_untracked_osc_commands() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe(b"\x1b]52;c;SGVsbG8=\x07\x1b]7;file:///tmp\x07"); + + assert!(tracker.drain_pending().is_empty()); + } + + #[test] + fn osc_debug_tracker_sanitizes_control_characters() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe(b"\x1b]0;before\x01after\x07"); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "0".to_string(), + payload: "beforeafter".to_string(), + }] + ); + } + + #[test] + fn osc_debug_tracker_recovers_after_oversized_payload() { + let mut tracker = enabled_osc_debug_tracker(); + let oversized = vec![b'a'; 4097]; + + tracker.observe(b"\x1b]0;"); + tracker.observe(&oversized); + tracker.observe(b"\x07\x1b]0;ok\x07"); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "0".to_string(), + payload: "ok".to_string(), + }] + ); + } + #[test] fn default_color_event_tracker_detects_queries_sets_and_resets() { let mut tracker = DefaultColorEventTracker::default(); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index d697f76d..9caa3dba 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -23,8 +23,9 @@ use super::{ osc::{ contains_scrollback_clear_sequence, current_transient_default_color_owner, maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed, - write_host_terminal_theme, CwdOscTracker, DefaultColorEvent, DefaultColorEventTracker, - DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder, + write_host_terminal_theme, AgentOscStateTracker, CwdOscTracker, DefaultColorEvent, + DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, + DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker, }, xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse}, }; @@ -125,6 +126,8 @@ pub(crate) struct GhosttyPaneCore { pub child_default_background_changed: bool, pub osc52_forwarder: Osc52Forwarder, pub cwd_osc_tracker: CwdOscTracker, + pub osc_debug_tracker: OscDebugTracker, + pub agent_osc_state: AgentOscStateTracker, pub xtgettcap_query_tracker: XtgettcapQueryTracker, } @@ -267,6 +270,21 @@ impl PaneTerminal { .maybe_restore_host_terminal_theme(pane_id, shell_pid) } + #[allow(dead_code)] // exposed for Stage C (detection loop wiring) + pub fn agent_osc_title(&self) -> String { + self.ghostty.agent_osc_title() + } + + #[allow(dead_code)] // exposed for Stage C (detection loop wiring) + pub fn agent_osc_progress(&self) -> String { + self.ghostty.agent_osc_progress() + } + + /// Clears retained OSC title/progress evidence on foreground agent change. + pub fn clear_agent_osc_state(&self) { + self.ghostty.clear_agent_osc_state() + } + pub fn keyboard_protocol( &self, fallback: crate::input::KeyboardProtocol, @@ -364,6 +382,8 @@ impl GhosttyPaneTerminal { child_default_background_changed: false, osc52_forwarder: Osc52Forwarder::default(), cwd_osc_tracker: CwdOscTracker::default(), + osc_debug_tracker: OscDebugTracker::default(), + agent_osc_state: AgentOscStateTracker::default(), xtgettcap_query_tracker: XtgettcapQueryTracker::default(), }), key_encoder: Mutex::new(key_encoder), @@ -417,6 +437,34 @@ impl GhosttyPaneTerminal { ) } + /// Returns the latest OSC 0/2 title retained for agent detection, or `""` + /// if no title has been seen or the last update was an empty clear. + #[allow(dead_code)] // exposed for Stage C (detection loop wiring) + pub fn agent_osc_title(&self) -> String { + self.core + .lock() + .map(|core| core.agent_osc_state.latest_title().to_owned()) + .unwrap_or_default() + } + + /// Returns the latest OSC 9 progress payload retained for agent detection, + /// or `""` if none has been seen. + #[allow(dead_code)] // exposed for Stage C (detection loop wiring) + pub fn agent_osc_progress(&self) -> String { + self.core + .lock() + .map(|core| core.agent_osc_state.latest_progress().to_owned()) + .unwrap_or_default() + } + + /// Clears retained OSC title/progress evidence when the pane's foreground + /// agent changes, so a new agent process starts from a blank OSC slate. + pub fn clear_agent_osc_state(&self) { + if let Ok(mut core) = self.core.lock() { + core.agent_osc_state.clear_retained(); + } + } + pub fn process_pty_bytes( &self, pane_id: PaneId, @@ -451,6 +499,16 @@ impl GhosttyPaneTerminal { let clipboard_writes = core.osc52_forwarder.drain_pending(); core.cwd_osc_tracker.observe(bytes); let reported_cwd = core.cwd_osc_tracker.drain_latest(); + core.osc_debug_tracker.observe(bytes); + for event in core.osc_debug_tracker.drain_pending() { + debug!( + pane = pane_id.raw(), + osc_command = %event.command, + osc_payload = ?event.payload, + "agent OSC evidence observed" + ); + } + core.agent_osc_state.observe(bytes); let alternate_screen = core .terminal |
