feat(ui): dialog: add file picker dialog with image preview

Ayman Bagabas created

Change summary

go.mod                           |   3 
go.sum                           |   6 
internal/ui/dialog/common.go     |  21 +
internal/ui/dialog/filepicker.go | 292 ++++++++++++++++++++++++++++++++++
internal/ui/image/image.go       | 164 +++++++++++++++++++
internal/ui/model/ui.go          |  40 ++++
internal/ui/styles/styles.go     |   4 
7 files changed, 521 insertions(+), 9 deletions(-)

Detailed changes

go.mod 🔗

@@ -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

go.sum 🔗

@@ -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=

internal/ui/dialog/common.go 🔗

@@ -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)
 }

internal/ui/dialog/filepicker.go 🔗

@@ -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
+}

internal/ui/image/image.go 🔗

@@ -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 ""
+	}
+}

internal/ui/model/ui.go 🔗

@@ -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
 }

internal/ui/styles/styles.go 🔗

@@ -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