diff --git a/packages/core/dev/test-tmux-graphics-334.sh b/packages/core/dev/test-tmux-graphics-334.sh new file mode 100755 index 000000000..0f6a5eae9 --- /dev/null +++ b/packages/core/dev/test-tmux-graphics-334.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Test script for issue #334: Kitty Graphics Protocol query leaks into tmux pane title +# +# This script verifies that the kitty graphics query doesn't corrupt tmux pane titles. +# Run this inside tmux to test. +# +# Usage: ./test-tmux-graphics-334.sh +# +# Expected results: +# - Test 1 (Direct query): FAIL - demonstrates the bug +# - Test 2 (DCS passthrough): PASS - demonstrates the fix +# - Test 3 (No query): PASS - control test + +set -e + +SESSION_NAME="opentui-test-334-$$" +EXPECTED_TITLE="test-title" + +cleanup() { + tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true +} +trap cleanup EXIT + +run_test() { + local test_name="$1" + local query_cmd="$2" + + cleanup + tmux new-session -d -s "$SESSION_NAME" -x 80 -y 24 + tmux select-pane -t "$SESSION_NAME" -T "$EXPECTED_TITLE" + + if [ -n "$query_cmd" ]; then + tmux send-keys -t "$SESSION_NAME" "$query_cmd" Enter + sleep 0.5 + fi + + local after_title + after_title=$(tmux display-message -t "$SESSION_NAME" -p '#{pane_title}') + + if [[ "$after_title" == *"Gi=31337"* ]] || [[ "$after_title" == *"i=31337"* ]]; then + echo "FAIL: $test_name - pane title corrupted: '$after_title'" + return 1 + elif [[ "$after_title" != "$EXPECTED_TITLE" ]]; then + echo "WARN: $test_name - pane title changed: '$after_title'" + return 1 + else + echo "PASS: $test_name" + return 0 + fi +} + +echo "=== Issue #334 Test: Kitty Graphics Query in tmux ===" +echo "tmux version: $(tmux -V)" +echo "" + +echo "Test 1: Direct query (demonstrates bug)" +run_test "Direct kitty graphics query" \ + "printf '\\x1b_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\\x1b\\\\\\x1b[c'" || true + +echo "Test 2: DCS passthrough wrapped query (demonstrates fix)" +run_test "DCS passthrough wrapped" \ + "printf '\\x1bPtmux;\\x1b\\x1b_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\\x1b\\x1b\\\\\\x1b\\x1b[c\\x1b\\\\'" + +echo "Test 3: No query (control)" +run_test "No query" "" + +echo "" +echo "If Test 1 fails and Test 2 passes, the fix is working correctly." diff --git a/packages/core/docs/env-vars.md b/packages/core/docs/env-vars.md index 9bce3dbd2..5711398fe 100644 --- a/packages/core/docs/env-vars.md +++ b/packages/core/docs/env-vars.md @@ -58,6 +58,13 @@ Force Mode 2026 Unicode support in terminal capabilities **Type:** `boolean` **Default:** `false` +## OPENTUI_NO_GRAPHICS + +Disable Kitty graphics protocol detection + +**Type:** `boolean` +**Default:** `false` + ## OTUI_USE_CONSOLE Whether to use the console. Will not capture console output if set to false. @@ -100,6 +107,13 @@ Override the stdout stream. This is useful for debugging. **Type:** `boolean` **Default:** `true` +## OTUI_DEBUG + +Enable debug mode to capture all raw input for debugging purposes. + +**Type:** `boolean` +**Default:** `false` + --- _generated via packages/core/dev/print-env-vars.ts_ diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 8a2c801e2..7c68e804b 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -58,6 +58,12 @@ registerEnvVar({ type: "boolean", default: false, }) +registerEnvVar({ + name: "OPENTUI_NO_GRAPHICS", + description: "Disable Kitty graphics protocol detection", + type: "boolean", + default: false, +}) // Global singleton state for FFI tracing to prevent duplicate exit handlers let globalTraceSymbols: Record | null = null diff --git a/packages/core/src/zig/ansi.zig b/packages/core/src/zig/ansi.zig index f12586a95..a5a65ae01 100644 --- a/packages/core/src/zig/ansi.zig +++ b/packages/core/src/zig/ansi.zig @@ -89,6 +89,8 @@ pub const ANSI = struct { pub const decrqmColorScheme = "\x1b[?2031$p"; pub const csiUQuery = "\x1b[?u"; pub const kittyGraphicsQuery = "\x1b_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"; + // tmux DCS passthrough variant (ESC chars doubled) to avoid pane title corruption (#334) + pub const kittyGraphicsQueryTmux = "\x1bPtmux;\x1b\x1b_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\x1b\\\x1b\x1b[c\x1b\\"; pub const sixelGeometryQuery = "\x1b[?2;1;0S"; pub const cursorPositionRequest = "\x1b[6n"; pub const explicitWidthQuery = "\x1b]66;w=1; \x1b\\"; diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index c6c85a456..0720ed03d 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -953,6 +953,15 @@ pub const CliRenderer = struct { pub fn processCapabilityResponse(self: *CliRenderer, response: []const u8) void { self.terminal.processCapabilityResponse(response); const writer = self.stdoutWriter.writer(); + const did_send = self.terminal.sendPendingGraphicsQuery(writer) catch |err| blk: { + logger.warn("Failed to send kitty graphics query: {}", .{err}); + break :blk false; + }; + if (did_send) { + self.stdoutWriter.flush() catch |err| { + logger.warn("Failed to flush kitty graphics query: {}", .{err}); + }; + } const useKitty = self.terminal.opts.kitty_keyboard_flags > 0; self.terminal.enableDetectedFeatures(writer, useKitty) catch {}; } diff --git a/packages/core/src/zig/terminal.zig b/packages/core/src/zig/terminal.zig index 46e70b63b..85c5f10cb 100644 --- a/packages/core/src/zig/terminal.zig +++ b/packages/core/src/zig/terminal.zig @@ -65,6 +65,10 @@ pub const TerminalInfo = struct { caps: Capabilities = .{}, opts: Options = .{}, +in_tmux: bool = false, +skip_graphics_query: bool = false, +graphics_query_pending: bool = false, + state: struct { alt_screen: bool = false, kitty_keyboard: bool = false, @@ -157,8 +161,10 @@ pub fn exitAltScreen(self: *Terminal, tty: anytype) !void { pub fn queryTerminalSend(self: *Terminal, tty: anytype) !void { self.checkEnvironmentOverrides(); + self.graphics_query_pending = !self.skip_graphics_query; - try tty.writeAll(ansi.ANSI.hideCursor ++ + try tty.writeAll(ansi.ANSI.xtversion ++ + ansi.ANSI.hideCursor ++ ansi.ANSI.saveCursorState ++ ansi.ANSI.decrqmSgrPixels ++ ansi.ANSI.decrqmUnicode ++ @@ -178,15 +184,29 @@ pub fn queryTerminalSend(self: *Terminal, tty: anytype) !void { ansi.ANSI.cursorPositionRequest ++ // Version and capability queries - ansi.ANSI.xtversion ++ - ansi.ANSI.csiUQuery ++ - // Kitty graphics detection: sends dummy query + DA1 - // Terminal will respond with ESC_Gi=31337;OK/ERROR ESC\ if supported, or just DA1 if not - // NOTE: deactivated temporarily due to issues with tmux showing the query as pane title - // ansi.ANSI.kittyGraphicsQuery ++ - ansi.ANSI.restoreCursorState - // ++ ansi.ANSI.sixelGeometryQuery - ); + ansi.ANSI.csiUQuery); + + try tty.writeAll(ansi.ANSI.restoreCursorState); +} + +pub fn sendPendingGraphicsQuery(self: *Terminal, tty: anytype) !bool { + if (!self.graphics_query_pending) return false; + if (self.skip_graphics_query) { + self.graphics_query_pending = false; + return false; + } + + if (!self.term_info.from_xtversion and !self.in_tmux) return false; + + const is_tmux = self.in_tmux or self.isXtversionTmux(); + if (is_tmux) { + try tty.writeAll(ansi.ANSI.kittyGraphicsQueryTmux); + } else { + try tty.writeAll(ansi.ANSI.kittyGraphicsQuery); + } + + self.graphics_query_pending = false; + return true; } pub fn enableDetectedFeatures(self: *Terminal, tty: anytype, use_kitty_keyboard: bool) !void { @@ -223,6 +243,9 @@ pub fn enableDetectedFeatures(self: *Terminal, tty: anytype, use_kitty_keyboard: } fn checkEnvironmentOverrides(self: *Terminal) void { + self.in_tmux = false; + self.skip_graphics_query = false; + var env_map = std.process.getEnvMap(std.heap.page_allocator) catch return; defer env_map.deinit(); @@ -230,13 +253,22 @@ fn checkEnvironmentOverrides(self: *Terminal) void { self.caps.bracketed_paste = true; if (env_map.get("TMUX")) |_| { + self.in_tmux = true; self.caps.unicode = .wcwidth; } else if (env_map.get("TERM")) |term| { - if (std.mem.startsWith(u8, term, "tmux") or std.mem.startsWith(u8, term, "screen")) { + if (std.mem.startsWith(u8, term, "tmux")) { + self.in_tmux = true; + self.caps.unicode = .wcwidth; + } else if (std.mem.startsWith(u8, term, "screen")) { + self.skip_graphics_query = true; self.caps.unicode = .wcwidth; } } + if (env_map.get("OPENTUI_NO_GRAPHICS")) |_| { + self.skip_graphics_query = true; + } + // Extract terminal name and version from environment variables // These will be overridden by xtversion responses if available if (!self.term_info.from_xtversion) { @@ -561,6 +593,10 @@ fn parseXtversion(self: *Terminal, term_str: []const u8) void { }); } +fn isXtversionTmux(self: *Terminal) bool { + return self.term_info.from_xtversion and std.mem.eql(u8, self.getTerminalName(), "tmux"); +} + pub fn getTerminalInfo(self: *Terminal) TerminalInfo { return self.term_info; }