on main help sign in
feat(ui): dialog: add file picker dialog with image preview
5d2c533Ayman Bagabas
go.mod+2−1
@@ -29,6 +29,7 @@ require (
29 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
29 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
30 github.com/charmbracelet/x/exp/ordered v0.1.0
30 github.com/charmbracelet/x/exp/ordered v0.1.0
31 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
31 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32+ github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383
32 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
33 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
33 github.com/charmbracelet/x/term v0.2.2
34 github.com/charmbracelet/x/term v0.2.2
34 github.com/denisbrodbeck/machineid v1.0.1
35 github.com/denisbrodbeck/machineid v1.0.1
@@ -171,7 +172,7 @@ require (
171 go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
172 go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
172 golang.org/x/crypto v0.47.0 // indirect
173 golang.org/x/crypto v0.47.0 // indirect
173 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
174 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
174− golang.org/x/image v0.27.0 // indirect
175+ golang.org/x/image v0.34.0 // indirect
175 golang.org/x/oauth2 v0.34.0 // indirect
176 golang.org/x/oauth2 v0.34.0 // indirect
176 golang.org/x/sys v0.40.0 // indirect
177 golang.org/x/sys v0.40.0 // indirect
177 golang.org/x/term v0.39.0 // indirect
178 golang.org/x/term v0.39.0 // indirect
go.sum+4−2
@@ -118,6 +118,8 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/
118github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
118github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
119github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
119github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
120github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
120github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
121+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y=
122+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY=
121github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
123github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
122github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
124github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
123github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
125github.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=
389golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
391golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
390golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
392golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
391golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
393golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
392−golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
394+golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
393−golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
395+golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
394golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
396golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
395golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
397golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
396golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
398golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
internal/ui/dialog/common.go+15−6
@@ -45,12 +45,21 @@ func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, in
45 listStyle := t.Dialog.List.Height(listHeight)
45 listStyle := t.Dialog.List.Height(listHeight)
46 listContent := listStyle.Render(list)
46 listContent := listStyle.Render(list)
47
47
48− content := strings.Join([]string{
48+ parts := []string{}
49+ if len(header) > 0 {
49− titleStyle.Render(header),
50+ parts = append(parts, titleStyle.Render(header))
51+ }
52+ if len(input) > 0 {
50− inputStyle.Render(input),
53+ parts = append(parts, inputStyle.Render(input))
54+ }
55+ if len(list) > 0 {
51− listContent,
56+ parts = append(parts, listContent)
57+ }
58+ if len(help) > 0 {
52− helpStyle.Render(help),
59+ parts = append(parts, helpStyle.Render(help))
53− }, "\n")
60+ }
61+
62+ content := strings.Join(parts, "\n")
54
63
55 return dialogStyle.Render(content)
64 return dialogStyle.Render(content)
56}
65}
internal/ui/dialog/filepicker.gonew file+292
@@ -0,0 +1,292 @@
1+package dialog
2+
3+import (
4+ "hash/fnv"
5+ "image"
6+ _ "image/jpeg" // register JPEG format
7+ _ "image/png" // register PNG format
8+ "io"
9+ "os"
10+ "strings"
11+ "sync"
12+
13+ "charm.land/bubbles/v2/filepicker"
14+ "charm.land/bubbles/v2/help"
15+ "charm.land/bubbles/v2/key"
16+ tea "charm.land/bubbletea/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+var (
24+ transmittedImages = map[uint64]struct{}{}
25+ transmittedMutex sync.RWMutex
26+)
27+
28+// FilePickerID is the identifier for the FilePicker dialog.
29+const FilePickerID = "filepicker"
30+
31+// FilePicker is a dialog that allows users to select files or directories.
32+type FilePicker struct {
33+ com *common.Common
34+
35+ width int
36+ imgPrevWidth, imgPrevHeight int
37+ imageCaps *fimage.Capabilities
38+
39+ img *fimage.Image
40+ fp filepicker.Model
41+ help help.Model
42+ previewingImage bool // indicates if an image is being previewed
43+
44+ km struct {
45+ Select,
46+ Down,
47+ Up,
48+ Forward,
49+ Backward,
50+ Navigate,
51+ Close key.Binding
52+ }
53+}
54+
55+var _ Dialog = (*FilePicker)(nil)
56+
57+// NewFilePicker creates a new [FilePicker] dialog.
58+func NewFilePicker(com *common.Common) (*FilePicker, Action) {
59+ f := new(FilePicker)
60+ f.com = com
61+
62+ help := help.New()
63+ help.Styles = com.Styles.DialogHelpStyles()
64+
65+ f.help = help
66+
67+ f.km.Select = key.NewBinding(
68+ key.WithKeys("enter"),
69+ key.WithHelp("enter", "accept"),
70+ )
71+ f.km.Down = key.NewBinding(
72+ key.WithKeys("down", "j"),
73+ key.WithHelp("down/j", "move down"),
74+ )
75+ f.km.Up = key.NewBinding(
76+ key.WithKeys("up", "k"),
77+ key.WithHelp("up/k", "move up"),
78+ )
79+ f.km.Forward = key.NewBinding(
80+ key.WithKeys("right", "l"),
81+ key.WithHelp("right/l", "move forward"),
82+ )
83+ f.km.Backward = key.NewBinding(
84+ key.WithKeys("left", "h"),
85+ key.WithHelp("left/h", "move backward"),
86+ )
87+ f.km.Navigate = key.NewBinding(
88+ key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
89+ key.WithHelp("↑↓←→", "navigate"),
90+ )
91+ f.km.Close = key.NewBinding(
92+ key.WithKeys("esc", "alt+esc"),
93+ key.WithHelp("esc", "close/exit"),
94+ )
95+
96+ fp := filepicker.New()
97+ fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
98+ fp.ShowPermissions = false
99+ fp.ShowSize = false
100+ fp.AutoHeight = false
101+ fp.Styles = com.Styles.FilePicker
102+ fp.Cursor = ""
103+ fp.CurrentDirectory = f.WorkingDir()
104+
105+ f.fp = fp
106+
107+ return f, ActionCmd{f.fp.Init()}
108+}
109+
110+// SetImageCapabilities sets the image capabilities for the [FilePicker].
111+func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
112+ f.imageCaps = caps
113+}
114+
115+// WorkingDir returns the current working directory of the [FilePicker].
116+func (f *FilePicker) WorkingDir() string {
117+ wd := f.com.Config().WorkingDir()
118+ if len(wd) > 0 {
119+ return wd
120+ }
121+
122+ cwd, err := os.Getwd()
123+ if err != nil {
124+ return home.Dir()
125+ }
126+
127+ return cwd
128+}
129+
130+// SetWindowSize sets the desired size of the [FilePicker] dialog window.
131+func (f *FilePicker) SetWindowSize(width, height int) {
132+ f.width = width
133+ f.imgPrevWidth = width/2 - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
134+ // Use square preview for simplicity same size as width
135+ f.imgPrevHeight = width/2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
136+ f.fp.SetHeight(height)
137+ innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
138+ styles := f.com.Styles.FilePicker
139+ styles.File = styles.File.Width(innerWidth)
140+ styles.Directory = styles.Directory.Width(innerWidth)
141+ styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
142+ styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
143+ f.fp.Styles = styles
144+}
145+
146+// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
147+func (f *FilePicker) ShortHelp() []key.Binding {
148+ return []key.Binding{
149+ f.km.Navigate,
150+ f.km.Select,
151+ f.km.Close,
152+ }
153+}
154+
155+// FullHelp returns the full help key bindings for the [FilePicker] dialog.
156+func (f *FilePicker) FullHelp() [][]key.Binding {
157+ return [][]key.Binding{
158+ {
159+ f.km.Select,
160+ f.km.Down,
161+ f.km.Up,
162+ f.km.Forward,
163+ },
164+ {
165+ f.km.Backward,
166+ f.km.Close,
167+ },
168+ }
169+}
170+
171+// ID returns the identifier of the [FilePicker] dialog.
172+func (f *FilePicker) ID() string {
173+ return FilePickerID
174+}
175+
176+// Init implements the [Dialog] interface.
177+func (f *FilePicker) Init() tea.Cmd {
178+ return f.fp.Init()
179+}
180+
181+// HandleMsg updates the [FilePicker] dialog based on the given message.
182+func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
183+ var cmds []tea.Cmd
184+ switch msg := msg.(type) {
185+ case tea.KeyPressMsg:
186+ switch {
187+ case key.Matches(msg, f.km.Close):
188+ return ActionClose{}
189+ }
190+ }
191+
192+ var cmd tea.Cmd
193+ f.fp, cmd = f.fp.Update(msg)
194+ if selFile := f.fp.HighlightedPath(); selFile != "" {
195+ var allowed bool
196+ for _, allowedExt := range f.fp.AllowedTypes {
197+ if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
198+ allowed = true
199+ break
200+ }
201+ }
202+
203+ f.previewingImage = allowed
204+ if allowed {
205+ id := uniquePathID(selFile)
206+
207+ transmittedMutex.RLock()
208+ _, transmitted := transmittedImages[id]
209+ transmittedMutex.RUnlock()
210+ if !transmitted {
211+ img, err := loadImage(selFile)
212+ if err != nil {
213+ f.previewingImage = false
214+ }
215+
216+ timg, err := fimage.New(selFile, img, f.imgPrevWidth, f.imgPrevHeight)
217+ if err != nil {
218+ f.previewingImage = false
219+ }
220+
221+ f.img = timg
222+ if err == nil {
223+ cmds = append(cmds, f.img.Transmit())
224+ transmittedMutex.Lock()
225+ transmittedImages[id] = struct{}{}
226+ transmittedMutex.Unlock()
227+ }
228+ }
229+ }
230+ }
231+ if cmd != nil {
232+ cmds = append(cmds, cmd)
233+ }
234+
235+ return ActionCmd{tea.Batch(cmds...)}
236+}
237+
238+// Draw renders the [FilePicker] dialog as a string.
239+func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
240+ t := f.com.Styles
241+ titleStyle := f.com.Styles.Dialog.Title
242+ dialogStyle := f.com.Styles.Dialog.View
243+ header := common.DialogTitle(t, "Add Image",
244+ max(0, f.width-dialogStyle.GetHorizontalFrameSize()-
245+ titleStyle.GetHorizontalFrameSize()))
246+ files := strings.TrimSpace(f.fp.View())
247+ filesHeight := f.fp.Height()
248+ imgPreview := t.Dialog.ImagePreview.Render(f.imagePreview())
249+ view := HeaderInputListHelpView(t, f.width, filesHeight, header, imgPreview, files, f.help.View(f))
250+ DrawCenter(scr, area, view)
251+ return nil
252+}
253+
254+// imagePreview returns the image preview section of the [FilePicker] dialog.
255+func (f *FilePicker) imagePreview() string {
256+ if !f.previewingImage || f.img == nil {
257+ // TODO: Cache this?
258+ var sb strings.Builder
259+ for y := 0; y < f.imgPrevHeight; y++ {
260+ for x := 0; x < f.imgPrevWidth; x++ {
261+ sb.WriteRune('╱')
262+ }
263+ if y < f.imgPrevHeight-1 {
264+ sb.WriteRune('\n')
265+ }
266+ }
267+ return sb.String()
268+ }
269+
270+ return f.img.Render()
271+}
272+
273+func uniquePathID(path string) uint64 {
274+ h := fnv.New64a()
275+ _, _ = io.WriteString(h, path)
276+ return h.Sum64()
277+}
278+
279+func loadImage(path string) (img image.Image, err error) {
280+ file, err := os.Open(path)
281+ if err != nil {
282+ return nil, err
283+ }
284+ defer file.Close()
285+
286+ img, _, err = image.Decode(file)
287+ if err != nil {
288+ return nil, err
289+ }
290+
291+ return img, nil
292+}
internal/ui/image/image.gonew file+164
@@ -0,0 +1,164 @@
1+package image
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "hash/fnv"
7+ "image"
8+ "image/color"
9+ "io"
10+ "log/slog"
11+
12+ tea "charm.land/bubbletea/v2"
13+ "github.com/charmbracelet/crush/internal/uiutil"
14+ "github.com/charmbracelet/x/ansi"
15+ "github.com/charmbracelet/x/ansi/kitty"
16+ "github.com/charmbracelet/x/mosaic"
17+)
18+
19+// Capabilities represents the capabilities of displaying images on the
20+// terminal.
21+type Capabilities struct {
22+ // SupportsKittyGraphics indicates whether the terminal supports the Kitty
23+ // graphics protocol.
24+ SupportsKittyGraphics bool
25+}
26+
27+// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
28+// its image related capabilities to the program.
29+func RequestCapabilities() tea.Cmd {
30+ return tea.Raw(
31+ // ID 31 is just a random ID used to detect Kitty graphics support.
32+ ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
33+ )
34+}
35+
36+// Encoding represents the encoding format of the image.
37+type Encoding byte
38+
39+// Image encodings.
40+const (
41+ EncodingBlocks Encoding = iota
42+ EncodingKitty
43+)
44+
45+// Image represents an image that can be displayed on the terminal.
46+type Image struct {
47+ id int
48+ img image.Image
49+ cols, rows int // in terminal cells
50+ enc Encoding
51+}
52+
53+// New creates a new [Image] instance with the given unique id, image, and
54+// dimensions in terminal cells.
55+func New(id string, img image.Image, cols, rows int) (*Image, error) {
56+ i := new(Image)
57+ h := fnv.New64a()
58+ if _, err := io.WriteString(h, id); err != nil {
59+ return nil, err
60+ }
61+ i.id = int(h.Sum64())
62+ i.img = img
63+ i.cols = cols
64+ i.rows = rows
65+ return i, nil
66+}
67+
68+// SetEncoding sets the encoding format for the image.
69+func (i *Image) SetEncoding(enc Encoding) {
70+ i.enc = enc
71+}
72+
73+// Transmit returns a [tea.Cmd] that sends the image data to the terminal.
74+// This is needed for the [EncodingKitty] protocol so that the terminal can
75+// cache the image for later rendering.
76+//
77+// This should only happen once per image.
78+func (i *Image) Transmit() tea.Cmd {
79+ if i.enc != EncodingKitty {
80+ return nil
81+ }
82+
83+ var buf bytes.Buffer
84+ bounds := i.img.Bounds()
85+ imgWidth := bounds.Dx()
86+ imgHeight := bounds.Dy()
87+
88+ // RGBA is 4 bytes per pixel
89+ imgSize := imgWidth * imgHeight * 4
90+
91+ if err := kitty.EncodeGraphics(&buf, i.img, &kitty.Options{
92+ ID: i.id,
93+ Action: kitty.TransmitAndPut,
94+ Transmission: kitty.Direct,
95+ Format: kitty.RGBA,
96+ Size: imgSize,
97+ Width: imgWidth,
98+ Height: imgHeight,
99+ Columns: i.cols,
100+ Rows: i.rows,
101+ VirtualPlacement: true,
102+ Quite: 2,
103+ }); err != nil {
104+ slog.Error("failed to encode image for kitty graphics", "err", err)
105+ return uiutil.ReportError(fmt.Errorf("failed to encode image"))
106+ }
107+
108+ return tea.Raw(buf.String())
109+}
110+
111+// Render renders the image to a string that can be displayed on the terminal.
112+func (i *Image) Render() string {
113+ // Check cache first
114+ switch i.enc {
115+ case EncodingBlocks:
116+ m := mosaic.New().Width(i.cols).Height(i.rows).Scale(2)
117+ return m.Render(i.img)
118+ case EncodingKitty:
119+ // Build Kitty graphics unicode place holders
120+ var fg color.Color
121+ var extra int
122+ var r, g, b int
123+ extra, r, g, b = i.id>>24&0xff, i.id>>16&0xff, i.id>>8&0xff, i.id&0xff
124+
125+ if r == 0 && g == 0 {
126+ fg = ansi.IndexedColor(b)
127+ } else {
128+ fg = color.RGBA{
129+ R: uint8(r), //nolint:gosec
130+ G: uint8(g), //nolint:gosec
131+ B: uint8(b), //nolint:gosec
132+ A: 0xff,
133+ }
134+ }
135+
136+ fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
137+
138+ var buf bytes.Buffer
139+ for y := 0; y < i.rows; y++ {
140+ // As an optimization, we only write the fg color sequence id, and
141+ // column-row data once on the first cell. The terminal will handle
142+ // the rest.
143+ buf.WriteString(fgStyle)
144+ buf.WriteRune(kitty.Placeholder)
145+ buf.WriteRune(kitty.Diacritic(y))
146+ buf.WriteRune(kitty.Diacritic(0))
147+ if extra > 0 {
148+ buf.WriteRune(kitty.Diacritic(extra))
149+ }
150+ for x := 1; x < i.cols; x++ {
151+ buf.WriteString(fgStyle)
152+ buf.WriteRune(kitty.Placeholder)
153+ }
154+ if y < i.rows-1 {
155+ buf.WriteByte('\n')
156+ }
157+ }
158+
159+ return buf.String()
160+
161+ default:
162+ return ""
163+ }
164+}
internal/ui/model/ui.go+40
@@ -36,6 +36,7 @@ import (
36 "github.com/charmbracelet/crush/internal/ui/common"
36 "github.com/charmbracelet/crush/internal/ui/common"
37 "github.com/charmbracelet/crush/internal/ui/completions"
37 "github.com/charmbracelet/crush/internal/ui/completions"
38 "github.com/charmbracelet/crush/internal/ui/dialog"
38 "github.com/charmbracelet/crush/internal/ui/dialog"
39+ timage "github.com/charmbracelet/crush/internal/ui/image"
39 "github.com/charmbracelet/crush/internal/ui/logo"
40 "github.com/charmbracelet/crush/internal/ui/logo"
40 "github.com/charmbracelet/crush/internal/ui/styles"
41 "github.com/charmbracelet/crush/internal/ui/styles"
41 "github.com/charmbracelet/crush/internal/uiutil"
42 "github.com/charmbracelet/crush/internal/uiutil"
@@ -144,6 +145,9 @@ type UI struct {
144
145
145 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
146 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
146 sidebarLogo string
147 sidebarLogo string
148+
149+ // imageCaps stores the terminal image capabilities.
150+ imageCaps timage.Capabilities
147}
151}
148
152
149// New creates a new instance of the [UI] model.
153// New creates a new instance of the [UI] model.
@@ -224,6 +228,11 @@ func (m *UI) Init() tea.Cmd {
224 var cmds []tea.Cmd
228 var cmds []tea.Cmd
225 if m.QueryVersion {
229 if m.QueryVersion {
226 cmds = append(cmds, tea.RequestTerminalVersion)
230 cmds = append(cmds, tea.RequestTerminalVersion)
231+ // XXX: Right now, we're using the same logic to determine image
232+ // support. Terminals like Apple Terminal and possibly others might
233+ // bleed characters when querying for Kitty graphics via APC escape
234+ // sequences.
235+ cmds = append(cmds, timage.RequestCapabilities())
227 }
236 }
228 return tea.Batch(cmds...)
237 return tea.Batch(cmds...)
229}
238}
@@ -426,6 +435,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
426 if m.completionsOpen {
435 if m.completionsOpen {
427 m.completions.SetFiles(msg.Files)
436 m.completions.SetFiles(msg.Files)
428 }
437 }
438+ case uv.KittyGraphicsEvent:
439+ // [timage.RequestCapabilities] sends a Kitty graphics query and this
440+ // captures the response. Any response means the terminal understands
441+ // the protocol.
442+ m.imageCaps.SupportsKittyGraphics = true
429 default:
443 default:
430 if m.dialog.HasDialogs() {
444 if m.dialog.HasDialogs() {
431 if cmd := m.handleDialogMsg(msg); cmd != nil {
445 if cmd := m.handleDialogMsg(msg); cmd != nil {
@@ -926,6 +940,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
926 }
940 }
927
941
928 switch {
942 switch {
943+ case key.Matches(msg, m.keyMap.Editor.AddImage):
944+ if cmd := m.openFilesDialog(); cmd != nil {
945+ cmds = append(cmds, cmd)
946+ }
947+
929 case key.Matches(msg, m.keyMap.Editor.SendMessage):
948 case key.Matches(msg, m.keyMap.Editor.SendMessage):
930 value := m.textarea.Value()
949 value := m.textarea.Value()
931 if before, ok := strings.CutSuffix(value, "\\"); ok {
950 if before, ok := strings.CutSuffix(value, "\\"); ok {
@@ -2032,6 +2051,27 @@ func (m *UI) openSessionsDialog() tea.Cmd {
2032 }
2051 }
2033
2052
2034 m.dialog.OpenDialog(dialog)
2053 m.dialog.OpenDialog(dialog)
2054+ return nil
2055+}
2056+
2057+// openFilesDialog opens the file picker dialog.
2058+func (m *UI) openFilesDialog() tea.Cmd {
2059+ if m.dialog.ContainsDialog(dialog.FilePickerID) {
2060+ // Bring to front
2061+ m.dialog.BringToFront(dialog.FilePickerID)
2062+ return nil
2063+ }
2064+
2065+ const desiredFilePickerHeight = 10
2066+ filePicker, action := dialog.NewFilePicker(m.com)
2067+ filePicker.SetWindowSize(min(80, m.width-8), desiredFilePickerHeight)
2068+ filePicker.SetImageCapabilities(&m.imageCaps)
2069+ m.dialog.OpenDialog(filePicker)
2070+
2071+ switch action := action.(type) {
2072+ case dialog.ActionCmd:
2073+ return action.Cmd
2074+ }
2035
2075
2036 return nil
2076 return nil
2037}
2077}
internal/ui/styles/styles.go+4
@@ -328,6 +328,8 @@ type Styles struct {
328 ScrollbarTrack lipgloss.Style
328 ScrollbarTrack lipgloss.Style
329
329
330 Commands struct{}
330 Commands struct{}
331+
332+ ImagePreview lipgloss.Style
331 }
333 }
332
334
333 // Status bar and help
335 // Status bar and help
@@ -1186,6 +1188,8 @@ func DefaultStyles() Styles {
1186 s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1188 s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1187 s.Dialog.ScrollbarTrack = base.Foreground(border)
1189 s.Dialog.ScrollbarTrack = base.Foreground(border)
1188
1190
1191+ s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(1)
1192+
1189 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1193 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1190 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1194 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1191 s.Status.InfoIndicator = s.Status.SuccessIndicator
1195 s.Status.InfoIndicator = s.Status.SuccessIndicator