From 53e4b27165caeeaaf206907d082d3614876b2e4c Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 1 Jun 2026 18:15:56 +0300 Subject: [PATCH] fix: answer osc 4 palette queries refs #387 --- docs/next/CHANGELOG.md | 1 + src/ghostty/mod.rs | 2 + src/pane/osc.rs | 57 +++++++++++++++- src/pane/terminal.rs | 149 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 199 insertions(+), 10 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index f8b405db..43a505de 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed - Pane input no longer waits behind the PTY actor's idle read poll, restoring responsive typing at quiet shell prompts. (#379) +- Pane apps that query OSC 4 ANSI palette colors now receive the active terminal palette response, so OpenCode and similar TUIs can enable system-theme behavior inside Herdr. (#387) ## [0.6.6] - 2026-05-31 diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 9eda90d7..e2b592d2 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -372,6 +372,7 @@ fn cell_color_from_style_color(color: ffi::GhosttyStyleColor) -> Option Option { b"11;?" => Some(DefaultColorEvent::Query(DefaultColorQuery::Background)), b"110" | b"110;" => Some(DefaultColorEvent::Reset(DefaultColorQuery::Foreground)), b"111" | b"111;" => Some(DefaultColorEvent::Reset(DefaultColorQuery::Background)), - _ => parse_default_color_set_event(body), + _ => parse_palette_color_query(body).or_else(|| parse_default_color_set_event(body)), } } +fn parse_palette_color_query(body: &[u8]) -> Option { + let index = body.strip_prefix(b"4;")?.strip_suffix(b";?")?; + if index.is_empty() || index.len() > 3 || !index.iter().all(|byte| byte.is_ascii_digit()) { + return None; + } + let mut value: u16 = 0; + for &digit in index { + value = value * 10 + u16::from(digit - b'0'); + } + u8::try_from(value) + .ok() + .map(DefaultColorEvent::PaletteQuery) +} + fn parse_default_color_set_event(body: &[u8]) -> Option { let separator = body.iter().position(|byte| *byte == b';')?; let query = match &body[..separator] { @@ -576,13 +591,16 @@ mod tests { fn default_color_event_tracker_detects_queries_sets_and_resets() { let mut tracker = DefaultColorEventTracker::default(); - tracker.observe(b"\x1b]10;?\x07\x1b]11;?\x1b\\\x1b]10;rgb:11/22/33\x07\x1b]111\x07"); + tracker.observe( + b"\x1b]10;?\x07\x1b]11;?\x1b\\\x1b]4;0;?\x07\x1b]10;rgb:11/22/33\x07\x1b]111\x07", + ); assert_eq!( tracker.drain_pending(), vec![ DefaultColorEvent::Query(DefaultColorQuery::Foreground), DefaultColorEvent::Query(DefaultColorQuery::Background), + DefaultColorEvent::PaletteQuery(0), DefaultColorEvent::Set(DefaultColorQuery::Foreground), DefaultColorEvent::Reset(DefaultColorQuery::Background), ] @@ -590,7 +608,7 @@ mod tests { } #[test] - fn default_color_event_tracker_handles_split_queries() { + fn default_color_event_tracker_handles_split_default_color_queries() { let mut tracker = DefaultColorEventTracker::default(); tracker.observe(b"\x1b]11"); @@ -605,6 +623,39 @@ mod tests { ); } + #[test] + fn default_color_event_tracker_handles_split_palette_color_queries() { + let mut tracker = DefaultColorEventTracker::default(); + + tracker.observe(b"\x1b]4;25"); + assert!(tracker.drain_pending().is_empty()); + tracker.observe(b"5;?\x1b"); + assert!(tracker.drain_pending().is_empty()); + tracker.observe(b"\\"); + + assert_eq!( + tracker.drain_pending(), + vec![DefaultColorEvent::PaletteQuery(255)] + ); + } + + #[test] + fn default_color_event_tracker_rejects_malformed_palette_color_queries() { + let mut tracker = DefaultColorEventTracker::default(); + + tracker.observe(b"\x1b]4;;?\x07"); + tracker.observe(b"\x1b]4;-1;?\x07"); + tracker.observe(b"\x1b]4;256;?\x07"); + tracker.observe(b"\x1b]4;0;?;1;?\x07"); + tracker.observe(b"\x1b]4;0;rgb:1111/2222/3333\x07"); + tracker.observe(b"\x1b]4;0;?\x07"); + + assert_eq!( + tracker.drain_pending(), + vec![DefaultColorEvent::PaletteQuery(0)] + ); + } + #[test] fn default_color_event_tracker_ignores_other_osc_and_dcs_payloads() { let mut tracker = DefaultColorEventTracker::default(); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index cc316bf7..db05d1fa 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1305,6 +1305,11 @@ fn respond_to_default_color_events( terminal_responses.push(response); } } + DefaultColorEvent::PaletteQuery(index) => { + if let Some(response) = palette_color_query_response(index, core) { + terminal_responses.push(response); + } + } DefaultColorEvent::Set(query) => mark_child_default_color_changed(core, query, true), DefaultColorEvent::Reset(query) => mark_child_default_color_changed(core, query, false), } @@ -1321,13 +1326,36 @@ fn default_color_query_response(query: DefaultColorQuery, core: &GhosttyPaneCore } _ => None, }?; - let r = u16::from(color.r) * 257; - let g = u16::from(color.g) * 257; - let b = u16::from(color.b) * 257; - Some(Bytes::from(format!( - "\x1b]{};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\", - query.osc_number() - ))) + Some(osc_rgb_response( + &query.osc_number().to_string(), + color.r, + color.g, + color.b, + )) +} + +fn palette_color_query_response(index: u8, core: &mut GhosttyPaneCore) -> Option { + let GhosttyPaneCore { + terminal, + render_state, + .. + } = core; + render_state.update(terminal).ok()?; + let colors = render_state.colors().ok()?; + let color = colors.palette[usize::from(index)]; + Some(osc_rgb_response( + &format!("4;{index}"), + color.r, + color.g, + color.b, + )) +} + +fn osc_rgb_response(command: &str, r: u8, g: u8, b: u8) -> Bytes { + let r = u16::from(r) * 257; + let g = u16::from(g) * 257; + let b = u16::from(b) * 257; + Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\")) } fn mark_child_default_color_changed( @@ -1456,6 +1484,24 @@ mod tests { terminal.write(b"END"); } + fn current_palette_color(pane: &GhosttyPaneTerminal, index: u8) -> crate::ghostty::RgbColor { + let mut core = pane.core.lock().unwrap(); + let GhosttyPaneCore { + terminal, + render_state, + .. + } = &mut *core; + render_state.update(terminal).unwrap(); + render_state.colors().unwrap().palette[usize::from(index)] + } + + fn expected_osc_rgb_response(command: &str, color: crate::ghostty::RgbColor) -> Bytes { + let r = u16::from(color.r) * 257; + let g = u16::from(color.g) * 257; + let b = u16::from(color.b) * 257; + Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\")) + } + #[test] fn decscusr_cursor_shape_preserves_blinking_variants() { assert_eq!( @@ -2317,6 +2363,95 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[test] + fn process_pty_bytes_returns_palette_color_query_response_without_queuing_input() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + let color = current_palette_color(&pane, 0); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]4;0;?\x07", &tx); + + assert_eq!( + result.terminal_responses, + vec![expected_osc_rgb_response("4;0", color)] + ); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_returns_split_palette_color_query_response() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + let color = current_palette_color(&pane, 255); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]4;25", &tx); + assert!(result.terminal_responses.is_empty()); + assert!(rx.try_recv().is_err()); + let result = pane.process_pty_bytes(pane_id, 0, b"5;?\x1b", &tx); + assert!(result.terminal_responses.is_empty()); + assert!(rx.try_recv().is_err()); + let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx); + + assert_eq!( + result.terminal_responses, + vec![expected_osc_rgb_response("4;255", color)] + ); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_ignores_malformed_palette_color_queries() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + let result = pane.process_pty_bytes( + pane_id, + 0, + b"\x1b]4;;?\x07\x1b]4;-1;?\x07\x1b]4;256;?\x07\x1b]4;0;?;1;?\x07\x1b]4;0;rgb:1111/2222/3333\x07", + &tx, + ); + + assert!(result.terminal_responses.is_empty()); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_orders_palette_reply_before_following_terminal_replies() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + let color = current_palette_color(&pane, 0); + pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme { + foreground: None, + background: Some(crate::terminal_theme::RgbColor { + r: 0x00, + g: 0x2b, + b: 0x36, + }), + }); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]4;0;?\x07\x1b]11;?\x07\x1b[c", &tx); + + assert_eq!(result.terminal_responses.len(), 3); + assert_eq!( + result.terminal_responses[0], + expected_osc_rgb_response("4;0", color) + ); + assert_eq!( + result.terminal_responses[1], + Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\") + ); + assert!(String::from_utf8_lossy(&result.terminal_responses[2]).contains('c')); + assert!(rx.try_recv().is_err()); + } + #[test] fn process_pty_bytes_returns_default_color_query_responses_without_queuing_input() { let (tx, mut rx) = mpsc::channel(4);