Detailed changes
@@ -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")
@@ -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...)
+}
@@ -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
@@ -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 {
@@ -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