aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 19:37:34 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 19:37:34 +0300
commit1b9a165f9e9fe9e8082d38e15942cfe0962344e2 (patch)
tree4467b1a94ddd2fe9b4b2f59a75a4a0ca7fbef26e /src
parentc25f116c5365b2a9dcb621432a7f96b3f5160d43 (diff)
fix: answer pane default color queries
refs #253
Diffstat (limited to 'src')
-rw-r--r--src/pane/osc.rs246
-rw-r--r--src/pane/terminal.rs149
2 files changed, 388 insertions, 7 deletions
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index 0f04b045..217e0c86 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -6,6 +6,28 @@ use crate::layout::PaneId;
use super::terminal::GhosttyPaneCore;
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum DefaultColorQuery {
+ Foreground,
+ Background,
+}
+
+impl DefaultColorQuery {
+ pub(super) fn osc_number(self) -> u8 {
+ match self {
+ Self::Foreground => 10,
+ Self::Background => 11,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum DefaultColorEvent {
+ Query(DefaultColorQuery),
+ Set(DefaultColorQuery),
+ Reset(DefaultColorQuery),
+}
+
#[derive(Debug, Default)]
pub(super) struct DefaultColorOscTracker {
state: DefaultColorOscTrackerState,
@@ -19,6 +41,14 @@ enum DefaultColorOscTrackerState {
Escape,
OscBody,
OscEscape,
+ IgnoreString,
+ IgnoreStringEscape,
+ OversizedOsc,
+ OversizedOscEscape,
+}
+
+fn is_ignored_string_intro(byte: u8) -> bool {
+ matches!(byte, b'P' | b'_' | b'^' | b'X')
}
impl DefaultColorOscTracker {
@@ -36,6 +66,9 @@ impl DefaultColorOscTracker {
if byte == b']' {
self.body.clear();
self.state = DefaultColorOscTrackerState::OscBody;
+ } else if is_ignored_string_intro(byte) {
+ self.body.clear();
+ self.state = DefaultColorOscTrackerState::IgnoreString;
} else if byte == 0x1b {
self.state = DefaultColorOscTrackerState::Escape;
} else {
@@ -62,11 +95,37 @@ impl DefaultColorOscTracker {
self.state = DefaultColorOscTrackerState::OscBody;
}
}
+ DefaultColorOscTrackerState::IgnoreString => {
+ if byte == 0x1b {
+ self.state = DefaultColorOscTrackerState::IgnoreStringEscape;
+ }
+ }
+ DefaultColorOscTrackerState::IgnoreStringEscape => {
+ if byte == b'\\' {
+ self.state = DefaultColorOscTrackerState::Ground;
+ } else if byte != 0x1b {
+ self.state = DefaultColorOscTrackerState::IgnoreString;
+ }
+ }
+ DefaultColorOscTrackerState::OversizedOsc => {
+ if byte == 0x1b {
+ self.state = DefaultColorOscTrackerState::OversizedOscEscape;
+ } else if byte == 0x07 {
+ self.state = DefaultColorOscTrackerState::Ground;
+ }
+ }
+ DefaultColorOscTrackerState::OversizedOscEscape => {
+ if byte == b'\\' {
+ self.state = DefaultColorOscTrackerState::Ground;
+ } else if byte != 0x1b {
+ self.state = DefaultColorOscTrackerState::OversizedOsc;
+ }
+ }
}
if self.body.len() > 1024 {
self.body.clear();
- self.state = DefaultColorOscTrackerState::Ground;
+ self.state = DefaultColorOscTrackerState::OversizedOsc;
}
}
@@ -75,12 +134,125 @@ impl DefaultColorOscTracker {
}
fn is_default_color_set_osc(body: &[u8]) -> bool {
- let Some(separator) = body.iter().position(|byte| *byte == b';') else {
- return false;
+ matches!(
+ parse_default_color_event(body),
+ Some(DefaultColorEvent::Set(_))
+ )
+}
+
+#[derive(Debug, Default)]
+pub(super) struct DefaultColorEventTracker {
+ state: DefaultColorOscTrackerState,
+ body: Vec<u8>,
+ pending: Vec<DefaultColorEvent>,
+}
+
+impl DefaultColorEventTracker {
+ pub(super) fn observe(&mut self, bytes: &[u8]) {
+ for &byte in bytes {
+ match self.state {
+ DefaultColorOscTrackerState::Ground => {
+ if byte == 0x1b {
+ self.state = DefaultColorOscTrackerState::Escape;
+ }
+ }
+ DefaultColorOscTrackerState::Escape => {
+ if byte == b']' {
+ self.body.clear();
+ self.state = DefaultColorOscTrackerState::OscBody;
+ } else if is_ignored_string_intro(byte) {
+ self.body.clear();
+ self.state = DefaultColorOscTrackerState::IgnoreString;
+ } else if byte == 0x1b {
+ self.state = DefaultColorOscTrackerState::Escape;
+ } else {
+ self.state = DefaultColorOscTrackerState::Ground;
+ }
+ }
+ DefaultColorOscTrackerState::OscBody => match byte {
+ 0x07 => {
+ self.finalize();
+ self.state = DefaultColorOscTrackerState::Ground;
+ }
+ 0x1b => self.state = DefaultColorOscTrackerState::OscEscape,
+ _ => self.body.push(byte),
+ },
+ DefaultColorOscTrackerState::OscEscape => {
+ if byte == b'\\' {
+ self.finalize();
+ self.state = DefaultColorOscTrackerState::Ground;
+ } else {
+ self.body.push(0x1b);
+ self.body.push(byte);
+ self.state = DefaultColorOscTrackerState::OscBody;
+ }
+ }
+ DefaultColorOscTrackerState::IgnoreString => {
+ if byte == 0x1b {
+ self.state = DefaultColorOscTrackerState::IgnoreStringEscape;
+ }
+ }
+ DefaultColorOscTrackerState::IgnoreStringEscape => {
+ if byte == b'\\' {
+ self.state = DefaultColorOscTrackerState::Ground;
+ } else if byte != 0x1b {
+ self.state = DefaultColorOscTrackerState::IgnoreString;
+ }
+ }
+ DefaultColorOscTrackerState::OversizedOsc => {
+ if byte == 0x1b {
+ self.state = DefaultColorOscTrackerState::OversizedOscEscape;
+ } else if byte == 0x07 {
+ self.state = DefaultColorOscTrackerState::Ground;
+ }
+ }
+ DefaultColorOscTrackerState::OversizedOscEscape => {
+ if byte == b'\\' {
+ self.state = DefaultColorOscTrackerState::Ground;
+ } else if byte != 0x1b {
+ self.state = DefaultColorOscTrackerState::OversizedOsc;
+ }
+ }
+ }
+
+ if self.body.len() > 1024 {
+ self.body.clear();
+ self.state = DefaultColorOscTrackerState::OversizedOsc;
+ }
+ }
+ }
+
+ fn finalize(&mut self) {
+ if let Some(event) = parse_default_color_event(&self.body) {
+ self.pending.push(event);
+ }
+ self.body.clear();
+ }
+
+ pub(super) fn drain_pending(&mut self) -> Vec<DefaultColorEvent> {
+ std::mem::take(&mut self.pending)
+ }
+}
+
+fn parse_default_color_event(body: &[u8]) -> Option<DefaultColorEvent> {
+ match body {
+ b"10;?" => Some(DefaultColorEvent::Query(DefaultColorQuery::Foreground)),
+ 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),
+ }
+}
+
+fn parse_default_color_set_event(body: &[u8]) -> Option<DefaultColorEvent> {
+ let separator = body.iter().position(|byte| *byte == b';')?;
+ let query = match &body[..separator] {
+ b"10" => DefaultColorQuery::Foreground,
+ b"11" => DefaultColorQuery::Background,
+ _ => return None,
};
- let command = &body[..separator];
let value = &body[separator + 1..];
- matches!(command, b"10" | b"11") && !value.is_empty() && value != b"?"
+ (!value.is_empty() && value != b"?").then_some(DefaultColorEvent::Set(query))
}
/// 256 KiB of base64 ≈ 192 KiB of text — enough for real source-file copies
@@ -329,6 +501,8 @@ pub(super) fn restore_host_terminal_theme_if_needed(
}
core.transient_default_color_owner_pgid = None;
+ core.child_default_foreground_changed = false;
+ core.child_default_background_changed = false;
write_host_terminal_theme(&mut core.terminal, core.host_terminal_theme);
info!(
pane = pane_id.raw(),
@@ -399,6 +573,68 @@ mod tests {
}
#[test]
+ 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");
+
+ assert_eq!(
+ tracker.drain_pending(),
+ vec![
+ DefaultColorEvent::Query(DefaultColorQuery::Foreground),
+ DefaultColorEvent::Query(DefaultColorQuery::Background),
+ DefaultColorEvent::Set(DefaultColorQuery::Foreground),
+ DefaultColorEvent::Reset(DefaultColorQuery::Background),
+ ]
+ );
+ }
+
+ #[test]
+ fn default_color_event_tracker_handles_split_queries() {
+ let mut tracker = DefaultColorEventTracker::default();
+
+ tracker.observe(b"\x1b]11");
+ assert!(tracker.drain_pending().is_empty());
+ tracker.observe(b";?\x1b");
+ assert!(tracker.drain_pending().is_empty());
+ tracker.observe(b"\\");
+
+ assert_eq!(
+ tracker.drain_pending(),
+ vec![DefaultColorEvent::Query(DefaultColorQuery::Background)]
+ );
+ }
+
+ #[test]
+ fn default_color_event_tracker_ignores_other_osc_and_dcs_payloads() {
+ let mut tracker = DefaultColorEventTracker::default();
+
+ tracker.observe(b"\x1b]0;title\x07");
+ tracker.observe(b"\x1b]52;c;?\x07");
+ tracker.observe(b"\x1bPtmux;\x1b\x1b]11;?\x07\x1b\\");
+ tracker.observe(b"\x1bPtmux;payload\x07\x1b]11;?\x07\x1b\\");
+
+ assert!(tracker.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn default_color_event_tracker_ignores_oversized_osc_until_terminator() {
+ let mut tracker = DefaultColorEventTracker::default();
+ let mut oversized = Vec::from(b"\x1b]11;".as_slice());
+ oversized.extend(std::iter::repeat_n(b'a', 1025));
+ oversized.extend_from_slice(b"\x1b]11;?\x07");
+
+ tracker.observe(&oversized);
+ assert!(tracker.drain_pending().is_empty());
+
+ tracker.observe(b"\x1b]11;?\x07");
+ assert_eq!(
+ tracker.drain_pending(),
+ vec![DefaultColorEvent::Query(DefaultColorQuery::Background)]
+ );
+ }
+
+ #[test]
fn osc52_forwarder_detects_write_with_bel() {
let mut fw = Osc52Forwarder::default();
fw.observe(b"\x1b]52;c;aGVsbG8=\x07");
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 6e29774a..e1a57e89 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -20,7 +20,8 @@ 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, DefaultColorOscTracker, Osc52Forwarder,
+ write_host_terminal_theme, DefaultColorEvent, DefaultColorEventTracker,
+ DefaultColorOscTracker, DefaultColorQuery, Osc52Forwarder,
},
};
@@ -93,6 +94,9 @@ pub(crate) struct GhosttyPaneCore {
pub host_terminal_theme: crate::terminal_theme::TerminalTheme,
pub transient_default_color_owner_pgid: Option<u32>,
pub default_color_tracker: DefaultColorOscTracker,
+ pub default_color_event_tracker: DefaultColorEventTracker,
+ pub child_default_foreground_changed: bool,
+ pub child_default_background_changed: bool,
pub osc52_forwarder: Osc52Forwarder,
}
@@ -282,6 +286,9 @@ impl GhosttyPaneTerminal {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
transient_default_color_owner_pgid: None,
default_color_tracker: DefaultColorOscTracker::default(),
+ default_color_event_tracker: DefaultColorEventTracker::default(),
+ child_default_foreground_changed: false,
+ child_default_background_changed: false,
osc52_forwarder: Osc52Forwarder::default(),
}),
key_encoder: Mutex::new(key_encoder),
@@ -292,6 +299,8 @@ impl GhosttyPaneTerminal {
if let Ok(mut core) = self.core.lock() {
core.host_terminal_theme = theme;
core.transient_default_color_owner_pgid = None;
+ core.child_default_foreground_changed = false;
+ core.child_default_background_changed = false;
write_host_terminal_theme(&mut core.terminal, theme);
}
}
@@ -387,6 +396,10 @@ impl GhosttyPaneTerminal {
}
core.terminal.write(filtered_bytes.as_ref());
+ core.default_color_event_tracker
+ .observe(filtered_bytes.as_ref());
+ respond_to_default_color_events(&mut core, response_writer);
+
let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled()
&& contains_kitty_graphics_sequence(filtered_bytes.as_ref());
if has_kitty_graphics_sequence {
@@ -399,7 +412,6 @@ impl GhosttyPaneTerminal {
.terminal
.mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT)
.unwrap_or(false);
- let _ = response_writer;
ProcessBytesResult {
request_render: !synchronized_output && !has_kitty_graphics_sequence,
render_delay: (!synchronized_output && has_kitty_graphics_sequence)
@@ -1132,6 +1144,53 @@ fn ghostty_cell_style(
style.add_modifier(modifiers)
}
+fn respond_to_default_color_events(
+ core: &mut GhosttyPaneCore,
+ response_writer: &mpsc::Sender<Bytes>,
+) {
+ for event in core.default_color_event_tracker.drain_pending() {
+ match event {
+ DefaultColorEvent::Query(query) => {
+ if let Some(response) = default_color_query_response(query, core) {
+ let _ = response_writer.try_send(response);
+ }
+ }
+ DefaultColorEvent::Set(query) => mark_child_default_color_changed(core, query, true),
+ DefaultColorEvent::Reset(query) => mark_child_default_color_changed(core, query, false),
+ }
+ }
+}
+
+fn default_color_query_response(query: DefaultColorQuery, core: &GhosttyPaneCore) -> Option<Bytes> {
+ let color = match query {
+ DefaultColorQuery::Foreground if !core.child_default_foreground_changed => {
+ core.host_terminal_theme.foreground
+ }
+ DefaultColorQuery::Background if !core.child_default_background_changed => {
+ core.host_terminal_theme.background
+ }
+ _ => 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()
+ )))
+}
+
+fn mark_child_default_color_changed(
+ core: &mut GhosttyPaneCore,
+ query: DefaultColorQuery,
+ changed: bool,
+) {
+ match query {
+ DefaultColorQuery::Foreground => core.child_default_foreground_changed = changed,
+ DefaultColorQuery::Background => core.child_default_background_changed = changed,
+ }
+}
+
fn ghostty_default_fg(
color: crate::ghostty::RgbColor,
host_theme: crate::terminal_theme::TerminalTheme,
@@ -1962,6 +2021,92 @@ mod tests {
}
#[test]
+ fn process_pty_bytes_responds_to_default_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);
+ pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0x65,
+ g: 0x7b,
+ b: 0x83,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0xfd,
+ g: 0xf6,
+ b: 0xe3,
+ }),
+ });
+
+ pane.process_pty_bytes(pane_id, 0, b"\x1b]10;?\x07\x1b]11;?\x07", &tx);
+
+ assert_eq!(
+ rx.try_recv().unwrap().as_ref(),
+ b"\x1b]10;rgb:6565/7b7b/8383\x1b\\"
+ );
+ assert_eq!(
+ rx.try_recv().unwrap().as_ref(),
+ b"\x1b]11;rgb:fdfd/f6f6/e3e3\x1b\\"
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn process_pty_bytes_responds_to_split_default_color_query() {
+ 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);
+ pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
+ foreground: None,
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0xfd,
+ g: 0xf6,
+ b: 0xe3,
+ }),
+ });
+
+ pane.process_pty_bytes(pane_id, 0, b"\x1b]11", &tx);
+ assert!(rx.try_recv().is_err());
+ pane.process_pty_bytes(pane_id, 0, b";?\x1b", &tx);
+ assert!(rx.try_recv().is_err());
+ pane.process_pty_bytes(pane_id, 0, b"\\", &tx);
+
+ assert_eq!(
+ rx.try_recv().unwrap().as_ref(),
+ b"\x1b]11;rgb:fdfd/f6f6/e3e3\x1b\\"
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn process_pty_bytes_stops_replying_after_child_default_color_set() {
+ 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);
+ pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
+ foreground: None,
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0xfd,
+ g: 0xf6,
+ b: 0xe3,
+ }),
+ });
+
+ pane.process_pty_bytes(pane_id, 0, b"\x1b]11;rgb:11/22/33\x07\x1b]11;?\x07", &tx);
+ assert!(rx.try_recv().is_err());
+
+ pane.process_pty_bytes(pane_id, 0, b"\x1b]111\x07\x1b]11;?\x07", &tx);
+ assert_eq!(
+ rx.try_recv().unwrap().as_ref(),
+ b"\x1b]11;rgb:fdfd/f6f6/e3e3\x1b\\"
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
fn render_leaves_host_default_background_transparent() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();