on main help sign in
refactor: terminal capability handling (#2014)
c6b0a8aAyman Bagabas
internal/cmd/root.go+1−1
@@ -98,7 +98,6 @@ crush -y
98 slog.Info("New UI in control!")
98 slog.Info("New UI in control!")
99 com := common.DefaultCommon(app)
99 com := common.DefaultCommon(app)
100 ui := ui.New(com)
100 ui := ui.New(com)
101− ui.QueryCapabilities = shouldQueryCapabilities(env)
102 model = ui
101 model = ui
103 } else {
102 } else {
104 ui := tui.New(app)
103 ui := tui.New(app)
@@ -303,6 +302,7 @@ func createDotCrushDir(dir string) error {
303 return nil
302 return nil
304}
303}
305
304
305+// TODO: Remove me after dropping the old TUI.
306func shouldQueryCapabilities(env uv.Environ) bool {
306func shouldQueryCapabilities(env uv.Environ) bool {
307 const osVendorTypeApple = "Apple"
307 const osVendorTypeApple = "Apple"
308 termType := env.Getenv("TERM")
308 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 {
29
29
30 imgEnc fimage.Encoding
30 imgEnc fimage.Encoding
31 imgPrevWidth, imgPrevHeight int
31 imgPrevWidth, imgPrevHeight int
32− cellSize fimage.CellSize
32+ cellSizeW, cellSizeH int
33
33
34 fp filepicker.Model
34 fp filepicker.Model
35 help help.Model
35 help help.Model
@@ -47,6 +47,14 @@ type FilePicker struct {
47 }
47 }
48}
48}
49
49
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+
50var _ Dialog = (*FilePicker)(nil)
58var _ Dialog = (*FilePicker)(nil)
51
59
52// NewFilePicker creates a new [FilePicker] dialog.
60// NewFilePicker creates a new [FilePicker] dialog.
@@ -103,12 +111,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
103}
111}
104
112
105// SetImageCapabilities sets the image capabilities for the [FilePicker].
113// SetImageCapabilities sets the image capabilities for the [FilePicker].
106−func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
114+func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
107 if caps != nil {
115 if caps != nil {
108− if caps.SupportsKittyGraphics {
116+ if caps.SupportsKittyGraphics() {
109 f.imgEnc = fimage.EncodingKitty
117 f.imgEnc = fimage.EncodingKitty
110 }
118 }
111− f.cellSize = caps.CellSize()
119+ f.cellSizeW, f.cellSizeH = caps.CellSize()
112 _, f.isTmux = caps.Env.LookupEnv("TMUX")
120 _, f.isTmux = caps.Env.LookupEnv("TMUX")
113 }
121 }
114}
122}
@@ -186,7 +194,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
186 img, err := loadImage(selFile)
194 img, err := loadImage(selFile)
187 if err == nil {
195 if err == nil {
188 cmds = append(cmds, tea.Sequence(
196 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),
190 func() tea.Msg {
198 func() tea.Msg {
191 f.previewingImage = true
199 f.previewingImage = true
192 return nil
200 return nil
internal/ui/image/image.go−50
@@ -13,62 +13,12 @@ import (
13
13
14 tea "charm.land/bubbletea/v2"
14 tea "charm.land/bubbletea/v2"
15 "github.com/charmbracelet/crush/internal/uiutil"
15 "github.com/charmbracelet/crush/internal/uiutil"
16− uv "github.com/charmbracelet/ultraviolet"
17 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/ansi"
18 "github.com/charmbracelet/x/ansi/kitty"
17 "github.com/charmbracelet/x/ansi/kitty"
19 "github.com/disintegration/imaging"
18 "github.com/disintegration/imaging"
20 paintbrush "github.com/jordanella/go-ansi-paintbrush"
19 paintbrush "github.com/jordanella/go-ansi-paintbrush"
21)
20)
22
21
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−
72// TransmittedMsg is a message indicating that an image has been transmitted to
22// TransmittedMsg is a message indicating that an image has been transmitted to
73// the terminal.
23// the terminal.
74type TransmittedMsg struct {
24type TransmittedMsg struct {
internal/ui/model/ui.go+6−29
@@ -42,7 +42,6 @@ import (
42 "github.com/charmbracelet/crush/internal/ui/common"
42 "github.com/charmbracelet/crush/internal/ui/common"
43 "github.com/charmbracelet/crush/internal/ui/completions"
43 "github.com/charmbracelet/crush/internal/ui/completions"
44 "github.com/charmbracelet/crush/internal/ui/dialog"
44 "github.com/charmbracelet/crush/internal/ui/dialog"
45− timage "github.com/charmbracelet/crush/internal/ui/image"
46 "github.com/charmbracelet/crush/internal/ui/logo"
45 "github.com/charmbracelet/crush/internal/ui/logo"
47 "github.com/charmbracelet/crush/internal/ui/styles"
46 "github.com/charmbracelet/crush/internal/ui/styles"
48 "github.com/charmbracelet/crush/internal/uiutil"
47 "github.com/charmbracelet/crush/internal/uiutil"
@@ -145,9 +144,8 @@ type UI struct {
145 // terminal.
144 // terminal.
146 sendProgressBar bool
145 sendProgressBar bool
147
146
148− // QueryCapabilities instructs the TUI to query for the terminal version when it
147+ // caps hold different terminal capabilities that we query for.
149− // starts.
148+ caps common.Capabilities
150− QueryCapabilities bool
151
149
152 // Editor components
150 // Editor components
153 textarea textarea.Model
151 textarea textarea.Model
@@ -182,9 +180,6 @@ type UI struct {
182 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
180 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
183 sidebarLogo string
181 sidebarLogo string
184
182
185− // imgCaps stores the terminal image capabilities.
186− imgCaps timage.Capabilities
187−
188 // custom commands & mcp commands
183 // custom commands & mcp commands
189 customCommands []commands.CustomCommand
184 customCommands []commands.CustomCommand
190 mcpPrompts []commands.MCPPrompt
185 mcpPrompts []commands.MCPPrompt
@@ -304,9 +299,6 @@ func New(com *common.Common) *UI {
304// Init initializes the UI model.
299// Init initializes the UI model.
305func (m *UI) Init() tea.Cmd {
300func (m *UI) Init() tea.Cmd {
306 var cmds []tea.Cmd
301 var cmds []tea.Cmd
307− if m.QueryCapabilities {
308− cmds = append(cmds, tea.RequestTerminalVersion)
309− }
310 if m.state == uiOnboarding {
302 if m.state == uiOnboarding {
311 if cmd := m.openModelsDialog(); cmd != nil {
303 if cmd := m.openModelsDialog(); cmd != nil {
312 cmds = append(cmds, cmd)
304 cmds = append(cmds, cmd)
@@ -363,19 +355,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
363 m.updateLayoutAndSize()
355 m.updateLayoutAndSize()
364 }
356 }
365 }
357 }
358+ // Update terminal capabilities
359+ m.caps.Update(msg)
366 switch msg := msg.(type) {
360 switch msg := msg.(type) {
367 case tea.EnvMsg:
361 case tea.EnvMsg:
368 // Is this Windows Terminal?
362 // Is this Windows Terminal?
369 if !m.sendProgressBar {
363 if !m.sendProgressBar {
370 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
364 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
371 }
365 }
372− m.imgCaps.Env = uv.Environ(msg)
366+ cmds = append(cmds, common.QueryCmd(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− }
379 case loadSessionMsg:
367 case loadSessionMsg:
380 if m.forceCompactMode {
368 if m.forceCompactMode {
381 m.isCompact = true
369 m.isCompact = true
@@ -521,8 +509,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
521 case tea.WindowSizeMsg:
509 case tea.WindowSizeMsg:
522 m.width, m.height = msg.Width, msg.Height
510 m.width, m.height = msg.Width, msg.Height
523 m.updateLayoutAndSize()
511 m.updateLayoutAndSize()
524− // XXX: We need to store cell dimensions for image rendering.
525− m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
526 case tea.KeyboardEnhancementsMsg:
512 case tea.KeyboardEnhancementsMsg:
527 m.keyenh = msg
513 m.keyenh = msg
528 if msg.SupportsKeyDisambiguation() {
514 if msg.SupportsKeyDisambiguation() {
@@ -689,16 +675,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
689 if m.completionsOpen {
675 if m.completionsOpen {
690 m.completions.SetFiles(msg.Files)
676 m.completions.SetFiles(msg.Files)
691 }
677 }
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
697 case uv.KittyGraphicsEvent:
678 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
702 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
679 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
703 slog.Warn("unexpected Kitty graphics response",
680 slog.Warn("unexpected Kitty graphics response",
704 "response", string(msg.Payload),
681 "response", string(msg.Payload),
@@ -2776,7 +2753,7 @@ func (m *UI) openFilesDialog() tea.Cmd {
2776 }
2753 }
2777
2754
2778 filePicker, cmd := dialog.NewFilePicker(m.com)
2755 filePicker, cmd := dialog.NewFilePicker(m.com)
2779− filePicker.SetImageCapabilities(&m.imgCaps)
2756+ filePicker.SetImageCapabilities(&m.caps)
2780 m.dialog.OpenDialog(filePicker)
2757 m.dialog.OpenDialog(filePicker)
2781
2758
2782 return cmd
2759 return cmd