From 0e2b55e990644a241175d14541c8672f3e07cd83 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 27 Jun 2026 19:09:49 +0200 Subject: [PATCH] fix: detect Kitty graphics on Ghostty/WezTerm when probe misses (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image rendering gated entirely on a runtime Kitty graphics probe, and the only fallback when that probe missed was KITTY_WINDOW_ID. Ghostty and WezTerm never set that variable, so any probe miss (e.g. a slow first-frame/GPU init, or a reply arriving after the read deadline) left them with no image support even though both fully implement the protocol — hence images failed to show on Ghostty while working in kitty. - Add Environment.looksLikeKittyGraphicsTerminal() as the single source of truth for terminals that reliably support the protocol by identity, and use it for both the probe candidate set and the fallback. Outside a multiplexer (which may strip graphics), trust that identity when the probe misses. This also rescues kitty-over-SSH (TERM=xterm-kitty, no KITTY_WINDOW_ID). - Widen the graphics probe deadline 180ms -> 500ms so the reply, which is queued behind the other startup sequences, is captured more reliably. - Match the probe id as a whole comma-delimited field so a reply for image 99312 is not mistaken for probe id 9931. - Add unit tests for the probe response parser and the identity helper. --- src/core/environment.zig | 25 ++++++++++++++++++ src/terminal/terminal.zig | 54 ++++++++++++++++++++++++++++++++------- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/core/environment.zig b/src/core/environment.zig index ed0135e..9017c06 100644 --- a/src/core/environment.zig +++ b/src/core/environment.zig @@ -68,6 +68,18 @@ pub const Environment = struct { return self.has_kitty_window or self.termContains("kitty"); } + /// Terminals whose identity reliably implies Kitty graphics protocol support. + /// Used as a fallback when the live graphics probe misses (e.g. a slow + /// first-frame/GPU init on Ghostty, or a reply that lands after the read + /// deadline). Kitty, Ghostty, and WezTerm all implement the protocol. + pub fn looksLikeKittyGraphicsTerminal(self: *const Environment) bool { + return self.looksLikeKittyTerminal() or + self.termContains("ghostty") or + self.termProgramEquals("ghostty") or + self.termContains("wezterm") or + self.termProgramEquals("WezTerm"); + } + pub fn looksLikeIterm2Terminal(self: *const Environment) bool { return self.termProgramEquals("iTerm.app") or self.lcTerminalEquals("iTerm2"); } @@ -120,3 +132,16 @@ pub const Environment = struct { return null; } }; + +test "looksLikeKittyGraphicsTerminal recognizes graphics-capable terminals" { + // Ghostty: identified by TERM=xterm-ghostty and/or TERM_PROGRAM=ghostty. + try std.testing.expect((Environment{ .term = "xterm-ghostty" }).looksLikeKittyGraphicsTerminal()); + try std.testing.expect((Environment{ .term_program = "ghostty" }).looksLikeKittyGraphicsTerminal()); + // Kitty: KITTY_WINDOW_ID locally, or TERM=xterm-kitty (e.g. over SSH). + try std.testing.expect((Environment{ .has_kitty_window = true }).looksLikeKittyGraphicsTerminal()); + try std.testing.expect((Environment{ .term = "xterm-kitty" }).looksLikeKittyGraphicsTerminal()); + // WezTerm: identified by TERM_PROGRAM=WezTerm (case-insensitive). + try std.testing.expect((Environment{ .term_program = "WezTerm" }).looksLikeKittyGraphicsTerminal()); + // A plain terminal must not be treated as graphics-capable. + try std.testing.expect(!(Environment{ .term = "xterm-256color" }).looksLikeKittyGraphicsTerminal()); +} diff --git a/src/terminal/terminal.zig b/src/terminal/terminal.zig index ff36a4e..cb0f88b 100644 --- a/src/terminal/terminal.zig +++ b/src/terminal/terminal.zig @@ -1036,10 +1036,7 @@ pub const Terminal = struct { const env = self.environment; const term_features = env.term_features; - const kitty_candidate = env.looksLikeKittyTerminal() or - env.termProgramEquals("WezTerm") or - env.termContains("wezterm") or - env.termContains("ghostty"); + const kitty_candidate = env.looksLikeKittyGraphicsTerminal(); const iterm_candidate = env.looksLikeIterm2Terminal() or env.termProgramEquals("WezTerm"); const in_multiplexer = env.isInsideMultiplexer(); @@ -1049,9 +1046,15 @@ pub const Terminal = struct { if (kitty_candidate) { kitty = self.queryKittyGraphicsSupport() catch false; - // Keep an env fallback only outside multiplexers where probe failures are uncommon. + // The live probe can miss on terminals that genuinely support Kitty graphics + // (e.g. a slow first-frame/GPU init on Ghostty, or a reply that arrives after + // the read deadline). Since kitty_candidate already identifies a terminal that + // reliably implements the protocol by its identity, trust that identity when the + // probe misses — but only outside a multiplexer, which may strip graphics. + // Previously this fell back to KITTY_WINDOW_ID only, so Ghostty/WezTerm (which + // never set it) were left with no image support whenever the probe missed (#113). if (!kitty and !in_multiplexer) { - kitty = env.has_kitty_window; + kitty = true; } } @@ -1391,7 +1394,9 @@ pub const Terminal = struct { var collected_len: usize = 0; const start = std.Io.Clock.Timestamp.now(self.io, .boot); - while (withinDeadline(self.io, start, 180)) { + // Allow a generous window: the reply is queued behind the other startup + // sequences and can be delayed by first-frame/GPU init on some terminals. + while (withinDeadline(self.io, start, 500)) { var chunk: [128]u8 = undefined; const n = self.readInput(&chunk, 30) catch 0; if (n == 0) continue; @@ -1426,8 +1431,13 @@ pub const Terminal = struct { const params = bytes[content_start..semicolon]; const payload = bytes[semicolon + 1 .. st_index]; - if (std.mem.indexOf(u8, params, expected_id) != null) { - return std.mem.startsWith(u8, payload, "OK"); + // Match the id as a whole comma-delimited field so that, e.g., a reply + // for image 99312 is not mistaken for our probe id 9931. + var param_it = std.mem.splitScalar(u8, params, ','); + while (param_it.next()) |param| { + if (std.mem.eql(u8, param, expected_id)) { + return std.mem.startsWith(u8, payload, "OK"); + } } search_from = st_index + 2; @@ -1870,3 +1880,29 @@ test "parseOsc52Response tmux passthrough ST" { try std.testing.expectEqual(bytes.len, parsed.consume_end); try std.testing.expectEqualStrings("YQ==", parsed.payload_b64); } + +test "parseKittyGraphicsProbeResponse OK reply means supported" { + const bytes = "\x1b_Gi=9931;OK\x1b\\"; + try std.testing.expectEqual(@as(?bool, true), Terminal.parseKittyGraphicsProbeResponse(bytes, 9931)); +} + +test "parseKittyGraphicsProbeResponse OK reply after stale prefix" { + // A leftover mode-2027 / CPR report may precede the graphics reply in the buffer. + const bytes = "\x1b[?2027;2$y\x1b_Gi=9931;OK\x1b\\"; + try std.testing.expectEqual(@as(?bool, true), Terminal.parseKittyGraphicsProbeResponse(bytes, 9931)); +} + +test "parseKittyGraphicsProbeResponse error reply means unsupported" { + const bytes = "\x1b_Gi=9931;ENOENT:bad\x1b\\"; + try std.testing.expectEqual(@as(?bool, false), Terminal.parseKittyGraphicsProbeResponse(bytes, 9931)); +} + +test "parseKittyGraphicsProbeResponse truncated reply keeps reading" { + const bytes = "\x1b_Gi=9931;OK"; // no ST terminator yet + try std.testing.expectEqual(@as(?bool, null), Terminal.parseKittyGraphicsProbeResponse(bytes, 9931)); +} + +test "parseKittyGraphicsProbeResponse superstring id does not match" { + const bytes = "\x1b_Gi=99312;OK\x1b\\"; + try std.testing.expectEqual(@as(?bool, null), Terminal.parseKittyGraphicsProbeResponse(bytes, 9931)); +}