fix: answer osc 4 palette queries

refs #387
This commit is contained in:
Ogulcan Celik 2026-06-01 18:15:56 +03:00
parent 748c3fbb9a
commit 53e4b27165
4 changed files with 199 additions and 10 deletions

View File

@ -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

View File

@ -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),
})
}

View File

@ -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");
@ -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();

View File

@ -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!(
@ -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);