Skip to content
Merged
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
57 changes: 57 additions & 0 deletions pkg/pty/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Comment on lines +94 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid mutable package-level state

CLAUDE.md explicitly requires “No global mutable state,” but this package-level map can be mutated by any code in package pty and would change scrubGraphicsIdentityEnv behavior globally. Please make this immutable in practice, for example by using a switch/helper predicate or a local unexported slice that is not used as mutable state.

Useful? React with 👍 / 👎.


// 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") {
Comment on lines +124 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scrub Kitty-class TERM values too

When tuirec is launched from kitty or ghostty, the inherited environment commonly includes TERM=xterm-kitty or TERM=xterm-ghostty. This filter removes several identity variables but leaves TERM intact, and the later appendDefaultEnv(..., "TERM", "xterm-256color") will not override an existing value, so the child can still detect a Kitty-class terminal and emit Kitty graphics that agg cannot render. Please drop or rewrite those TERM values in the same scrub path.

Useful? React with 👍 / 👎.

continue
}
filtered = append(filtered, entry)
}

return filtered
}

func mergeEnv(base []string, overrides []string) []string {
merged := append([]string{}, base...)
for _, override := range overrides {
Expand Down
48 changes: 48 additions & 0 deletions pkg/pty/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
}
}
}