Detailed changes
@@ -29,6 +29,7 @@ require (
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
github.com/charmbracelet/x/exp/ordered v0.1.0
github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
+ github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383
github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
github.com/charmbracelet/x/term v0.2.2
github.com/denisbrodbeck/machineid v1.0.1
@@ -171,7 +172,7 @@ require (
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
- golang.org/x/image v0.27.0 // indirect
+ golang.org/x/image v0.34.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
@@ -118,6 +118,8 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/
github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y=
+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY=
github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -389,8 +391,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
-golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
-golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
+golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
+golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -45,12 +45,21 @@ func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, in
listStyle := t.Dialog.List.Height(listHeight)
listContent := listStyle.Render(list)
- content := strings.Join([]string{
- titleStyle.Render(header),
- inputStyle.Render(input),
- listContent,
- helpStyle.Render(help),
- }, "\n")
+ parts := []string{}
+ if len(header) > 0 {
+ parts = append(parts, titleStyle.Render(header))
+ }
+ if len(input) > 0 {
+ parts = append(parts, inputStyle.Render(input))
+ }
+ if len(list) > 0 {
+ parts = append(parts, listContent)
+ }
+ if len(help) > 0 {
+ parts = append(parts, helpStyle.Render(help))
+ }
+
+ content := strings.Join(parts, "\n")
return dialogStyle.Render(content)
}
@@ -0,0 +1,292 @@
+package dialog
+
+import (
+ "hash/fnv"
+ "image"
+ _ "image/jpeg" // register JPEG format
+ _ "image/png" // register PNG format
+ "io"
+ "os"
+ "strings"
+ "sync"
+
+ "charm.land/bubbles/v2/filepicker"
+ "charm.land/bubbles/v2/help"
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/home"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ fimage "github.com/charmbracelet/crush/internal/ui/image"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+var (
+ transmittedImages = map[uint64]struct{}{}
+ transmittedMutex sync.RWMutex
+)
+
+// FilePickerID is the identifier for the FilePicker dialog.
+const FilePickerID = "filepicker"
+
+// FilePicker is a dialog that allows users to select files or directories.
+type FilePicker struct {
+ com *common.Common
+
+ width int
+ imgPrevWidth, imgPrevHeight int
+ imageCaps *fimage.Capabilities
+
+ img *fimage.Image
+ fp filepicker.Model
+ help help.Model
+ previewingImage bool // indicates if an image is being previewed
+
+ km struct {
+ Select,
+ Down,
+ Up,
+ Forward,
+ Backward,
+ Navigate,
+ Close key.Binding
+ }
+}
+
+var _ Dialog = (*FilePicker)(nil)
+
+// NewFilePicker creates a new [FilePicker] dialog.
+func NewFilePicker(com *common.Common) (*FilePicker, Action) {
+ f := new(FilePicker)
+ f.com = com
+
+ help := help.New()
+ help.Styles = com.Styles.DialogHelpStyles()
+
+ f.help = help
+
+ f.km.Select = key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "accept"),
+ )
+ f.km.Down = key.NewBinding(
+ key.WithKeys("down", "j"),
+ key.WithHelp("down/j", "move down"),
+ )
+ f.km.Up = key.NewBinding(
+ key.WithKeys("up", "k"),
+ key.WithHelp("up/k", "move up"),
+ )
+ f.km.Forward = key.NewBinding(
+ key.WithKeys("right", "l"),
+ key.WithHelp("right/l", "move forward"),
+ )
+ f.km.Backward = key.NewBinding(
+ key.WithKeys("left", "h"),
+ key.WithHelp("left/h", "move backward"),
+ )
+ f.km.Navigate = key.NewBinding(
+ key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
+ key.WithHelp("↑↓←→", "navigate"),
+ )
+ f.km.Close = key.NewBinding(
+ key.WithKeys("esc", "alt+esc"),
+ key.WithHelp("esc", "close/exit"),
+ )
+
+ fp := filepicker.New()
+ fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
+ fp.ShowPermissions = false
+ fp.ShowSize = false
+ fp.AutoHeight = false
+ fp.Styles = com.Styles.FilePicker
+ fp.Cursor = ""
+ fp.CurrentDirectory = f.WorkingDir()
+
+ f.fp = fp
+
+ return f, ActionCmd{f.fp.Init()}
+}
+
+// SetImageCapabilities sets the image capabilities for the [FilePicker].
+func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
+ f.imageCaps = caps
+}
+
+// WorkingDir returns the current working directory of the [FilePicker].
+func (f *FilePicker) WorkingDir() string {
+ wd := f.com.Config().WorkingDir()
+ if len(wd) > 0 {
+ return wd
+ }
+
+ cwd, err := os.Getwd()
+ if err != nil {
+ return home.Dir()
+ }
+
+ return cwd
+}
+
+// SetWindowSize sets the desired size of the [FilePicker] dialog window.
+func (f *FilePicker) SetWindowSize(width, height int) {
+ f.width = width
+ f.imgPrevWidth = width/2 - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
+ // Use square preview for simplicity same size as width
+ f.imgPrevHeight = width/2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
+ f.fp.SetHeight(height)
+ innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
+ styles := f.com.Styles.FilePicker
+ styles.File = styles.File.Width(innerWidth)
+ styles.Directory = styles.Directory.Width(innerWidth)
+ styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
+ styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
+ f.fp.Styles = styles
+}
+
+// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
+func (f *FilePicker) ShortHelp() []key.Binding {
+ return []key.Binding{
+ f.km.Navigate,
+ f.km.Select,
+ f.km.Close,
+ }
+}
+
+// FullHelp returns the full help key bindings for the [FilePicker] dialog.
+func (f *FilePicker) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {
+ f.km.Select,
+ f.km.Down,
+ f.km.Up,
+ f.km.Forward,
+ },
+ {
+ f.km.Backward,
+ f.km.Close,
+ },
+ }
+}
+
+// ID returns the identifier of the [FilePicker] dialog.
+func (f *FilePicker) ID() string {
+ return FilePickerID
+}
+
+// Init implements the [Dialog] interface.
+func (f *FilePicker) Init() tea.Cmd {
+ return f.fp.Init()
+}
+
+// HandleMsg updates the [FilePicker] dialog based on the given message.
+func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, f.km.Close):
+ return ActionClose{}
+ }
+ }
+
+ var cmd tea.Cmd
+ f.fp, cmd = f.fp.Update(msg)
+ if selFile := f.fp.HighlightedPath(); selFile != "" {
+ var allowed bool
+ for _, allowedExt := range f.fp.AllowedTypes {
+ if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
+ allowed = true
+ break
+ }
+ }
+
+ f.previewingImage = allowed
+ if allowed {
+ id := uniquePathID(selFile)
+
+ transmittedMutex.RLock()
+ _, transmitted := transmittedImages[id]
+ transmittedMutex.RUnlock()
+ if !transmitted {
+ img, err := loadImage(selFile)
+ if err != nil {
+ f.previewingImage = false
+ }
+
+ timg, err := fimage.New(selFile, img, f.imgPrevWidth, f.imgPrevHeight)
+ if err != nil {
+ f.previewingImage = false
+ }
+
+ f.img = timg
+ if err == nil {
+ cmds = append(cmds, f.img.Transmit())
+ transmittedMutex.Lock()
+ transmittedImages[id] = struct{}{}
+ transmittedMutex.Unlock()
+ }
+ }
+ }
+ }
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
+ return ActionCmd{tea.Batch(cmds...)}
+}
+
+// Draw renders the [FilePicker] dialog as a string.
+func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ t := f.com.Styles
+ titleStyle := f.com.Styles.Dialog.Title
+ dialogStyle := f.com.Styles.Dialog.View
+ header := common.DialogTitle(t, "Add Image",
+ max(0, f.width-dialogStyle.GetHorizontalFrameSize()-
+ titleStyle.GetHorizontalFrameSize()))
+ files := strings.TrimSpace(f.fp.View())
+ filesHeight := f.fp.Height()
+ imgPreview := t.Dialog.ImagePreview.Render(f.imagePreview())
+ view := HeaderInputListHelpView(t, f.width, filesHeight, header, imgPreview, files, f.help.View(f))
+ DrawCenter(scr, area, view)
+ return nil
+}
+
+// imagePreview returns the image preview section of the [FilePicker] dialog.
+func (f *FilePicker) imagePreview() string {
+ if !f.previewingImage || f.img == nil {
+ // TODO: Cache this?
+ var sb strings.Builder
+ for y := 0; y < f.imgPrevHeight; y++ {
+ for x := 0; x < f.imgPrevWidth; x++ {
+ sb.WriteRune('╱')
+ }
+ if y < f.imgPrevHeight-1 {
+ sb.WriteRune('\n')
+ }
+ }
+ return sb.String()
+ }
+
+ return f.img.Render()
+}
+
+func uniquePathID(path string) uint64 {
+ h := fnv.New64a()
+ _, _ = io.WriteString(h, path)
+ return h.Sum64()
+}
+
+func loadImage(path string) (img image.Image, err error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ img, _, err = image.Decode(file)
+ if err != nil {
+ return nil, err
+ }
+
+ return img, nil
+}
@@ -0,0 +1,164 @@
+package image
+
+import (
+ "bytes"
+ "fmt"
+ "hash/fnv"
+ "image"
+ "image/color"
+ "io"
+ "log/slog"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/ansi/kitty"
+ "github.com/charmbracelet/x/mosaic"
+)
+
+// Capabilities represents the capabilities of displaying images on the
+// terminal.
+type Capabilities struct {
+ // SupportsKittyGraphics indicates whether the terminal supports the Kitty
+ // graphics protocol.
+ SupportsKittyGraphics bool
+}
+
+// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
+// its image related capabilities to the program.
+func RequestCapabilities() tea.Cmd {
+ return tea.Raw(
+ // ID 31 is just a random ID used to detect Kitty graphics support.
+ ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
+ )
+}
+
+// Encoding represents the encoding format of the image.
+type Encoding byte
+
+// Image encodings.
+const (
+ EncodingBlocks Encoding = iota
+ EncodingKitty
+)
+
+// Image represents an image that can be displayed on the terminal.
+type Image struct {
+ id int
+ img image.Image
+ cols, rows int // in terminal cells
+ enc Encoding
+}
+
+// New creates a new [Image] instance with the given unique id, image, and
+// dimensions in terminal cells.
+func New(id string, img image.Image, cols, rows int) (*Image, error) {
+ i := new(Image)
+ h := fnv.New64a()
+ if _, err := io.WriteString(h, id); err != nil {
+ return nil, err
+ }
+ i.id = int(h.Sum64())
+ i.img = img
+ i.cols = cols
+ i.rows = rows
+ return i, nil
+}
+
+// SetEncoding sets the encoding format for the image.
+func (i *Image) SetEncoding(enc Encoding) {
+ i.enc = enc
+}
+
+// Transmit returns a [tea.Cmd] that sends the image data to the terminal.
+// This is needed for the [EncodingKitty] protocol so that the terminal can
+// cache the image for later rendering.
+//
+// This should only happen once per image.
+func (i *Image) Transmit() tea.Cmd {
+ if i.enc != EncodingKitty {
+ return nil
+ }
+
+ var buf bytes.Buffer
+ bounds := i.img.Bounds()
+ imgWidth := bounds.Dx()
+ imgHeight := bounds.Dy()
+
+ // RGBA is 4 bytes per pixel
+ imgSize := imgWidth * imgHeight * 4
+
+ if err := kitty.EncodeGraphics(&buf, i.img, &kitty.Options{
+ ID: i.id,
+ Action: kitty.TransmitAndPut,
+ Transmission: kitty.Direct,
+ Format: kitty.RGBA,
+ Size: imgSize,
+ Width: imgWidth,
+ Height: imgHeight,
+ Columns: i.cols,
+ Rows: i.rows,
+ VirtualPlacement: true,
+ Quite: 2,
+ }); err != nil {
+ slog.Error("failed to encode image for kitty graphics", "err", err)
+ return uiutil.ReportError(fmt.Errorf("failed to encode image"))
+ }
+
+ return tea.Raw(buf.String())
+}
+
+// Render renders the image to a string that can be displayed on the terminal.
+func (i *Image) Render() string {
+ // Check cache first
+ switch i.enc {
+ case EncodingBlocks:
+ m := mosaic.New().Width(i.cols).Height(i.rows).Scale(2)
+ return m.Render(i.img)
+ case EncodingKitty:
+ // Build Kitty graphics unicode place holders
+ var fg color.Color
+ var extra int
+ var r, g, b int
+ extra, r, g, b = i.id>>24&0xff, i.id>>16&0xff, i.id>>8&0xff, i.id&0xff
+
+ if r == 0 && g == 0 {
+ fg = ansi.IndexedColor(b)
+ } else {
+ fg = color.RGBA{
+ R: uint8(r), //nolint:gosec
+ G: uint8(g), //nolint:gosec
+ B: uint8(b), //nolint:gosec
+ A: 0xff,
+ }
+ }
+
+ fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
+
+ var buf bytes.Buffer
+ for y := 0; y < i.rows; y++ {
+ // As an optimization, we only write the fg color sequence id, and
+ // column-row data once on the first cell. The terminal will handle
+ // the rest.
+ buf.WriteString(fgStyle)
+ buf.WriteRune(kitty.Placeholder)
+ buf.WriteRune(kitty.Diacritic(y))
+ buf.WriteRune(kitty.Diacritic(0))
+ if extra > 0 {
+ buf.WriteRune(kitty.Diacritic(extra))
+ }
+ for x := 1; x < i.cols; x++ {
+ buf.WriteString(fgStyle)
+ buf.WriteRune(kitty.Placeholder)
+ }
+ if y < i.rows-1 {
+ buf.WriteByte('\n')
+ }
+ }
+
+ return buf.String()
+
+ default:
+ return ""
+ }
+}
@@ -36,6 +36,7 @@ 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"
@@ -144,6 +145,9 @@ type UI struct {
// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
sidebarLogo string
+
+ // imageCaps stores the terminal image capabilities.
+ imageCaps timage.Capabilities
}
// New creates a new instance of the [UI] model.
@@ -224,6 +228,11 @@ func (m *UI) Init() tea.Cmd {
var cmds []tea.Cmd
if m.QueryVersion {
cmds = append(cmds, tea.RequestTerminalVersion)
+ // XXX: Right now, we're using the same logic to determine image
+ // support. Terminals like Apple Terminal and possibly others might
+ // bleed characters when querying for Kitty graphics via APC escape
+ // sequences.
+ cmds = append(cmds, timage.RequestCapabilities())
}
return tea.Batch(cmds...)
}
@@ -426,6 +435,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.completionsOpen {
m.completions.SetFiles(msg.Files)
}
+ case uv.KittyGraphicsEvent:
+ // [timage.RequestCapabilities] sends a Kitty graphics query and this
+ // captures the response. Any response means the terminal understands
+ // the protocol.
+ m.imageCaps.SupportsKittyGraphics = true
default:
if m.dialog.HasDialogs() {
if cmd := m.handleDialogMsg(msg); cmd != nil {
@@ -926,6 +940,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
switch {
+ case key.Matches(msg, m.keyMap.Editor.AddImage):
+ if cmd := m.openFilesDialog(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
case key.Matches(msg, m.keyMap.Editor.SendMessage):
value := m.textarea.Value()
if before, ok := strings.CutSuffix(value, "\\"); ok {
@@ -2032,6 +2051,27 @@ func (m *UI) openSessionsDialog() tea.Cmd {
}
m.dialog.OpenDialog(dialog)
+ return nil
+}
+
+// openFilesDialog opens the file picker dialog.
+func (m *UI) openFilesDialog() tea.Cmd {
+ if m.dialog.ContainsDialog(dialog.FilePickerID) {
+ // Bring to front
+ m.dialog.BringToFront(dialog.FilePickerID)
+ return nil
+ }
+
+ const desiredFilePickerHeight = 10
+ filePicker, action := dialog.NewFilePicker(m.com)
+ filePicker.SetWindowSize(min(80, m.width-8), desiredFilePickerHeight)
+ filePicker.SetImageCapabilities(&m.imageCaps)
+ m.dialog.OpenDialog(filePicker)
+
+ switch action := action.(type) {
+ case dialog.ActionCmd:
+ return action.Cmd
+ }
return nil
}
@@ -328,6 +328,8 @@ type Styles struct {
ScrollbarTrack lipgloss.Style
Commands struct{}
+
+ ImagePreview lipgloss.Style
}
// Status bar and help
@@ -1186,6 +1188,8 @@ func DefaultStyles() Styles {
s.Dialog.ScrollbarThumb = base.Foreground(secondary)
s.Dialog.ScrollbarTrack = base.Foreground(border)
+ s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(1)
+
s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
s.Status.InfoIndicator = s.Status.SuccessIndicator