feat: add new filepicker

Kujtim Hoxha created

Change summary

go.mod                                                   |  12 
go.sum                                                   |  17 
internal/tui/components/chat/editor/editor.go            |  15 
internal/tui/components/dialog/filepicker.go             | 932 +++++-----
internal/tui/components/dialogs/filepicker/filepicker.go |  47 
internal/tui/components/image/image.go                   |  89 
internal/tui/components/image/load.go                    | 157 +
internal/tui/image/images.go                             |  73 
8 files changed, 776 insertions(+), 566 deletions(-)

Detailed changes

go.mod 🔗

@@ -18,11 +18,14 @@ require (
 	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
 	github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
+	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
 	github.com/fsnotify/fsnotify v1.8.0
 	github.com/go-logfmt/logfmt v0.6.0
 	github.com/google/uuid v1.6.0
 	github.com/mark3labs/mcp-go v0.17.0
+	github.com/muesli/termenv v0.16.0
 	github.com/ncruces/go-sqlite3 v0.25.0
+	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
 	github.com/openai/openai-go v0.1.0-beta.2
 	github.com/pressly/goose/v3 v3.24.2
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
@@ -30,10 +33,17 @@ require (
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 	github.com/spf13/cobra v1.9.1
 	github.com/spf13/viper v1.20.0
+	github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
+	github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
 	github.com/stretchr/testify v1.10.0
 )
 
-require github.com/dustin/go-humanize v1.0.1 // indirect
+require (
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/disintegration/gift v1.1.2 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+)
 
 require (
 	cloud.google.com/go v0.116.0 // indirect

go.sum 🔗

@@ -58,6 +58,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudr
 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
 github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
 github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -66,8 +68,6 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
 github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
 github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607105053-36addcd3ab8c h1:Dgy7cOR3skvJjGVnhyaixW6ugxVxLtSjRCiMRSdbXSc=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607105053-36addcd3ab8c/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174 h1:TlVW+df0rdU/osP0O8DIVS9WFOAzXe3nuiMwJR4n+CA=
@@ -98,6 +98,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
+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.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
@@ -164,12 +168,16 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
 github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
 github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
 github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
 github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
 github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
 github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
@@ -215,6 +223,10 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
 github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -298,6 +310,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

internal/tui/components/chat/editor/editor.go 🔗

@@ -19,7 +19,6 @@ import (
 	"github.com/opencode-ai/opencode/internal/session"
 	"github.com/opencode-ai/opencode/internal/tui/components/chat"
 	"github.com/opencode-ai/opencode/internal/tui/components/completions"
-	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/styles"
 	"github.com/opencode-ai/opencode/internal/tui/util"
@@ -142,13 +141,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.session = msg
 		}
 		return m, nil
-	case dialog.AttachmentAddedMsg:
-		if len(m.attachments) >= maxAttachments {
-			logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
-			return m, cmd
-		}
-		m.attachments = append(m.attachments, msg.Attachment)
-		return m, nil
+	// case dialog.AttachmentAddedMsg:
+	// 	if len(m.attachments) >= maxAttachments {
+	// 		logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
+	// 		return m, cmd
+	// 	}
+	// 	m.attachments = append(m.attachments, msg.Attachment)
+	// 	return m, nil
 	case completions.CompletionsClosedMsg:
 		m.isCompletionsOpen = false
 		m.currentQuery = ""

internal/tui/components/dialog/filepicker.go 🔗

@@ -1,468 +1,468 @@
 package dialog
 
-import (
-	"fmt"
-	"net/http"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/textinput"
-	"github.com/charmbracelet/bubbles/v2/viewport"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/tui/image"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-const (
-	maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
-	downArrow         = "down"
-	upArrow           = "up"
-)
-
-type FilePrickerKeyMap struct {
-	Enter          key.Binding
-	Down           key.Binding
-	Up             key.Binding
-	Forward        key.Binding
-	Backward       key.Binding
-	OpenFilePicker key.Binding
-	Esc            key.Binding
-	InsertCWD      key.Binding
-}
-
-var filePickerKeyMap = FilePrickerKeyMap{
-	Enter: key.NewBinding(
-		key.WithKeys("enter"),
-		key.WithHelp("enter", "select file/enter directory"),
-	),
-	Down: key.NewBinding(
-		key.WithKeys("j", downArrow),
-		key.WithHelp("↓/j", "down"),
-	),
-	Up: key.NewBinding(
-		key.WithKeys("k", upArrow),
-		key.WithHelp("↑/k", "up"),
-	),
-	Forward: key.NewBinding(
-		key.WithKeys("l"),
-		key.WithHelp("l", "enter directory"),
-	),
-	Backward: key.NewBinding(
-		key.WithKeys("h", "backspace"),
-		key.WithHelp("h/backspace", "go back"),
-	),
-	OpenFilePicker: key.NewBinding(
-		key.WithKeys("ctrl+f"),
-		key.WithHelp("ctrl+f", "open file picker"),
-	),
-	Esc: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "close/exit"),
-	),
-	InsertCWD: key.NewBinding(
-		key.WithKeys("i"),
-		key.WithHelp("i", "manual path input"),
-	),
-}
-
-type filepickerCmp struct {
-	basePath       string
-	width          int
-	height         int
-	cursor         int
-	err            error
-	cursorChain    stack
-	viewport       viewport.Model
-	dirs           []os.DirEntry
-	cwdDetails     *DirNode
-	selectedFile   string
-	cwd            textinput.Model
-	ShowFilePicker bool
-	app            *app.App
-}
-
-type DirNode struct {
-	parent    *DirNode
-	child     *DirNode
-	directory string
-}
-type stack []int
-
-func (s stack) Push(v int) stack {
-	return append(s, v)
-}
-
-func (s stack) Pop() (stack, int) {
-	l := len(s)
-	return s[:l-1], s[l-1]
-}
-
-type AttachmentAddedMsg struct {
-	Attachment message.Attachment
-}
-
-func (f *filepickerCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmd tea.Cmd
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		f.width = 60
-		f.height = 20
-		f.viewport.SetWidth(80)
-		f.viewport.SetHeight(22)
-		f.cursor = 0
-		f.getCurrentFileBelowCursor()
-	case tea.KeyPressMsg:
-		if f.cwd.Focused() {
-			f.cwd, cmd = f.cwd.Update(msg)
-		}
-		switch {
-		case key.Matches(msg, filePickerKeyMap.InsertCWD):
-			f.cwd.Focus()
-			return f, cmd
-		case key.Matches(msg, filePickerKeyMap.Esc):
-			if f.cwd.Focused() {
-				f.cwd.Blur()
-			}
-		case key.Matches(msg, filePickerKeyMap.Down):
-			if !f.cwd.Focused() || msg.String() == downArrow {
-				if f.cursor < len(f.dirs)-1 {
-					f.cursor++
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Up):
-			if !f.cwd.Focused() || msg.String() == upArrow {
-				if f.cursor > 0 {
-					f.cursor--
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Enter):
-			var path string
-			var isPathDir bool
-			if f.cwd.Focused() {
-				path = f.cwd.Value()
-				fileInfo, err := os.Stat(path)
-				if err != nil {
-					logging.ErrorPersist("Invalid path")
-					return f, cmd
-				}
-				isPathDir = fileInfo.IsDir()
-			} else {
-				path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
-				isPathDir = f.dirs[f.cursor].IsDir()
-			}
-			if isPathDir {
-				newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
-				f.cwdDetails.child = &newWorkingDir
-				f.cwdDetails = f.cwdDetails.child
-				f.cursorChain = f.cursorChain.Push(f.cursor)
-				f.dirs = readDir(f.cwdDetails.directory, false)
-				f.cursor = 0
-				f.cwd.SetValue(f.cwdDetails.directory)
-				f.getCurrentFileBelowCursor()
-			} else {
-				f.selectedFile = path
-				return f.addAttachmentToMessage()
-			}
-		case key.Matches(msg, filePickerKeyMap.Esc):
-			if !f.cwd.Focused() {
-				f.cursorChain = make(stack, 0)
-				f.cursor = 0
-			} else {
-				f.cwd.Blur()
-			}
-		case key.Matches(msg, filePickerKeyMap.Forward):
-			if !f.cwd.Focused() {
-				if f.dirs[f.cursor].IsDir() {
-					path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
-					newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
-					f.cwdDetails.child = &newWorkingDir
-					f.cwdDetails = f.cwdDetails.child
-					f.cursorChain = f.cursorChain.Push(f.cursor)
-					f.dirs = readDir(f.cwdDetails.directory, false)
-					f.cursor = 0
-					f.cwd.SetValue(f.cwdDetails.directory)
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Backward):
-			if !f.cwd.Focused() {
-				if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
-					f.cursorChain, f.cursor = f.cursorChain.Pop()
-					f.cwdDetails = f.cwdDetails.parent
-					f.cwdDetails.child = nil
-					f.dirs = readDir(f.cwdDetails.directory, false)
-					f.cwd.SetValue(f.cwdDetails.directory)
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
-			f.dirs = readDir(f.cwdDetails.directory, false)
-			f.cursor = 0
-			f.getCurrentFileBelowCursor()
-		}
-	}
-	return f, cmd
-}
-
-func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
-	// modeInfo := GetSelectedModel(config.Get())
-	// if !modeInfo.SupportsAttachments {
-	// 	logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
-	// 	return f, nil
-	// }
-
-	selectedFilePath := f.selectedFile
-	if !isExtSupported(selectedFilePath) {
-		logging.ErrorPersist("Unsupported file")
-		return f, nil
-	}
-
-	isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
-	if err != nil {
-		logging.ErrorPersist("unable to read the image")
-		return f, nil
-	}
-	if isFileLarge {
-		logging.ErrorPersist("file too large, max 5MB")
-		return f, nil
-	}
-
-	content, err := os.ReadFile(selectedFilePath)
-	if err != nil {
-		logging.ErrorPersist("Unable read selected file")
-		return f, nil
-	}
-
-	mimeBufferSize := min(512, len(content))
-	mimeType := http.DetectContentType(content[:mimeBufferSize])
-	fileName := filepath.Base(selectedFilePath)
-	attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
-	f.selectedFile = ""
-	return f, util.CmdHandler(AttachmentAddedMsg{attachment})
-}
-
-func (f *filepickerCmp) View() tea.View {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-	const maxVisibleDirs = 20
-	const maxWidth = 80
-
-	adjustedWidth := maxWidth
-	for _, file := range f.dirs {
-		if len(file.Name()) > adjustedWidth-4 { // Account for padding
-			adjustedWidth = len(file.Name()) + 4
-		}
-	}
-	adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
-
-	files := make([]string, 0, maxVisibleDirs)
-	startIdx := 0
-
-	if len(f.dirs) > maxVisibleDirs {
-		halfVisible := maxVisibleDirs / 2
-		if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
-			startIdx = f.cursor - halfVisible
-		} else if f.cursor >= len(f.dirs)-halfVisible {
-			startIdx = len(f.dirs) - maxVisibleDirs
-		}
-	}
-
-	endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
-
-	for i := startIdx; i < endIdx; i++ {
-		file := f.dirs[i]
-		itemStyle := t.S().Text.Width(adjustedWidth)
-
-		if i == f.cursor {
-			itemStyle = itemStyle.
-				Background(t.Primary).
-				Bold(true)
-		}
-		filename := file.Name()
-
-		if len(filename) > adjustedWidth-4 {
-			filename = filename[:adjustedWidth-7] + "..."
-		}
-		if file.IsDir() {
-			filename = filename + "/"
-		}
-		// No need to reassign filename if it's not changing
-
-		files = append(files, itemStyle.Padding(0, 1).Render(filename))
-	}
-
-	// Pad to always show exactly 21 lines
-	for len(files) < maxVisibleDirs {
-		files = append(files, baseStyle.Width(adjustedWidth).Render(""))
-	}
-
-	currentPath := baseStyle.
-		Height(1).
-		Width(adjustedWidth).
-		Render(f.cwd.View())
-
-	viewportstyle := baseStyle.
-		Width(f.viewport.Width()).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus).
-		Padding(2).
-		Render(f.viewport.View())
-	var insertExitText string
-	if f.IsCWDFocused() {
-		insertExitText = "Press esc to exit typing path"
-	} else {
-		insertExitText = "Press i to start typing path"
-	}
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		currentPath,
-		baseStyle.Width(adjustedWidth).Render(""),
-		baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
-		baseStyle.Width(adjustedWidth).Render(""),
-		t.S().Muted.Width(adjustedWidth).Render(insertExitText),
-	)
-
-	f.cwd.SetValue(f.cwd.Value())
-	contentStyle := baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus).
-		Width(lipgloss.Width(content) + 4)
-
-	return tea.NewView(
-		lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
-	)
-}
-
-type FilepickerCmp interface {
-	util.Model
-	ToggleFilepicker(showFilepicker bool)
-	IsCWDFocused() bool
-}
-
-func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
-	f.ShowFilePicker = showFilepicker
-}
-
-func (f *filepickerCmp) IsCWDFocused() bool {
-	return f.cwd.Focused()
-}
-
-func NewFilepickerCmp(app *app.App) FilepickerCmp {
-	homepath, err := os.UserHomeDir()
-	if err != nil {
-		logging.Error("error loading user files")
-		return nil
-	}
-	baseDir := DirNode{parent: nil, directory: homepath}
-	dirs := readDir(homepath, false)
-	viewport := viewport.New()
-	currentDirectory := textinput.New()
-	currentDirectory.CharLimit = 200
-	currentDirectory.SetWidth(44)
-	currentDirectory.Cursor().Blink = true
-	currentDirectory.SetValue(baseDir.directory)
-	return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
-}
-
-func (f *filepickerCmp) getCurrentFileBelowCursor() {
-	if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
-		logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
-		f.viewport.SetContent("Preview unavailable")
-		return
-	}
-
-	dir := f.dirs[f.cursor]
-	filename := dir.Name()
-	if !dir.IsDir() && isExtSupported(filename) {
-		fullPath := f.cwdDetails.directory + "/" + dir.Name()
-
-		go func() {
-			imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
-			if err != nil {
-				logging.Error(err.Error())
-				f.viewport.SetContent("Preview unavailable")
-				return
-			}
-
-			f.viewport.SetContent(imageString)
-		}()
-	} else {
-		f.viewport.SetContent("Preview unavailable")
-	}
-}
-
-func readDir(path string, showHidden bool) []os.DirEntry {
-	logging.Info(fmt.Sprintf("Reading directory: %s", path))
-
-	entriesChan := make(chan []os.DirEntry, 1)
-	errChan := make(chan error, 1)
-
-	go func() {
-		dirEntries, err := os.ReadDir(path)
-		if err != nil {
-			logging.ErrorPersist(err.Error())
-			errChan <- err
-			return
-		}
-		entriesChan <- dirEntries
-	}()
-
-	select {
-	case dirEntries := <-entriesChan:
-		sort.Slice(dirEntries, func(i, j int) bool {
-			if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
-				return dirEntries[i].Name() < dirEntries[j].Name()
-			}
-			return dirEntries[i].IsDir()
-		})
-
-		if showHidden {
-			return dirEntries
-		}
-
-		var sanitizedDirEntries []os.DirEntry
-		for _, dirEntry := range dirEntries {
-			isHidden, _ := IsHidden(dirEntry.Name())
-			if !isHidden {
-				if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
-					sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
-				}
-			}
-		}
-
-		return sanitizedDirEntries
-
-	case err := <-errChan:
-		logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
-		return []os.DirEntry{}
-
-	case <-time.After(5 * time.Second):
-		logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
-		return []os.DirEntry{}
-	}
-}
-
-func IsHidden(file string) (bool, error) {
-	return strings.HasPrefix(file, "."), nil
-}
-
-func isExtSupported(path string) bool {
-	ext := strings.ToLower(filepath.Ext(path))
-	return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
-}
+// import (
+// 	"fmt"
+// 	"net/http"
+// 	"os"
+// 	"path/filepath"
+// 	"sort"
+// 	"strings"
+// 	"time"
+//
+// 	"github.com/charmbracelet/bubbles/v2/key"
+// 	"github.com/charmbracelet/bubbles/v2/textinput"
+// 	"github.com/charmbracelet/bubbles/v2/viewport"
+// 	tea "github.com/charmbracelet/bubbletea/v2"
+// 	"github.com/charmbracelet/lipgloss/v2"
+// 	"github.com/opencode-ai/opencode/internal/app"
+// 	"github.com/opencode-ai/opencode/internal/logging"
+// 	"github.com/opencode-ai/opencode/internal/message"
+// 	"github.com/opencode-ai/opencode/internal/tui/image"
+// 	"github.com/opencode-ai/opencode/internal/tui/styles"
+// 	"github.com/opencode-ai/opencode/internal/tui/util"
+// )
+//
+// const (
+// 	maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
+// 	downArrow         = "down"
+// 	upArrow           = "up"
+// )
+//
+// type FilePrickerKeyMap struct {
+// 	Enter          key.Binding
+// 	Down           key.Binding
+// 	Up             key.Binding
+// 	Forward        key.Binding
+// 	Backward       key.Binding
+// 	OpenFilePicker key.Binding
+// 	Esc            key.Binding
+// 	InsertCWD      key.Binding
+// }
+//
+// var filePickerKeyMap = FilePrickerKeyMap{
+// 	Enter: key.NewBinding(
+// 		key.WithKeys("enter"),
+// 		key.WithHelp("enter", "select file/enter directory"),
+// 	),
+// 	Down: key.NewBinding(
+// 		key.WithKeys("j", downArrow),
+// 		key.WithHelp("↓/j", "down"),
+// 	),
+// 	Up: key.NewBinding(
+// 		key.WithKeys("k", upArrow),
+// 		key.WithHelp("↑/k", "up"),
+// 	),
+// 	Forward: key.NewBinding(
+// 		key.WithKeys("l"),
+// 		key.WithHelp("l", "enter directory"),
+// 	),
+// 	Backward: key.NewBinding(
+// 		key.WithKeys("h", "backspace"),
+// 		key.WithHelp("h/backspace", "go back"),
+// 	),
+// 	OpenFilePicker: key.NewBinding(
+// 		key.WithKeys("ctrl+f"),
+// 		key.WithHelp("ctrl+f", "open file picker"),
+// 	),
+// 	Esc: key.NewBinding(
+// 		key.WithKeys("esc"),
+// 		key.WithHelp("esc", "close/exit"),
+// 	),
+// 	InsertCWD: key.NewBinding(
+// 		key.WithKeys("i"),
+// 		key.WithHelp("i", "manual path input"),
+// 	),
+// }
+//
+// type filepickerCmp struct {
+// 	basePath       string
+// 	width          int
+// 	height         int
+// 	cursor         int
+// 	err            error
+// 	cursorChain    stack
+// 	viewport       viewport.Model
+// 	dirs           []os.DirEntry
+// 	cwdDetails     *DirNode
+// 	selectedFile   string
+// 	cwd            textinput.Model
+// 	ShowFilePicker bool
+// 	app            *app.App
+// }
+//
+// type DirNode struct {
+// 	parent    *DirNode
+// 	child     *DirNode
+// 	directory string
+// }
+// type stack []int
+//
+// func (s stack) Push(v int) stack {
+// 	return append(s, v)
+// }
+//
+// func (s stack) Pop() (stack, int) {
+// 	l := len(s)
+// 	return s[:l-1], s[l-1]
+// }
+//
+// type AttachmentAddedMsg struct {
+// 	Attachment message.Attachment
+// }
+//
+// func (f *filepickerCmp) Init() tea.Cmd {
+// 	return nil
+// }
+//
+// func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+// 	var cmd tea.Cmd
+// 	switch msg := msg.(type) {
+// 	case tea.WindowSizeMsg:
+// 		f.width = 60
+// 		f.height = 20
+// 		f.viewport.SetWidth(80)
+// 		f.viewport.SetHeight(22)
+// 		f.cursor = 0
+// 		f.getCurrentFileBelowCursor()
+// 	case tea.KeyPressMsg:
+// 		if f.cwd.Focused() {
+// 			f.cwd, cmd = f.cwd.Update(msg)
+// 		}
+// 		switch {
+// 		case key.Matches(msg, filePickerKeyMap.InsertCWD):
+// 			f.cwd.Focus()
+// 			return f, cmd
+// 		case key.Matches(msg, filePickerKeyMap.Esc):
+// 			if f.cwd.Focused() {
+// 				f.cwd.Blur()
+// 			}
+// 		case key.Matches(msg, filePickerKeyMap.Down):
+// 			if !f.cwd.Focused() || msg.String() == downArrow {
+// 				if f.cursor < len(f.dirs)-1 {
+// 					f.cursor++
+// 					f.getCurrentFileBelowCursor()
+// 				}
+// 			}
+// 		case key.Matches(msg, filePickerKeyMap.Up):
+// 			if !f.cwd.Focused() || msg.String() == upArrow {
+// 				if f.cursor > 0 {
+// 					f.cursor--
+// 					f.getCurrentFileBelowCursor()
+// 				}
+// 			}
+// 		case key.Matches(msg, filePickerKeyMap.Enter):
+// 			var path string
+// 			var isPathDir bool
+// 			if f.cwd.Focused() {
+// 				path = f.cwd.Value()
+// 				fileInfo, err := os.Stat(path)
+// 				if err != nil {
+// 					logging.ErrorPersist("Invalid path")
+// 					return f, cmd
+// 				}
+// 				isPathDir = fileInfo.IsDir()
+// 			} else {
+// 				path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
+// 				isPathDir = f.dirs[f.cursor].IsDir()
+// 			}
+// 			if isPathDir {
+// 				newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
+// 				f.cwdDetails.child = &newWorkingDir
+// 				f.cwdDetails = f.cwdDetails.child
+// 				f.cursorChain = f.cursorChain.Push(f.cursor)
+// 				f.dirs = readDir(f.cwdDetails.directory, false)
+// 				f.cursor = 0
+// 				f.cwd.SetValue(f.cwdDetails.directory)
+// 				f.getCurrentFileBelowCursor()
+// 			} else {
+// 				f.selectedFile = path
+// 				return f.addAttachmentToMessage()
+// 			}
+// 		case key.Matches(msg, filePickerKeyMap.Esc):
+// 			if !f.cwd.Focused() {
+// 				f.cursorChain = make(stack, 0)
+// 				f.cursor = 0
+// 			} else {
+// 				f.cwd.Blur()
+// 			}
+// 		case key.Matches(msg, filePickerKeyMap.Forward):
+// 			if !f.cwd.Focused() {
+// 				if f.dirs[f.cursor].IsDir() {
+// 					path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
+// 					newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
+// 					f.cwdDetails.child = &newWorkingDir
+// 					f.cwdDetails = f.cwdDetails.child
+// 					f.cursorChain = f.cursorChain.Push(f.cursor)
+// 					f.dirs = readDir(f.cwdDetails.directory, false)
+// 					f.cursor = 0
+// 					f.cwd.SetValue(f.cwdDetails.directory)
+// 					f.getCurrentFileBelowCursor()
+// 				}
+// 			}
+// 		case key.Matches(msg, filePickerKeyMap.Backward):
+// 			if !f.cwd.Focused() {
+// 				if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
+// 					f.cursorChain, f.cursor = f.cursorChain.Pop()
+// 					f.cwdDetails = f.cwdDetails.parent
+// 					f.cwdDetails.child = nil
+// 					f.dirs = readDir(f.cwdDetails.directory, false)
+// 					f.cwd.SetValue(f.cwdDetails.directory)
+// 					f.getCurrentFileBelowCursor()
+// 				}
+// 			}
+// 		case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
+// 			f.dirs = readDir(f.cwdDetails.directory, false)
+// 			f.cursor = 0
+// 			f.getCurrentFileBelowCursor()
+// 		}
+// 	}
+// 	return f, cmd
+// }
+//
+// func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
+// 	// modeInfo := GetSelectedModel(config.Get())
+// 	// if !modeInfo.SupportsAttachments {
+// 	// 	logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
+// 	// 	return f, nil
+// 	// }
+//
+// 	selectedFilePath := f.selectedFile
+// 	if !isExtSupported(selectedFilePath) {
+// 		logging.ErrorPersist("Unsupported file")
+// 		return f, nil
+// 	}
+//
+// 	isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
+// 	if err != nil {
+// 		logging.ErrorPersist("unable to read the image")
+// 		return f, nil
+// 	}
+// 	if isFileLarge {
+// 		logging.ErrorPersist("file too large, max 5MB")
+// 		return f, nil
+// 	}
+//
+// 	content, err := os.ReadFile(selectedFilePath)
+// 	if err != nil {
+// 		logging.ErrorPersist("Unable read selected file")
+// 		return f, nil
+// 	}
+//
+// 	mimeBufferSize := min(512, len(content))
+// 	mimeType := http.DetectContentType(content[:mimeBufferSize])
+// 	fileName := filepath.Base(selectedFilePath)
+// 	attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
+// 	f.selectedFile = ""
+// 	return f, util.CmdHandler(AttachmentAddedMsg{attachment})
+// }
+//
+// func (f *filepickerCmp) View() tea.View {
+// 	t := styles.CurrentTheme()
+// 	baseStyle := t.S().Base
+// 	const maxVisibleDirs = 20
+// 	const maxWidth = 80
+//
+// 	adjustedWidth := maxWidth
+// 	for _, file := range f.dirs {
+// 		if len(file.Name()) > adjustedWidth-4 { // Account for padding
+// 			adjustedWidth = len(file.Name()) + 4
+// 		}
+// 	}
+// 	adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
+//
+// 	files := make([]string, 0, maxVisibleDirs)
+// 	startIdx := 0
+//
+// 	if len(f.dirs) > maxVisibleDirs {
+// 		halfVisible := maxVisibleDirs / 2
+// 		if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
+// 			startIdx = f.cursor - halfVisible
+// 		} else if f.cursor >= len(f.dirs)-halfVisible {
+// 			startIdx = len(f.dirs) - maxVisibleDirs
+// 		}
+// 	}
+//
+// 	endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
+//
+// 	for i := startIdx; i < endIdx; i++ {
+// 		file := f.dirs[i]
+// 		itemStyle := t.S().Text.Width(adjustedWidth)
+//
+// 		if i == f.cursor {
+// 			itemStyle = itemStyle.
+// 				Background(t.Primary).
+// 				Bold(true)
+// 		}
+// 		filename := file.Name()
+//
+// 		if len(filename) > adjustedWidth-4 {
+// 			filename = filename[:adjustedWidth-7] + "..."
+// 		}
+// 		if file.IsDir() {
+// 			filename = filename + "/"
+// 		}
+// 		// No need to reassign filename if it's not changing
+//
+// 		files = append(files, itemStyle.Padding(0, 1).Render(filename))
+// 	}
+//
+// 	// Pad to always show exactly 21 lines
+// 	for len(files) < maxVisibleDirs {
+// 		files = append(files, baseStyle.Width(adjustedWidth).Render(""))
+// 	}
+//
+// 	currentPath := baseStyle.
+// 		Height(1).
+// 		Width(adjustedWidth).
+// 		Render(f.cwd.View())
+//
+// 	viewportstyle := baseStyle.
+// 		Width(f.viewport.Width()).
+// 		Border(lipgloss.RoundedBorder()).
+// 		BorderForeground(t.BorderFocus).
+// 		Padding(2).
+// 		Render(f.viewport.View())
+// 	var insertExitText string
+// 	if f.IsCWDFocused() {
+// 		insertExitText = "Press esc to exit typing path"
+// 	} else {
+// 		insertExitText = "Press i to start typing path"
+// 	}
+//
+// 	content := lipgloss.JoinVertical(
+// 		lipgloss.Left,
+// 		currentPath,
+// 		baseStyle.Width(adjustedWidth).Render(""),
+// 		baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
+// 		baseStyle.Width(adjustedWidth).Render(""),
+// 		t.S().Muted.Width(adjustedWidth).Render(insertExitText),
+// 	)
+//
+// 	f.cwd.SetValue(f.cwd.Value())
+// 	contentStyle := baseStyle.Padding(1, 2).
+// 		Border(lipgloss.RoundedBorder()).
+// 		BorderForeground(t.BorderFocus).
+// 		Width(lipgloss.Width(content) + 4)
+//
+// 	return tea.NewView(
+// 		lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
+// 	)
+// }
+//
+// type FilepickerCmp interface {
+// 	util.Model
+// 	ToggleFilepicker(showFilepicker bool)
+// 	IsCWDFocused() bool
+// }
+//
+// func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
+// 	f.ShowFilePicker = showFilepicker
+// }
+//
+// func (f *filepickerCmp) IsCWDFocused() bool {
+// 	return f.cwd.Focused()
+// }
+//
+// func NewFilepickerCmp(app *app.App) FilepickerCmp {
+// 	homepath, err := os.UserHomeDir()
+// 	if err != nil {
+// 		logging.Error("error loading user files")
+// 		return nil
+// 	}
+// 	baseDir := DirNode{parent: nil, directory: homepath}
+// 	dirs := readDir(homepath, false)
+// 	viewport := viewport.New()
+// 	currentDirectory := textinput.New()
+// 	currentDirectory.CharLimit = 200
+// 	currentDirectory.SetWidth(44)
+// 	currentDirectory.Cursor().Blink = true
+// 	currentDirectory.SetValue(baseDir.directory)
+// 	return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
+// }
+//
+// func (f *filepickerCmp) getCurrentFileBelowCursor() {
+// 	if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
+// 		logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
+// 		f.viewport.SetContent("Preview unavailable")
+// 		return
+// 	}
+//
+// 	dir := f.dirs[f.cursor]
+// 	filename := dir.Name()
+// 	if !dir.IsDir() && isExtSupported(filename) {
+// 		fullPath := f.cwdDetails.directory + "/" + dir.Name()
+//
+// 		go func() {
+// 			imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
+// 			if err != nil {
+// 				logging.Error(err.Error())
+// 				f.viewport.SetContent("Preview unavailable")
+// 				return
+// 			}
+//
+// 			f.viewport.SetContent(imageString)
+// 		}()
+// 	} else {
+// 		f.viewport.SetContent("Preview unavailable")
+// 	}
+// }
+//
+// func readDir(path string, showHidden bool) []os.DirEntry {
+// 	logging.Info(fmt.Sprintf("Reading directory: %s", path))
+//
+// 	entriesChan := make(chan []os.DirEntry, 1)
+// 	errChan := make(chan error, 1)
+//
+// 	go func() {
+// 		dirEntries, err := os.ReadDir(path)
+// 		if err != nil {
+// 			logging.ErrorPersist(err.Error())
+// 			errChan <- err
+// 			return
+// 		}
+// 		entriesChan <- dirEntries
+// 	}()
+//
+// 	select {
+// 	case dirEntries := <-entriesChan:
+// 		sort.Slice(dirEntries, func(i, j int) bool {
+// 			if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
+// 				return dirEntries[i].Name() < dirEntries[j].Name()
+// 			}
+// 			return dirEntries[i].IsDir()
+// 		})
+//
+// 		if showHidden {
+// 			return dirEntries
+// 		}
+//
+// 		var sanitizedDirEntries []os.DirEntry
+// 		for _, dirEntry := range dirEntries {
+// 			isHidden, _ := IsHidden(dirEntry.Name())
+// 			if !isHidden {
+// 				if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
+// 					sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
+// 				}
+// 			}
+// 		}
+//
+// 		return sanitizedDirEntries
+//
+// 	case err := <-errChan:
+// 		logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
+// 		return []os.DirEntry{}
+//
+// 	case <-time.After(5 * time.Second):
+// 		logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
+// 		return []os.DirEntry{}
+// 	}
+// }
+//
+// func IsHidden(file string) (bool, error) {
+// 	return strings.HasPrefix(file, "."), nil
+// }
+//
+// func isExtSupported(path string) bool {
+// 	ext := strings.ToLower(filepath.Ext(path))
+// 	return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
+// }

internal/tui/components/dialogs/filepicker/filepicker.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/tui/components/core"
 	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
+	"github.com/opencode-ai/opencode/internal/tui/components/image"
 	"github.com/opencode-ai/opencode/internal/tui/styles"
 )
 
@@ -23,11 +24,13 @@ type FilePicker interface {
 }
 
 type filePicker struct {
-	wWidth       int
-	wHeight      int
-	width        int
-	filepicker   filepicker.Model
-	selectedFile string
+	wWidth          int
+	wHeight         int
+	width           int
+	filepicker      filepicker.Model
+	selectedFile    string
+	highlightedFile string
+	image           image.Model
 }
 
 func NewFilePickerCmp() FilePicker {
@@ -42,7 +45,8 @@ func NewFilePickerCmp() FilePicker {
 	fp.Cursor = ""
 	fp.SetHeight(fileSelectionHight)
 
-	return &filePicker{filepicker: fp}
+	image := image.New(1, 1, "")
+	return &filePicker{filepicker: fp, image: image}
 }
 
 func (m *filePicker) Init() tea.Cmd {
@@ -65,15 +69,24 @@ func (m *filePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 
 	var cmd tea.Cmd
+	var cmds []tea.Cmd
 	m.filepicker, cmd = m.filepicker.Update(msg)
+	cmds = append(cmds, cmd)
+	if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
+		w, h := m.imagePreviewSize()
+		cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
+		cmds = append(cmds, cmd)
+	}
+	m.highlightedFile = m.currentImage()
 
 	// Did the user select a file?
 	if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
 		// Get the path of the selected file.
 		m.selectedFile = path
 	}
-
-	return m, cmd
+	m.image, cmd = m.image.Update(msg)
+	cmds = append(cmds, cmd)
+	return m, tea.Batch(cmds...)
 }
 
 func (m *filePicker) View() tea.View {
@@ -98,21 +111,23 @@ func (m *filePicker) currentImage() string {
 }
 
 func (m *filePicker) imagePreview() string {
+	t := styles.CurrentTheme()
+	w, h := m.imagePreviewSize()
 	if m.currentImage() == "" {
-		return m.imagePreviewStyle().Render()
+		imgPreview := t.S().Base.
+			Width(w).
+			Height(h).
+			Background(t.BgOverlay)
+
+		return m.imagePreviewStyle().Render(imgPreview.Render())
 	}
 
-	return ""
+	return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
 }
 
 func (m *filePicker) imagePreviewStyle() lipgloss.Style {
 	t := styles.CurrentTheme()
-	w, h := m.imagePreviewSize()
-	return t.S().Base.
-		Width(w).
-		Height(h).
-		Margin(1).
-		Background(t.BgOverlay)
+	return t.S().Base.Padding(1, 1, 1, 1)
 }
 
 func (m *filePicker) imagePreviewSize() (int, int) {

internal/tui/components/image/image.go 🔗

@@ -0,0 +1,89 @@
+// Based on the implementation by @trashhalo at:
+// https://github.com/trashhalo/imgcat
+package image
+
+import (
+	"context"
+	"fmt"
+	_ "image/jpeg"
+	_ "image/png"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+)
+
+type Model struct {
+	url    string
+	image  string
+	width  uint
+	height uint
+	err    error
+
+	cancelAnimation context.CancelFunc
+}
+
+func New(width, height uint, url string) Model {
+	return Model{
+		width:  width,
+		height: height,
+		url:    url,
+	}
+}
+
+func (m Model) Init() tea.Cmd {
+	return nil
+}
+
+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case errMsg:
+		m.err = msg
+		return m, nil
+	case rewdrawMsg:
+		m.width = msg.width
+		m.height = msg.height
+		m.url = msg.url
+		return m, loadUrl(m.url)
+	case loadMsg:
+		return handleLoadMsg(m, msg)
+	}
+	return m, nil
+}
+
+func (m Model) View() string {
+	if m.err != nil {
+		return fmt.Sprintf("couldn't load image(s): %v", m.err)
+	}
+	return m.image
+}
+
+type errMsg struct{ error }
+
+func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
+	return func() tea.Msg {
+		return rewdrawMsg{
+			width:  width,
+			height: height,
+			url:    url,
+		}
+	}
+}
+
+func (m Model) UpdateUrl(url string) tea.Cmd {
+	return func() tea.Msg {
+		return rewdrawMsg{
+			width:  m.width,
+			height: m.height,
+			url:    url,
+		}
+	}
+}
+
+type rewdrawMsg struct {
+	width  uint
+	height uint
+	url    string
+}
+
+func (m Model) IsLoading() bool {
+	return m.image == ""
+}

internal/tui/components/image/load.go 🔗

@@ -0,0 +1,157 @@
+// Based on the implementation by @trashhalo at:
+// https://github.com/trashhalo/imgcat
+package image
+
+import (
+	"image"
+	"image/png"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/disintegration/imageorient"
+	"github.com/lucasb-eyer/go-colorful"
+	"github.com/muesli/termenv"
+	"github.com/nfnt/resize"
+	"github.com/srwiley/oksvg"
+	"github.com/srwiley/rasterx"
+)
+
+type loadMsg struct {
+	io.ReadCloser
+}
+
+func loadUrl(url string) tea.Cmd {
+	var r io.ReadCloser
+	var err error
+
+	if strings.HasPrefix(url, "http") {
+		var resp *http.Response
+		resp, err = http.Get(url)
+		r = resp.Body
+	} else {
+		r, err = os.Open(url)
+	}
+
+	if err != nil {
+		return func() tea.Msg {
+			return errMsg{err}
+		}
+	}
+
+	return load(r)
+}
+
+func load(r io.ReadCloser) tea.Cmd {
+	return func() tea.Msg {
+		return loadMsg{r}
+	}
+}
+
+func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
+	if m.cancelAnimation != nil {
+		m.cancelAnimation()
+	}
+
+	// blank out image so it says "loading..."
+	m.image = ""
+
+	return handleLoadMsgStatic(m, msg)
+}
+
+func handleLoadMsgStatic(m Model, msg loadMsg) (Model, tea.Cmd) {
+	defer msg.Close()
+
+	img, err := readerToimage(m.width, m.height, m.url, msg)
+	if err != nil {
+		return m, func() tea.Msg { return errMsg{err} }
+	}
+	m.image = img
+	return m, nil
+}
+
+func imageToString(width, height uint, url string, img image.Image) (string, error) {
+	img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
+	b := img.Bounds()
+	w := b.Max.X
+	h := b.Max.Y
+	p := termenv.ColorProfile()
+	str := strings.Builder{}
+	for y := 0; y < h; y += 2 {
+		for x := w; x < int(width); x = x + 2 {
+			str.WriteString(" ")
+		}
+		for x := 0; x < w; x++ {
+			c1, _ := colorful.MakeColor(img.At(x, y))
+			color1 := p.Color(c1.Hex())
+			c2, _ := colorful.MakeColor(img.At(x, y+1))
+			color2 := p.Color(c2.Hex())
+			str.WriteString(termenv.String("▀").
+				Foreground(color1).
+				Background(color2).
+				String())
+		}
+		str.WriteString("\n")
+	}
+	return str.String(), nil
+}
+
+func readerToimage(width uint, height uint, url string, r io.Reader) (string, error) {
+	if strings.HasSuffix(strings.ToLower(url), ".svg") {
+		return svgToimage(width, height, url, r)
+	}
+
+	img, _, err := imageorient.Decode(r)
+	if err != nil {
+		return "", err
+	}
+
+	return imageToString(width, height, url, img)
+}
+
+func svgToimage(width uint, height uint, url string, r io.Reader) (string, error) {
+	// Original author: https://stackoverflow.com/users/10826783/usual-human
+	// https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
+	// Adapted to use size from SVG, and to use temp file.
+
+	tmpPngFile, err := ioutil.TempFile("", "imgcat.*.png")
+	if err != nil {
+		return "", err
+	}
+	tmpPngPath := tmpPngFile.Name()
+	defer os.Remove(tmpPngPath)
+	defer tmpPngFile.Close()
+
+	// Rasterize the SVG:
+	icon, err := oksvg.ReadIconStream(r)
+	if err != nil {
+		return "", err
+	}
+	w := int(icon.ViewBox.W)
+	h := int(icon.ViewBox.H)
+	icon.SetTarget(0, 0, float64(w), float64(h))
+	rgba := image.NewRGBA(image.Rect(0, 0, w, h))
+	icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1)
+	// Write rasterized image as PNG:
+	err = png.Encode(tmpPngFile, rgba)
+	if err != nil {
+		tmpPngFile.Close()
+		return "", err
+	}
+	tmpPngFile.Close()
+
+	rPng, err := os.Open(tmpPngPath)
+	if err != nil {
+		return "", err
+	}
+	defer rPng.Close()
+
+	img, _, err := imageorient.Decode(rPng)
+	if err != nil {
+		return "", err
+	}
+	return imageToString(width, height, url, img)
+}

internal/tui/image/images.go 🔗

@@ -1,73 +0,0 @@
-package image
-
-import (
-	"fmt"
-	"image"
-	"image/color"
-	"os"
-	"strings"
-
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/disintegration/imaging"
-	"github.com/lucasb-eyer/go-colorful"
-)
-
-func ValidateFileSize(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
-}
-
-func ToString(width int, img image.Image) string {
-	img = imaging.Resize(img, width, 0, imaging.Lanczos)
-	b := img.Bounds()
-	imageWidth := b.Max.X
-	h := b.Max.Y
-	str := strings.Builder{}
-
-	for heightCounter := 0; heightCounter < h; heightCounter += 2 {
-		for x := range imageWidth {
-			c1, _ := colorful.MakeColor(img.At(x, heightCounter))
-			color1 := lipgloss.Color(c1.Hex())
-
-			var color2 color.Color
-			if heightCounter+1 < h {
-				c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
-				color2 = lipgloss.Color(c2.Hex())
-			} else {
-				color2 = color1
-			}
-
-			str.WriteString(lipgloss.NewStyle().Foreground(color1).
-				Background(color2).Render("▀"))
-		}
-
-		str.WriteString("\n")
-	}
-
-	return str.String()
-}
-
-func ImagePreview(width int, filename string) (string, error) {
-	imageContent, err := os.Open(filename)
-	if err != nil {
-		return "", err
-	}
-	defer imageContent.Close()
-
-	img, _, err := image.Decode(imageContent)
-	if err != nil {
-		return "", err
-	}
-
-	imageString := ToString(width, img)
-
-	return imageString, nil
-}