@@ -20,6 +20,12 @@ const restore_sequence = window.reset_state_sequence ++ "\x1b[?1049l";
2020
2121pub 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+
2329var signal_pipe : posix.fd_t = -1 ;
2430
2531fn 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.
180303pub 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\x07 y" ) != 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\x07 cd" ).? ;
599+ try std .testing .expectEqual (@as (usize , 2 ), span .start );
600+ try std .testing .expectEqual (@as (usize , 26 ), span .end );
601+ }
602+
452603test "control times out when the daemon never answers" {
453604 const alloc = std .testing .allocator ;
454605
0 commit comments