on main help sign in
Merge pull request #1813 from charmbracelet/ui-picker

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

commit181b04757b6f4563ca28f9d9b06172e2f8e50425 authorAyman Bagabas <ayman.bagabas@gmail.com> dateFri 16 Jan 2026, 16:52 (9 months ago) parent8b5cc43f97faa1
12 files changed+850−61
go.mod+2 go.sum+5 internal/ui/common/common.go+23 internal/ui/dialog/actions.go+57 internal/ui/dialog/commands.go+12−11 internal/ui/dialog/common.go+91−17 internal/ui/dialog/filepicker.go+302 internal/ui/dialog/models.go+9−14 internal/ui/dialog/sessions.go+8−9 internal/ui/image/image.go+284 internal/ui/model/ui.go+53−10 internal/ui/styles/styles.go+4
go.mod+2
@@ -30,10 +30,12 @@ require (
3030 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
3131 github.com/charmbracelet/x/exp/ordered v0.1.0
3232 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
33+ github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383
3334 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
3435 github.com/charmbracelet/x/term v0.2.2
3536 github.com/denisbrodbeck/machineid v1.0.1
3637 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
38+ github.com/disintegration/imaging v1.6.2
3739 github.com/dustin/go-humanize v1.0.1
3840 github.com/google/uuid v1.6.0
3941 github.com/invopop/jsonschema v0.13.0
go.sum+5
@@ -120,6 +120,8 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/
120120github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
121121github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
122122github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
123+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y=
124+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY=
123125github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
124126github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
125127github.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
148150github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
149151github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
150152github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
153+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
154+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
151155github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
152156github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
153157github.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=
393397golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
394398golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
395399golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
400+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
396401golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
397402golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
398403golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
internal/ui/common/common.go+23
@@ -1,7 +1,9 @@
11package common
22
33import (
4+ "fmt"
45 "image"
6+ "os"
57
68 "github.com/charmbracelet/crush/internal/app"
79 "github.com/charmbracelet/crush/internal/config"
@@ -9,6 +11,12 @@ import (
911 uv "github.com/charmbracelet/ultraviolet"
1012)
1113
14+// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB).
15+const MaxAttachmentSize = int64(5 * 1024 * 1024)
16+
17+// AllowedImageTypes defines the permitted image file types.
18+var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
19+
1220// Common defines common UI options and configurations.
1321type Common struct {
1422 App *app.App
@@ -40,3 +48,18 @@ func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
4048 maxY := minY + height
4149 return image.Rect(minX, minY, maxX, maxY)
4250}
51+
52+// IsFileTooBig checks if the file at the given path exceeds the specified size
53+// limit.
54+func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
55+ fileInfo, err := os.Stat(filePath)
56+ if err != nil {
57+ return false, fmt.Errorf("error getting file info: %w", err)
58+ }
59+
60+ if fileInfo.Size() > sizeLimit {
61+ return true, nil
62+ }
63+
64+ return false, nil
65+}
internal/ui/dialog/actions.go+57
@@ -1,13 +1,21 @@
11package dialog
22
33import (
4+ "fmt"
5+ "net/http"
6+ "os"
7+ "path/filepath"
8+
49 tea "charm.land/bubbletea/v2"
510 "github.com/charmbracelet/catwalk/pkg/catwalk"
611 "github.com/charmbracelet/crush/internal/commands"
712 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/message"
814 "github.com/charmbracelet/crush/internal/oauth"
915 "github.com/charmbracelet/crush/internal/permission"
1016 "github.com/charmbracelet/crush/internal/session"
17+ "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/uiutil"
1119)
1220
1321// ActionClose is a message to close the current dialog.
@@ -102,3 +110,52 @@ type (
102110type ActionCmd struct {
103111 Cmd tea.Cmd
104112}
113+
114+// ActionFilePickerSelected is a message indicating a file has been selected in
115+// the file picker dialog.
116+type ActionFilePickerSelected struct {
117+ Path string
118+}
119+
120+// Cmd returns a command that reads the file at path and sends a
121+// [message.Attachement] to the program.
122+func (a ActionFilePickerSelected) Cmd() tea.Cmd {
123+ path := a.Path
124+ if path == "" {
125+ return nil
126+ }
127+ return func() tea.Msg {
128+ isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize)
129+ if err != nil {
130+ return uiutil.InfoMsg{
131+ Type: uiutil.InfoTypeError,
132+ Msg: fmt.Sprintf("unable to read the image: %v", err),
133+ }
134+ }
135+ if isFileLarge {
136+ return uiutil.InfoMsg{
137+ Type: uiutil.InfoTypeError,
138+ Msg: "file too large, max 5MB",
139+ }
140+ }
141+
142+ content, err := os.ReadFile(path)
143+ if err != nil {
144+ return uiutil.InfoMsg{
145+ Type: uiutil.InfoTypeError,
146+ Msg: fmt.Sprintf("unable to read the image: %v", err),
147+ }
148+ }
149+
150+ mimeBufferSize := min(512, len(content))
151+ mimeType := http.DetectContentType(content[:mimeBufferSize])
152+ fileName := filepath.Base(path)
153+
154+ return message.Attachment{
155+ FilePath: path,
156+ FileName: fileName,
157+ MimeType: mimeType,
158+ Content: content,
159+ }
160+ }
161+}
internal/ui/dialog/commands.go+12−11
@@ -9,7 +9,6 @@ import (
99 "charm.land/bubbles/v2/spinner"
1010 "charm.land/bubbles/v2/textinput"
1111 tea "charm.land/bubbletea/v2"
12− "charm.land/lipgloss/v2"
1312 "github.com/charmbracelet/catwalk/pkg/catwalk"
1413 "github.com/charmbracelet/crush/internal/commands"
1514 "github.com/charmbracelet/crush/internal/config"
@@ -17,7 +16,6 @@ import (
1716 "github.com/charmbracelet/crush/internal/ui/list"
1817 "github.com/charmbracelet/crush/internal/ui/styles"
1918 uv "github.com/charmbracelet/ultraviolet"
20− "github.com/charmbracelet/x/ansi"
2119)
2220
2321// CommandsID is the identifier for the commands dialog.
@@ -247,6 +245,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
247245 // we need to reset the command items when width changes
248246 c.setCommandItems(c.selected)
249247 }
248+
250249 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
251250 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
252251 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -257,18 +256,20 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
257256 c.list.SetSize(innerWidth, height-heightOffset)
258257 c.help.SetWidth(innerWidth)
259258
260− radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
261− titleStyle := t.Dialog.Title
262− dialogStyle := t.Dialog.View.Width(width)
263− headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
264− helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
265− header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
259+ rc := NewRenderContext(t, width)
260+ rc.Title = "Commands"
261+ rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
262+ inputView := t.Dialog.InputPrompt.Render(c.input.View())
263+ rc.AddPart(inputView)
264+ listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
265+ rc.AddPart(listView)
266+ rc.Help = c.help.View(c)
266267
267268 if c.loading {
268− helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...")
269+ rc.Help = c.spinner.View() + " Generating Prompt..."
269270 }
270− view := HeaderInputListHelpView(t, width, c.list.Height(), header,
271− c.input.View(), c.list.Render(), helpView)
271+
272+ view := rc.Render()
272273
273274 cur := c.Cursor()
274275 DrawCenterCursor(scr, area, view, cur)
internal/ui/dialog/common.go+91−17
@@ -4,7 +4,10 @@ import (
44 "strings"
55
66 tea "charm.land/bubbletea/v2"
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/ui/common"
79 "github.com/charmbracelet/crush/internal/ui/styles"
10+ "github.com/charmbracelet/x/ansi"
811)
912
1013// 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 {
3437 return cur
3538}
3639
37−// HeaderInputListHelpView generates a view for dialogs with a header, input,
38−// list, and help sections.
39−func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string {
40− titleStyle := t.Dialog.Title
41− helpStyle := t.Dialog.HelpView
42− dialogStyle := t.Dialog.View.Width(width)
43− inputStyle := t.Dialog.InputPrompt
44− helpStyle = helpStyle.Width(width - dialogStyle.GetHorizontalFrameSize())
45− listStyle := t.Dialog.List.Height(listHeight)
46− listContent := listStyle.Render(list)
47−
48− content := strings.Join([]string{
49− titleStyle.Render(header),
50− inputStyle.Render(input),
51− listContent,
52− helpStyle.Render(help),
53− }, "\n")
40+// RenderContext is a dialog rendering context that can be used to render
41+// common dialog layouts.
42+type RenderContext struct {
43+ // Styles is the styles to use for rendering.
44+ Styles *styles.Styles
45+ // Width is the total width of the dialog including any margins, borders,
46+ // and paddings.
47+ Width int
48+ // Gap is the gap between content parts. Zero means no gap.
49+ Gap int
50+ // Title is the title of the dialog. This will be styled using the default
51+ // dialog title style and prepended to the content parts slice.
52+ Title string
53+ // TitleInfo is additional information to display next to the title. This
54+ // part is displayed as is, any styling must be applied before setting this
55+ // field.
56+ TitleInfo string
57+ // Parts are the rendered parts of the dialog.
58+ Parts []string
59+ // Help is the help view content. This will be appended to the content parts
60+ // slice using the default dialog help style.
61+ Help string
62+}
63+
64+// NewRenderContext creates a new RenderContext with the provided styles and width.
65+func NewRenderContext(t *styles.Styles, width int) *RenderContext {
66+ return &RenderContext{
67+ Styles: t,
68+ Width: width,
69+ Parts: []string{},
70+ }
71+}
72+
73+// AddPart adds a rendered part to the dialog.
74+func (rc *RenderContext) AddPart(part string) {
75+ if len(part) > 0 {
76+ rc.Parts = append(rc.Parts, part)
77+ }
78+}
79+
80+// Render renders the dialog using the provided context.
81+func (rc *RenderContext) Render() string {
82+ titleStyle := rc.Styles.Dialog.Title
83+ dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
84+
85+ parts := []string{}
86+ if len(rc.Title) > 0 {
87+ var titleInfoWidth int
88+ if len(rc.TitleInfo) > 0 {
89+ titleInfoWidth = lipgloss.Width(rc.TitleInfo)
90+ }
91+ title := common.DialogTitle(rc.Styles, rc.Title,
92+ max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
93+ titleStyle.GetHorizontalFrameSize()-
94+ titleInfoWidth))
95+ if len(rc.TitleInfo) > 0 {
96+ title += rc.TitleInfo
97+ }
98+ parts = append(parts, titleStyle.Render(title))
99+ if rc.Gap > 0 {
100+ parts = append(parts, make([]string, rc.Gap)...)
101+ }
102+ }
103+
104+ if rc.Gap <= 0 {
105+ parts = append(parts, rc.Parts...)
106+ } else {
107+ for i, p := range rc.Parts {
108+ if len(p) > 0 {
109+ parts = append(parts, p)
110+ }
111+ if i < len(rc.Parts)-1 {
112+ parts = append(parts, make([]string, rc.Gap)...)
113+ }
114+ }
115+ }
116+
117+ if len(rc.Help) > 0 {
118+ if rc.Gap > 0 {
119+ parts = append(parts, make([]string, rc.Gap)...)
120+ }
121+ helpStyle := rc.Styles.Dialog.HelpView
122+ helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
123+ helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "")
124+ parts = append(parts, helpView)
125+ }
126+
127+ content := strings.Join(parts, "\n")
54128
55129 return dialogStyle.Render(content)
56130}
internal/ui/dialog/filepicker.gonew file+302
@@ -0,0 +1,302 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "image"
6+ _ "image/jpeg" // register JPEG format
7+ _ "image/png" // register PNG format
8+ "os"
9+ "strings"
10+ "sync"
11+
12+ "charm.land/bubbles/v2/filepicker"
13+ "charm.land/bubbles/v2/help"
14+ "charm.land/bubbles/v2/key"
15+ tea "charm.land/bubbletea/v2"
16+ "charm.land/lipgloss/v2"
17+ "github.com/charmbracelet/crush/internal/home"
18+ "github.com/charmbracelet/crush/internal/ui/common"
19+ fimage "github.com/charmbracelet/crush/internal/ui/image"
20+ uv "github.com/charmbracelet/ultraviolet"
21+)
22+
23+// FilePickerID is the identifier for the FilePicker dialog.
24+const FilePickerID = "filepicker"
25+
26+// FilePicker is a dialog that allows users to select files or directories.
27+type FilePicker struct {
28+ com *common.Common
29+
30+ imgEnc fimage.Encoding
31+ imgPrevWidth, imgPrevHeight int
32+ cellSize fimage.CellSize
33+
34+ fp filepicker.Model
35+ help help.Model
36+ previewingImage bool // indicates if an image is being previewed
37+
38+ km struct {
39+ Select,
40+ Down,
41+ Up,
42+ Forward,
43+ Backward,
44+ Navigate,
45+ Close key.Binding
46+ }
47+}
48+
49+var _ Dialog = (*FilePicker)(nil)
50+
51+// NewFilePicker creates a new [FilePicker] dialog.
52+func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
53+ f := new(FilePicker)
54+ f.com = com
55+
56+ help := help.New()
57+ help.Styles = com.Styles.DialogHelpStyles()
58+
59+ f.help = help
60+
61+ f.km.Select = key.NewBinding(
62+ key.WithKeys("enter"),
63+ key.WithHelp("enter", "accept"),
64+ )
65+ f.km.Down = key.NewBinding(
66+ key.WithKeys("down", "j"),
67+ key.WithHelp("down/j", "move down"),
68+ )
69+ f.km.Up = key.NewBinding(
70+ key.WithKeys("up", "k"),
71+ key.WithHelp("up/k", "move up"),
72+ )
73+ f.km.Forward = key.NewBinding(
74+ key.WithKeys("right", "l"),
75+ key.WithHelp("right/l", "move forward"),
76+ )
77+ f.km.Backward = key.NewBinding(
78+ key.WithKeys("left", "h"),
79+ key.WithHelp("left/h", "move backward"),
80+ )
81+ f.km.Navigate = key.NewBinding(
82+ key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
83+ key.WithHelp("↑↓←→", "navigate"),
84+ )
85+ f.km.Close = key.NewBinding(
86+ key.WithKeys("esc", "alt+esc"),
87+ key.WithHelp("esc", "close/exit"),
88+ )
89+
90+ fp := filepicker.New()
91+ fp.AllowedTypes = common.AllowedImageTypes
92+ fp.ShowPermissions = false
93+ fp.ShowSize = false
94+ fp.AutoHeight = false
95+ fp.Styles = com.Styles.FilePicker
96+ fp.Cursor = ""
97+ fp.CurrentDirectory = f.WorkingDir()
98+
99+ f.fp = fp
100+
101+ return f, f.fp.Init()
102+}
103+
104+// SetImageCapabilities sets the image capabilities for the [FilePicker].
105+func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
106+ if caps != nil {
107+ if caps.SupportsKittyGraphics {
108+ f.imgEnc = fimage.EncodingKitty
109+ }
110+ f.cellSize = caps.CellSize()
111+ }
112+}
113+
114+// WorkingDir returns the current working directory of the [FilePicker].
115+func (f *FilePicker) WorkingDir() string {
116+ wd := f.com.Config().WorkingDir()
117+ if len(wd) > 0 {
118+ return wd
119+ }
120+
121+ cwd, err := os.Getwd()
122+ if err != nil {
123+ return home.Dir()
124+ }
125+
126+ return cwd
127+}
128+
129+// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
130+func (f *FilePicker) ShortHelp() []key.Binding {
131+ return []key.Binding{
132+ f.km.Navigate,
133+ f.km.Select,
134+ f.km.Close,
135+ }
136+}
137+
138+// FullHelp returns the full help key bindings for the [FilePicker] dialog.
139+func (f *FilePicker) FullHelp() [][]key.Binding {
140+ return [][]key.Binding{
141+ {
142+ f.km.Select,
143+ f.km.Down,
144+ f.km.Up,
145+ f.km.Forward,
146+ },
147+ {
148+ f.km.Backward,
149+ f.km.Close,
150+ },
151+ }
152+}
153+
154+// ID returns the identifier of the [FilePicker] dialog.
155+func (f *FilePicker) ID() string {
156+ return FilePickerID
157+}
158+
159+// HandleMsg updates the [FilePicker] dialog based on the given message.
160+func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
161+ var cmds []tea.Cmd
162+ switch msg := msg.(type) {
163+ case tea.KeyPressMsg:
164+ switch {
165+ case key.Matches(msg, f.km.Close):
166+ return ActionClose{}
167+ }
168+ }
169+
170+ var cmd tea.Cmd
171+ f.fp, cmd = f.fp.Update(msg)
172+ if selFile := f.fp.HighlightedPath(); selFile != "" {
173+ var allowed bool
174+ for _, allowedExt := range f.fp.AllowedTypes {
175+ if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
176+ allowed = true
177+ break
178+ }
179+ }
180+
181+ f.previewingImage = allowed
182+ if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
183+ f.previewingImage = false
184+ img, err := loadImage(selFile)
185+ if err == nil {
186+ cmds = append(cmds, tea.Sequence(
187+ f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight),
188+ func() tea.Msg {
189+ f.previewingImage = true
190+ return nil
191+ },
192+ ))
193+ }
194+ }
195+ }
196+ if cmd != nil {
197+ cmds = append(cmds, cmd)
198+ }
199+
200+ if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
201+ return ActionFilePickerSelected{Path: path}
202+ }
203+
204+ return ActionCmd{tea.Batch(cmds...)}
205+}
206+
207+const (
208+ filePickerMinWidth = 70
209+ filePickerMinHeight = 10
210+)
211+
212+// Draw renders the [FilePicker] dialog as a string.
213+func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
214+ width := max(0, min(filePickerMinWidth, area.Dx()))
215+ height := max(0, min(10, area.Dy()))
216+ innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
217+ imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
218+ imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
219+ f.imgPrevWidth = imgPrevWidth
220+ f.imgPrevHeight = imgPrevHeight
221+ f.fp.SetHeight(height)
222+
223+ styles := f.com.Styles.FilePicker
224+ styles.File = styles.File.Width(innerWidth)
225+ styles.Directory = styles.Directory.Width(innerWidth)
226+ styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
227+ styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
228+ f.fp.Styles = styles
229+
230+ t := f.com.Styles
231+ rc := NewRenderContext(t, width)
232+ rc.Gap = 1
233+ rc.Title = "Add Image"
234+ rc.Help = f.help.View(f)
235+
236+ imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
237+ rc.AddPart(imgPreview)
238+
239+ files := strings.TrimSpace(f.fp.View())
240+ rc.AddPart(files)
241+
242+ view := rc.Render()
243+
244+ DrawCenter(scr, area, view)
245+ return nil
246+}
247+
248+var (
249+ imagePreviewCache = map[string]string{}
250+ imagePreviewMutex sync.RWMutex
251+)
252+
253+// imagePreview returns the image preview section of the [FilePicker] dialog.
254+func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
255+ if !f.previewingImage {
256+ key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
257+ imagePreviewMutex.RLock()
258+ cached, ok := imagePreviewCache[key]
259+ imagePreviewMutex.RUnlock()
260+ if ok {
261+ return cached
262+ }
263+
264+ var sb strings.Builder
265+ for y := range imgPrevHeight {
266+ for range imgPrevWidth {
267+ sb.WriteRune('█')
268+ }
269+ if y < imgPrevHeight-1 {
270+ sb.WriteRune('\n')
271+ }
272+ }
273+
274+ imagePreviewMutex.Lock()
275+ imagePreviewCache[key] = sb.String()
276+ imagePreviewMutex.Unlock()
277+
278+ return sb.String()
279+ }
280+
281+ if id := f.fp.HighlightedPath(); id != "" {
282+ r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
283+ return r
284+ }
285+
286+ return ""
287+}
288+
289+func loadImage(path string) (img image.Image, err error) {
290+ file, err := os.Open(path)
291+ if err != nil {
292+ return nil, err
293+ }
294+ defer file.Close()
295+
296+ img, _, err = image.Decode(file)
297+ if err != nil {
298+ return nil, err
299+ }
300+
301+ return img, nil
302+}
internal/ui/dialog/models.go+9−14
@@ -10,13 +10,11 @@ import (
1010 "charm.land/bubbles/v2/key"
1111 "charm.land/bubbles/v2/textinput"
1212 tea "charm.land/bubbletea/v2"
13− "charm.land/lipgloss/v2"
1413 "github.com/charmbracelet/catwalk/pkg/catwalk"
1514 "github.com/charmbracelet/crush/internal/config"
1615 "github.com/charmbracelet/crush/internal/ui/common"
1716 "github.com/charmbracelet/crush/internal/uiutil"
1817 uv "github.com/charmbracelet/ultraviolet"
19− "github.com/charmbracelet/x/ansi"
2018)
2119
2220// ModelType represents the type of model to select.
@@ -253,19 +251,16 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
253251 m.list.SetSize(innerWidth, height-heightOffset)
254252 m.help.SetWidth(innerWidth)
255253
256− titleStyle := t.Dialog.Title
257− dialogStyle := t.Dialog.View
254+ rc := NewRenderContext(t, width)
255+ rc.Title = "Switch Model"
256+ rc.TitleInfo = m.modelTypeRadioView()
257+ inputView := t.Dialog.InputPrompt.Render(m.input.View())
258+ rc.AddPart(inputView)
259+ listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
260+ rc.AddPart(listView)
261+ rc.Help = m.help.View(m)
258262
259− radios := m.modelTypeRadioView()
260−
261− headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
262− dialogStyle.GetHorizontalFrameSize()
263−
264− header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios
265−
266− helpView := ansi.Truncate(m.help.View(m), innerWidth, "")
267− view := HeaderInputListHelpView(t, width, m.list.Height(), header,
268− m.input.View(), m.list.Render(), helpView)
263+ view := rc.Render()
269264
270265 cur := m.Cursor()
271266 DrawCenterCursor(scr, area, view, cur)
internal/ui/dialog/sessions.go+8−9
@@ -10,7 +10,6 @@ import (
1010 "github.com/charmbracelet/crush/internal/ui/common"
1111 "github.com/charmbracelet/crush/internal/ui/list"
1212 uv "github.com/charmbracelet/ultraviolet"
13− "github.com/charmbracelet/x/ansi"
1413)
1514
1615// 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 {
154153 s.list.SetSize(innerWidth, height-heightOffset)
155154 s.help.SetWidth(innerWidth)
156155
157− titleStyle := s.com.Styles.Dialog.Title
158− dialogStyle := s.com.Styles.Dialog.View.Width(width)
159− header := common.DialogTitle(s.com.Styles, "Switch Session",
160− max(0, width-dialogStyle.GetHorizontalFrameSize()-
161− titleStyle.GetHorizontalFrameSize()))
156+ rc := NewRenderContext(t, width)
157+ rc.Title = "Switch Session"
158+ inputView := t.Dialog.InputPrompt.Render(s.input.View())
159+ rc.AddPart(inputView)
160+ listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
161+ rc.AddPart(listView)
162+ rc.Help = s.help.View(s)
162163
163− helpView := ansi.Truncate(s.help.View(s), innerWidth, "")
164− view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header,
165− s.input.View(), s.list.Render(), helpView)
164+ view := rc.Render()
166165
167166 cur := s.Cursor()
168167 DrawCenterCursor(scr, area, view, cur)
internal/ui/image/image.gonew file+284
@@ -0,0 +1,284 @@
1+package image
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "hash/fnv"
7+ "image"
8+ "image/color"
9+ "io"
10+ "log/slog"
11+ "strings"
12+ "sync"
13+
14+ tea "charm.land/bubbletea/v2"
15+ "github.com/charmbracelet/crush/internal/uiutil"
16+ "github.com/charmbracelet/x/ansi"
17+ "github.com/charmbracelet/x/ansi/kitty"
18+ "github.com/charmbracelet/x/mosaic"
19+ "github.com/disintegration/imaging"
20+)
21+
22+// Capabilities represents the capabilities of displaying images on the
23+// terminal.
24+type Capabilities struct {
25+ // Columns is the number of character columns in the terminal.
26+ Columns int
27+ // Rows is the number of character rows in the terminal.
28+ Rows int
29+ // PixelWidth is the width of the terminal in pixels.
30+ PixelWidth int
31+ // PixelHeight is the height of the terminal in pixels.
32+ PixelHeight int
33+ // SupportsKittyGraphics indicates whether the terminal supports the Kitty
34+ // graphics protocol.
35+ SupportsKittyGraphics bool
36+}
37+
38+// CellSize returns the size of a single terminal cell in pixels.
39+func (c Capabilities) CellSize() CellSize {
40+ return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
41+}
42+
43+// CalculateCellSize calculates the size of a single terminal cell in pixels
44+// based on the terminal's pixel dimensions and character dimensions.
45+func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
46+ if charWidth == 0 || charHeight == 0 {
47+ return CellSize{}
48+ }
49+
50+ return CellSize{
51+ Width: pixelWidth / charWidth,
52+ Height: pixelHeight / charHeight,
53+ }
54+}
55+
56+// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
57+// its image related capabilities to the program.
58+func RequestCapabilities() tea.Cmd {
59+ return tea.Raw(
60+ ansi.WindowOp(14) + // Window size in pixels
61+ // ID 31 is just a random ID used to detect Kitty graphics support.
62+ ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
63+ )
64+}
65+
66+// TransmittedMsg is a message indicating that an image has been transmitted to
67+// the terminal.
68+type TransmittedMsg struct {
69+ ID string
70+}
71+
72+// Encoding represents the encoding format of the image.
73+type Encoding byte
74+
75+// Image encodings.
76+const (
77+ EncodingBlocks Encoding = iota
78+ EncodingKitty
79+)
80+
81+type imageKey struct {
82+ id string
83+ cols int
84+ rows int
85+}
86+
87+// Hash returns a hash value for the image key.
88+// This uses FNV-32a for simplicity and speed.
89+func (k imageKey) Hash() uint32 {
90+ h := fnv.New32a()
91+ _, _ = io.WriteString(h, k.ID())
92+ return h.Sum32()
93+}
94+
95+// ID returns a unique string representation of the image key.
96+func (k imageKey) ID() string {
97+ return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows)
98+}
99+
100+// CellSize represents the size of a single terminal cell in pixels.
101+type CellSize struct {
102+ Width, Height int
103+}
104+
105+type cachedImage struct {
106+ img image.Image
107+ cols, rows int
108+}
109+
110+var (
111+ cachedImages = map[imageKey]cachedImage{}
112+ cachedMutex sync.RWMutex
113+)
114+
115+// fitImage resizes the image to fit within the specified dimensions in
116+// terminal cells, maintaining the aspect ratio.
117+func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
118+ if img == nil {
119+ return nil
120+ }
121+
122+ key := imageKey{id: id, cols: cols, rows: rows}
123+
124+ cachedMutex.RLock()
125+ cached, ok := cachedImages[key]
126+ cachedMutex.RUnlock()
127+ if ok {
128+ return cached.img
129+ }
130+
131+ if cs.Width == 0 || cs.Height == 0 {
132+ return img
133+ }
134+
135+ maxWidth := cols * cs.Width
136+ maxHeight := rows * cs.Height
137+
138+ img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
139+
140+ cachedMutex.Lock()
141+ cachedImages[key] = cachedImage{
142+ img: img,
143+ cols: cols,
144+ rows: rows,
145+ }
146+ cachedMutex.Unlock()
147+
148+ return img
149+}
150+
151+// HasTransmitted checks if the image with the given ID has already been
152+// transmitted to the terminal.
153+func HasTransmitted(id string, cols, rows int) bool {
154+ key := imageKey{id: id, cols: cols, rows: rows}
155+
156+ cachedMutex.RLock()
157+ _, ok := cachedImages[key]
158+ cachedMutex.RUnlock()
159+ return ok
160+}
161+
162+// Transmit transmits the image data to the terminal if needed. This is used to
163+// cache the image on the terminal for later rendering.
164+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd {
165+ if img == nil {
166+ return nil
167+ }
168+
169+ key := imageKey{id: id, cols: cols, rows: rows}
170+
171+ cachedMutex.RLock()
172+ _, ok := cachedImages[key]
173+ cachedMutex.RUnlock()
174+ if ok {
175+ return nil
176+ }
177+
178+ cmd := func() tea.Msg {
179+ if e != EncodingKitty {
180+ cachedMutex.Lock()
181+ cachedImages[key] = cachedImage{
182+ img: img,
183+ cols: cols,
184+ rows: rows,
185+ }
186+ cachedMutex.Unlock()
187+ return TransmittedMsg{ID: key.ID()}
188+ }
189+
190+ var buf bytes.Buffer
191+ img := fitImage(id, img, cs, cols, rows)
192+ bounds := img.Bounds()
193+ imgWidth := bounds.Dx()
194+ imgHeight := bounds.Dy()
195+
196+ imgID := int(key.Hash())
197+ if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
198+ ID: imgID,
199+ Action: kitty.TransmitAndPut,
200+ Transmission: kitty.Direct,
201+ Format: kitty.RGBA,
202+ ImageWidth: imgWidth,
203+ ImageHeight: imgHeight,
204+ Columns: cols,
205+ Rows: rows,
206+ VirtualPlacement: true,
207+ Quite: 1,
208+ }); err != nil {
209+ slog.Error("failed to encode image for kitty graphics", "err", err)
210+ return uiutil.ReportError(fmt.Errorf("failed to encode image"))
211+ }
212+
213+ return tea.RawMsg{Msg: buf.String()}
214+ }
215+
216+ return cmd
217+}
218+
219+// Render renders the given image within the specified dimensions using the
220+// specified encoding.
221+func (e Encoding) Render(id string, cols, rows int) string {
222+ key := imageKey{id: id, cols: cols, rows: rows}
223+ cachedMutex.RLock()
224+ cached, ok := cachedImages[key]
225+ cachedMutex.RUnlock()
226+ if !ok {
227+ return ""
228+ }
229+
230+ img := cached.img
231+
232+ switch e {
233+ case EncodingBlocks:
234+ m := mosaic.New().Width(cols).Height(rows).Scale(1)
235+ return strings.TrimSpace(m.Render(img))
236+ case EncodingKitty:
237+ // Build Kitty graphics unicode place holders
238+ var fg color.Color
239+ var extra int
240+ var r, g, b int
241+ hashedID := key.Hash()
242+ id := int(hashedID)
243+ extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
244+
245+ if id <= 255 {
246+ fg = ansi.IndexedColor(b)
247+ } else {
248+ fg = color.RGBA{
249+ R: uint8(r), //nolint:gosec
250+ G: uint8(g), //nolint:gosec
251+ B: uint8(b), //nolint:gosec
252+ A: 0xff,
253+ }
254+ }
255+
256+ fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
257+
258+ var buf bytes.Buffer
259+ for y := range rows {
260+ // As an optimization, we only write the fg color sequence id, and
261+ // column-row data once on the first cell. The terminal will handle
262+ // the rest.
263+ buf.WriteString(fgStyle)
264+ buf.WriteRune(kitty.Placeholder)
265+ buf.WriteRune(kitty.Diacritic(y))
266+ buf.WriteRune(kitty.Diacritic(0))
267+ if extra > 0 {
268+ buf.WriteRune(kitty.Diacritic(extra))
269+ }
270+ for x := 1; x < cols; x++ {
271+ buf.WriteString(fgStyle)
272+ buf.WriteRune(kitty.Placeholder)
273+ }
274+ if y < rows-1 {
275+ buf.WriteByte('\n')
276+ }
277+ }
278+
279+ return buf.String()
280+
281+ default:
282+ return ""
283+ }
284+}
internal/ui/model/ui.go+53−10
@@ -39,6 +39,7 @@ import (
3939 "github.com/charmbracelet/crush/internal/ui/common"
4040 "github.com/charmbracelet/crush/internal/ui/completions"
4141 "github.com/charmbracelet/crush/internal/ui/dialog"
42+ timage "github.com/charmbracelet/crush/internal/ui/image"
4243 "github.com/charmbracelet/crush/internal/ui/logo"
4344 "github.com/charmbracelet/crush/internal/ui/styles"
4445 "github.com/charmbracelet/crush/internal/uiutil"
@@ -48,12 +49,6 @@ import (
4849 "github.com/charmbracelet/x/editor"
4950)
5051
51−// Max file size set to 5M.
52−const maxAttachmentSize = int64(5 * 1024 * 1024)
53−
54−// Allowed image formats.
55−var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
56−
5752// Compact mode breakpoints.
5853const (
5954 compactModeWidthBreakpoint = 120
@@ -176,6 +171,9 @@ type UI struct {
176171 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
177172 sidebarLogo string
178173
174+ // imgCaps stores the terminal image capabilities.
175+ imgCaps timage.Capabilities
176+
179177 // custom commands & mcp commands
180178 customCommands []commands.CustomCommand
181179 mcpPrompts []commands.MCPPrompt
@@ -272,6 +270,11 @@ func (m *UI) Init() tea.Cmd {
272270 var cmds []tea.Cmd
273271 if m.QueryVersion {
274272 cmds = append(cmds, tea.RequestTerminalVersion)
273+ // XXX: Right now, we're using the same logic to determine image
274+ // support. Terminals like Apple Terminal and possibly others might
275+ // bleed characters when querying for Kitty graphics via APC escape
276+ // sequences.
277+ cmds = append(cmds, timage.RequestCapabilities())
275278 }
276279 // load the user commands async
277280 cmds = append(cmds, m.loadCustomCommands())
@@ -414,6 +417,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
414417 m.width, m.height = msg.Width, msg.Height
415418 m.handleCompactMode(m.width, m.height)
416419 m.updateLayoutAndSize()
420+ // XXX: We need to store cell dimensions for image rendering.
421+ m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
417422 case tea.KeyboardEnhancementsMsg:
418423 m.keyenh = msg
419424 if msg.SupportsKeyDisambiguation() {
@@ -543,6 +548,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
543548 if m.completionsOpen {
544549 m.completions.SetFiles(msg.Files)
545550 }
551+ case uv.WindowPixelSizeEvent:
552+ // [timage.RequestCapabilities] requests the terminal to send a window
553+ // size event to help determine pixel dimensions.
554+ m.imgCaps.PixelWidth = msg.Width
555+ m.imgCaps.PixelHeight = msg.Height
556+ case uv.KittyGraphicsEvent:
557+ // [timage.RequestCapabilities] sends a Kitty graphics query and this
558+ // captures the response. Any response means the terminal understands
559+ // the protocol.
560+ m.imgCaps.SupportsKittyGraphics = true
546561 default:
547562 if m.dialog.HasDialogs() {
548563 if cmd := m.handleDialogMsg(msg); cmd != nil {
@@ -974,6 +989,15 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
974989 m.com.App.Permissions.Deny(msg.Permission)
975990 }
976991
992+ case dialog.ActionFilePickerSelected:
993+ cmds = append(cmds, tea.Sequence(
994+ msg.Cmd(),
995+ func() tea.Msg {
996+ m.dialog.CloseDialog(dialog.FilePickerID)
997+ return nil
998+ },
999+ ))
1000+
9771001 case dialog.ActionRunCustomCommand:
9781002 if len(msg.Arguments) > 0 && msg.Args == nil {
9791003 m.dialog.CloseFrontDialog()
@@ -1140,6 +1164,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
11401164 }
11411165
11421166 switch {
1167+ case key.Matches(msg, m.keyMap.Editor.AddImage):
1168+ if cmd := m.openFilesDialog(); cmd != nil {
1169+ cmds = append(cmds, cmd)
1170+ }
1171+
11431172 case key.Matches(msg, m.keyMap.Editor.SendMessage):
11441173 value := m.textarea.Value()
11451174 if before, ok := strings.CutSuffix(value, "\\"); ok {
@@ -2305,10 +2334,24 @@ func (m *UI) openSessionsDialog() tea.Cmd {
23052334 }
23062335
23072336 m.dialog.OpenDialog(dialog)
2308−
23092337 return nil
23102338}
23112339
2340+// openFilesDialog opens the file picker dialog.
2341+func (m *UI) openFilesDialog() tea.Cmd {
2342+ if m.dialog.ContainsDialog(dialog.FilePickerID) {
2343+ // Bring to front
2344+ m.dialog.BringToFront(dialog.FilePickerID)
2345+ return nil
2346+ }
2347+
2348+ filePicker, cmd := dialog.NewFilePicker(m.com)
2349+ filePicker.SetImageCapabilities(&m.imgCaps)
2350+ m.dialog.OpenDialog(filePicker)
2351+
2352+ return cmd
2353+}
2354+
23122355// openPermissionsDialog opens the permissions dialog for a permission request.
23132356func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
23142357 // Close any existing permissions dialog first.
@@ -2371,7 +2414,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
23712414 if strings.Count(msg.Content, "\n") > 2 {
23722415 return func() tea.Msg {
23732416 content := []byte(msg.Content)
2374− if int64(len(content)) > maxAttachmentSize {
2417+ if int64(len(content)) > common.MaxAttachmentSize {
23752418 return uiutil.ReportWarn("Paste is too big (>5mb)")
23762419 }
23772420 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
@@ -2398,7 +2441,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
23982441 // Check if file has an allowed image extension.
23992442 isAllowedType := false
24002443 lowerPath := strings.ToLower(path)
2401− for _, ext := range allowedImageTypes {
2444+ for _, ext := range common.AllowedImageTypes {
24022445 if strings.HasSuffix(lowerPath, ext) {
24032446 isAllowedType = true
24042447 break
@@ -2414,7 +2457,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
24142457 if err != nil {
24152458 return uiutil.ReportError(err)
24162459 }
2417− if fileInfo.Size() > maxAttachmentSize {
2460+ if fileInfo.Size() > common.MaxAttachmentSize {
24182461 return uiutil.ReportWarn("File is too big (>5mb)")
24192462 }
24202463
internal/ui/styles/styles.go+4
@@ -355,6 +355,8 @@ type Styles struct {
355355 }
356356
357357 Commands struct{}
358+
359+ ImagePreview lipgloss.Style
358360 }
359361
360362 // Status bar and help
@@ -1227,6 +1229,8 @@ func DefaultStyles() Styles {
12271229 s.Dialog.ScrollbarThumb = base.Foreground(secondary)
12281230 s.Dialog.ScrollbarTrack = base.Foreground(border)
12291231
1232+ s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle)
1233+
12301234 s.Dialog.Arguments.Content = base.Padding(1)
12311235 s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3)
12321236 s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted)