aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 18:15:56 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 18:17:11 +0300
commit53e4b27165caeeaaf206907d082d3614876b2e4c (patch)
tree216dcb9c68640b8de04814b389956d923f45d201
parent748c3fbb9a3d56d325b5cb802eb0707f30f2b354 (diff)
fix: answer osc 4 palette queries
refs #387
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--src/ghostty/mod.rs2
-rw-r--r--src/pane/osc.rs57
-rw-r--r--src/pane/terminal.rs149
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<CellColo
pub struct RenderColors {
pub background: RgbColor,
pub foreground: RgbColor,
+ pub palette: [RgbColor; 256],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1870,6 +1871,7 @@ impl RenderState {
Ok(RenderColors {
background: colors.background.into(),
foreground: colors.foreground.into(),
+ palette: colors.palette.map(Into::into),
})
}
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index 217e0c86..1512f336 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -26,6 +26,7 @@ pub(super) enum DefaultColorEvent {
Query(DefaultColorQuery),
Set(DefaultColorQuery),
Reset(DefaultColorQuery),
+ PaletteQuery(u8),
}
#[derive(Debug, Default)]
@@ -240,10 +241,24 @@ fn parse_default_color_event(body: &[u8]) -> Option<DefaultColorEvent> {
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<DefaultColorEvent> {
+ 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<DefaultColorEvent> {
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");
@@ -606,6 +624,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<Bytes> {
+ 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!(
@@ -2318,6 +2364,95 @@ mod tests {
}
#[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);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();