on main help sign in
refactor: terminal capability handling (#2014)
commitc6b0a8a13ec47ebddcbc2b6ab14d2006cc1d4acd authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 27 Jan 2026, 17:25 (9 months ago) parent99aabb0
5 files changed+153−85
internal/cmd/root.go+1−1 internal/ui/common/capabilities.go+133 internal/ui/dialog/filepicker.go+13−5 internal/ui/image/image.go−50 internal/ui/model/ui.go+6−29
internal/cmd/root.go+1−1
@@ -98,7 +98,6 @@ crush -y
9898 slog.Info("New UI in control!")
9999 com := common.DefaultCommon(app)
100100 ui := ui.New(com)
101− ui.QueryCapabilities = shouldQueryCapabilities(env)
102101 model = ui
103102 } else {
104103 ui := tui.New(app)
@@ -303,6 +302,7 @@ func createDotCrushDir(dir string) error {
303302 return nil
304303}
305304
305+// TODO: Remove me after dropping the old TUI.
306306func shouldQueryCapabilities(env uv.Environ) bool {
307307 const osVendorTypeApple = "Apple"
308308 termType := env.Getenv("TERM")
internal/ui/common/capabilities.gonew file+133
@@ -0,0 +1,133 @@
1+package common
2+
3+import (
4+ "slices"
5+ "strings"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "github.com/charmbracelet/colorprofile"
9+ uv "github.com/charmbracelet/ultraviolet"
10+ "github.com/charmbracelet/x/ansi"
11+ xstrings "github.com/charmbracelet/x/exp/strings"
12+)
13+
14+// Capabilities define different terminal capabilities supported.
15+type Capabilities struct {
16+ // Profile is the terminal color profile used to determine how colors are
17+ // rendered.
18+ Profile colorprofile.Profile
19+ // Columns is the number of character columns in the terminal.
20+ Columns int
21+ // Rows is the number of character rows in the terminal.
22+ Rows int
23+ // PixelX is the width of the terminal in pixels.
24+ PixelX int
25+ // PixelY is the height of the terminal in pixels.
26+ PixelY int
27+ // KittyGraphics indicates whether the terminal supports the Kitty graphics
28+ // protocol.
29+ KittyGraphics bool
30+ // SixelGraphics indicates whether the terminal supports Sixel graphics.
31+ SixelGraphics bool
32+ // Env is the terminal environment variables.
33+ Env uv.Environ
34+ // TerminalVersion is the terminal version string.
35+ TerminalVersion string
36+ // ReportFocusEvents indicates whether the terminal supports focus events.
37+ ReportFocusEvents bool
38+}
39+
40+// Update updates the capabilities based on the given message.
41+func (c *Capabilities) Update(msg any) {
42+ switch m := msg.(type) {
43+ case tea.EnvMsg:
44+ c.Env = uv.Environ(m)
45+ case tea.ColorProfileMsg:
46+ c.Profile = m.Profile
47+ case tea.WindowSizeMsg:
48+ c.Columns = m.Width
49+ c.Rows = m.Height
50+ case uv.WindowPixelSizeEvent:
51+ c.PixelX = m.Width
52+ c.PixelY = m.Height
53+ case uv.KittyGraphicsEvent:
54+ c.KittyGraphics = true
55+ case uv.PrimaryDeviceAttributesEvent:
56+ if slices.Contains(m, 4) {
57+ c.SixelGraphics = true
58+ }
59+ case tea.TerminalVersionMsg:
60+ c.TerminalVersion = m.Name
61+ case uv.ModeReportEvent:
62+ switch m.Mode {
63+ case ansi.ModeFocusEvent:
64+ c.ReportFocusEvents = modeSupported(m.Value)
65+ }
66+ }
67+}
68+
69+// QueryCmd returns a [tea.Cmd] that queries the terminal for different
70+// capabilities.
71+func QueryCmd(env uv.Environ) tea.Cmd {
72+ var sb strings.Builder
73+ sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
74+
75+ // Queries that should only be sent to "smart" normal terminals.
76+ shouldQueryFor := shouldQueryCapabilities(env)
77+ if shouldQueryFor {
78+ sb.WriteString(ansi.RequestNameVersion)
79+ // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications.
80+ sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
81+ kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
82+ if _, isTmux := env.LookupEnv("TMUX"); isTmux {
83+ kittyReq = ansi.TmuxPassthrough(kittyReq)
84+ }
85+ sb.WriteString(kittyReq)
86+ }
87+
88+ return tea.Raw(sb.String())
89+}
90+
91+// SupportsTrueColor returns true if the terminal supports true color.
92+func (c Capabilities) SupportsTrueColor() bool {
93+ return c.Profile == colorprofile.TrueColor
94+}
95+
96+// SupportsKittyGraphics returns true if the terminal supports Kitty graphics.
97+func (c Capabilities) SupportsKittyGraphics() bool {
98+ return c.KittyGraphics
99+}
100+
101+// SupportsSixelGraphics returns true if the terminal supports Sixel graphics.
102+func (c Capabilities) SupportsSixelGraphics() bool {
103+ return c.SixelGraphics
104+}
105+
106+// CellSize returns the size of a single terminal cell in pixels.
107+func (c Capabilities) CellSize() (width, height int) {
108+ if c.Columns == 0 || c.Rows == 0 {
109+ return 0, 0
110+ }
111+ return c.PixelX / c.Columns, c.PixelY / c.Rows
112+}
113+
114+func modeSupported(v ansi.ModeSetting) bool {
115+ return v.IsSet() || v.IsReset()
116+}
117+
118+// kittyTerminals defines terminals supporting querying capabilities.
119+var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"}
120+
121+func shouldQueryCapabilities(env uv.Environ) bool {
122+ const osVendorTypeApple = "Apple"
123+ termType := env.Getenv("TERM")
124+ termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
125+ _, okSSHTTY := env.LookupEnv("SSH_TTY")
126+ if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
127+ return false
128+ }
129+ return (!okTermProg && !okSSHTTY) ||
130+ (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
131+ // Terminals that do support XTVERSION.
132+ xstrings.ContainsAnyOf(termType, kittyTerminals...)
133+}
internal/ui/dialog/filepicker.go+13−5
@@ -29,7 +29,7 @@ type FilePicker struct {
2929
3030 imgEnc fimage.Encoding
3131 imgPrevWidth, imgPrevHeight int
32− cellSize fimage.CellSize
32+ cellSizeW, cellSizeH int
3333
3434 fp filepicker.Model
3535 help help.Model
@@ -47,6 +47,14 @@ type FilePicker struct {
4747 }
4848}
4949
50+// CellSize returns the cell size used for image rendering.
51+func (f *FilePicker) CellSize() fimage.CellSize {
52+ return fimage.CellSize{
53+ Width: f.cellSizeW,
54+ Height: f.cellSizeH,
55+ }
56+}
57+
5058var _ Dialog = (*FilePicker)(nil)
5159
5260// NewFilePicker creates a new [FilePicker] dialog.
@@ -103,12 +111,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
103111}
104112
105113// SetImageCapabilities sets the image capabilities for the [FilePicker].
106−func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
114+func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
107115 if caps != nil {
108− if caps.SupportsKittyGraphics {
116+ if caps.SupportsKittyGraphics() {
109117 f.imgEnc = fimage.EncodingKitty
110118 }
111− f.cellSize = caps.CellSize()
119+ f.cellSizeW, f.cellSizeH = caps.CellSize()
112120 _, f.isTmux = caps.Env.LookupEnv("TMUX")
113121 }
114122}
@@ -186,7 +194,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
186194 img, err := loadImage(selFile)
187195 if err == nil {
188196 cmds = append(cmds, tea.Sequence(
189− f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
197+ f.imgEnc.Transmit(selFile, img, f.CellSize(), f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
190198 func() tea.Msg {
191199 f.previewingImage = true
192200 return nil
internal/ui/image/image.go−50
@@ -13,62 +13,12 @@ import (
1313
1414 tea "charm.land/bubbletea/v2"
1515 "github.com/charmbracelet/crush/internal/uiutil"
16− uv "github.com/charmbracelet/ultraviolet"
1716 "github.com/charmbracelet/x/ansi"
1817 "github.com/charmbracelet/x/ansi/kitty"
1918 "github.com/disintegration/imaging"
2019 paintbrush "github.com/jordanella/go-ansi-paintbrush"
2120)
2221
23−// Capabilities represents the capabilities of displaying images on the
24−// terminal.
25−type Capabilities struct {
26− // Columns is the number of character columns in the terminal.
27− Columns int
28− // Rows is the number of character rows in the terminal.
29− Rows int
30− // PixelWidth is the width of the terminal in pixels.
31− PixelWidth int
32− // PixelHeight is the height of the terminal in pixels.
33− PixelHeight int
34− // SupportsKittyGraphics indicates whether the terminal supports the Kitty
35− // graphics protocol.
36− SupportsKittyGraphics bool
37− // Env is the terminal environment variables.
38− Env uv.Environ
39−}
40−
41−// CellSize returns the size of a single terminal cell in pixels.
42−func (c Capabilities) CellSize() CellSize {
43− return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
44−}
45−
46−// CalculateCellSize calculates the size of a single terminal cell in pixels
47−// based on the terminal's pixel dimensions and character dimensions.
48−func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
49− if charWidth == 0 || charHeight == 0 {
50− return CellSize{}
51− }
52−
53− return CellSize{
54− Width: pixelWidth / charWidth,
55− Height: pixelHeight / charHeight,
56− }
57−}
58−
59−// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
60−// its image related capabilities to the program.
61−func RequestCapabilities(env uv.Environ) tea.Cmd {
62− winOpReq := ansi.WindowOp(14) // Window size in pixels
63− // ID 31 is just a random ID used to detect Kitty graphics support.
64− kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
65− if _, isTmux := env.LookupEnv("TMUX"); isTmux {
66− kittyReq = ansi.TmuxPassthrough(kittyReq)
67− }
68−
69− return tea.Raw(winOpReq + kittyReq)
70−}
71−
7222// TransmittedMsg is a message indicating that an image has been transmitted to
7323// the terminal.
7424type TransmittedMsg struct {
internal/ui/model/ui.go+6−29
@@ -42,7 +42,6 @@ import (
4242 "github.com/charmbracelet/crush/internal/ui/common"
4343 "github.com/charmbracelet/crush/internal/ui/completions"
4444 "github.com/charmbracelet/crush/internal/ui/dialog"
45− timage "github.com/charmbracelet/crush/internal/ui/image"
4645 "github.com/charmbracelet/crush/internal/ui/logo"
4746 "github.com/charmbracelet/crush/internal/ui/styles"
4847 "github.com/charmbracelet/crush/internal/uiutil"
@@ -145,9 +144,8 @@ type UI struct {
145144 // terminal.
146145 sendProgressBar bool
147146
148− // QueryCapabilities instructs the TUI to query for the terminal version when it
149− // starts.
150− QueryCapabilities bool
147+ // caps hold different terminal capabilities that we query for.
148+ caps common.Capabilities
151149
152150 // Editor components
153151 textarea textarea.Model
@@ -182,9 +180,6 @@ type UI struct {
182180 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
183181 sidebarLogo string
184182
185− // imgCaps stores the terminal image capabilities.
186− imgCaps timage.Capabilities
187−
188183 // custom commands & mcp commands
189184 customCommands []commands.CustomCommand
190185 mcpPrompts []commands.MCPPrompt
@@ -304,9 +299,6 @@ func New(com *common.Common) *UI {
304299// Init initializes the UI model.
305300func (m *UI) Init() tea.Cmd {
306301 var cmds []tea.Cmd
307− if m.QueryCapabilities {
308− cmds = append(cmds, tea.RequestTerminalVersion)
309− }
310302 if m.state == uiOnboarding {
311303 if cmd := m.openModelsDialog(); cmd != nil {
312304 cmds = append(cmds, cmd)
@@ -363,19 +355,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
363355 m.updateLayoutAndSize()
364356 }
365357 }
358+ // Update terminal capabilities
359+ m.caps.Update(msg)
366360 switch msg := msg.(type) {
367361 case tea.EnvMsg:
368362 // Is this Windows Terminal?
369363 if !m.sendProgressBar {
370364 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
371365 }
372− m.imgCaps.Env = uv.Environ(msg)
373− // Only query for image capabilities if the terminal is known to
374− // support Kitty graphics protocol. This prevents character bleeding
375− // on terminals that don't understand the APC escape sequences.
376− if m.QueryCapabilities {
377− cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
378− }
366+ cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
379367 case loadSessionMsg:
380368 if m.forceCompactMode {
381369 m.isCompact = true
@@ -521,8 +509,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
521509 case tea.WindowSizeMsg:
522510 m.width, m.height = msg.Width, msg.Height
523511 m.updateLayoutAndSize()
524− // XXX: We need to store cell dimensions for image rendering.
525− m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
526512 case tea.KeyboardEnhancementsMsg:
527513 m.keyenh = msg
528514 if msg.SupportsKeyDisambiguation() {
@@ -689,16 +675,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
689675 if m.completionsOpen {
690676 m.completions.SetFiles(msg.Files)
691677 }
692− case uv.WindowPixelSizeEvent:
693− // [timage.RequestCapabilities] requests the terminal to send a window
694− // size event to help determine pixel dimensions.
695− m.imgCaps.PixelWidth = msg.Width
696− m.imgCaps.PixelHeight = msg.Height
697678 case uv.KittyGraphicsEvent:
698− // [timage.RequestCapabilities] sends a Kitty graphics query and this
699− // captures the response. Any response means the terminal understands
700− // the protocol.
701− m.imgCaps.SupportsKittyGraphics = true
702679 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
703680 slog.Warn("unexpected Kitty graphics response",
704681 "response", string(msg.Payload),
@@ -2776,7 +2753,7 @@ func (m *UI) openFilesDialog() tea.Cmd {
27762753 }
27772754
27782755 filePicker, cmd := dialog.NewFilePicker(m.com)
2779− filePicker.SetImageCapabilities(&m.imgCaps)
2756+ filePicker.SetImageCapabilities(&m.caps)
27802757 m.dialog.OpenDialog(filePicker)
27812758
27822759 return cmd