From c6b0a8a13ec47ebddcbc2b6ab14d2006cc1d4acd Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 27 Jan 2026 17:25:57 -0500 Subject: [PATCH] refactor: terminal capability handling (#2014) --- 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(-) create mode 100644 internal/ui/common/capabilities.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 351c9d414dd28b596374cf3a99459a1098d3c41b..577d4ccb4abaa79275a5a556c463cb52b16aab11 100644 --- a/internal/cmd/root.go +++ b/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") diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go new file mode 100644 index 0000000000000000000000000000000000000000..6636976d7d4f86d9283be2db759b44f948ad40f5 --- /dev/null +++ b/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...) +} diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index ce4adcf8b2dc759f5eceff6ad0d7f6d1728fb7de..4b0b844e4ed869a4347af10e9d0b1b3c70a7d2f0 100644 --- a/internal/ui/dialog/filepicker.go +++ b/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 diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 6af76531ff5b542f180e38fb7db105e4a86b49b6..5644146fec5b1e4e1e3a96c92a315c0bf986180d 100644 --- a/internal/ui/image/image.go +++ b/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 { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 58a6525672310684ff7950ab33c48bce3b00ddfe..1f2d7f86ef1953bf97e98109cbbe5d791c94122f 100644 --- a/internal/ui/model/ui.go +++ b/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