@@ -39,6 +39,7 @@ import (
39 "github.com/charmbracelet/crush/internal/ui/common"
39 "github.com/charmbracelet/crush/internal/ui/common"
40 "github.com/charmbracelet/crush/internal/ui/completions"
40 "github.com/charmbracelet/crush/internal/ui/completions"
41 "github.com/charmbracelet/crush/internal/ui/dialog"
41 "github.com/charmbracelet/crush/internal/ui/dialog"
42+ timage "github.com/charmbracelet/crush/internal/ui/image"
42 "github.com/charmbracelet/crush/internal/ui/logo"
43 "github.com/charmbracelet/crush/internal/ui/logo"
43 "github.com/charmbracelet/crush/internal/ui/styles"
44 "github.com/charmbracelet/crush/internal/ui/styles"
44 "github.com/charmbracelet/crush/internal/uiutil"
45 "github.com/charmbracelet/crush/internal/uiutil"
@@ -48,12 +49,6 @@ import (
48 "github.com/charmbracelet/x/editor"
49 "github.com/charmbracelet/x/editor"
51−// Max file size set to 5M.
52−const maxAttachmentSize = int64(5 * 1024 * 1024)
54−// Allowed image formats.
55−var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
57// Compact mode breakpoints.
52// Compact mode breakpoints.
59 compactModeWidthBreakpoint = 120
54 compactModeWidthBreakpoint = 120
@@ -176,6 +171,9 @@ type UI struct {
176 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
171 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
177 sidebarLogo string
172 sidebarLogo string
174+ // imgCaps stores the terminal image capabilities.
175+ imgCaps timage.Capabilities
179 // custom commands & mcp commands
177 // custom commands & mcp commands
180 customCommands []commands.CustomCommand
178 customCommands []commands.CustomCommand
181 mcpPrompts []commands.MCPPrompt
179 mcpPrompts []commands.MCPPrompt
@@ -272,6 +270,11 @@ func (m *UI) Init() tea.Cmd {
272 var cmds []tea.Cmd
270 var cmds []tea.Cmd
273 if m.QueryVersion {
271 if m.QueryVersion {
274 cmds = append(cmds, tea.RequestTerminalVersion)
272 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
277+ cmds = append(cmds, timage.RequestCapabilities())
276 // load the user commands async
279 // load the user commands async
277 cmds = append(cmds, m.loadCustomCommands())
280 cmds = append(cmds, m.loadCustomCommands())
@@ -414,6 +417,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
414 m.width, m.height = msg.Width, msg.Height
417 m.width, m.height = msg.Width, msg.Height
415 m.handleCompactMode(m.width, m.height)
418 m.handleCompactMode(m.width, m.height)
416 m.updateLayoutAndSize()
419 m.updateLayoutAndSize()
420+ // XXX: We need to store cell dimensions for image rendering.
421+ m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
417 case tea.KeyboardEnhancementsMsg:
422 case tea.KeyboardEnhancementsMsg:
418 m.keyenh = msg
423 m.keyenh = msg
419 if msg.SupportsKeyDisambiguation() {
424 if msg.SupportsKeyDisambiguation() {
@@ -543,6 +548,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
543 if m.completionsOpen {
548 if m.completionsOpen {
544 m.completions.SetFiles(msg.Files)
549 m.completions.SetFiles(msg.Files)
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
560+ m.imgCaps.SupportsKittyGraphics = true
547 if m.dialog.HasDialogs() {
562 if m.dialog.HasDialogs() {
548 if cmd := m.handleDialogMsg(msg); cmd != nil {
563 if cmd := m.handleDialogMsg(msg); cmd != nil {
@@ -974,6 +989,15 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
974 m.com.App.Permissions.Deny(msg.Permission)
989 m.com.App.Permissions.Deny(msg.Permission)
992+ case dialog.ActionFilePickerSelected:
993+ cmds = append(cmds, tea.Sequence(
996+ m.dialog.CloseDialog(dialog.FilePickerID)
977 case dialog.ActionRunCustomCommand:
1001 case dialog.ActionRunCustomCommand:
978 if len(msg.Arguments) > 0 && msg.Args == nil {
1002 if len(msg.Arguments) > 0 && msg.Args == nil {
979 m.dialog.CloseFrontDialog()
1003 m.dialog.CloseFrontDialog()
@@ -1140,6 +1164,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1142 switch {
1166 switch {
1167+ case key.Matches(msg, m.keyMap.Editor.AddImage):
1168+ if cmd := m.openFilesDialog(); cmd != nil {
1169+ cmds = append(cmds, cmd)
1143 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1172 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1144 value := m.textarea.Value()
1173 value := m.textarea.Value()
1145 if before, ok := strings.CutSuffix(value, "\\"); ok {
1174 if before, ok := strings.CutSuffix(value, "\\"); ok {
@@ -2305,10 +2334,24 @@ func (m *UI) openSessionsDialog() tea.Cmd {
2307 m.dialog.OpenDialog(dialog)
2336 m.dialog.OpenDialog(dialog)
2309 return nil
2337 return nil
2340+// openFilesDialog opens the file picker dialog.
2341+func (m *UI) openFilesDialog() tea.Cmd {
2342+ if m.dialog.ContainsDialog(dialog.FilePickerID) {
2344+ m.dialog.BringToFront(dialog.FilePickerID)
2348+ filePicker, cmd := dialog.NewFilePicker(m.com)
2349+ filePicker.SetImageCapabilities(&m.imgCaps)
2350+ m.dialog.OpenDialog(filePicker)
2312// openPermissionsDialog opens the permissions dialog for a permission request.
2355// openPermissionsDialog opens the permissions dialog for a permission request.
2313func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2356func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2314 // Close any existing permissions dialog first.
2357 // Close any existing permissions dialog first.
@@ -2371,7 +2414,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2371 if strings.Count(msg.Content, "\n") > 2 {
2414 if strings.Count(msg.Content, "\n") > 2 {
2372 return func() tea.Msg {
2415 return func() tea.Msg {
2373 content := []byte(msg.Content)
2416 content := []byte(msg.Content)
2374− if int64(len(content)) > maxAttachmentSize {
2417+ if int64(len(content)) > common.MaxAttachmentSize {
2375 return uiutil.ReportWarn("Paste is too big (>5mb)")
2418 return uiutil.ReportWarn("Paste is too big (>5mb)")
2377 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2420 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
@@ -2398,7 +2441,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2398 // Check if file has an allowed image extension.
2441 // Check if file has an allowed image extension.
2399 isAllowedType := false
2442 isAllowedType := false
2400 lowerPath := strings.ToLower(path)
2443 lowerPath := strings.ToLower(path)
2401− for _, ext := range allowedImageTypes {
2444+ for _, ext := range common.AllowedImageTypes {
2402 if strings.HasSuffix(lowerPath, ext) {
2445 if strings.HasSuffix(lowerPath, ext) {
2403 isAllowedType = true
2446 isAllowedType = true
@@ -2414,7 +2457,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2414 if err != nil {
2457 if err != nil {
2415 return uiutil.ReportError(err)
2458 return uiutil.ReportError(err)
2417− if fileInfo.Size() > maxAttachmentSize {
2460+ if fileInfo.Size() > common.MaxAttachmentSize {
2418 return uiutil.ReportWarn("File is too big (>5mb)")
2461 return uiutil.ReportWarn("File is too big (>5mb)")