Merge pull request #1813 from charmbracelet/ui-picker

Ayman Bagabas created

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

Change summary

go.mod                           |   2 
go.sum                           |   5 
internal/ui/common/common.go     |  23 ++
internal/ui/dialog/actions.go    |  57 ++++++
internal/ui/dialog/commands.go   |  23 +-
internal/ui/dialog/common.go     | 108 ++++++++++-
internal/ui/dialog/filepicker.go | 302 ++++++++++++++++++++++++++++++++++
internal/ui/dialog/models.go     |  23 +-
internal/ui/dialog/sessions.go   |  17 -
internal/ui/image/image.go       | 284 +++++++++++++++++++++++++++++++
internal/ui/model/ui.go          |  63 +++++-
internal/ui/styles/styles.go     |   4 
12 files changed, 850 insertions(+), 61 deletions(-)

Detailed changes

go.mod 🔗

@@ -30,10 +30,12 @@ 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-20260113142046-c1fa3de7983b
 	github.com/charmbracelet/x/term v0.2.2
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
+	github.com/disintegration/imaging v1.6.2
 	github.com/dustin/go-humanize v1.0.1
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0

go.sum 🔗

@@ -120,6 +120,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-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -148,6 +150,8 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G
 github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -393,6 +397,7 @@ 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.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 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=

internal/ui/common/common.go 🔗

@@ -1,7 +1,9 @@
 package common
 
 import (
+	"fmt"
 	"image"
+	"os"
 
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
@@ -9,6 +11,12 @@ import (
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
+// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB).
+const MaxAttachmentSize = int64(5 * 1024 * 1024)
+
+// AllowedImageTypes defines the permitted image file types.
+var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
+
 // Common defines common UI options and configurations.
 type Common struct {
 	App    *app.App
@@ -40,3 +48,18 @@ func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
 	maxY := minY + height
 	return image.Rect(minX, minY, maxX, maxY)
 }
+
+// IsFileTooBig checks if the file at the given path exceeds the specified size
+// limit.
+func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		return false, fmt.Errorf("error getting file info: %w", err)
+	}
+
+	if fileInfo.Size() > sizeLimit {
+		return true, nil
+	}
+
+	return false, nil
+}

internal/ui/dialog/actions.go 🔗

@@ -1,13 +1,21 @@
 package dialog
 
 import (
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
 )
 
 // ActionClose is a message to close the current dialog.
@@ -102,3 +110,52 @@ type (
 type ActionCmd struct {
 	Cmd tea.Cmd
 }
+
+// ActionFilePickerSelected is a message indicating a file has been selected in
+// the file picker dialog.
+type ActionFilePickerSelected struct {
+	Path string
+}
+
+// Cmd returns a command that reads the file at path and sends a
+// [message.Attachement] to the program.
+func (a ActionFilePickerSelected) Cmd() tea.Cmd {
+	path := a.Path
+	if path == "" {
+		return nil
+	}
+	return func() tea.Msg {
+		isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize)
+		if err != nil {
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  fmt.Sprintf("unable to read the image: %v", err),
+			}
+		}
+		if isFileLarge {
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  "file too large, max 5MB",
+			}
+		}
+
+		content, err := os.ReadFile(path)
+		if err != nil {
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  fmt.Sprintf("unable to read the image: %v", err),
+			}
+		}
+
+		mimeBufferSize := min(512, len(content))
+		mimeType := http.DetectContentType(content[:mimeBufferSize])
+		fileName := filepath.Base(path)
+
+		return message.Attachment{
+			FilePath: path,
+			FileName: fileName,
+			MimeType: mimeType,
+			Content:  content,
+		}
+	}
+}

internal/ui/dialog/commands.go 🔗

@@ -9,7 +9,6 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
@@ -17,7 +16,6 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	uv "github.com/charmbracelet/ultraviolet"
-	"github.com/charmbracelet/x/ansi"
 )
 
 // CommandsID is the identifier for the commands dialog.
@@ -247,6 +245,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		// we need to reset the command items when width changes
 		c.setCommandItems(c.selected)
 	}
+
 	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
 	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
 		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -257,18 +256,20 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	c.list.SetSize(innerWidth, height-heightOffset)
 	c.help.SetWidth(innerWidth)
 
-	radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
-	titleStyle := t.Dialog.Title
-	dialogStyle := t.Dialog.View.Width(width)
-	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
-	helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
-	header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
+	rc := NewRenderContext(t, width)
+	rc.Title = "Commands"
+	rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
+	inputView := t.Dialog.InputPrompt.Render(c.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
+	rc.AddPart(listView)
+	rc.Help = c.help.View(c)
 
 	if c.loading {
-		helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...")
+		rc.Help = c.spinner.View() + " Generating Prompt..."
 	}
-	view := HeaderInputListHelpView(t, width, c.list.Height(), header,
-		c.input.View(), c.list.Render(), helpView)
+
+	view := rc.Render()
 
 	cur := c.Cursor()
 	DrawCenterCursor(scr, area, view, cur)

internal/ui/dialog/common.go 🔗

@@ -4,7 +4,10 @@ import (
 	"strings"
 
 	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
 )
 
 // InputCursor adjusts the cursor position for an input field within a dialog.
@@ -34,23 +37,94 @@ func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
 	return cur
 }
 
-// HeaderInputListHelpView generates a view for dialogs with a header, input,
-// list, and help sections.
-func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string {
-	titleStyle := t.Dialog.Title
-	helpStyle := t.Dialog.HelpView
-	dialogStyle := t.Dialog.View.Width(width)
-	inputStyle := t.Dialog.InputPrompt
-	helpStyle = helpStyle.Width(width - dialogStyle.GetHorizontalFrameSize())
-	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")
+// RenderContext is a dialog rendering context that can be used to render
+// common dialog layouts.
+type RenderContext struct {
+	// Styles is the styles to use for rendering.
+	Styles *styles.Styles
+	// Width is the total width of the dialog including any margins, borders,
+	// and paddings.
+	Width int
+	// Gap is the gap between content parts. Zero means no gap.
+	Gap int
+	// Title is the title of the dialog. This will be styled using the default
+	// dialog title style and prepended to the content parts slice.
+	Title string
+	// TitleInfo is additional information to display next to the title. This
+	// part is displayed as is, any styling must be applied before setting this
+	// field.
+	TitleInfo string
+	// Parts are the rendered parts of the dialog.
+	Parts []string
+	// Help is the help view content. This will be appended to the content parts
+	// slice using the default dialog help style.
+	Help string
+}
+
+// NewRenderContext creates a new RenderContext with the provided styles and width.
+func NewRenderContext(t *styles.Styles, width int) *RenderContext {
+	return &RenderContext{
+		Styles: t,
+		Width:  width,
+		Parts:  []string{},
+	}
+}
+
+// AddPart adds a rendered part to the dialog.
+func (rc *RenderContext) AddPart(part string) {
+	if len(part) > 0 {
+		rc.Parts = append(rc.Parts, part)
+	}
+}
+
+// Render renders the dialog using the provided context.
+func (rc *RenderContext) Render() string {
+	titleStyle := rc.Styles.Dialog.Title
+	dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
+
+	parts := []string{}
+	if len(rc.Title) > 0 {
+		var titleInfoWidth int
+		if len(rc.TitleInfo) > 0 {
+			titleInfoWidth = lipgloss.Width(rc.TitleInfo)
+		}
+		title := common.DialogTitle(rc.Styles, rc.Title,
+			max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
+				titleStyle.GetHorizontalFrameSize()-
+				titleInfoWidth))
+		if len(rc.TitleInfo) > 0 {
+			title += rc.TitleInfo
+		}
+		parts = append(parts, titleStyle.Render(title))
+		if rc.Gap > 0 {
+			parts = append(parts, make([]string, rc.Gap)...)
+		}
+	}
+
+	if rc.Gap <= 0 {
+		parts = append(parts, rc.Parts...)
+	} else {
+		for i, p := range rc.Parts {
+			if len(p) > 0 {
+				parts = append(parts, p)
+			}
+			if i < len(rc.Parts)-1 {
+				parts = append(parts, make([]string, rc.Gap)...)
+			}
+		}
+	}
+
+	if len(rc.Help) > 0 {
+		if rc.Gap > 0 {
+			parts = append(parts, make([]string, rc.Gap)...)
+		}
+		helpStyle := rc.Styles.Dialog.HelpView
+		helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
+		helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "")
+		parts = append(parts, helpView)
+	}
+
+	content := strings.Join(parts, "\n")
 
 	return dialogStyle.Render(content)
 }

internal/ui/dialog/filepicker.go 🔗

@@ -0,0 +1,302 @@
+package dialog
+
+import (
+	"fmt"
+	"image"
+	_ "image/jpeg" // register JPEG format
+	_ "image/png"  // register PNG format
+	"os"
+	"strings"
+	"sync"
+
+	"charm.land/bubbles/v2/filepicker"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/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"
+)
+
+// 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
+
+	imgEnc                      fimage.Encoding
+	imgPrevWidth, imgPrevHeight int
+	cellSize                    fimage.CellSize
+
+	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, tea.Cmd) {
+	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 = common.AllowedImageTypes
+	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, f.fp.Init()
+}
+
+// SetImageCapabilities sets the image capabilities for the [FilePicker].
+func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
+	if caps != nil {
+		if caps.SupportsKittyGraphics {
+			f.imgEnc = fimage.EncodingKitty
+		}
+		f.cellSize = caps.CellSize()
+	}
+}
+
+// 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
+}
+
+// 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
+}
+
+// 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 && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
+			f.previewingImage = false
+			img, err := loadImage(selFile)
+			if err == nil {
+				cmds = append(cmds, tea.Sequence(
+					f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight),
+					func() tea.Msg {
+						f.previewingImage = true
+						return nil
+					},
+				))
+			}
+		}
+	}
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
+		return ActionFilePickerSelected{Path: path}
+	}
+
+	return ActionCmd{tea.Batch(cmds...)}
+}
+
+const (
+	filePickerMinWidth  = 70
+	filePickerMinHeight = 10
+)
+
+// Draw renders the [FilePicker] dialog as a string.
+func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	width := max(0, min(filePickerMinWidth, area.Dx()))
+	height := max(0, min(10, area.Dy()))
+	innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
+	imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
+	imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
+	f.imgPrevWidth = imgPrevWidth
+	f.imgPrevHeight = imgPrevHeight
+	f.fp.SetHeight(height)
+
+	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
+
+	t := f.com.Styles
+	rc := NewRenderContext(t, width)
+	rc.Gap = 1
+	rc.Title = "Add Image"
+	rc.Help = f.help.View(f)
+
+	imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
+	rc.AddPart(imgPreview)
+
+	files := strings.TrimSpace(f.fp.View())
+	rc.AddPart(files)
+
+	view := rc.Render()
+
+	DrawCenter(scr, area, view)
+	return nil
+}
+
+var (
+	imagePreviewCache = map[string]string{}
+	imagePreviewMutex sync.RWMutex
+)
+
+// imagePreview returns the image preview section of the [FilePicker] dialog.
+func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
+	if !f.previewingImage {
+		key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
+		imagePreviewMutex.RLock()
+		cached, ok := imagePreviewCache[key]
+		imagePreviewMutex.RUnlock()
+		if ok {
+			return cached
+		}
+
+		var sb strings.Builder
+		for y := range imgPrevHeight {
+			for range imgPrevWidth {
+				sb.WriteRune('█')
+			}
+			if y < imgPrevHeight-1 {
+				sb.WriteRune('\n')
+			}
+		}
+
+		imagePreviewMutex.Lock()
+		imagePreviewCache[key] = sb.String()
+		imagePreviewMutex.Unlock()
+
+		return sb.String()
+	}
+
+	if id := f.fp.HighlightedPath(); id != "" {
+		r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
+		return r
+	}
+
+	return ""
+}
+
+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/dialog/models.go 🔗

@@ -10,13 +10,11 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/uiutil"
 	uv "github.com/charmbracelet/ultraviolet"
-	"github.com/charmbracelet/x/ansi"
 )
 
 // ModelType represents the type of model to select.
@@ -253,19 +251,16 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	m.list.SetSize(innerWidth, height-heightOffset)
 	m.help.SetWidth(innerWidth)
 
-	titleStyle := t.Dialog.Title
-	dialogStyle := t.Dialog.View
+	rc := NewRenderContext(t, width)
+	rc.Title = "Switch Model"
+	rc.TitleInfo = m.modelTypeRadioView()
+	inputView := t.Dialog.InputPrompt.Render(m.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
+	rc.AddPart(listView)
+	rc.Help = m.help.View(m)
 
-	radios := m.modelTypeRadioView()
-
-	headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
-		dialogStyle.GetHorizontalFrameSize()
-
-	header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios
-
-	helpView := ansi.Truncate(m.help.View(m), innerWidth, "")
-	view := HeaderInputListHelpView(t, width, m.list.Height(), header,
-		m.input.View(), m.list.Render(), helpView)
+	view := rc.Render()
 
 	cur := m.Cursor()
 	DrawCenterCursor(scr, area, view, cur)

internal/ui/dialog/sessions.go 🔗

@@ -10,7 +10,6 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	uv "github.com/charmbracelet/ultraviolet"
-	"github.com/charmbracelet/x/ansi"
 )
 
 // SessionsID is the identifier for the session selector dialog.
@@ -154,15 +153,15 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	s.list.SetSize(innerWidth, height-heightOffset)
 	s.help.SetWidth(innerWidth)
 
-	titleStyle := s.com.Styles.Dialog.Title
-	dialogStyle := s.com.Styles.Dialog.View.Width(width)
-	header := common.DialogTitle(s.com.Styles, "Switch Session",
-		max(0, width-dialogStyle.GetHorizontalFrameSize()-
-			titleStyle.GetHorizontalFrameSize()))
+	rc := NewRenderContext(t, width)
+	rc.Title = "Switch Session"
+	inputView := t.Dialog.InputPrompt.Render(s.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
+	rc.AddPart(listView)
+	rc.Help = s.help.View(s)
 
-	helpView := ansi.Truncate(s.help.View(s), innerWidth, "")
-	view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header,
-		s.input.View(), s.list.Render(), helpView)
+	view := rc.Render()
 
 	cur := s.Cursor()
 	DrawCenterCursor(scr, area, view, cur)

internal/ui/image/image.go 🔗

@@ -0,0 +1,284 @@
+package image
+
+import (
+	"bytes"
+	"fmt"
+	"hash/fnv"
+	"image"
+	"image/color"
+	"io"
+	"log/slog"
+	"strings"
+	"sync"
+
+	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"
+	"github.com/disintegration/imaging"
+)
+
+// 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
+}
+
+// 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() tea.Cmd {
+	return tea.Raw(
+		ansi.WindowOp(14) + // Window size in pixels
+			// 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"),
+	)
+}
+
+// TransmittedMsg is a message indicating that an image has been transmitted to
+// the terminal.
+type TransmittedMsg struct {
+	ID string
+}
+
+// Encoding represents the encoding format of the image.
+type Encoding byte
+
+// Image encodings.
+const (
+	EncodingBlocks Encoding = iota
+	EncodingKitty
+)
+
+type imageKey struct {
+	id   string
+	cols int
+	rows int
+}
+
+// Hash returns a hash value for the image key.
+// This uses FNV-32a for simplicity and speed.
+func (k imageKey) Hash() uint32 {
+	h := fnv.New32a()
+	_, _ = io.WriteString(h, k.ID())
+	return h.Sum32()
+}
+
+// ID returns a unique string representation of the image key.
+func (k imageKey) ID() string {
+	return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows)
+}
+
+// CellSize represents the size of a single terminal cell in pixels.
+type CellSize struct {
+	Width, Height int
+}
+
+type cachedImage struct {
+	img        image.Image
+	cols, rows int
+}
+
+var (
+	cachedImages = map[imageKey]cachedImage{}
+	cachedMutex  sync.RWMutex
+)
+
+// fitImage resizes the image to fit within the specified dimensions in
+// terminal cells, maintaining the aspect ratio.
+func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
+	if img == nil {
+		return nil
+	}
+
+	key := imageKey{id: id, cols: cols, rows: rows}
+
+	cachedMutex.RLock()
+	cached, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	if ok {
+		return cached.img
+	}
+
+	if cs.Width == 0 || cs.Height == 0 {
+		return img
+	}
+
+	maxWidth := cols * cs.Width
+	maxHeight := rows * cs.Height
+
+	img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
+
+	cachedMutex.Lock()
+	cachedImages[key] = cachedImage{
+		img:  img,
+		cols: cols,
+		rows: rows,
+	}
+	cachedMutex.Unlock()
+
+	return img
+}
+
+// HasTransmitted checks if the image with the given ID has already been
+// transmitted to the terminal.
+func HasTransmitted(id string, cols, rows int) bool {
+	key := imageKey{id: id, cols: cols, rows: rows}
+
+	cachedMutex.RLock()
+	_, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	return ok
+}
+
+// Transmit transmits the image data to the terminal if needed. This is used to
+// cache the image on the terminal for later rendering.
+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd {
+	if img == nil {
+		return nil
+	}
+
+	key := imageKey{id: id, cols: cols, rows: rows}
+
+	cachedMutex.RLock()
+	_, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	if ok {
+		return nil
+	}
+
+	cmd := func() tea.Msg {
+		if e != EncodingKitty {
+			cachedMutex.Lock()
+			cachedImages[key] = cachedImage{
+				img:  img,
+				cols: cols,
+				rows: rows,
+			}
+			cachedMutex.Unlock()
+			return TransmittedMsg{ID: key.ID()}
+		}
+
+		var buf bytes.Buffer
+		img := fitImage(id, img, cs, cols, rows)
+		bounds := img.Bounds()
+		imgWidth := bounds.Dx()
+		imgHeight := bounds.Dy()
+
+		imgID := int(key.Hash())
+		if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
+			ID:               imgID,
+			Action:           kitty.TransmitAndPut,
+			Transmission:     kitty.Direct,
+			Format:           kitty.RGBA,
+			ImageWidth:       imgWidth,
+			ImageHeight:      imgHeight,
+			Columns:          cols,
+			Rows:             rows,
+			VirtualPlacement: true,
+			Quite:            1,
+		}); err != nil {
+			slog.Error("failed to encode image for kitty graphics", "err", err)
+			return uiutil.ReportError(fmt.Errorf("failed to encode image"))
+		}
+
+		return tea.RawMsg{Msg: buf.String()}
+	}
+
+	return cmd
+}
+
+// Render renders the given image within the specified dimensions using the
+// specified encoding.
+func (e Encoding) Render(id string, cols, rows int) string {
+	key := imageKey{id: id, cols: cols, rows: rows}
+	cachedMutex.RLock()
+	cached, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	if !ok {
+		return ""
+	}
+
+	img := cached.img
+
+	switch e {
+	case EncodingBlocks:
+		m := mosaic.New().Width(cols).Height(rows).Scale(1)
+		return strings.TrimSpace(m.Render(img))
+	case EncodingKitty:
+		// Build Kitty graphics unicode place holders
+		var fg color.Color
+		var extra int
+		var r, g, b int
+		hashedID := key.Hash()
+		id := int(hashedID)
+		extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
+
+		if id <= 255 {
+			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 := range rows {
+			// 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 < cols; x++ {
+				buf.WriteString(fgStyle)
+				buf.WriteRune(kitty.Placeholder)
+			}
+			if y < rows-1 {
+				buf.WriteByte('\n')
+			}
+		}
+
+		return buf.String()
+
+	default:
+		return ""
+	}
+}

internal/ui/model/ui.go 🔗

@@ -39,6 +39,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"
@@ -48,12 +49,6 @@ import (
 	"github.com/charmbracelet/x/editor"
 )
 
-// Max file size set to 5M.
-const maxAttachmentSize = int64(5 * 1024 * 1024)
-
-// Allowed image formats.
-var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
-
 // Compact mode breakpoints.
 const (
 	compactModeWidthBreakpoint  = 120
@@ -176,6 +171,9 @@ 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
@@ -272,6 +270,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())
 	}
 	// load the user commands async
 	cmds = append(cmds, m.loadCustomCommands())
@@ -414,6 +417,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.width, m.height = msg.Width, msg.Height
 		m.handleCompactMode(m.width, m.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() {
@@ -543,6 +548,16 @@ 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
 	default:
 		if m.dialog.HasDialogs() {
 			if cmd := m.handleDialogMsg(msg); cmd != nil {
@@ -974,6 +989,15 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			m.com.App.Permissions.Deny(msg.Permission)
 		}
 
+	case dialog.ActionFilePickerSelected:
+		cmds = append(cmds, tea.Sequence(
+			msg.Cmd(),
+			func() tea.Msg {
+				m.dialog.CloseDialog(dialog.FilePickerID)
+				return nil
+			},
+		))
+
 	case dialog.ActionRunCustomCommand:
 		if len(msg.Arguments) > 0 && msg.Args == nil {
 			m.dialog.CloseFrontDialog()
@@ -1140,6 +1164,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 {
@@ -2305,10 +2334,24 @@ 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
+	}
+
+	filePicker, cmd := dialog.NewFilePicker(m.com)
+	filePicker.SetImageCapabilities(&m.imgCaps)
+	m.dialog.OpenDialog(filePicker)
+
+	return cmd
+}
+
 // openPermissionsDialog opens the permissions dialog for a permission request.
 func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
 	// Close any existing permissions dialog first.
@@ -2371,7 +2414,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 	if strings.Count(msg.Content, "\n") > 2 {
 		return func() tea.Msg {
 			content := []byte(msg.Content)
-			if int64(len(content)) > maxAttachmentSize {
+			if int64(len(content)) > common.MaxAttachmentSize {
 				return uiutil.ReportWarn("Paste is too big (>5mb)")
 			}
 			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
@@ -2398,7 +2441,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 	// Check if file has an allowed image extension.
 	isAllowedType := false
 	lowerPath := strings.ToLower(path)
-	for _, ext := range allowedImageTypes {
+	for _, ext := range common.AllowedImageTypes {
 		if strings.HasSuffix(lowerPath, ext) {
 			isAllowedType = true
 			break
@@ -2414,7 +2457,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 		if err != nil {
 			return uiutil.ReportError(err)
 		}
-		if fileInfo.Size() > maxAttachmentSize {
+		if fileInfo.Size() > common.MaxAttachmentSize {
 			return uiutil.ReportWarn("File is too big (>5mb)")
 		}
 

internal/ui/styles/styles.go 🔗

@@ -355,6 +355,8 @@ type Styles struct {
 		}
 
 		Commands struct{}
+
+		ImagePreview lipgloss.Style
 	}
 
 	// Status bar and help
@@ -1227,6 +1229,8 @@ func DefaultStyles() Styles {
 	s.Dialog.ScrollbarThumb = base.Foreground(secondary)
 	s.Dialog.ScrollbarTrack = base.Foreground(border)
 
+	s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle)
+
 	s.Dialog.Arguments.Content = base.Padding(1)
 	s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3)
 	s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted)