diff --git a/src/client.zig b/src/client.zig index ce2f2c3..781d5d9 100644 --- a/src/client.zig +++ b/src/client.zig @@ -20,6 +20,12 @@ const restore_sequence = window.reset_state_sequence ++ "\x1b[?1049l"; pub const Outcome = enum { detached, stolen, ended, lost }; +/// How long to wait for the terminal to answer the startup OSC 11 +/// background probe. Terminals that support it answer within a few +/// milliseconds; the probe returns as soon as the reply arrives, so this +/// bound only delays attach on a terminal that never answers. +const probe_timeout_ms = 150; + var signal_pipe: posix.fd_t = -1; fn handleSignal(sig: c_int) callconv(.c) void { @@ -82,13 +88,28 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome { defer restoreTty(tty, saved, restore_sequence, eof_guard, 'd'); try protocol.writeAll(1, enter_sequence); - // Handshake with our current size. + // Handshake with our current size first so the daemon sees the + // attach promptly. The background probe below blocks briefly; a kill + // or resize racing a slow attach would otherwise break the + // connection or miss the initial size. const ws = ptypkg.getSize(tty) catch ptypkg.makeWinsize(24, 80); try protocol.writeMsg(sock, .attach, &(protocol.SizePayload{ .rows = ws.row, .cols = ws.col, }).encode()); + // Probe the real terminal's background color so the daemon can + // answer OSC 11 theme queries from inside the session, where the + // application can no longer reach this terminal. The probe yields to + // a pending signal, and any keystrokes typed during it are forwarded + // as input afterward. + var probe_scratch: [256]u8 = undefined; + var leftover_len: usize = 0; + if (probeBackground(tty, pipe_fds[0], &probe_scratch, &leftover_len)) |color| { + protocol.writeMsg(sock, .bg_color, &color.encode()) catch {}; + } + if (leftover_len > 0) protocol.writeMsg(sock, .input, probe_scratch[0..leftover_len]) catch {}; + var decoder: protocol.Decoder = .init(alloc); defer decoder.deinit(); @@ -175,6 +196,108 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome { } } +/// Probe the real terminal for its background color via an OSC 11 query +/// and parse the reply. Returns null if the terminal does not answer +/// within `probe_timeout_ms` or a pending signal (resize/quit) on +/// `signal_fd` cuts the probe short so the caller can service it. Bytes +/// read while waiting that are not the reply (e.g. a keystroke typed +/// during attach) are left in `scratch[0..leftover_len.*]` for the +/// caller to forward as input. Pass -1 for `signal_fd` to skip the +/// signal check. +pub fn probeBackground( + tty: posix.fd_t, + signal_fd: posix.fd_t, + scratch: []u8, + leftover_len: *usize, +) ?protocol.RgbPayload { + leftover_len.* = 0; + // The query goes to the terminal on stdout; the reply arrives on the + // input fd. For an attached client and the ui both are the same tty. + protocol.writeAll(posix.STDOUT_FILENO, "\x1b]11;?\x07") catch return null; + + const deadline = std.time.milliTimestamp() + probe_timeout_ms; + var len: usize = 0; + while (len < scratch.len) { + const now = std.time.milliTimestamp(); + if (now >= deadline) break; + var fds = [_]posix.pollfd{ + .{ .fd = tty, .events = posix.POLL.IN, .revents = 0 }, + .{ .fd = signal_fd, .events = posix.POLL.IN, .revents = 0 }, + }; + const ready = posix.poll(&fds, @intCast(deadline - now)) catch break; + if (ready == 0) break; + // A pending signal (resize or quit) must reach the caller's loop + // without delay; stop probing and leave it queued. + if (fds[1].revents != 0) break; + if (fds[0].revents == 0) continue; + const n = posix.read(tty, scratch[len..]) catch break; + if (n == 0) break; + len += n; + if (findOsc11Reply(scratch[0..len])) |span| { + const color = parseOsc11Reply(scratch[span.start..span.end]); + // Drop the reply from the buffer; keep anything else (typed + // input) as leftover for the caller to forward. + const removed = span.end - span.start; + std.mem.copyForwards(u8, scratch[span.start..], scratch[span.end..len]); + leftover_len.* = len - removed; + return color; + } + } + leftover_len.* = len; + return null; +} + +const Osc11Span = struct { start: usize, end: usize }; + +/// Locate a complete OSC 11 reply (`ESC ] 11 ; ... BEL|ST`) in `data`, +/// returning the byte span it occupies, terminator included. +fn findOsc11Reply(data: []const u8) ?Osc11Span { + const marker = "\x1b]11;"; + const start = std.mem.indexOf(u8, data, marker) orelse return null; + const body = data[start + marker.len ..]; + if (std.mem.indexOfScalar(u8, body, 0x07)) |bel| { + return .{ .start = start, .end = start + marker.len + bel + 1 }; + } + if (std.mem.indexOf(u8, body, "\x1b\\")) |st| { + return .{ .start = start, .end = start + marker.len + st + 2 }; + } + return null; +} + +/// Parse an OSC 11 reply (`ESC ] 11 ; rgb:R/G/B` with a BEL or ST +/// terminator) into a 16-bit RGB. Each channel may be 1-4 hex digits and +/// is scaled to 16-bit. Returns null for anything it does not recognize. +pub fn parseOsc11Reply(data: []const u8) ?protocol.RgbPayload { + const marker = "\x1b]11;"; + const start = std.mem.indexOf(u8, data, marker) orelse return null; + var body = data[start + marker.len ..]; + if (std.mem.indexOfScalar(u8, body, 0x07)) |bel| { + body = body[0..bel]; + } else if (std.mem.indexOf(u8, body, "\x1b\\")) |st| { + body = body[0..st]; + } + const rgb_prefix = "rgb:"; + if (!std.mem.startsWith(u8, body, rgb_prefix)) return null; + var it = std.mem.splitScalar(u8, body[rgb_prefix.len..], '/'); + const r = parseChannel(it.next() orelse return null) orelse return null; + const g = parseChannel(it.next() orelse return null) orelse return null; + const b = parseChannel(it.next() orelse return null) orelse return null; + if (it.next() != null) return null; + return .{ .r = r, .g = g, .b = b }; +} + +fn parseChannel(s: []const u8) ?u16 { + if (s.len == 0 or s.len > 4) return null; + const v = std.fmt.parseInt(u16, s, 16) catch return null; + const wide: u32 = v; + return switch (s.len) { + 1 => @intCast(wide * 0x1111), // 0xF -> 0xFFFF + 2 => @intCast(wide * 0x101), // 0xFF -> 0xFFFF + 3 => @intCast((wide * 0xffff) / 0xfff), // 0xFFF -> 0xFFFF + else => @intCast(wide), // already 16-bit + }; +} + /// Configure a termios for raw byte-at-a-time input. Shared with the /// boo ui client, which manages its own terminal lifecycle. pub fn rawMode(t: *posix.termios) void { @@ -449,6 +572,34 @@ test "ReleaseScan: non-release CSI and plain bytes never trigger" { try std.testing.expect(!scan.feed("\x1b[A\x1b[100;1:2u")); } +test "parseOsc11Reply: BEL and ST terminators, various channel widths" { + // 16-bit channels, BEL-terminated (ghostty's format). + try std.testing.expectEqual( + protocol.RgbPayload{ .r = 0x1234, .g = 0x5678, .b = 0x9abc }, + parseOsc11Reply("\x1b]11;rgb:1234/5678/9abc\x07").?, + ); + // ST-terminated, 2-digit channels scaled to 16-bit. + try std.testing.expectEqual( + protocol.RgbPayload{ .r = 0xffff, .g = 0x0000, .b = 0x8080 }, + parseOsc11Reply("\x1b]11;rgb:ff/00/80\x1b\\").?, + ); + // A reply embedded among other bytes still parses. + try std.testing.expect(parseOsc11Reply("x\x1b]11;rgb:0000/0000/0000\x07y") != null); + // A query is not a reply, and junk is rejected. + try std.testing.expect(parseOsc11Reply("\x1b]11;?\x07") == null); + try std.testing.expect(parseOsc11Reply("garbage") == null); + try std.testing.expect(parseOsc11Reply("\x1b]11;rgb:00/00\x07") == null); // too few channels +} + +test "findOsc11Reply: only a fully terminated reply is located" { + // Incomplete (no terminator yet): not found. + try std.testing.expect(findOsc11Reply("\x1b]11;rgb:1111/2222/3333") == null); + // BEL-terminated reply: span covers the terminator (exclusive end). + const span = findOsc11Reply("ab\x1b]11;rgb:1111/2222/3333\x07cd").?; + try std.testing.expectEqual(@as(usize, 2), span.start); + try std.testing.expectEqual(@as(usize, 26), span.end); +} + test "control times out when the daemon never answers" { const alloc = std.testing.allocator; diff --git a/src/daemon.zig b/src/daemon.zig index 3ca5a2c..e1f560e 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -376,6 +376,14 @@ pub const Daemon = struct { switch (msg.type) { .ui => conn.ui = true, + .bg_color => { + // The client probed its real terminal's background and + // reported it; the window uses it to answer OSC 11 + // queries and the color-scheme DSR from the session. + const bg = protocol.RgbPayload.decode(msg.payload) catch return; + if (self.liveWindow()) |w| w.setBackground(bg); + }, + .attach => { const size = try protocol.SizePayload.decode(msg.payload); // Steal from any previously attached client. @@ -662,9 +670,23 @@ pub const Daemon = struct { const detached = self.attachedConn() == null; if (detached) self.unread = true; + // Strip OSC 11 background queries up front and answer them from + // the reported terminal background. They must not also reach an + // attached client's real terminal, which would answer them a + // second time, so this runs before any passthrough forwarding. + // The filter only removes bytes, so the cleaned copy never + // outgrows the chunk; on the impossible overflow, fall back to + // the raw chunk rather than dropping output. + var clean_buf: [32 * 1024]u8 = undefined; + var clean_writer = std.Io.Writer.fixed(&clean_buf); + const cleaned = cleaned: { + win.filterColorQueries(chunk, &clean_writer) catch break :cleaned chunk; + break :cleaned clean_writer.buffered(); + }; + const conn = (if (win.passthrough) self.attachedConn() else null) orelse { // Not passed through: the window answers queries itself. - win.feed(chunk); + win.feed(cleaned); self.noteBell(win, detached, now); return; }; @@ -680,16 +702,16 @@ pub const Daemon = struct { // without a 47/1047/1049 toggle) still has to repaint so the // client's `.screen` state stays authoritative. const was_alt = win.onAltScreen(); - const result = win.alt_filter.feed(chunk, &writer) catch + const result = win.alt_filter.feed(cleaned, &writer) catch altscreen.Filter.Result{ .switched = true, .discard_start = 0 }; // Bytes up to the discard point reach the client's real // terminal, which answers any queries among them. The repaint // re-renders the discarded tail from terminal state, but it // cannot answer queries, so the window must. - const split = result.discard_start orelse chunk.len; - win.feed(chunk[0..split]); - if (split < chunk.len) win.feedDiscarded(chunk[split..]); + const split = result.discard_start orelse cleaned.len; + win.feed(cleaned[0..split]); + if (split < cleaned.len) win.feedDiscarded(cleaned[split..]); self.noteBell(win, detached, now); const filtered = writer.buffered(); diff --git a/src/main.zig b/src/main.zig index 6adabfd..64fa5e6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1129,6 +1129,7 @@ test { _ = @import("keys.zig"); _ = @import("pty.zig"); _ = @import("altscreen.zig"); + _ = @import("oscquery.zig"); _ = @import("window.zig"); _ = @import("daemon.zig"); _ = @import("client.zig"); diff --git a/src/oscquery.zig b/src/oscquery.zig new file mode 100644 index 0000000..79aa70b --- /dev/null +++ b/src/oscquery.zig @@ -0,0 +1,196 @@ +//! Filtering of OSC 11 background-color *queries* out of a window's +//! output stream. +//! +//! Applications detect the terminal's light/dark theme by querying the +//! background color with OSC 11 (`ESC ] 11 ; ? BEL`). Inside a boo +//! session the real terminal is not reachable: a `boo attach` strips the +//! query along with the alternate-screen tail it rides behind, and a +//! `boo ui` view renders into a client-side terminal that never answers +//! it. The daemon therefore answers these queries itself from the +//! background a client reported (see `window.zig`). +//! +//! For that answer to be the only one, the query must not also reach the +//! real terminal, or an attached client would see a duplicate reply land +//! in its input. This filter removes the query from the passthrough +//! stream and reports each removal so the caller can produce exactly one +//! reply. Only the background query (OSC 11) is stripped; OSC 10/12 and +//! every other sequence pass through unchanged. Candidate sequences split +//! across feeds are carried over to the next call. + +const std = @import("std"); + +/// The two encodings of an OSC 11 background query: BEL-terminated (what +/// nvim and most apps send) and ST-terminated (`ESC \`). +const query_bel = "\x1b]11;?\x07"; +const query_st = "\x1b]11;?\x1b\\"; + +const Match = enum { no, partial, complete }; + +/// Classify `bytes` against a single target: a strict prefix is +/// `partial`, an exact match is `complete`, anything else is `no`. +fn matchTarget(target: []const u8, bytes: []const u8) Match { + if (bytes.len > target.len) return .no; + if (!std.mem.eql(u8, target[0..bytes.len], bytes)) return .no; + return if (bytes.len == target.len) .complete else .partial; +} + +/// Classify `bytes` against both query encodings, taking the strongest +/// result (a complete match of either, else a partial of either). +fn classify(bytes: []const u8) Match { + const bel = matchTarget(query_bel, bytes); + const st = matchTarget(query_st, bytes); + if (bel == .complete or st == .complete) return .complete; + if (bel == .partial or st == .partial) return .partial; + return .no; +} + +/// Incremental scanner that copies input to a writer, removing any OSC 11 +/// background query. Held candidate bytes persist across feeds so a query +/// split over reads is still recognized. +pub const Filter = struct { + /// Held candidate bytes: always a strict prefix of an OSC 11 query. + /// Empty when not mid-candidate. The longest prefix is + /// `ESC ] 1 1 ; ? ESC` (8 bytes, awaiting the ST `\`). + buf: [16]u8 = undefined, + len: usize = 0, + + pub const Result = struct { + /// Number of OSC 11 background queries removed from the stream. + background_queries: usize = 0, + }; + + /// Scan `input`, writing passthrough bytes to `writer` and removing + /// OSC 11 background queries. Reports how many were removed. + pub fn feed( + self: *Filter, + input: []const u8, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!Result { + var result: Result = .{}; + var run_start: usize = 0; + var i: usize = 0; + while (i < input.len) : (i += 1) { + const byte = input[i]; + if (self.len == 0) { + // Ground: only ESC can begin a query candidate. + if (byte == 0x1b) { + try writer.writeAll(input[run_start..i]); + self.hold(byte); + } + continue; + } + + // Mid-candidate: try to extend the held prefix by one byte. + self.buf[self.len] = byte; + switch (classify(self.buf[0 .. self.len + 1])) { + .partial => self.len += 1, + .complete => { + // A full query: drop it. The daemon answers it. + self.len = 0; + result.background_queries += 1; + run_start = i + 1; + }, + .no => { + // The held prefix was ordinary output after all; + // emit it verbatim, then reconsider the current byte. + try writer.writeAll(self.buf[0..self.len]); + self.len = 0; + if (byte == 0x1b) { + self.hold(byte); + run_start = i + 1; + } else { + run_start = i; + } + }, + } + } + if (self.len == 0) { + try writer.writeAll(input[run_start..]); + } + return result; + } + + fn hold(self: *Filter, byte: u8) void { + self.buf[self.len] = byte; + self.len += 1; + } +}; + +fn testFeed(filter: *Filter, input: []const u8, expected: []const u8, queries: usize) !void { + var buf: [256]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + const got = try filter.feed(input, &writer); + try std.testing.expectEqualStrings(expected, writer.buffered()); + try std.testing.expectEqual(queries, got.background_queries); +} + +test "plain text and ordinary sequences pass through" { + var f: Filter = .{}; + try testFeed(&f, "hello \x1b[2J\x1b[1;5H\x1b[31mworld", "hello \x1b[2J\x1b[1;5H\x1b[31mworld", 0); +} + +test "OSC 11 background query (BEL) is removed" { + var f: Filter = .{}; + try testFeed(&f, "a\x1b]11;?\x07b", "ab", 1); +} + +test "OSC 11 background query (ST) is removed" { + var f: Filter = .{}; + try testFeed(&f, "a\x1b]11;?\x1b\\b", "ab", 1); +} + +test "query right after an alt-screen switch is removed" { + // The exact shape nvim/helix emit: enter alt screen, clear, query. + var f: Filter = .{}; + try testFeed( + &f, + "\x1b[?1049h\x1b[H\x1b[2J\x1b]11;?\x07rest", + "\x1b[?1049h\x1b[H\x1b[2Jrest", + 1, + ); +} + +test "OSC 11 set (not a query) passes through" { + var f: Filter = .{}; + try testFeed(&f, "\x1b]11;rgb:1234/5678/9abc\x07", "\x1b]11;rgb:1234/5678/9abc\x07", 0); +} + +test "OSC 10 and 12 queries pass through" { + // Only the background query is stripped; foreground/cursor are left + // for the real terminal to answer as before. + var f: Filter = .{}; + try testFeed(&f, "\x1b]10;?\x07\x1b]12;?\x07", "\x1b]10;?\x07\x1b]12;?\x07", 0); +} + +test "other OSC sequences pass through" { + var f: Filter = .{}; + try testFeed(&f, "\x1b]2;a title\x07text", "\x1b]2;a title\x07text", 0); +} + +test "back-to-back queries are both removed" { + var f: Filter = .{}; + try testFeed(&f, "\x1b]11;?\x07\x1b]11;?\x07x", "x", 2); +} + +test "query split across feeds is still removed" { + var f: Filter = .{}; + try testFeed(&f, "before\x1b]11", "before", 0); + try testFeed(&f, ";?\x07after", "after", 1); +} + +test "ST query split before the backslash is still removed" { + var f: Filter = .{}; + try testFeed(&f, "x\x1b]11;?\x1b", "x", 0); + try testFeed(&f, "\\y", "y", 1); +} + +test "near-miss prefix is emitted verbatim" { + var f: Filter = .{}; + // ESC ] 1 1 ; X is not a query; nothing is dropped. + try testFeed(&f, "\x1b]11;X\x07", "\x1b]11;X\x07", 0); +} + +test "lone ESC then ordinary bytes pass through" { + var f: Filter = .{}; + try testFeed(&f, "\x1bMtext", "\x1bMtext", 0); +} diff --git a/src/protocol.zig b/src/protocol.zig index e5e097a..2ec1967 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -28,6 +28,14 @@ pub const MsgType = enum(u8) { /// and simply attaches the view with no history, so a new ui client /// stays compatible with an already-running older daemon. ui = 6, + /// The background color of the client's real terminal, as a + /// `RgbPayload`. The client probes its terminal once at startup and + /// reports the answer so the daemon can satisfy OSC 11 background + /// queries (and the color-scheme DSR) from inside the session, where + /// the application can no longer reach the real terminal. An older + /// daemon ignores the unknown type, so theme detection simply stays + /// as it was before this message existed. + bg_color = 7, // Daemon to client. output = 64, @@ -69,6 +77,45 @@ pub const SizePayload = struct { } }; +/// A terminal color as three 16-bit channels, matching the precision a +/// terminal reports for an OSC 10/11/12 query (`rgb:RRRR/GGGG/BBBB`). +pub const RgbPayload = struct { + r: u16, + g: u16, + b: u16, + + pub fn encode(self: RgbPayload) [6]u8 { + var buf: [6]u8 = undefined; + std.mem.writeInt(u16, buf[0..2], self.r, .little); + std.mem.writeInt(u16, buf[2..4], self.g, .little); + std.mem.writeInt(u16, buf[4..6], self.b, .little); + return buf; + } + + pub fn decode(payload: []const u8) error{InvalidPayload}!RgbPayload { + if (payload.len != 6) return error.InvalidPayload; + return .{ + .r = std.mem.readInt(u16, payload[0..2], .little), + .g = std.mem.readInt(u16, payload[2..4], .little), + .b = std.mem.readInt(u16, payload[4..6], .little), + }; + } + + /// Perceived luminance on a 0..255 scale (Rec. 601 weights), computed + /// from the high byte of each channel. Used to classify the + /// background as light or dark for the color-scheme DSR. + pub fn luminance(self: RgbPayload) u8 { + const r: u32 = self.r >> 8; + const g: u32 = self.g >> 8; + const b: u32 = self.b >> 8; + return @intCast((r * 299 + g * 587 + b * 114) / 1000); + } + + pub fn isDark(self: RgbPayload) bool { + return self.luminance() < 128; + } +}; + /// Write a full frame to a fd. Handles short writes. pub fn writeMsg(fd: std.posix.fd_t, msg_type: MsgType, payload: []const u8) !void { std.debug.assert(payload.len <= max_payload); @@ -176,6 +223,19 @@ test "size payload roundtrip" { try std.testing.expectError(error.InvalidPayload, SizePayload.decode("abc")); } +test "rgb payload roundtrip and luminance" { + const white: RgbPayload = .{ .r = 0xffff, .g = 0xffff, .b = 0xffff }; + const enc = white.encode(); + try std.testing.expectEqual(white, try RgbPayload.decode(&enc)); + try std.testing.expectError(error.InvalidPayload, RgbPayload.decode("abc")); + + // White is light, black is dark. + try std.testing.expect(!white.isDark()); + try std.testing.expect((RgbPayload{ .r = 0, .g = 0, .b = 0 }).isDark()); + // A typical dark editor background. + try std.testing.expect((RgbPayload{ .r = 0x2828, .g = 0x2c2c, .b = 0x3434 }).isDark()); +} + test "argv roundtrip" { const alloc = std.testing.allocator; const argv = [_][]const u8{ "stuff", "hello world\n" }; diff --git a/src/ui.zig b/src/ui.zig index ba31165..ee9be23 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -697,6 +697,7 @@ pub const View = struct { socket_path: []const u8, rows: u16, cols: u16, + bg: ?protocol.RgbPayload, ) !*View { const self = try alloc.create(View); errdefer alloc.destroy(self); @@ -748,6 +749,10 @@ pub const View = struct { .rows = @max(rows, 1), .cols = @max(cols, 1), }).encode()); + // Tell the daemon the real terminal's background so the session + // can detect the theme via OSC 11 (see window.zig). Best effort: + // a session simply keeps its prior behavior without it. + if (bg) |color| protocol.writeMsg(sock, .bg_color, &color.encode()) catch {}; return self; } @@ -1134,6 +1139,14 @@ pub fn run(alloc: std.mem.Allocator, dir: []const u8) !void { defer client.restoreTty(tty, saved, restore_sequence, ui.eof_guard, ui.parser.swallow_cp orelse 0); try protocol.writeAll(1, enter_sequence); + // Probe the real terminal's background color so each attached view + // can report it to its daemon, letting sessions detect the theme + // (see window.zig). Input typed during the probe is fed through the + // parser before the loop so it is not lost. + var probe_scratch: [256]u8 = undefined; + var probe_leftover: usize = 0; + ui.term_bg = client.probeBackground(tty, pipe_fds[0], &probe_scratch, &probe_leftover); + const ws = ptypkg.getSize(tty) catch ptypkg.makeWinsize(24, 80); ui.layout = .init(ws.row, ws.col); // Running inside a boo session: never attach the session hosting @@ -1143,6 +1156,8 @@ pub fn run(alloc: std.mem.Allocator, dir: []const u8) !void { try ui.refreshSessions(); if (ui.selected == null) ui.selectInitial(); + if (probe_leftover > 0) ui.feedStartupInput(probe_scratch[0..probe_leftover]); + try ui.loop(pipe_fds[0]); } @@ -1184,6 +1199,9 @@ const Ui = struct { alloc: std.mem.Allocator, dir: []const u8, tty: posix.fd_t, + /// Real terminal background, probed once at startup and forwarded to + /// each view's daemon so sessions can detect the light/dark theme. + term_bg: ?protocol.RgbPayload = null, layout: Layout = .{ .rows = 24, .cols = 80, .sidebar_w = 24 }, sessions: std.ArrayList(Entry) = .empty, @@ -1400,6 +1418,19 @@ const Ui = struct { // -- Terminal input ------------------------------------------------------ + /// Feed input captured during the startup background probe through + /// the parser before the main loop, so a keystroke that raced + /// startup still reaches the ui. + fn feedStartupInput(self: *Ui, bytes: []const u8) void { + const Handler = struct { + ui: *Ui, + pub fn event(h: @This(), ev: InputEvent) !void { + try h.ui.handleEvent(ev); + } + }; + self.parser.feed(bytes, .{}, Handler{ .ui = self }) catch {}; + } + fn readTty(self: *Ui, buf: []u8) !void { const n = posix.read(self.tty, buf) catch 0; if (n == 0) { @@ -2295,6 +2326,7 @@ const Ui = struct { sock, self.layout.viewportRows(), self.layout.viewportCols(), + self.term_bg, ) catch |err| { self.setMessage("attach {s} failed: {s}", .{ name, @errorName(err) }); return; diff --git a/src/window.zig b/src/window.zig index 5aa08e8..0729fa7 100644 --- a/src/window.zig +++ b/src/window.zig @@ -7,6 +7,7 @@ const std = @import("std"); const posix = std.posix; const vt = @import("ghostty-vt"); const altscreen = @import("altscreen.zig"); +const oscquery = @import("oscquery.zig"); const ptypkg = @import("pty.zig"); const protocol = @import("protocol.zig"); @@ -44,6 +45,21 @@ pub const Window = struct { /// when passing through. feeding_discarded: bool = false, + /// Background color of the real terminal, reported by an attached + /// client. Lets the daemon answer OSC 11 background queries and the + /// color-scheme DSR from inside the session, where the application + /// can no longer reach the real terminal. Null until a client + /// reports it. + term_bg: ?protocol.RgbPayload = null, + /// An OSC 11 background query arrived before any client had reported + /// a background color. Answered as soon as one is reported, so a + /// session that queries the theme the instant it starts still gets + /// an answer once the attaching client probes its terminal. + bg_query_pending: bool = false, + /// Strips OSC 11 background queries from the child's output so the + /// daemon answers each exactly once (see oscquery.zig). + color_filter: oscquery.Filter = .{}, + /// Strips alternate-screen toggles from passthrough output; the /// daemon repaints from terminal state on screen switches instead. alt_filter: altscreen.Filter = .{ .discard_after_switch = true }, @@ -97,7 +113,7 @@ pub const Window = struct { handler.effects = .{ .write_pty = effectWritePty, .bell = effectBell, - .color_scheme = null, + .color_scheme = effectColorScheme, .device_attributes = effectDeviceAttributes, .enquiry = null, .size = effectSize, @@ -142,6 +158,10 @@ pub const Window = struct { // ghostty-vt root, so derive it from the effects callback signature. const DeviceAttributes = EffectReturn("device_attributes"); + // The color scheme effect returns an optional ColorScheme; recover + // the enum the same way as the device attributes type above. + const ColorScheme = @typeInfo(EffectReturn("color_scheme")).optional.child; + fn EffectReturn(comptime field_name: []const u8) type { const Effects = Stream.Handler.Effects; const field = std.meta.fieldInfo( @@ -161,6 +181,16 @@ pub const Window = struct { return .{}; } + /// Answer the color-scheme DSR (CSI ? 996 n) from the reported + /// background's luminance. Returns null (no reply) until a client + /// reports a background, matching ghostty-vt's behavior for an + /// unknown color scheme. + fn effectColorScheme(handler: *Stream.Handler) ?ColorScheme { + const self = fromHandler(handler); + const bg = self.term_bg orelse return null; + return if (bg.isDark()) .dark else .light; + } + fn effectSize(handler: *Stream.Handler) ?vt.size_report.Size { const self = fromHandler(handler); return .{ @@ -197,6 +227,52 @@ pub const Window = struct { try protocol.writeAll(self.pty_fd, bytes); } + /// Copy `input` to `writer` with OSC 11 background queries removed, + /// answering each one. The query is answered with the background a + /// client reported; until one is known the query is recorded and + /// answered later by `setBackground`. Removing the query keeps it + /// from also reaching an attached client's real terminal, which + /// would answer it a second time. + pub fn filterColorQueries( + self: *Window, + input: []const u8, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const result = try self.color_filter.feed(input, writer); + for (0..result.background_queries) |_| self.answerBackgroundQuery(); + } + + fn answerBackgroundQuery(self: *Window) void { + const bg = self.term_bg orelse { + self.bg_query_pending = true; + return; + }; + self.writeBackgroundReply(bg); + } + + /// Record the real terminal's background color and answer any query + /// that arrived before it was known. + pub fn setBackground(self: *Window, bg: protocol.RgbPayload) void { + self.term_bg = bg; + if (self.bg_query_pending) { + self.bg_query_pending = false; + self.writeBackgroundReply(bg); + } + } + + fn writeBackgroundReply(self: *Window, bg: protocol.RgbPayload) void { + if (self.pty_fd < 0) return; + var buf: [40]u8 = undefined; + const reply = std.fmt.bufPrint( + &buf, + "\x1b]11;rgb:{x:0>4}/{x:0>4}/{x:0>4}\x07", + .{ bg.r, bg.g, bg.b }, + ) catch return; + protocol.writeAll(self.pty_fd, reply) catch |err| { + log.warn("window: failed writing OSC 11 reply: {}", .{err}); + }; + } + pub fn resize(self: *Window, rows: u16, cols: u16) !void { try self.term.resize(self.alloc, cols, rows); if (self.pty_fd >= 0) { @@ -587,3 +663,86 @@ test "title set via OSC is tracked and emitted sanitized" { try writeTitle("a\x1b\x07b\x7f!", &writer); try std.testing.expectEqualStrings("\x1b]2;ab!\x07", writer.buffered()); } + +/// Build a childless window whose PTY master is the write end of a pipe, +/// so a test can read back whatever the window writes to the child. +fn testWindowWithPipe(alloc: std.mem.Allocator, read_fd: *posix.fd_t) !Window { + const fds = try posix.pipe(); + read_fd.* = fds[0]; + return .{ + .alloc = alloc, + .pty_fd = fds[1], + .child_pid = -1, + .command_title = "test", + .last_output_ms = 0, + .term = try vt.Terminal.init(alloc, .{ .cols = 20, .rows = 5 }), + .stream = undefined, + }; +} + +test "OSC 11 background query is answered from the reported background" { + const alloc = std.testing.allocator; + var read_fd: posix.fd_t = undefined; + var win = try testWindowWithPipe(alloc, &read_fd); + defer posix.close(read_fd); + defer posix.close(win.pty_fd); + defer win.term.deinit(alloc); + + // A query before any background is known is deferred, not dropped. + var sink: [16]u8 = undefined; + var w = std.Io.Writer.fixed(&sink); + try win.filterColorQueries("\x1b]11;?\x07", &w); + try std.testing.expectEqualStrings("", w.buffered()); // query stripped + try std.testing.expect(win.bg_query_pending); + + // Learning the background answers the pending query. + win.setBackground(.{ .r = 0xffff, .g = 0xffff, .b = 0xffff }); + try std.testing.expect(!win.bg_query_pending); + var buf: [64]u8 = undefined; + const n = try posix.read(read_fd, &buf); + try std.testing.expectEqualStrings("\x1b]11;rgb:ffff/ffff/ffff\x07", buf[0..n]); + + // A later query is answered immediately, now that the color is known. + w = std.Io.Writer.fixed(&sink); + try win.filterColorQueries("\x1b]11;?\x07", &w); + const n2 = try posix.read(read_fd, &buf); + try std.testing.expectEqualStrings("\x1b]11;rgb:ffff/ffff/ffff\x07", buf[0..n2]); +} + +test "color-scheme DSR answered from background luminance" { + const alloc = std.testing.allocator; + var read_fd: posix.fd_t = undefined; + var win = try testWindowWithPipe(alloc, &read_fd); + defer posix.close(read_fd); + defer posix.close(win.pty_fd); + defer win.term.deinit(alloc); + + // Wire the real handler so CSI ? 996 n routes through effectColorScheme. + var handler: Window.Stream.Handler = .init(&win.term); + handler.effects = .{ + .write_pty = Window.effectWritePty, + .bell = null, + .color_scheme = Window.effectColorScheme, + .device_attributes = null, + .enquiry = null, + .size = null, + .title_changed = null, + .pwd_changed = null, + .xtversion = null, + }; + win.stream = .initAlloc(alloc, handler); + defer win.stream.deinit(); + + // A light background reports color scheme 2 (light). + win.setBackground(.{ .r = 0xffff, .g = 0xffff, .b = 0xffff }); + win.feed("\x1b[?996n"); + var buf: [32]u8 = undefined; + const n = try posix.read(read_fd, &buf); + try std.testing.expectEqualStrings("\x1b[?997;2n", buf[0..n]); + + // A dark background reports color scheme 1 (dark). + win.setBackground(.{ .r = 0x1e1e, .g = 0x1e1e, .b = 0x2222 }); + win.feed("\x1b[?996n"); + const n2 = try posix.read(read_fd, &buf); + try std.testing.expectEqualStrings("\x1b[?997;1n", buf[0..n2]); +}