From 8e45ddf36efbe8455bba6b7f9173dba6f4877b8b Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 21 Jun 2026 19:40:29 -0700 Subject: [PATCH] Scrub Kitty/Ghostty identity env vars from recorded apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal.Gui (and other libraries) detect Kitty graphics-protocol support purely from environment variables — KITTY_WINDOW_ID, or TERM_PROGRAM set to kitty/ghostty — never from a terminal query/response. When both Kitty and sixel look supported, TG prefers Kitty. tuirec presents a deterministic, sixel-capable xterm identity (TERM forced to xterm-256color, DA1/geometry interceptor advertises sixel) and the agg replay pipeline renders sixel but not Kitty graphics. Recording from inside a kitty/ghostty terminal leaked those markers through to the recorded app, so it detected Kitty, preferred it over sixel, and emitted Kitty graphics that agg cannot render — the GIF came out blank. normalizeEnv now strips the Kitty/Ghostty identity variables (and TERM_PROGRAM only when it names a Kitty-class terminal), so the recorded app falls back to the sixel path tuirec actually supports. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/pty/session.go | 57 +++++++++++++++++++++++++++++++++++++++++ pkg/pty/session_test.go | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/pkg/pty/session.go b/pkg/pty/session.go index 7cef6be..4f96cb4 100644 --- a/pkg/pty/session.go +++ b/pkg/pty/session.go @@ -70,12 +70,69 @@ func normalizeEnv(env []string) []string { normalized = mergeEnv(normalized, env) } + normalized = scrubGraphicsIdentityEnv(normalized) + normalized = appendDefaultEnv(normalized, "TERM", "xterm-256color") normalized = appendDefaultEnv(normalized, "COLORTERM", "truecolor") return normalized } +// kittyIdentityVars are environment variables that mark the host terminal as +// Kitty- or Ghostty-class. Apps detect Kitty graphics-protocol support purely +// from these (e.g. Terminal.Gui's KittyGraphicsSupportDetector keys off +// KITTY_WINDOW_ID, never a query/response) — see also the TERM_PROGRAM handling +// in scrubGraphicsIdentityEnv. +// +// tuirec presents a deterministic, sixel-capable xterm identity: TERM is forced +// to xterm-256color and the DA1/geometry interceptor advertises sixel. The agg +// replay pipeline renders sixel but not Kitty graphics. If these markers leak in +// from the recording shell (i.e. recording from inside kitty/ghostty), the app +// detects Kitty, prefers it over sixel, and emits Kitty graphics that agg cannot +// render — the GIF comes out blank. Strip them so the app uses the sixel path +// tuirec actually supports. +var kittyIdentityVars = map[string]bool{ + "KITTY_WINDOW_ID": true, + "KITTY_PID": true, + "KITTY_INSTALLATION_DIR": true, + "KITTY_LISTEN_ON": true, + "KITTY_PUBLIC_KEY": true, + "GHOSTTY_RESOURCES_DIR": true, + "GHOSTTY_BIN_DIR": true, +} + +// scrubGraphicsIdentityEnv removes the environment variables that would make a +// recorded app believe it is running under a Kitty graphics-capable terminal. +// TERM_PROGRAM is only dropped when it names a Kitty-class terminal (kitty or +// ghostty), so unrelated values (vscode, iTerm.app, WezTerm, Apple_Terminal, +// ...) and any app behavior keyed off them are left untouched. +func scrubGraphicsIdentityEnv(env []string) []string { + dropTermProgram := false + for _, entry := range env { + if key, ok := envKey(entry); ok && key == "TERM_PROGRAM" { + value := entry[len(key)+1:] + if strings.EqualFold(value, "kitty") || strings.EqualFold(value, "ghostty") { + dropTermProgram = true + break + } + } + } + + filtered := env[:0] + for _, entry := range env { + key, ok := envKey(entry) + if ok && kittyIdentityVars[key] { + continue + } + if ok && dropTermProgram && (key == "TERM_PROGRAM" || key == "TERM_PROGRAM_VERSION") { + continue + } + filtered = append(filtered, entry) + } + + return filtered +} + func mergeEnv(base []string, overrides []string) []string { merged := append([]string{}, base...) for _, override := range overrides { diff --git a/pkg/pty/session_test.go b/pkg/pty/session_test.go index 62f673c..5507fca 100644 --- a/pkg/pty/session_test.go +++ b/pkg/pty/session_test.go @@ -73,6 +73,43 @@ func TestNormalizeEnvOverridesParentValues(t *testing.T) { } } +func TestNormalizeEnvScrubsKittyIdentityVars(t *testing.T) { + t.Setenv("KITTY_WINDOW_ID", "1") + t.Setenv("KITTY_PID", "4242") + t.Setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty") + + env := normalizeEnv(nil) + assertEnvKeyAbsent(t, env, "KITTY_WINDOW_ID") + assertEnvKeyAbsent(t, env, "KITTY_PID") + assertEnvKeyAbsent(t, env, "GHOSTTY_RESOURCES_DIR") +} + +func TestNormalizeEnvDropsKittyClassTermProgram(t *testing.T) { + t.Setenv("TERM_PROGRAM", "ghostty") + t.Setenv("TERM_PROGRAM_VERSION", "1.0.0") + + env := normalizeEnv(nil) + assertEnvKeyAbsent(t, env, "TERM_PROGRAM") + assertEnvKeyAbsent(t, env, "TERM_PROGRAM_VERSION") +} + +func TestNormalizeEnvPreservesNonKittyTermProgram(t *testing.T) { + t.Setenv("TERM_PROGRAM", "vscode") + t.Setenv("TERM_PROGRAM_VERSION", "1.90.0") + + env := normalizeEnv(nil) + assertEnvContains(t, env, "TERM_PROGRAM=vscode") + assertEnvContains(t, env, "TERM_PROGRAM_VERSION=1.90.0") +} + +func TestNormalizeEnvScrubsKittyIdentityFromOverrides(t *testing.T) { + t.Parallel() + + env := normalizeEnv([]string{"KITTY_WINDOW_ID=9", "PATH=/bin"}) + assertEnvKeyAbsent(t, env, "KITTY_WINDOW_ID") + assertEnvContains(t, env, "PATH=/bin") +} + func assertEnvContains(t *testing.T, env []string, want string) { t.Helper() @@ -97,3 +134,14 @@ func assertEnvKeyPresent(t *testing.T, env []string, key string) { t.Fatalf("env %v does not contain key %q", env, key) } + +func assertEnvKeyAbsent(t *testing.T, env []string, key string) { + t.Helper() + + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + t.Fatalf("env %v unexpectedly contains key %q", env, key) + } + } +}