on main help sign in
fix(ui): prevent AAAA probe bleed in terminals without Kitty graphics support (#1967)

* fix(ui): prevent AAAA probe bleed in terminals without Kitty graphics support * refactor(ui): add an OS vendor type for Apple and use DRY for Kitty terminals * refactor(ui): do not export private symbols, fix a LookupEnv for mocks * refactor(ui): typo * refactor(ui): remove dead code * fix(ui): unify capability querying logic for terminal version and image capabilities --------- Co-authored-by: Ayman Bagabas <ayman.bagabas@gmail.com>

commit8a37a3405a2461305b8300ba5deceb9bb2c753c8 authorM1xA <AnyCPU@users.noreply.github.com> dateFri 23 Jan 2026, 21:59 (9 months ago) parentb23f1d7
3 files changed+181−13
internal/cmd/root.go+12−5 internal/cmd/root_test.go+160 internal/ui/model/ui.go+9−8
internal/cmd/root.go+12−5
@@ -33,6 +33,9 @@ import (
3333 "github.com/spf13/cobra"
3434)
3535
36+// kittyTerminals defines terminals supporting querying capabilities.
37+var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"}
38+
3639func init() {
3740 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
3841 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
@@ -94,11 +97,11 @@ crush -y
9497 slog.Info("New UI in control!")
9598 com := common.DefaultCommon(app)
9699 ui := ui.New(com)
97− ui.QueryVersion = shouldQueryTerminalVersion(env)
100+ ui.QueryCapabilities = shouldQueryCapabilities(env)
98101 model = ui
99102 } else {
100103 ui := tui.New(app)
101− ui.QueryVersion = shouldQueryTerminalVersion(env)
104+ ui.QueryVersion = shouldQueryCapabilities(env)
102105 model = ui
103106 }
104107 program := tea.NewProgram(
@@ -299,12 +302,16 @@ func createDotCrushDir(dir string) error {
299302 return nil
300303}
301304
302−func shouldQueryTerminalVersion(env uv.Environ) bool {
305+func shouldQueryCapabilities(env uv.Environ) bool {
306+ const osVendorTypeApple = "Apple"
303307 termType := env.Getenv("TERM")
304308 termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
305309 _, okSSHTTY := env.LookupEnv("SSH_TTY")
310+ if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
311+ return false
312+ }
306313 return (!okTermProg && !okSSHTTY) ||
307− (!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
314+ (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
308315 // Terminals that do support XTVERSION.
309− stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
316+ stringext.ContainsAny(termType, kittyTerminals...)
310317}
internal/cmd/root_test.gonew file+160
@@ -0,0 +1,160 @@
1+package cmd
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/stringext"
8+ uv "github.com/charmbracelet/ultraviolet"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type mockEnviron []string
13+
14+func (m mockEnviron) Getenv(key string) string {
15+ v, _ := m.LookupEnv(key)
16+ return v
17+}
18+
19+func (m mockEnviron) LookupEnv(key string) (string, bool) {
20+ for _, env := range m {
21+ kv := strings.SplitN(env, "=", 2)
22+ if len(kv) == 2 && kv[0] == key {
23+ return kv[1], true
24+ }
25+ }
26+ return "", false
27+}
28+
29+func (m mockEnviron) ExpandEnv(s string) string {
30+ return s // Not implemented for tests
31+}
32+
33+func (m mockEnviron) Slice() []string {
34+ return []string(m)
35+}
36+
37+func TestShouldQueryImageCapabilities(t *testing.T) {
38+ t.Parallel()
39+
40+ tests := []struct {
41+ name string
42+ env mockEnviron
43+ want bool
44+ }{
45+ {
46+ name: "kitty terminal",
47+ env: mockEnviron{"TERM=xterm-kitty"},
48+ want: true,
49+ },
50+ {
51+ name: "wezterm terminal",
52+ env: mockEnviron{"TERM=xterm-256color"},
53+ want: true,
54+ },
55+ {
56+ name: "wezterm with WEZTERM env",
57+ env: mockEnviron{"TERM=xterm-256color", "WEZTERM_EXECUTABLE=/Applications/WezTerm.app/Contents/MacOS/wezterm-gui"},
58+ want: true, // Not detected via TERM, only via stringext.ContainsAny which checks TERM
59+ },
60+ {
61+ name: "Apple Terminal",
62+ env: mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-256color"},
63+ want: false,
64+ },
65+ {
66+ name: "alacritty",
67+ env: mockEnviron{"TERM=alacritty"},
68+ want: true,
69+ },
70+ {
71+ name: "ghostty",
72+ env: mockEnviron{"TERM=xterm-ghostty"},
73+ want: true,
74+ },
75+ {
76+ name: "rio",
77+ env: mockEnviron{"TERM=rio"},
78+ want: true,
79+ },
80+ {
81+ name: "wezterm (detected via TERM)",
82+ env: mockEnviron{"TERM=wezterm"},
83+ want: true,
84+ },
85+ {
86+ name: "SSH session",
87+ env: mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-256color"},
88+ want: false,
89+ },
90+ {
91+ name: "generic terminal",
92+ env: mockEnviron{"TERM=xterm-256color"},
93+ want: true,
94+ },
95+ {
96+ name: "kitty over SSH",
97+ env: mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-kitty"},
98+ want: true,
99+ },
100+ {
101+ name: "Apple Terminal with kitty TERM (should still be false due to TERM_PROGRAM)",
102+ env: mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-kitty"},
103+ want: false,
104+ },
105+ }
106+
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ t.Parallel()
110+ got := shouldQueryCapabilities(uv.Environ(tt.env))
111+ require.Equal(t, tt.want, got, "shouldQueryImageCapabilities() = %v, want %v", got, tt.want)
112+ })
113+ }
114+}
115+
116+// This is a helper to test the underlying logic of stringext.ContainsAny
117+// which is used by shouldQueryImageCapabilities
118+func TestStringextContainsAny(t *testing.T) {
119+ t.Parallel()
120+
121+ tests := []struct {
122+ name string
123+ s string
124+ substr []string
125+ want bool
126+ }{
127+ {
128+ name: "kitty in TERM",
129+ s: "xterm-kitty",
130+ substr: kittyTerminals,
131+ want: true,
132+ },
133+ {
134+ name: "wezterm in TERM",
135+ s: "wezterm",
136+ substr: kittyTerminals,
137+ want: true,
138+ },
139+ {
140+ name: "alacritty in TERM",
141+ s: "alacritty",
142+ substr: kittyTerminals,
143+ want: true,
144+ },
145+ {
146+ name: "generic terminal not in list",
147+ s: "xterm-256color",
148+ substr: kittyTerminals,
149+ want: false,
150+ },
151+ }
152+
153+ for _, tt := range tests {
154+ t.Run(tt.name, func(t *testing.T) {
155+ t.Parallel()
156+ got := stringext.ContainsAny(tt.s, tt.substr...)
157+ require.Equal(t, tt.want, got)
158+ })
159+ }
160+}
internal/ui/model/ui.go+9−8
@@ -145,9 +145,9 @@ type UI struct {
145145 // terminal.
146146 sendProgressBar bool
147147
148− // QueryVersion instructs the TUI to query for the terminal version when it
148+ // QueryCapabilities instructs the TUI to query for the terminal version when it
149149 // starts.
150− QueryVersion bool
150+ QueryCapabilities bool
151151
152152 // Editor components
153153 textarea textarea.Model
@@ -300,7 +300,7 @@ func New(com *common.Common) *UI {
300300// Init initializes the UI model.
301301func (m *UI) Init() tea.Cmd {
302302 var cmds []tea.Cmd
303− if m.QueryVersion {
303+ if m.QueryCapabilities {
304304 cmds = append(cmds, tea.RequestTerminalVersion)
305305 }
306306 // load the user commands async
@@ -351,11 +351,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
351351 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
352352 }
353353 m.imgCaps.Env = uv.Environ(msg)
354− // XXX: Right now, we're using the same logic to determine image
355− // support. Terminals like Apple Terminal and possibly others might
356− // bleed characters when querying for Kitty graphics via APC escape
357− // sequences.
358− cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
354+ // Only query for image capabilities if the terminal is known to
355+ // support Kitty graphics protocol. This prevents character bleeding
356+ // on terminals that don't understand the APC escape sequences.
357+ if m.QueryCapabilities {
358+ cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
359+ }
359360 case loadSessionMsg:
360361 m.state = uiChat
361362 if m.forceCompactMode {