Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/core/dev/test-tmux-graphics-334.sh
Original file line number Diff line number Diff line change
@@ -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."
14 changes: 14 additions & 0 deletions packages/core/docs/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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_
6 changes: 6 additions & 0 deletions packages/core/src/zig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]> | null = null
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/zig/ansi.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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\\";
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/zig/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
}
Expand Down
58 changes: 47 additions & 11 deletions packages/core/src/zig/terminal.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ++
Expand All @@ -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 {
Expand Down Expand Up @@ -223,20 +243,32 @@ 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();

// Always just try to enable bracketed paste, even if it was reported as not supported
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) {
Expand Down Expand Up @@ -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;
}
Expand Down
Loading