Skip to content

Commit f0bf8e5

Browse files
authored
fix(src): detect light/dark theme inside sessions via OSC 11 (#92)
Probe the real terminal's background and answer OSC 11 and the color-scheme DSR from the daemon so programs detect the light/dark theme inside boo sessions. Fixes #91.
1 parent 938d4d9 commit f0bf8e5

7 files changed

Lines changed: 628 additions & 7 deletions

File tree

src/client.zig

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ const restore_sequence = window.reset_state_sequence ++ "\x1b[?1049l";
2020

2121
pub const Outcome = enum { detached, stolen, ended, lost };
2222

23+
/// How long to wait for the terminal to answer the startup OSC 11
24+
/// background probe. Terminals that support it answer within a few
25+
/// milliseconds; the probe returns as soon as the reply arrives, so this
26+
/// bound only delays attach on a terminal that never answers.
27+
const probe_timeout_ms = 150;
28+
2329
var signal_pipe: posix.fd_t = -1;
2430

2531
fn handleSignal(sig: c_int) callconv(.c) void {
@@ -82,13 +88,28 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome {
8288
defer restoreTty(tty, saved, restore_sequence, eof_guard, 'd');
8389
try protocol.writeAll(1, enter_sequence);
8490

85-
// Handshake with our current size.
91+
// Handshake with our current size first so the daemon sees the
92+
// attach promptly. The background probe below blocks briefly; a kill
93+
// or resize racing a slow attach would otherwise break the
94+
// connection or miss the initial size.
8695
const ws = ptypkg.getSize(tty) catch ptypkg.makeWinsize(24, 80);
8796
try protocol.writeMsg(sock, .attach, &(protocol.SizePayload{
8897
.rows = ws.row,
8998
.cols = ws.col,
9099
}).encode());
91100

101+
// Probe the real terminal's background color so the daemon can
102+
// answer OSC 11 theme queries from inside the session, where the
103+
// application can no longer reach this terminal. The probe yields to
104+
// a pending signal, and any keystrokes typed during it are forwarded
105+
// as input afterward.
106+
var probe_scratch: [256]u8 = undefined;
107+
var leftover_len: usize = 0;
108+
if (probeBackground(tty, pipe_fds[0], &probe_scratch, &leftover_len)) |color| {
109+
protocol.writeMsg(sock, .bg_color, &color.encode()) catch {};
110+
}
111+
if (leftover_len > 0) protocol.writeMsg(sock, .input, probe_scratch[0..leftover_len]) catch {};
112+
92113
var decoder: protocol.Decoder = .init(alloc);
93114
defer decoder.deinit();
94115

@@ -175,6 +196,108 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome {
175196
}
176197
}
177198

199+
/// Probe the real terminal for its background color via an OSC 11 query
200+
/// and parse the reply. Returns null if the terminal does not answer
201+
/// within `probe_timeout_ms` or a pending signal (resize/quit) on
202+
/// `signal_fd` cuts the probe short so the caller can service it. Bytes
203+
/// read while waiting that are not the reply (e.g. a keystroke typed
204+
/// during attach) are left in `scratch[0..leftover_len.*]` for the
205+
/// caller to forward as input. Pass -1 for `signal_fd` to skip the
206+
/// signal check.
207+
pub fn probeBackground(
208+
tty: posix.fd_t,
209+
signal_fd: posix.fd_t,
210+
scratch: []u8,
211+
leftover_len: *usize,
212+
) ?protocol.RgbPayload {
213+
leftover_len.* = 0;
214+
// The query goes to the terminal on stdout; the reply arrives on the
215+
// input fd. For an attached client and the ui both are the same tty.
216+
protocol.writeAll(posix.STDOUT_FILENO, "\x1b]11;?\x07") catch return null;
217+
218+
const deadline = std.time.milliTimestamp() + probe_timeout_ms;
219+
var len: usize = 0;
220+
while (len < scratch.len) {
221+
const now = std.time.milliTimestamp();
222+
if (now >= deadline) break;
223+
var fds = [_]posix.pollfd{
224+
.{ .fd = tty, .events = posix.POLL.IN, .revents = 0 },
225+
.{ .fd = signal_fd, .events = posix.POLL.IN, .revents = 0 },
226+
};
227+
const ready = posix.poll(&fds, @intCast(deadline - now)) catch break;
228+
if (ready == 0) break;
229+
// A pending signal (resize or quit) must reach the caller's loop
230+
// without delay; stop probing and leave it queued.
231+
if (fds[1].revents != 0) break;
232+
if (fds[0].revents == 0) continue;
233+
const n = posix.read(tty, scratch[len..]) catch break;
234+
if (n == 0) break;
235+
len += n;
236+
if (findOsc11Reply(scratch[0..len])) |span| {
237+
const color = parseOsc11Reply(scratch[span.start..span.end]);
238+
// Drop the reply from the buffer; keep anything else (typed
239+
// input) as leftover for the caller to forward.
240+
const removed = span.end - span.start;
241+
std.mem.copyForwards(u8, scratch[span.start..], scratch[span.end..len]);
242+
leftover_len.* = len - removed;
243+
return color;
244+
}
245+
}
246+
leftover_len.* = len;
247+
return null;
248+
}
249+
250+
const Osc11Span = struct { start: usize, end: usize };
251+
252+
/// Locate a complete OSC 11 reply (`ESC ] 11 ; ... BEL|ST`) in `data`,
253+
/// returning the byte span it occupies, terminator included.
254+
fn findOsc11Reply(data: []const u8) ?Osc11Span {
255+
const marker = "\x1b]11;";
256+
const start = std.mem.indexOf(u8, data, marker) orelse return null;
257+
const body = data[start + marker.len ..];
258+
if (std.mem.indexOfScalar(u8, body, 0x07)) |bel| {
259+
return .{ .start = start, .end = start + marker.len + bel + 1 };
260+
}
261+
if (std.mem.indexOf(u8, body, "\x1b\\")) |st| {
262+
return .{ .start = start, .end = start + marker.len + st + 2 };
263+
}
264+
return null;
265+
}
266+
267+
/// Parse an OSC 11 reply (`ESC ] 11 ; rgb:R/G/B` with a BEL or ST
268+
/// terminator) into a 16-bit RGB. Each channel may be 1-4 hex digits and
269+
/// is scaled to 16-bit. Returns null for anything it does not recognize.
270+
pub fn parseOsc11Reply(data: []const u8) ?protocol.RgbPayload {
271+
const marker = "\x1b]11;";
272+
const start = std.mem.indexOf(u8, data, marker) orelse return null;
273+
var body = data[start + marker.len ..];
274+
if (std.mem.indexOfScalar(u8, body, 0x07)) |bel| {
275+
body = body[0..bel];
276+
} else if (std.mem.indexOf(u8, body, "\x1b\\")) |st| {
277+
body = body[0..st];
278+
}
279+
const rgb_prefix = "rgb:";
280+
if (!std.mem.startsWith(u8, body, rgb_prefix)) return null;
281+
var it = std.mem.splitScalar(u8, body[rgb_prefix.len..], '/');
282+
const r = parseChannel(it.next() orelse return null) orelse return null;
283+
const g = parseChannel(it.next() orelse return null) orelse return null;
284+
const b = parseChannel(it.next() orelse return null) orelse return null;
285+
if (it.next() != null) return null;
286+
return .{ .r = r, .g = g, .b = b };
287+
}
288+
289+
fn parseChannel(s: []const u8) ?u16 {
290+
if (s.len == 0 or s.len > 4) return null;
291+
const v = std.fmt.parseInt(u16, s, 16) catch return null;
292+
const wide: u32 = v;
293+
return switch (s.len) {
294+
1 => @intCast(wide * 0x1111), // 0xF -> 0xFFFF
295+
2 => @intCast(wide * 0x101), // 0xFF -> 0xFFFF
296+
3 => @intCast((wide * 0xffff) / 0xfff), // 0xFFF -> 0xFFFF
297+
else => @intCast(wide), // already 16-bit
298+
};
299+
}
300+
178301
/// Configure a termios for raw byte-at-a-time input. Shared with the
179302
/// boo ui client, which manages its own terminal lifecycle.
180303
pub fn rawMode(t: *posix.termios) void {
@@ -449,6 +572,34 @@ test "ReleaseScan: non-release CSI and plain bytes never trigger" {
449572
try std.testing.expect(!scan.feed("\x1b[A\x1b[100;1:2u"));
450573
}
451574

575+
test "parseOsc11Reply: BEL and ST terminators, various channel widths" {
576+
// 16-bit channels, BEL-terminated (ghostty's format).
577+
try std.testing.expectEqual(
578+
protocol.RgbPayload{ .r = 0x1234, .g = 0x5678, .b = 0x9abc },
579+
parseOsc11Reply("\x1b]11;rgb:1234/5678/9abc\x07").?,
580+
);
581+
// ST-terminated, 2-digit channels scaled to 16-bit.
582+
try std.testing.expectEqual(
583+
protocol.RgbPayload{ .r = 0xffff, .g = 0x0000, .b = 0x8080 },
584+
parseOsc11Reply("\x1b]11;rgb:ff/00/80\x1b\\").?,
585+
);
586+
// A reply embedded among other bytes still parses.
587+
try std.testing.expect(parseOsc11Reply("x\x1b]11;rgb:0000/0000/0000\x07y") != null);
588+
// A query is not a reply, and junk is rejected.
589+
try std.testing.expect(parseOsc11Reply("\x1b]11;?\x07") == null);
590+
try std.testing.expect(parseOsc11Reply("garbage") == null);
591+
try std.testing.expect(parseOsc11Reply("\x1b]11;rgb:00/00\x07") == null); // too few channels
592+
}
593+
594+
test "findOsc11Reply: only a fully terminated reply is located" {
595+
// Incomplete (no terminator yet): not found.
596+
try std.testing.expect(findOsc11Reply("\x1b]11;rgb:1111/2222/3333") == null);
597+
// BEL-terminated reply: span covers the terminator (exclusive end).
598+
const span = findOsc11Reply("ab\x1b]11;rgb:1111/2222/3333\x07cd").?;
599+
try std.testing.expectEqual(@as(usize, 2), span.start);
600+
try std.testing.expectEqual(@as(usize, 26), span.end);
601+
}
602+
452603
test "control times out when the daemon never answers" {
453604
const alloc = std.testing.allocator;
454605

src/daemon.zig

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,14 @@ pub const Daemon = struct {
376376
switch (msg.type) {
377377
.ui => conn.ui = true,
378378

379+
.bg_color => {
380+
// The client probed its real terminal's background and
381+
// reported it; the window uses it to answer OSC 11
382+
// queries and the color-scheme DSR from the session.
383+
const bg = protocol.RgbPayload.decode(msg.payload) catch return;
384+
if (self.liveWindow()) |w| w.setBackground(bg);
385+
},
386+
379387
.attach => {
380388
const size = try protocol.SizePayload.decode(msg.payload);
381389
// Steal from any previously attached client.
@@ -662,9 +670,23 @@ pub const Daemon = struct {
662670
const detached = self.attachedConn() == null;
663671
if (detached) self.unread = true;
664672

673+
// Strip OSC 11 background queries up front and answer them from
674+
// the reported terminal background. They must not also reach an
675+
// attached client's real terminal, which would answer them a
676+
// second time, so this runs before any passthrough forwarding.
677+
// The filter only removes bytes, so the cleaned copy never
678+
// outgrows the chunk; on the impossible overflow, fall back to
679+
// the raw chunk rather than dropping output.
680+
var clean_buf: [32 * 1024]u8 = undefined;
681+
var clean_writer = std.Io.Writer.fixed(&clean_buf);
682+
const cleaned = cleaned: {
683+
win.filterColorQueries(chunk, &clean_writer) catch break :cleaned chunk;
684+
break :cleaned clean_writer.buffered();
685+
};
686+
665687
const conn = (if (win.passthrough) self.attachedConn() else null) orelse {
666688
// Not passed through: the window answers queries itself.
667-
win.feed(chunk);
689+
win.feed(cleaned);
668690
self.noteBell(win, detached, now);
669691
return;
670692
};
@@ -680,16 +702,16 @@ pub const Daemon = struct {
680702
// without a 47/1047/1049 toggle) still has to repaint so the
681703
// client's `.screen` state stays authoritative.
682704
const was_alt = win.onAltScreen();
683-
const result = win.alt_filter.feed(chunk, &writer) catch
705+
const result = win.alt_filter.feed(cleaned, &writer) catch
684706
altscreen.Filter.Result{ .switched = true, .discard_start = 0 };
685707

686708
// Bytes up to the discard point reach the client's real
687709
// terminal, which answers any queries among them. The repaint
688710
// re-renders the discarded tail from terminal state, but it
689711
// cannot answer queries, so the window must.
690-
const split = result.discard_start orelse chunk.len;
691-
win.feed(chunk[0..split]);
692-
if (split < chunk.len) win.feedDiscarded(chunk[split..]);
712+
const split = result.discard_start orelse cleaned.len;
713+
win.feed(cleaned[0..split]);
714+
if (split < cleaned.len) win.feedDiscarded(cleaned[split..]);
693715
self.noteBell(win, detached, now);
694716

695717
const filtered = writer.buffered();

src/main.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,7 @@ test {
11291129
_ = @import("keys.zig");
11301130
_ = @import("pty.zig");
11311131
_ = @import("altscreen.zig");
1132+
_ = @import("oscquery.zig");
11321133
_ = @import("window.zig");
11331134
_ = @import("daemon.zig");
11341135
_ = @import("client.zig");

0 commit comments

Comments
 (0)