Skip to content

Commit c57f281

Browse files
committed
Add OSC 4/10/11 color query responses (fixes #75)
libghostty parses OSC 4/10/11 sets but silently drops the query form (`?` value) and does not expose a callback for them, so programs like duf that auto-detect the theme via termenv get no reply. Scan raw input in `fnWriteInput` for OSC 4/10/11 query payloads and reply via the existing `ghostel--flush-output` path, using the effective colors from libghostty. The extractor runs before `vtWrite` so the color reply is on the wire before any response libghostty generates (e.g. the CSI 6n cursor-position reply duf sends in the same write) — termenv reads the first chunk off stdin, so ordering matters. A single-pass scanner walks ESC `]` introducers in source order so replies to multiple different-type queries in one write come back in the same order the client wrote them. Only fully-terminated OSC sequences produce a reply — a query split across process-output chunks is left for a later call. `parseDecimal` uses `std.fmt.parseInt` so numeric overflow is rejected instead of wrapping into the valid palette range. The process filter previously batched all output for the redraw timer, which delayed the reply past duf's read timeout. Detect color queries in `ghostel--filter` and flush the pending buffer synchronously so the reply is written back through the PTY before the filter returns.
1 parent 79a6b86 commit c57f281

6 files changed

Lines changed: 291 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ loaded scrollback.
219219
### Terminal Emulation
220220
- Full VT terminal emulation via libghostty-vt
221221
- 256-color and RGB (24-bit true color) support
222+
- **OSC 4 / 10 / 11 color queries** — TUI programs can query the current palette, foreground, and background colors, so tools like `duf`, `btop`, `delta`, and anything else using `termenv` auto-detect the right light/dark theme from the Emacs face colors
222223
- Text attributes: bold, italic, faint, underline (single/double/curly/dotted/dashed with color), strikethrough, inverse
223224
- Cursor styles: block, bar, underline, hollow block
224225
- Alternate screen buffer (for TUI apps like htop, vim, etc.)
@@ -546,6 +547,7 @@ powering Neovim's built-in terminal.
546547
| Feature | ghostel | vterm |
547548
|-------------------------------|-----------|---------|
548549
| True color (24-bit) | Yes | Yes |
550+
| OSC 4/10/11 color queries | Yes | No |
549551
| Bold / italic / faint | Yes | Yes |
550552
| Underline styles (5 types) | Yes | No |
551553
| Underline color | Yes | No |

ghostel.el

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1882,6 +1882,14 @@ the redraw is performed immediately to minimize typing latency."
18821882
(when ghostel--term
18831883
;; Accumulate output for batched write-input at redraw time.
18841884
(push output ghostel--pending-output)
1885+
;; Respond to OSC 4/10/11 color queries immediately: programs like
1886+
;; `duf' read stdin with a tight timeout and give up if the reply
1887+
;; waits for the redraw timer. Flushing runs the extractor in the
1888+
;; native module, which writes the reply back through the PTY
1889+
;; before this filter returns.
1890+
(when (string-match-p
1891+
"\e\\]\\(?:4;[0-9]+;\\?\\|10;\\?\\|11;\\?\\)" output)
1892+
(ghostel--flush-pending-output))
18851893
;; Immediate redraw for interactive echo: small output arriving
18861894
;; within `ghostel-immediate-redraw-interval' of last keystroke.
18871895
(if (and (> ghostel-immediate-redraw-threshold 0)

src/ghostty.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ pub const OPT_COLOR_FOREGROUND = c.GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND;
6161
pub const OPT_COLOR_BACKGROUND = c.GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND;
6262
pub const OPT_COLOR_PALETTE = c.GHOSTTY_TERMINAL_OPT_COLOR_PALETTE;
6363
pub const DATA_COLOR_PALETTE = c.GHOSTTY_TERMINAL_DATA_COLOR_PALETTE;
64+
pub const DATA_COLOR_FOREGROUND = c.GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND;
65+
pub const DATA_COLOR_BACKGROUND = c.GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND;
6466

6567
// Terminal data constants
6668
pub const DATA_COLS = c.GHOSTTY_TERMINAL_DATA_COLS;

src/module.zig

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ fn fnWriteInput(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*
143143
// Done here in Zig to avoid Elisp unibyte→multibyte corruption.
144144
const raw = data.?;
145145

146+
// Respond to OSC 4/10/11 color queries BEFORE feeding libghostty.
147+
// libghostty will synchronously emit responses for other queries in
148+
// the same write (e.g. CSI 6n cursor-position report) via the
149+
// write_pty callback, and termenv-based programs read only the first
150+
// response chunk — so the color reply must be on the wire first or
151+
// the program discards our reply as noise.
152+
extractOscColorQueries(env, term, raw);
153+
146154
// Count bare \n to determine output size.
147155
var extra_cr: usize = 0;
148156
for (0..raw.len) |i| {
@@ -320,6 +328,150 @@ fn extractOsc133(env: emacs.Env, data: []const u8) void {
320328
}
321329
}
322330

331+
/// Send `OSC N;rgb:RRRR/GGGG/BBBB <term>` for a dynamic color (OSC 10/11).
332+
fn sendDynamicColorReply(
333+
env: emacs.Env,
334+
osc_num: u8,
335+
color: gt.ColorRgb,
336+
term_bytes: []const u8,
337+
) void {
338+
var buf: [64]u8 = undefined;
339+
const written = std.fmt.bufPrint(
340+
&buf,
341+
"\x1b]{d};rgb:{x:0>2}{x:0>2}/{x:0>2}{x:0>2}/{x:0>2}{x:0>2}{s}",
342+
.{
343+
osc_num,
344+
color.r, color.r,
345+
color.g, color.g,
346+
color.b, color.b,
347+
term_bytes,
348+
},
349+
) catch return;
350+
_ = env.call1(emacs.sym.@"ghostel--flush-output", env.makeString(written));
351+
}
352+
353+
/// Send `OSC 4;INDEX;rgb:RRRR/GGGG/BBBB <term>` for a palette entry.
354+
fn sendPaletteColorReply(
355+
env: emacs.Env,
356+
index: u16,
357+
color: gt.ColorRgb,
358+
term_bytes: []const u8,
359+
) void {
360+
var buf: [64]u8 = undefined;
361+
const written = std.fmt.bufPrint(
362+
&buf,
363+
"\x1b]4;{d};rgb:{x:0>2}{x:0>2}/{x:0>2}{x:0>2}/{x:0>2}{x:0>2}{s}",
364+
.{
365+
index,
366+
color.r, color.r,
367+
color.g, color.g,
368+
color.b, color.b,
369+
term_bytes,
370+
},
371+
) catch return;
372+
_ = env.call1(emacs.sym.@"ghostel--flush-output", env.makeString(written));
373+
}
374+
375+
/// Parse a non-negative decimal integer. Returns null on empty input,
376+
/// any non-digit byte, or numeric overflow of `u32`.
377+
fn parseDecimal(s: []const u8) ?u32 {
378+
if (s.len == 0) return null;
379+
return std.fmt.parseInt(u32, s, 10) catch null;
380+
}
381+
382+
/// Scan data for OSC 4/10/11 color queries and emit responses in source
383+
/// order. libghostty applies OSC 4/10/11 **sets** internally but silently
384+
/// drops the query form (`?` value), so ghostel scans the raw input and
385+
/// replies itself.
386+
///
387+
/// Colors come from the terminal's currently effective state, which reflects
388+
/// sets applied by earlier write-input calls — but NOT sets that appear
389+
/// earlier in *this* input buffer, because this extractor runs before
390+
/// `vtWrite` so the color reply is on the wire before any reply libghostty
391+
/// generates itself (e.g. the CSI 6n cursor-position reply some programs
392+
/// send in the same write). Termenv-based readers consume the first chunk
393+
/// off stdin, so ordering matters more than same-chunk freshness.
394+
///
395+
/// Only fully-terminated OSC sequences produce a reply: a query split
396+
/// across two process-output chunks is ignored until a later call carries
397+
/// the terminator.
398+
fn extractOscColorQueries(env: emacs.Env, term: *Terminal, data: []const u8) void {
399+
var palette: [256]gt.ColorRgb = undefined;
400+
var palette_loaded = false;
401+
402+
var pos: usize = 0;
403+
while (pos + 1 < data.len) {
404+
// Find next OSC introducer "ESC ]".
405+
const osc_rel = std.mem.indexOfPos(u8, data, pos, "\x1b]") orelse break;
406+
const code_start = osc_rel + 2;
407+
408+
// Read the decimal OSC code up to the first ';'.
409+
var code_end = code_start;
410+
while (code_end < data.len and data[code_end] >= '0' and data[code_end] <= '9') {
411+
code_end += 1;
412+
}
413+
if (code_end == code_start or code_end >= data.len or data[code_end] != ';') {
414+
pos = code_start;
415+
continue;
416+
}
417+
const payload_start = code_end + 1;
418+
419+
// Find the terminator (BEL or ST). Require a real one — partial OSCs
420+
// split across chunks are left for the next call so we don't reply
421+
// before the client has finished writing its query.
422+
var end = payload_start;
423+
var term_len: usize = 0;
424+
while (end < data.len) : (end += 1) {
425+
if (data[end] == 0x07) {
426+
term_len = 1;
427+
break;
428+
}
429+
if (data[end] == 0x1b and end + 1 < data.len and data[end + 1] == '\\') {
430+
term_len = 2;
431+
break;
432+
}
433+
}
434+
if (term_len == 0) break;
435+
436+
const payload = data[payload_start..end];
437+
const term_bytes = data[end .. end + term_len];
438+
pos = end + term_len;
439+
440+
const code = parseDecimal(data[code_start..code_end]) orelse continue;
441+
switch (code) {
442+
10 => {
443+
if (!std.mem.eql(u8, payload, "?")) continue;
444+
var fg: gt.ColorRgb = undefined;
445+
if (!term.getColorForeground(&fg)) continue;
446+
sendDynamicColorReply(env, 10, fg, term_bytes);
447+
},
448+
11 => {
449+
if (!std.mem.eql(u8, payload, "?")) continue;
450+
var bg: gt.ColorRgb = undefined;
451+
if (!term.getColorBackground(&bg)) continue;
452+
sendDynamicColorReply(env, 11, bg, term_bytes);
453+
},
454+
4 => {
455+
// Payload is a ';'-separated list of `index;value` pairs.
456+
// Reply only to pairs whose value is literally "?".
457+
var it = std.mem.splitScalar(u8, payload, ';');
458+
while (it.next()) |index_tok| {
459+
const value_tok = it.next() orelse break;
460+
if (!std.mem.eql(u8, value_tok, "?")) continue;
461+
const idx = parseDecimal(index_tok) orelse continue;
462+
if (idx >= 256) continue;
463+
if (!palette_loaded) {
464+
if (!term.getColorPalette(&palette)) break;
465+
palette_loaded = true;
466+
}
467+
sendPaletteColorReply(env, @intCast(idx), palette[idx], term_bytes);
468+
}
469+
},
470+
else => {},
471+
}
472+
}
473+
}
474+
323475
/// (ghostel--set-size TERM ROWS COLS)
324476
fn fnSetSize(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyopaque) callconv(.c) c.emacs_value {
325477
const env = emacs.Env.init(raw_env.?);

src/terminal.zig

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ pub fn getColorPalette(self: *Self, palette: *[256]gt.ColorRgb) bool {
160160
) == gt.SUCCESS;
161161
}
162162

163+
/// Get the effective foreground color (honouring any OSC 10 override).
164+
pub fn getColorForeground(self: *Self, out: *gt.ColorRgb) bool {
165+
return gt.c.ghostty_terminal_get(
166+
self.terminal,
167+
gt.DATA_COLOR_FOREGROUND,
168+
@ptrCast(out),
169+
) == gt.SUCCESS;
170+
}
171+
172+
/// Get the effective background color (honouring any OSC 11 override).
173+
pub fn getColorBackground(self: *Self, out: *gt.ColorRgb) bool {
174+
return gt.c.ghostty_terminal_get(
175+
self.terminal,
176+
gt.DATA_COLOR_BACKGROUND,
177+
@ptrCast(out),
178+
) == gt.SUCCESS;
179+
}
180+
163181
/// Feed VT data from the PTY into the terminal.
164182
pub fn vtWrite(self: *Self, data: []const u8) void {
165183
gt.c.ghostty_terminal_vt_write(self.terminal, data.ptr, data.len);

test/ghostel-test.el

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,115 @@ Mirrors the real zsh case where the directory still contains a
717717
(ghostel--write-input term "\e]52;c;?\e\\")
718718
(should (equal nil kill-ring))))) ; osc52 query ignored
719719

720+
;; -----------------------------------------------------------------------
721+
;; Test: OSC 4/10/11 color query responses
722+
;; -----------------------------------------------------------------------
723+
724+
(ert-deftest ghostel-test-osc-color-query ()
725+
"Test that OSC 4/10/11 color queries get responses."
726+
(let* ((term (ghostel--new 25 80 1000))
727+
(sent-bytes nil))
728+
(cl-letf (((symbol-function 'ghostel--flush-output)
729+
(lambda (data)
730+
(setq sent-bytes (concat sent-bytes data)))))
731+
732+
;; OSC 11 background query with ST terminator.
733+
(setq sent-bytes nil)
734+
(ghostel--write-input term "\e]11;?\e\\")
735+
(should sent-bytes)
736+
(should (string-match-p "\\`\e\\]11;rgb:[0-9a-f]\\{4\\}/[0-9a-f]\\{4\\}/[0-9a-f]\\{4\\}\e\\\\\\'"
737+
sent-bytes))
738+
739+
;; OSC 10 foreground query with BEL terminator.
740+
(setq sent-bytes nil)
741+
(ghostel--write-input term "\e]10;?\a")
742+
(should sent-bytes)
743+
(should (string-match-p "\\`\e\\]10;rgb:[0-9a-f]\\{4\\}/[0-9a-f]\\{4\\}/[0-9a-f]\\{4\\}\a\\'"
744+
sent-bytes))
745+
746+
;; OSC 4 palette query for index 1, after a prior set. The extractor
747+
;; runs before vtWrite inside a single write-input, so the set must
748+
;; land in a previous call for the new value to be visible.
749+
(setq sent-bytes nil)
750+
(ghostel--write-input term "\e]4;1;rgb:11/22/33\e\\")
751+
(should (equal nil sent-bytes)) ; set: no reply
752+
(ghostel--write-input term "\e]4;1;?\e\\")
753+
(should (equal "\e]4;1;rgb:1111/2222/3333\e\\" sent-bytes))
754+
755+
;; OSC 10 with a set value (not a query) — no response.
756+
(setq sent-bytes nil)
757+
(ghostel--write-input term "\e]10;rgb:aa/bb/cc\e\\")
758+
(should (equal nil sent-bytes))
759+
760+
;; OSC 4 set (not a query) — no response.
761+
(setq sent-bytes nil)
762+
(ghostel--write-input term "\e]4;2;rgb:44/55/66\e\\")
763+
(should (equal nil sent-bytes))
764+
765+
;; Malformed OSC 4 payloads — don't crash, don't reply.
766+
(setq sent-bytes nil)
767+
(ghostel--write-input term "\e]4;\e\\") ; empty
768+
(ghostel--write-input term "\e]4;xyz;?\e\\") ; non-numeric index
769+
(ghostel--write-input term "\e]4;999;?\e\\") ; index out of range
770+
(ghostel--write-input term "\e]4;0\e\\") ; index without value
771+
(ghostel--write-input term "\e]4;99999999999999999999;?\e\\") ; overflow
772+
(should (equal nil sent-bytes))
773+
774+
;; Multiple different-type queries in one write must reply in source
775+
;; order so termenv-style readers can match by position.
776+
(setq sent-bytes nil)
777+
(ghostel--write-input term "\e]11;?\e\\\e]10;?\e\\")
778+
(should (string-match-p "\\`\e\\]11;rgb:.*?\e\\\\\e\\]10;rgb:.*?\e\\\\\\'"
779+
sent-bytes))
780+
781+
;; Multi-pair OSC 4 with mixed set+query: the extractor runs before
782+
;; vtWrite, so the set is not yet visible to the query in the same
783+
;; payload — but the index=1 value seeded in the earlier write
784+
;; above is still there, and both indices get replied to in order.
785+
(setq sent-bytes nil)
786+
(ghostel--write-input term "\e]4;1;?;3;?\e\\")
787+
(should (string-match-p
788+
"\\`\e\\]4;1;rgb:1111/2222/3333\e\\\\\e\\]4;3;rgb:.*?\e\\\\\\'"
789+
sent-bytes))
790+
791+
;; Unterminated OSC query — reply is withheld until the terminator
792+
;; arrives. (We don't buffer across write-input calls, so the
793+
;; terminator must be in the same call to get a reply.)
794+
(setq sent-bytes nil)
795+
(ghostel--write-input term "\e]11;?")
796+
(should (equal nil sent-bytes)))))
797+
798+
(ert-deftest ghostel-test-osc-color-query-filter-flush ()
799+
"The process filter must flush synchronously on a color query.
800+
Programs like `duf' read stdin with a short timeout and give up if
801+
the reply waits for the redraw timer."
802+
(let ((buf (generate-new-buffer " *ghostel-osc-flush*"))
803+
(fake-proc (make-symbol "fake-proc"))
804+
(sent nil))
805+
(unwind-protect
806+
(with-current-buffer buf
807+
(setq ghostel--term (ghostel--new 25 80 1000))
808+
(setq ghostel--process fake-proc)
809+
(cl-letf (((symbol-function 'process-buffer) (lambda (_) buf))
810+
((symbol-function 'process-live-p) (lambda (_) t))
811+
((symbol-function 'ghostel--flush-output)
812+
(lambda (data) (setq sent (concat sent data))))
813+
((symbol-function 'ghostel--invalidate) #'ignore))
814+
;; OSC 11 query arrives — reply must be produced before
815+
;; `ghostel--filter' returns, not on a later timer tick.
816+
(ghostel--filter fake-proc "\e]11;?\e\\")
817+
(should sent)
818+
(should (string-match-p "\\`\e\\]11;rgb:" sent))
819+
(should (equal nil ghostel--pending-output))
820+
821+
;; A non-query OSC 11 set must NOT trigger the sync flush,
822+
;; so the data stays pending for the redraw timer.
823+
(setq sent nil)
824+
(ghostel--filter fake-proc "\e]11;rgb:11/22/33\e\\")
825+
(should (equal nil sent))
826+
(should ghostel--pending-output)))
827+
(kill-buffer buf))))
828+
720829
;; -----------------------------------------------------------------------
721830
;; Test: focus events gated by mode 1004
722831
;; -----------------------------------------------------------------------

0 commit comments

Comments
 (0)