refactor: terminal capability handling (#2014)

Ayman Bagabas created

Change summary

internal/cmd/root.go               |   2 
internal/ui/common/capabilities.go | 133 ++++++++++++++++++++++++++++++++
internal/ui/dialog/filepicker.go   |  18 +++-
internal/ui/image/image.go         |  50 ------------
internal/ui/model/ui.go            |  35 +------
5 files changed, 153 insertions(+), 85 deletions(-)

Detailed changes

internal/cmd/root.go 🔗

@@ -98,7 +98,6 @@ crush -y
 			slog.Info("New UI in control!")
 			com := common.DefaultCommon(app)
 			ui := ui.New(com)
-			ui.QueryCapabilities = shouldQueryCapabilities(env)
 			model = ui
 		} else {
 			ui := tui.New(app)
@@ -303,6 +302,7 @@ func createDotCrushDir(dir string) error {
 	return nil
 }
 
+// TODO: Remove me after dropping the old TUI.
 func shouldQueryCapabilities(env uv.Environ) bool {
 	const osVendorTypeApple = "Apple"
 	termType := env.Getenv("TERM")

internal/ui/common/capabilities.go 🔗

@@ -0,0 +1,133 @@
+package common
+
+import (
+	"slices"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/colorprofile"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+	xstrings "github.com/charmbracelet/x/exp/strings"
+)
+
+// Capabilities define different terminal capabilities supported.
+type Capabilities struct {
+	// Profile is the terminal color profile used to determine how colors are
+	// rendered.
+	Profile colorprofile.Profile
+	// Columns is the number of character columns in the terminal.
+	Columns int
+	// Rows is the number of character rows in the terminal.
+	Rows int
+	// PixelX is the width of the terminal in pixels.
+	PixelX int
+	// PixelY is the height of the terminal in pixels.
+	PixelY int
+	// KittyGraphics indicates whether the terminal supports the Kitty graphics
+	// protocol.
+	KittyGraphics bool
+	// SixelGraphics indicates whether the terminal supports Sixel graphics.
+	SixelGraphics bool
+	// Env is the terminal environment variables.
+	Env uv.Environ
+	// TerminalVersion is the terminal version string.
+	TerminalVersion string
+	// ReportFocusEvents indicates whether the terminal supports focus events.
+	ReportFocusEvents bool
+}
+
+// Update updates the capabilities based on the given message.
+func (c *Capabilities) Update(msg any) {
+	switch m := msg.(type) {
+	case tea.EnvMsg:
+		c.Env = uv.Environ(m)
+	case tea.ColorProfileMsg:
+		c.Profile = m.Profile
+	case tea.WindowSizeMsg:
+		c.Columns = m.Width
+		c.Rows = m.Height
+	case uv.WindowPixelSizeEvent:
+		c.PixelX = m.Width
+		c.PixelY = m.Height
+	case uv.KittyGraphicsEvent:
+		c.KittyGraphics = true
+	case uv.PrimaryDeviceAttributesEvent:
+		if slices.Contains(m, 4) {
+			c.SixelGraphics = true
+		}
+	case tea.TerminalVersionMsg:
+		c.TerminalVersion = m.Name
+	case uv.ModeReportEvent:
+		switch m.Mode {
+		case ansi.ModeFocusEvent:
+			c.ReportFocusEvents = modeSupported(m.Value)
+		}
+	}
+}
+
+// QueryCmd returns a [tea.Cmd] that queries the terminal for different
+// capabilities.
+func QueryCmd(env uv.Environ) tea.Cmd {
+	var sb strings.Builder
+	sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
+
+	// Queries that should only be sent to "smart" normal terminals.
+	shouldQueryFor := shouldQueryCapabilities(env)
+	if shouldQueryFor {
+		sb.WriteString(ansi.RequestNameVersion)
+		// sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications.
+		sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
+		kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
+		if _, isTmux := env.LookupEnv("TMUX"); isTmux {
+			kittyReq = ansi.TmuxPassthrough(kittyReq)
+		}
+		sb.WriteString(kittyReq)
+	}
+
+	return tea.Raw(sb.String())
+}
+
+// SupportsTrueColor returns true if the terminal supports true color.
+func (c Capabilities) SupportsTrueColor() bool {
+	return c.Profile == colorprofile.TrueColor
+}
+
+// SupportsKittyGraphics returns true if the terminal supports Kitty graphics.
+func (c Capabilities) SupportsKittyGraphics() bool {
+	return c.KittyGraphics
+}
+
+// SupportsSixelGraphics returns true if the terminal supports Sixel graphics.
+func (c Capabilities) SupportsSixelGraphics() bool {
+	return c.SixelGraphics
+}
+
+// CellSize returns the size of a single terminal cell in pixels.
+func (c Capabilities) CellSize() (width, height int) {
+	if c.Columns == 0 || c.Rows == 0 {
+		return 0, 0
+	}
+	return c.PixelX / c.Columns, c.PixelY / c.Rows
+}
+
+func modeSupported(v ansi.ModeSetting) bool {
+	return v.IsSet() || v.IsReset()
+}
+
+// kittyTerminals defines terminals supporting querying capabilities.
+var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"}
+
+func shouldQueryCapabilities(env uv.Environ) bool {
+	const osVendorTypeApple = "Apple"
+	termType := env.Getenv("TERM")
+	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
+	_, okSSHTTY := env.LookupEnv("SSH_TTY")
+	if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
+		return false
+	}
+	return (!okTermProg && !okSSHTTY) ||
+		(!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
+		// Terminals that do support XTVERSION.
+		xstrings.ContainsAnyOf(termType, kittyTerminals...)
+}

internal/ui/dialog/filepicker.go 🔗

@@ -29,7 +29,7 @@ type FilePicker struct {
 
 	imgEnc                      fimage.Encoding
 	imgPrevWidth, imgPrevHeight int
-	cellSize                    fimage.CellSize
+	cellSizeW, cellSizeH        int
 
 	fp              filepicker.Model
 	help            help.Model
@@ -47,6 +47,14 @@ type FilePicker struct {
 	}
 }
 
+// CellSize returns the cell size used for image rendering.
+func (f *FilePicker) CellSize() fimage.CellSize {
+	return fimage.CellSize{
+		Width:  f.cellSizeW,
+		Height: f.cellSizeH,
+	}
+}
+
 var _ Dialog = (*FilePicker)(nil)
 
 // NewFilePicker creates a new [FilePicker] dialog.
@@ -103,12 +111,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
 }
 
 // SetImageCapabilities sets the image capabilities for the [FilePicker].
-func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
+func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
 	if caps != nil {
-		if caps.SupportsKittyGraphics {
+		if caps.SupportsKittyGraphics() {
 			f.imgEnc = fimage.EncodingKitty
 		}
-		f.cellSize = caps.CellSize()
+		f.cellSizeW, f.cellSizeH = caps.CellSize()
 		_, f.isTmux = caps.Env.LookupEnv("TMUX")
 	}
 }
@@ -186,7 +194,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
 			img, err := loadImage(selFile)
 			if err == nil {
 				cmds = append(cmds, tea.Sequence(
-					f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
+					f.imgEnc.Transmit(selFile, img, f.CellSize(), f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
 					func() tea.Msg {
 						f.previewingImage = true
 						return nil

internal/ui/image/image.go 🔗

@@ -13,62 +13,12 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/uiutil"
-	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/ansi/kitty"
 	"github.com/disintegration/imaging"
 	paintbrush "github.com/jordanella/go-ansi-paintbrush"
 )
 
-// Capabilities represents the capabilities of displaying images on the
-// terminal.
-type Capabilities struct {
-	// Columns is the number of character columns in the terminal.
-	Columns int
-	// Rows is the number of character rows in the terminal.
-	Rows int
-	// PixelWidth is the width of the terminal in pixels.
-	PixelWidth int
-	// PixelHeight is the height of the terminal in pixels.
-	PixelHeight int
-	// SupportsKittyGraphics indicates whether the terminal supports the Kitty
-	// graphics protocol.
-	SupportsKittyGraphics bool
-	// Env is the terminal environment variables.
-	Env uv.Environ
-}
-
-// CellSize returns the size of a single terminal cell in pixels.
-func (c Capabilities) CellSize() CellSize {
-	return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
-}
-
-// CalculateCellSize calculates the size of a single terminal cell in pixels
-// based on the terminal's pixel dimensions and character dimensions.
-func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
-	if charWidth == 0 || charHeight == 0 {
-		return CellSize{}
-	}
-
-	return CellSize{
-		Width:  pixelWidth / charWidth,
-		Height: pixelHeight / charHeight,
-	}
-}
-
-// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
-// its image related capabilities to the program.
-func RequestCapabilities(env uv.Environ) tea.Cmd {
-	winOpReq := ansi.WindowOp(14) // Window size in pixels
-	// ID 31 is just a random ID used to detect Kitty graphics support.
-	kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
-	if _, isTmux := env.LookupEnv("TMUX"); isTmux {
-		kittyReq = ansi.TmuxPassthrough(kittyReq)
-	}
-
-	return tea.Raw(winOpReq + kittyReq)
-}
-
 // TransmittedMsg is a message indicating that an image has been transmitted to
 // the terminal.
 type TransmittedMsg struct {

internal/ui/model/ui.go 🔗

@@ -42,7 +42,6 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/completions"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
-	timage "github.com/charmbracelet/crush/internal/ui/image"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/uiutil"
@@ -145,9 +144,8 @@ type UI struct {
 	// terminal.
 	sendProgressBar bool
 
-	// QueryCapabilities instructs the TUI to query for the terminal version when it
-	// starts.
-	QueryCapabilities bool
+	// caps hold different terminal capabilities that we query for.
+	caps common.Capabilities
 
 	// Editor components
 	textarea textarea.Model
@@ -182,9 +180,6 @@ type UI struct {
 	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 	sidebarLogo string
 
-	// imgCaps stores the terminal image capabilities.
-	imgCaps timage.Capabilities
-
 	// custom commands & mcp commands
 	customCommands []commands.CustomCommand
 	mcpPrompts     []commands.MCPPrompt
@@ -304,9 +299,6 @@ func New(com *common.Common) *UI {
 // Init initializes the UI model.
 func (m *UI) Init() tea.Cmd {
 	var cmds []tea.Cmd
-	if m.QueryCapabilities {
-		cmds = append(cmds, tea.RequestTerminalVersion)
-	}
 	if m.state == uiOnboarding {
 		if cmd := m.openModelsDialog(); cmd != nil {
 			cmds = append(cmds, cmd)
@@ -363,19 +355,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.updateLayoutAndSize()
 		}
 	}
+	// Update terminal capabilities
+	m.caps.Update(msg)
 	switch msg := msg.(type) {
 	case tea.EnvMsg:
 		// Is this Windows Terminal?
 		if !m.sendProgressBar {
 			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 		}
-		m.imgCaps.Env = uv.Environ(msg)
-		// Only query for image capabilities if the terminal is known to
-		// support Kitty graphics protocol. This prevents character bleeding
-		// on terminals that don't understand the APC escape sequences.
-		if m.QueryCapabilities {
-			cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
-		}
+		cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
 	case loadSessionMsg:
 		if m.forceCompactMode {
 			m.isCompact = true
@@ -521,8 +509,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.width, m.height = msg.Width, msg.Height
 		m.updateLayoutAndSize()
-		// XXX: We need to store cell dimensions for image rendering.
-		m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
 	case tea.KeyboardEnhancementsMsg:
 		m.keyenh = msg
 		if msg.SupportsKeyDisambiguation() {
@@ -689,16 +675,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.completionsOpen {
 			m.completions.SetFiles(msg.Files)
 		}
-	case uv.WindowPixelSizeEvent:
-		// [timage.RequestCapabilities] requests the terminal to send a window
-		// size event to help determine pixel dimensions.
-		m.imgCaps.PixelWidth = msg.Width
-		m.imgCaps.PixelHeight = msg.Height
 	case uv.KittyGraphicsEvent:
-		// [timage.RequestCapabilities] sends a Kitty graphics query and this
-		// captures the response. Any response means the terminal understands
-		// the protocol.
-		m.imgCaps.SupportsKittyGraphics = true
 		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
 			slog.Warn("unexpected Kitty graphics response",
 				"response", string(msg.Payload),
@@ -2776,7 +2753,7 @@ func (m *UI) openFilesDialog() tea.Cmd {
 	}
 
 	filePicker, cmd := dialog.NewFilePicker(m.com)
-	filePicker.SetImageCapabilities(&m.imgCaps)
+	filePicker.SetImageCapabilities(&m.caps)
 	m.dialog.OpenDialog(filePicker)
 
 	return cmd