on main help sign in
feat: add new filepicker
15d195aKujtim Hoxha
go.mod+11−1
@@ -18,11 +18,14 @@ require (
18 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
18 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
19 github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
19 github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
20 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
20 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
21+ github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
21 github.com/fsnotify/fsnotify v1.8.0
22 github.com/fsnotify/fsnotify v1.8.0
22 github.com/go-logfmt/logfmt v0.6.0
23 github.com/go-logfmt/logfmt v0.6.0
23 github.com/google/uuid v1.6.0
24 github.com/google/uuid v1.6.0
24 github.com/mark3labs/mcp-go v0.17.0
25 github.com/mark3labs/mcp-go v0.17.0
26+ github.com/muesli/termenv v0.16.0
25 github.com/ncruces/go-sqlite3 v0.25.0
27 github.com/ncruces/go-sqlite3 v0.25.0
28+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
26 github.com/openai/openai-go v0.1.0-beta.2
29 github.com/openai/openai-go v0.1.0-beta.2
27 github.com/pressly/goose/v3 v3.24.2
30 github.com/pressly/goose/v3 v3.24.2
28 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
31 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
@@ -30,10 +33,17 @@ require (
30 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
33 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
31 github.com/spf13/cobra v1.9.1
34 github.com/spf13/cobra v1.9.1
32 github.com/spf13/viper v1.20.0
35 github.com/spf13/viper v1.20.0
36+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
37+ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
33 github.com/stretchr/testify v1.10.0
38 github.com/stretchr/testify v1.10.0
34)
39)
35
40
41+require (
42+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
43+ github.com/disintegration/gift v1.1.2 // indirect
36−require github.com/dustin/go-humanize v1.0.1 // indirect
44+ github.com/dustin/go-humanize v1.0.1 // indirect
45+ github.com/mattn/go-isatty v0.0.20 // indirect
46+)
37
47
38require (
48require (
39 cloud.google.com/go v0.116.0 // indirect
49 cloud.google.com/go v0.116.0 // indirect
go.sum+15−2
@@ -58,6 +58,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudr
58github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
58github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
59github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
59github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
60github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
60github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
61+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
62+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
61github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
63github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
62github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
64github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
63github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
65github.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
66github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
68github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
67github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
69github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
68github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
70github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
69−github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607105053-36addcd3ab8c h1:Dgy7cOR3skvJjGVnhyaixW6ugxVxLtSjRCiMRSdbXSc=
70−github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607105053-36addcd3ab8c/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
71github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
71github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
72github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
72github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
73github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174 h1:TlVW+df0rdU/osP0O8DIVS9WFOAzXe3nuiMwJR4n+CA=
73github.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
98github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
98github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
99github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
99github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
100github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
100github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
101+github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
102+github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
103+github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
104+github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
101github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
105github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
102github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
106github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
103github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
107github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
@@ -164,12 +168,16 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
164github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
168github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
165github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
169github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
166github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
170github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
171+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
172+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
167github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
173github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
168github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
174github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
169github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
175github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
170github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
176github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
171github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
177github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
172github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
178github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
179+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
180+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
173github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
181github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
174github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
182github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
175github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
183github.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=
215github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
223github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
216github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
224github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
217github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
225github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
226+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
227+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
228+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
229+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
218github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
230github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
219github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
231github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
220github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
232github.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
298golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
310golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
299golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
311golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
300golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
312golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
313+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
301golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
314golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
302golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
315golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
303golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
316golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
internal/tui/components/chat/editor/editor.go+7−8
@@ -19,7 +19,6 @@ import (
19 "github.com/opencode-ai/opencode/internal/session"
19 "github.com/opencode-ai/opencode/internal/session"
20 "github.com/opencode-ai/opencode/internal/tui/components/chat"
20 "github.com/opencode-ai/opencode/internal/tui/components/chat"
21 "github.com/opencode-ai/opencode/internal/tui/components/completions"
21 "github.com/opencode-ai/opencode/internal/tui/components/completions"
22− "github.com/opencode-ai/opencode/internal/tui/components/dialog"
23 "github.com/opencode-ai/opencode/internal/tui/layout"
22 "github.com/opencode-ai/opencode/internal/tui/layout"
24 "github.com/opencode-ai/opencode/internal/tui/styles"
23 "github.com/opencode-ai/opencode/internal/tui/styles"
25 "github.com/opencode-ai/opencode/internal/tui/util"
24 "github.com/opencode-ai/opencode/internal/tui/util"
@@ -142,13 +141,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
142 m.session = msg
141 m.session = msg
143 }
142 }
144 return m, nil
143 return m, nil
145− case dialog.AttachmentAddedMsg:
144+ // case dialog.AttachmentAddedMsg:
146− if len(m.attachments) >= maxAttachments {
145+ // if len(m.attachments) >= maxAttachments {
147− logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
146+ // logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
148− return m, cmd
147+ // return m, cmd
149− }
148+ // }
150− m.attachments = append(m.attachments, msg.Attachment)
149+ // m.attachments = append(m.attachments, msg.Attachment)
151− return m, nil
150+ // return m, nil
152 case completions.CompletionsClosedMsg:
151 case completions.CompletionsClosedMsg:
153 m.isCompletionsOpen = false
152 m.isCompletionsOpen = false
154 m.currentQuery = ""
153 m.currentQuery = ""
internal/tui/components/dialog/filepicker.go+466−466
@@ -1,468 +1,468 @@
1package dialog
1package dialog
2
2
3−import (
3+// import (
4− "fmt"
4+// "fmt"
5− "net/http"
5+// "net/http"
6− "os"
6+// "os"
7− "path/filepath"
7+// "path/filepath"
8− "sort"
8+// "sort"
9− "strings"
9+// "strings"
10− "time"
10+// "time"
11−
11+//
12− "github.com/charmbracelet/bubbles/v2/key"
12+// "github.com/charmbracelet/bubbles/v2/key"
13− "github.com/charmbracelet/bubbles/v2/textinput"
13+// "github.com/charmbracelet/bubbles/v2/textinput"
14− "github.com/charmbracelet/bubbles/v2/viewport"
14+// "github.com/charmbracelet/bubbles/v2/viewport"
15− tea "github.com/charmbracelet/bubbletea/v2"
15+// tea "github.com/charmbracelet/bubbletea/v2"
16− "github.com/charmbracelet/lipgloss/v2"
16+// "github.com/charmbracelet/lipgloss/v2"
17− "github.com/opencode-ai/opencode/internal/app"
17+// "github.com/opencode-ai/opencode/internal/app"
18− "github.com/opencode-ai/opencode/internal/logging"
18+// "github.com/opencode-ai/opencode/internal/logging"
19− "github.com/opencode-ai/opencode/internal/message"
19+// "github.com/opencode-ai/opencode/internal/message"
20− "github.com/opencode-ai/opencode/internal/tui/image"
20+// "github.com/opencode-ai/opencode/internal/tui/image"
21− "github.com/opencode-ai/opencode/internal/tui/styles"
21+// "github.com/opencode-ai/opencode/internal/tui/styles"
22− "github.com/opencode-ai/opencode/internal/tui/util"
22+// "github.com/opencode-ai/opencode/internal/tui/util"
23−)
23+// )
24−
24+//
25−const (
25+// const (
26− maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
26+// maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
27− downArrow = "down"
27+// downArrow = "down"
28− upArrow = "up"
28+// upArrow = "up"
29−)
29+// )
30−
30+//
31−type FilePrickerKeyMap struct {
31+// type FilePrickerKeyMap struct {
32− Enter key.Binding
32+// Enter key.Binding
33− Down key.Binding
33+// Down key.Binding
34− Up key.Binding
34+// Up key.Binding
35− Forward key.Binding
35+// Forward key.Binding
36− Backward key.Binding
36+// Backward key.Binding
37− OpenFilePicker key.Binding
37+// OpenFilePicker key.Binding
38− Esc key.Binding
38+// Esc key.Binding
39− InsertCWD key.Binding
39+// InsertCWD key.Binding
40−}
40+// }
41−
41+//
42−var filePickerKeyMap = FilePrickerKeyMap{
42+// var filePickerKeyMap = FilePrickerKeyMap{
43− Enter: key.NewBinding(
43+// Enter: key.NewBinding(
44− key.WithKeys("enter"),
44+// key.WithKeys("enter"),
45− key.WithHelp("enter", "select file/enter directory"),
45+// key.WithHelp("enter", "select file/enter directory"),
46− ),
46+// ),
47− Down: key.NewBinding(
47+// Down: key.NewBinding(
48− key.WithKeys("j", downArrow),
48+// key.WithKeys("j", downArrow),
49− key.WithHelp("↓/j", "down"),
49+// key.WithHelp("↓/j", "down"),
50− ),
50+// ),
51− Up: key.NewBinding(
51+// Up: key.NewBinding(
52− key.WithKeys("k", upArrow),
52+// key.WithKeys("k", upArrow),
53− key.WithHelp("↑/k", "up"),
53+// key.WithHelp("↑/k", "up"),
54− ),
54+// ),
55− Forward: key.NewBinding(
55+// Forward: key.NewBinding(
56− key.WithKeys("l"),
56+// key.WithKeys("l"),
57− key.WithHelp("l", "enter directory"),
57+// key.WithHelp("l", "enter directory"),
58− ),
58+// ),
59− Backward: key.NewBinding(
59+// Backward: key.NewBinding(
60− key.WithKeys("h", "backspace"),
60+// key.WithKeys("h", "backspace"),
61− key.WithHelp("h/backspace", "go back"),
61+// key.WithHelp("h/backspace", "go back"),
62− ),
62+// ),
63− OpenFilePicker: key.NewBinding(
63+// OpenFilePicker: key.NewBinding(
64− key.WithKeys("ctrl+f"),
64+// key.WithKeys("ctrl+f"),
65− key.WithHelp("ctrl+f", "open file picker"),
65+// key.WithHelp("ctrl+f", "open file picker"),
66− ),
66+// ),
67− Esc: key.NewBinding(
67+// Esc: key.NewBinding(
68− key.WithKeys("esc"),
68+// key.WithKeys("esc"),
69− key.WithHelp("esc", "close/exit"),
69+// key.WithHelp("esc", "close/exit"),
70− ),
70+// ),
71− InsertCWD: key.NewBinding(
71+// InsertCWD: key.NewBinding(
72− key.WithKeys("i"),
72+// key.WithKeys("i"),
73− key.WithHelp("i", "manual path input"),
73+// key.WithHelp("i", "manual path input"),
74− ),
74+// ),
75−}
75+// }
76−
76+//
77−type filepickerCmp struct {
77+// type filepickerCmp struct {
78− basePath string
78+// basePath string
79− width int
79+// width int
80− height int
80+// height int
81− cursor int
81+// cursor int
82− err error
82+// err error
83− cursorChain stack
83+// cursorChain stack
84− viewport viewport.Model
84+// viewport viewport.Model
85− dirs []os.DirEntry
85+// dirs []os.DirEntry
86− cwdDetails *DirNode
86+// cwdDetails *DirNode
87− selectedFile string
87+// selectedFile string
88− cwd textinput.Model
88+// cwd textinput.Model
89− ShowFilePicker bool
89+// ShowFilePicker bool
90− app *app.App
90+// app *app.App
91−}
91+// }
92−
92+//
93−type DirNode struct {
93+// type DirNode struct {
94− parent *DirNode
94+// parent *DirNode
95− child *DirNode
95+// child *DirNode
96− directory string
96+// directory string
97−}
97+// }
98−type stack []int
98+// type stack []int
99−
99+//
100−func (s stack) Push(v int) stack {
100+// func (s stack) Push(v int) stack {
101− return append(s, v)
101+// return append(s, v)
102−}
102+// }
103−
103+//
104−func (s stack) Pop() (stack, int) {
104+// func (s stack) Pop() (stack, int) {
105− l := len(s)
105+// l := len(s)
106− return s[:l-1], s[l-1]
106+// return s[:l-1], s[l-1]
107−}
107+// }
108−
108+//
109−type AttachmentAddedMsg struct {
109+// type AttachmentAddedMsg struct {
110− Attachment message.Attachment
110+// Attachment message.Attachment
111−}
111+// }
112−
112+//
113−func (f *filepickerCmp) Init() tea.Cmd {
113+// func (f *filepickerCmp) Init() tea.Cmd {
114− return nil
114+// return nil
115−}
115+// }
116−
116+//
117−func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117+// func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118− var cmd tea.Cmd
118+// var cmd tea.Cmd
119− switch msg := msg.(type) {
119+// switch msg := msg.(type) {
120− case tea.WindowSizeMsg:
120+// case tea.WindowSizeMsg:
121− f.width = 60
121+// f.width = 60
122− f.height = 20
122+// f.height = 20
123− f.viewport.SetWidth(80)
123+// f.viewport.SetWidth(80)
124− f.viewport.SetHeight(22)
124+// f.viewport.SetHeight(22)
125− f.cursor = 0
125+// f.cursor = 0
126− f.getCurrentFileBelowCursor()
126+// f.getCurrentFileBelowCursor()
127− case tea.KeyPressMsg:
127+// case tea.KeyPressMsg:
128− if f.cwd.Focused() {
128+// if f.cwd.Focused() {
129− f.cwd, cmd = f.cwd.Update(msg)
129+// f.cwd, cmd = f.cwd.Update(msg)
130− }
130+// }
131− switch {
131+// switch {
132− case key.Matches(msg, filePickerKeyMap.InsertCWD):
132+// case key.Matches(msg, filePickerKeyMap.InsertCWD):
133− f.cwd.Focus()
133+// f.cwd.Focus()
134− return f, cmd
134+// return f, cmd
135− case key.Matches(msg, filePickerKeyMap.Esc):
135+// case key.Matches(msg, filePickerKeyMap.Esc):
136− if f.cwd.Focused() {
136+// if f.cwd.Focused() {
137− f.cwd.Blur()
137+// f.cwd.Blur()
138− }
138+// }
139− case key.Matches(msg, filePickerKeyMap.Down):
139+// case key.Matches(msg, filePickerKeyMap.Down):
140− if !f.cwd.Focused() || msg.String() == downArrow {
140+// if !f.cwd.Focused() || msg.String() == downArrow {
141− if f.cursor < len(f.dirs)-1 {
141+// if f.cursor < len(f.dirs)-1 {
142− f.cursor++
142+// f.cursor++
143− f.getCurrentFileBelowCursor()
143+// f.getCurrentFileBelowCursor()
144− }
144+// }
145− }
145+// }
146− case key.Matches(msg, filePickerKeyMap.Up):
146+// case key.Matches(msg, filePickerKeyMap.Up):
147− if !f.cwd.Focused() || msg.String() == upArrow {
147+// if !f.cwd.Focused() || msg.String() == upArrow {
148− if f.cursor > 0 {
148+// if f.cursor > 0 {
149− f.cursor--
149+// f.cursor--
150− f.getCurrentFileBelowCursor()
150+// f.getCurrentFileBelowCursor()
151− }
151+// }
152− }
152+// }
153− case key.Matches(msg, filePickerKeyMap.Enter):
153+// case key.Matches(msg, filePickerKeyMap.Enter):
154− var path string
154+// var path string
155− var isPathDir bool
155+// var isPathDir bool
156− if f.cwd.Focused() {
156+// if f.cwd.Focused() {
157− path = f.cwd.Value()
157+// path = f.cwd.Value()
158− fileInfo, err := os.Stat(path)
158+// fileInfo, err := os.Stat(path)
159− if err != nil {
159+// if err != nil {
160− logging.ErrorPersist("Invalid path")
160+// logging.ErrorPersist("Invalid path")
161− return f, cmd
161+// return f, cmd
162− }
162+// }
163− isPathDir = fileInfo.IsDir()
163+// isPathDir = fileInfo.IsDir()
164− } else {
164+// } else {
165− path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
165+// path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
166− isPathDir = f.dirs[f.cursor].IsDir()
166+// isPathDir = f.dirs[f.cursor].IsDir()
167− }
167+// }
168− if isPathDir {
168+// if isPathDir {
169− newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
169+// newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
170− f.cwdDetails.child = &newWorkingDir
170+// f.cwdDetails.child = &newWorkingDir
171− f.cwdDetails = f.cwdDetails.child
171+// f.cwdDetails = f.cwdDetails.child
172− f.cursorChain = f.cursorChain.Push(f.cursor)
172+// f.cursorChain = f.cursorChain.Push(f.cursor)
173− f.dirs = readDir(f.cwdDetails.directory, false)
173+// f.dirs = readDir(f.cwdDetails.directory, false)
174− f.cursor = 0
174+// f.cursor = 0
175− f.cwd.SetValue(f.cwdDetails.directory)
175+// f.cwd.SetValue(f.cwdDetails.directory)
176− f.getCurrentFileBelowCursor()
176+// f.getCurrentFileBelowCursor()
177− } else {
177+// } else {
178− f.selectedFile = path
178+// f.selectedFile = path
179− return f.addAttachmentToMessage()
179+// return f.addAttachmentToMessage()
180− }
180+// }
181− case key.Matches(msg, filePickerKeyMap.Esc):
181+// case key.Matches(msg, filePickerKeyMap.Esc):
182− if !f.cwd.Focused() {
182+// if !f.cwd.Focused() {
183− f.cursorChain = make(stack, 0)
183+// f.cursorChain = make(stack, 0)
184− f.cursor = 0
184+// f.cursor = 0
185− } else {
185+// } else {
186− f.cwd.Blur()
186+// f.cwd.Blur()
187− }
187+// }
188− case key.Matches(msg, filePickerKeyMap.Forward):
188+// case key.Matches(msg, filePickerKeyMap.Forward):
189− if !f.cwd.Focused() {
189+// if !f.cwd.Focused() {
190− if f.dirs[f.cursor].IsDir() {
190+// if f.dirs[f.cursor].IsDir() {
191− path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
191+// path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
192− newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
192+// newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
193− f.cwdDetails.child = &newWorkingDir
193+// f.cwdDetails.child = &newWorkingDir
194− f.cwdDetails = f.cwdDetails.child
194+// f.cwdDetails = f.cwdDetails.child
195− f.cursorChain = f.cursorChain.Push(f.cursor)
195+// f.cursorChain = f.cursorChain.Push(f.cursor)
196− f.dirs = readDir(f.cwdDetails.directory, false)
196+// f.dirs = readDir(f.cwdDetails.directory, false)
197− f.cursor = 0
197+// f.cursor = 0
198− f.cwd.SetValue(f.cwdDetails.directory)
198+// f.cwd.SetValue(f.cwdDetails.directory)
199− f.getCurrentFileBelowCursor()
199+// f.getCurrentFileBelowCursor()
200− }
200+// }
201− }
201+// }
202− case key.Matches(msg, filePickerKeyMap.Backward):
202+// case key.Matches(msg, filePickerKeyMap.Backward):
203− if !f.cwd.Focused() {
203+// if !f.cwd.Focused() {
204− if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
204+// if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
205− f.cursorChain, f.cursor = f.cursorChain.Pop()
205+// f.cursorChain, f.cursor = f.cursorChain.Pop()
206− f.cwdDetails = f.cwdDetails.parent
206+// f.cwdDetails = f.cwdDetails.parent
207− f.cwdDetails.child = nil
207+// f.cwdDetails.child = nil
208− f.dirs = readDir(f.cwdDetails.directory, false)
208+// f.dirs = readDir(f.cwdDetails.directory, false)
209− f.cwd.SetValue(f.cwdDetails.directory)
209+// f.cwd.SetValue(f.cwdDetails.directory)
210− f.getCurrentFileBelowCursor()
210+// f.getCurrentFileBelowCursor()
211− }
211+// }
212− }
212+// }
213− case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
213+// case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
214− f.dirs = readDir(f.cwdDetails.directory, false)
214+// f.dirs = readDir(f.cwdDetails.directory, false)
215− f.cursor = 0
215+// f.cursor = 0
216− f.getCurrentFileBelowCursor()
216+// f.getCurrentFileBelowCursor()
217− }
217+// }
218− }
218+// }
219− return f, cmd
219+// return f, cmd
220−}
220+// }
221−
221+//
222−func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
222+// func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
223− // modeInfo := GetSelectedModel(config.Get())
223+// // modeInfo := GetSelectedModel(config.Get())
224− // if !modeInfo.SupportsAttachments {
224+// // if !modeInfo.SupportsAttachments {
225− // logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
225+// // logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
226− // return f, nil
226+// // return f, nil
227− // }
227+// // }
228−
228+//
229− selectedFilePath := f.selectedFile
229+// selectedFilePath := f.selectedFile
230− if !isExtSupported(selectedFilePath) {
230+// if !isExtSupported(selectedFilePath) {
231− logging.ErrorPersist("Unsupported file")
231+// logging.ErrorPersist("Unsupported file")
232− return f, nil
232+// return f, nil
233− }
233+// }
234−
234+//
235− isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
235+// isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
236− if err != nil {
236+// if err != nil {
237− logging.ErrorPersist("unable to read the image")
237+// logging.ErrorPersist("unable to read the image")
238− return f, nil
238+// return f, nil
239− }
239+// }
240− if isFileLarge {
240+// if isFileLarge {
241− logging.ErrorPersist("file too large, max 5MB")
241+// logging.ErrorPersist("file too large, max 5MB")
242− return f, nil
242+// return f, nil
243− }
243+// }
244−
244+//
245− content, err := os.ReadFile(selectedFilePath)
245+// content, err := os.ReadFile(selectedFilePath)
246− if err != nil {
246+// if err != nil {
247− logging.ErrorPersist("Unable read selected file")
247+// logging.ErrorPersist("Unable read selected file")
248− return f, nil
248+// return f, nil
249− }
249+// }
250−
250+//
251− mimeBufferSize := min(512, len(content))
251+// mimeBufferSize := min(512, len(content))
252− mimeType := http.DetectContentType(content[:mimeBufferSize])
252+// mimeType := http.DetectContentType(content[:mimeBufferSize])
253− fileName := filepath.Base(selectedFilePath)
253+// fileName := filepath.Base(selectedFilePath)
254− attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
254+// attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
255− f.selectedFile = ""
255+// f.selectedFile = ""
256− return f, util.CmdHandler(AttachmentAddedMsg{attachment})
256+// return f, util.CmdHandler(AttachmentAddedMsg{attachment})
257−}
257+// }
258−
258+//
259−func (f *filepickerCmp) View() tea.View {
259+// func (f *filepickerCmp) View() tea.View {
260− t := styles.CurrentTheme()
260+// t := styles.CurrentTheme()
261− baseStyle := t.S().Base
261+// baseStyle := t.S().Base
262− const maxVisibleDirs = 20
262+// const maxVisibleDirs = 20
263− const maxWidth = 80
263+// const maxWidth = 80
264−
264+//
265− adjustedWidth := maxWidth
265+// adjustedWidth := maxWidth
266− for _, file := range f.dirs {
266+// for _, file := range f.dirs {
267− if len(file.Name()) > adjustedWidth-4 { // Account for padding
267+// if len(file.Name()) > adjustedWidth-4 { // Account for padding
268− adjustedWidth = len(file.Name()) + 4
268+// adjustedWidth = len(file.Name()) + 4
269− }
269+// }
270− }
270+// }
271− adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
271+// adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
272−
272+//
273− files := make([]string, 0, maxVisibleDirs)
273+// files := make([]string, 0, maxVisibleDirs)
274− startIdx := 0
274+// startIdx := 0
275−
275+//
276− if len(f.dirs) > maxVisibleDirs {
276+// if len(f.dirs) > maxVisibleDirs {
277− halfVisible := maxVisibleDirs / 2
277+// halfVisible := maxVisibleDirs / 2
278− if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
278+// if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
279− startIdx = f.cursor - halfVisible
279+// startIdx = f.cursor - halfVisible
280− } else if f.cursor >= len(f.dirs)-halfVisible {
280+// } else if f.cursor >= len(f.dirs)-halfVisible {
281− startIdx = len(f.dirs) - maxVisibleDirs
281+// startIdx = len(f.dirs) - maxVisibleDirs
282− }
282+// }
283− }
283+// }
284−
284+//
285− endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
285+// endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
286−
286+//
287− for i := startIdx; i < endIdx; i++ {
287+// for i := startIdx; i < endIdx; i++ {
288− file := f.dirs[i]
288+// file := f.dirs[i]
289− itemStyle := t.S().Text.Width(adjustedWidth)
289+// itemStyle := t.S().Text.Width(adjustedWidth)
290−
290+//
291− if i == f.cursor {
291+// if i == f.cursor {
292− itemStyle = itemStyle.
292+// itemStyle = itemStyle.
293− Background(t.Primary).
293+// Background(t.Primary).
294− Bold(true)
294+// Bold(true)
295− }
295+// }
296− filename := file.Name()
296+// filename := file.Name()
297−
297+//
298− if len(filename) > adjustedWidth-4 {
298+// if len(filename) > adjustedWidth-4 {
299− filename = filename[:adjustedWidth-7] + "..."
299+// filename = filename[:adjustedWidth-7] + "..."
300− }
300+// }
301− if file.IsDir() {
301+// if file.IsDir() {
302− filename = filename + "/"
302+// filename = filename + "/"
303− }
303+// }
304− // No need to reassign filename if it's not changing
304+// // No need to reassign filename if it's not changing
305−
305+//
306− files = append(files, itemStyle.Padding(0, 1).Render(filename))
306+// files = append(files, itemStyle.Padding(0, 1).Render(filename))
307− }
307+// }
308−
308+//
309− // Pad to always show exactly 21 lines
309+// // Pad to always show exactly 21 lines
310− for len(files) < maxVisibleDirs {
310+// for len(files) < maxVisibleDirs {
311− files = append(files, baseStyle.Width(adjustedWidth).Render(""))
311+// files = append(files, baseStyle.Width(adjustedWidth).Render(""))
312− }
312+// }
313−
313+//
314− currentPath := baseStyle.
314+// currentPath := baseStyle.
315− Height(1).
315+// Height(1).
316− Width(adjustedWidth).
316+// Width(adjustedWidth).
317− Render(f.cwd.View())
317+// Render(f.cwd.View())
318−
318+//
319− viewportstyle := baseStyle.
319+// viewportstyle := baseStyle.
320− Width(f.viewport.Width()).
320+// Width(f.viewport.Width()).
321− Border(lipgloss.RoundedBorder()).
321+// Border(lipgloss.RoundedBorder()).
322− BorderForeground(t.BorderFocus).
322+// BorderForeground(t.BorderFocus).
323− Padding(2).
323+// Padding(2).
324− Render(f.viewport.View())
324+// Render(f.viewport.View())
325− var insertExitText string
325+// var insertExitText string
326− if f.IsCWDFocused() {
326+// if f.IsCWDFocused() {
327− insertExitText = "Press esc to exit typing path"
327+// insertExitText = "Press esc to exit typing path"
328− } else {
328+// } else {
329− insertExitText = "Press i to start typing path"
329+// insertExitText = "Press i to start typing path"
330− }
330+// }
331−
331+//
332− content := lipgloss.JoinVertical(
332+// content := lipgloss.JoinVertical(
333− lipgloss.Left,
333+// lipgloss.Left,
334− currentPath,
334+// currentPath,
335− baseStyle.Width(adjustedWidth).Render(""),
335+// baseStyle.Width(adjustedWidth).Render(""),
336− baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
336+// baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
337− baseStyle.Width(adjustedWidth).Render(""),
337+// baseStyle.Width(adjustedWidth).Render(""),
338− t.S().Muted.Width(adjustedWidth).Render(insertExitText),
338+// t.S().Muted.Width(adjustedWidth).Render(insertExitText),
339− )
339+// )
340−
340+//
341− f.cwd.SetValue(f.cwd.Value())
341+// f.cwd.SetValue(f.cwd.Value())
342− contentStyle := baseStyle.Padding(1, 2).
342+// contentStyle := baseStyle.Padding(1, 2).
343− Border(lipgloss.RoundedBorder()).
343+// Border(lipgloss.RoundedBorder()).
344− BorderForeground(t.BorderFocus).
344+// BorderForeground(t.BorderFocus).
345− Width(lipgloss.Width(content) + 4)
345+// Width(lipgloss.Width(content) + 4)
346−
346+//
347− return tea.NewView(
347+// return tea.NewView(
348− lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
348+// lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
349− )
349+// )
350−}
350+// }
351−
351+//
352−type FilepickerCmp interface {
352+// type FilepickerCmp interface {
353− util.Model
353+// util.Model
354− ToggleFilepicker(showFilepicker bool)
354+// ToggleFilepicker(showFilepicker bool)
355− IsCWDFocused() bool
355+// IsCWDFocused() bool
356−}
356+// }
357−
357+//
358−func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
358+// func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
359− f.ShowFilePicker = showFilepicker
359+// f.ShowFilePicker = showFilepicker
360−}
360+// }
361−
361+//
362−func (f *filepickerCmp) IsCWDFocused() bool {
362+// func (f *filepickerCmp) IsCWDFocused() bool {
363− return f.cwd.Focused()
363+// return f.cwd.Focused()
364−}
364+// }
365−
365+//
366−func NewFilepickerCmp(app *app.App) FilepickerCmp {
366+// func NewFilepickerCmp(app *app.App) FilepickerCmp {
367− homepath, err := os.UserHomeDir()
367+// homepath, err := os.UserHomeDir()
368− if err != nil {
368+// if err != nil {
369− logging.Error("error loading user files")
369+// logging.Error("error loading user files")
370− return nil
370+// return nil
371− }
371+// }
372− baseDir := DirNode{parent: nil, directory: homepath}
372+// baseDir := DirNode{parent: nil, directory: homepath}
373− dirs := readDir(homepath, false)
373+// dirs := readDir(homepath, false)
374− viewport := viewport.New()
374+// viewport := viewport.New()
375− currentDirectory := textinput.New()
375+// currentDirectory := textinput.New()
376− currentDirectory.CharLimit = 200
376+// currentDirectory.CharLimit = 200
377− currentDirectory.SetWidth(44)
377+// currentDirectory.SetWidth(44)
378− currentDirectory.Cursor().Blink = true
378+// currentDirectory.Cursor().Blink = true
379− currentDirectory.SetValue(baseDir.directory)
379+// currentDirectory.SetValue(baseDir.directory)
380− return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
380+// return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
381−}
381+// }
382−
382+//
383−func (f *filepickerCmp) getCurrentFileBelowCursor() {
383+// func (f *filepickerCmp) getCurrentFileBelowCursor() {
384− if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
384+// if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
385− logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
385+// logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
386− f.viewport.SetContent("Preview unavailable")
386+// f.viewport.SetContent("Preview unavailable")
387− return
387+// return
388− }
388+// }
389−
389+//
390− dir := f.dirs[f.cursor]
390+// dir := f.dirs[f.cursor]
391− filename := dir.Name()
391+// filename := dir.Name()
392− if !dir.IsDir() && isExtSupported(filename) {
392+// if !dir.IsDir() && isExtSupported(filename) {
393− fullPath := f.cwdDetails.directory + "/" + dir.Name()
393+// fullPath := f.cwdDetails.directory + "/" + dir.Name()
394−
394+//
395− go func() {
395+// go func() {
396− imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
396+// imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
397− if err != nil {
397+// if err != nil {
398− logging.Error(err.Error())
398+// logging.Error(err.Error())
399− f.viewport.SetContent("Preview unavailable")
399+// f.viewport.SetContent("Preview unavailable")
400− return
400+// return
401− }
401+// }
402−
402+//
403− f.viewport.SetContent(imageString)
403+// f.viewport.SetContent(imageString)
404− }()
404+// }()
405− } else {
405+// } else {
406− f.viewport.SetContent("Preview unavailable")
406+// f.viewport.SetContent("Preview unavailable")
407− }
407+// }
408−}
408+// }
409−
409+//
410−func readDir(path string, showHidden bool) []os.DirEntry {
410+// func readDir(path string, showHidden bool) []os.DirEntry {
411− logging.Info(fmt.Sprintf("Reading directory: %s", path))
411+// logging.Info(fmt.Sprintf("Reading directory: %s", path))
412−
412+//
413− entriesChan := make(chan []os.DirEntry, 1)
413+// entriesChan := make(chan []os.DirEntry, 1)
414− errChan := make(chan error, 1)
414+// errChan := make(chan error, 1)
415−
415+//
416− go func() {
416+// go func() {
417− dirEntries, err := os.ReadDir(path)
417+// dirEntries, err := os.ReadDir(path)
418− if err != nil {
418+// if err != nil {
419− logging.ErrorPersist(err.Error())
419+// logging.ErrorPersist(err.Error())
420− errChan <- err
420+// errChan <- err
421− return
421+// return
422− }
422+// }
423− entriesChan <- dirEntries
423+// entriesChan <- dirEntries
424− }()
424+// }()
425−
425+//
426− select {
426+// select {
427− case dirEntries := <-entriesChan:
427+// case dirEntries := <-entriesChan:
428− sort.Slice(dirEntries, func(i, j int) bool {
428+// sort.Slice(dirEntries, func(i, j int) bool {
429− if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
429+// if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
430− return dirEntries[i].Name() < dirEntries[j].Name()
430+// return dirEntries[i].Name() < dirEntries[j].Name()
431− }
431+// }
432− return dirEntries[i].IsDir()
432+// return dirEntries[i].IsDir()
433− })
433+// })
434−
434+//
435− if showHidden {
435+// if showHidden {
436− return dirEntries
436+// return dirEntries
437− }
437+// }
438−
438+//
439− var sanitizedDirEntries []os.DirEntry
439+// var sanitizedDirEntries []os.DirEntry
440− for _, dirEntry := range dirEntries {
440+// for _, dirEntry := range dirEntries {
441− isHidden, _ := IsHidden(dirEntry.Name())
441+// isHidden, _ := IsHidden(dirEntry.Name())
442− if !isHidden {
442+// if !isHidden {
443− if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
443+// if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
444− sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
444+// sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
445− }
445+// }
446− }
446+// }
447− }
447+// }
448−
448+//
449− return sanitizedDirEntries
449+// return sanitizedDirEntries
450−
450+//
451− case err := <-errChan:
451+// case err := <-errChan:
452− logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
452+// logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
453− return []os.DirEntry{}
453+// return []os.DirEntry{}
454−
454+//
455− case <-time.After(5 * time.Second):
455+// case <-time.After(5 * time.Second):
456− logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
456+// logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
457− return []os.DirEntry{}
457+// return []os.DirEntry{}
458− }
458+// }
459−}
459+// }
460−
460+//
461−func IsHidden(file string) (bool, error) {
461+// func IsHidden(file string) (bool, error) {
462− return strings.HasPrefix(file, "."), nil
462+// return strings.HasPrefix(file, "."), nil
463−}
463+// }
464−
464+//
465−func isExtSupported(path string) bool {
465+// func isExtSupported(path string) bool {
466− ext := strings.ToLower(filepath.Ext(path))
466+// ext := strings.ToLower(filepath.Ext(path))
467− return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
467+// return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
468−}
468+// }
internal/tui/components/dialogs/filepicker/filepicker.go+31−16
@@ -9,6 +9,7 @@ import (
9 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/opencode-ai/opencode/internal/tui/components/core"
10 "github.com/opencode-ai/opencode/internal/tui/components/core"
11 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
11 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
12+ "github.com/opencode-ai/opencode/internal/tui/components/image"
12 "github.com/opencode-ai/opencode/internal/tui/styles"
13 "github.com/opencode-ai/opencode/internal/tui/styles"
13)
14)
14
15
@@ -23,11 +24,13 @@ type FilePicker interface {
23}
24}
24
25
25type filePicker struct {
26type filePicker struct {
26− wWidth int
27+ wWidth int
27− wHeight int
28+ wHeight int
28− width int
29+ width int
29− filepicker filepicker.Model
30+ filepicker filepicker.Model
30− selectedFile string
31+ selectedFile string
32+ highlightedFile string
33+ image image.Model
31}
34}
32
35
33func NewFilePickerCmp() FilePicker {
36func NewFilePickerCmp() FilePicker {
@@ -42,7 +45,8 @@ func NewFilePickerCmp() FilePicker {
42 fp.Cursor = ""
45 fp.Cursor = ""
43 fp.SetHeight(fileSelectionHight)
46 fp.SetHeight(fileSelectionHight)
44
47
48+ image := image.New(1, 1, "")
45− return &filePicker{filepicker: fp}
49+ return &filePicker{filepicker: fp, image: image}
46}
50}
47
51
48func (m *filePicker) Init() tea.Cmd {
52func (m *filePicker) Init() tea.Cmd {
@@ -65,15 +69,24 @@ func (m *filePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65 }
69 }
66
70
67 var cmd tea.Cmd
71 var cmd tea.Cmd
72+ var cmds []tea.Cmd
68 m.filepicker, cmd = m.filepicker.Update(msg)
73 m.filepicker, cmd = m.filepicker.Update(msg)
74+ cmds = append(cmds, cmd)
75+ if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
76+ w, h := m.imagePreviewSize()
77+ cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
78+ cmds = append(cmds, cmd)
79+ }
80+ m.highlightedFile = m.currentImage()
69
81
70 // Did the user select a file?
82 // Did the user select a file?
71 if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
83 if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
72 // Get the path of the selected file.
84 // Get the path of the selected file.
73 m.selectedFile = path
85 m.selectedFile = path
74 }
86 }
75−
87+ m.image, cmd = m.image.Update(msg)
88+ cmds = append(cmds, cmd)
76− return m, cmd
89+ return m, tea.Batch(cmds...)
77}
90}
78
91
79func (m *filePicker) View() tea.View {
92func (m *filePicker) View() tea.View {
@@ -98,21 +111,23 @@ func (m *filePicker) currentImage() string {
98}
111}
99
112
100func (m *filePicker) imagePreview() string {
113func (m *filePicker) imagePreview() string {
114+ t := styles.CurrentTheme()
115+ w, h := m.imagePreviewSize()
101 if m.currentImage() == "" {
116 if m.currentImage() == "" {
117+ imgPreview := t.S().Base.
118+ Width(w).
119+ Height(h).
120+ Background(t.BgOverlay)
121+
102− return m.imagePreviewStyle().Render()
122+ return m.imagePreviewStyle().Render(imgPreview.Render())
103 }
123 }
104
124
105− return ""
125+ return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
106}
126}
107
127
108func (m *filePicker) imagePreviewStyle() lipgloss.Style {
128func (m *filePicker) imagePreviewStyle() lipgloss.Style {
109 t := styles.CurrentTheme()
129 t := styles.CurrentTheme()
110− w, h := m.imagePreviewSize()
111− return t.S().Base.
130+ return t.S().Base.Padding(1, 1, 1, 1)
112− Width(w).
113− Height(h).
114− Margin(1).
115− Background(t.BgOverlay)
116}
131}
117
132
118func (m *filePicker) imagePreviewSize() (int, int) {
133func (m *filePicker) imagePreviewSize() (int, int) {
internal/tui/components/image/image.gonew file+89
@@ -0,0 +1,89 @@
1+// Based on the implementation by @trashhalo at:
2+// https://github.com/trashhalo/imgcat
3+package image
4+
5+import (
6+ "context"
7+ "fmt"
8+ _ "image/jpeg"
9+ _ "image/png"
10+
11+ tea "github.com/charmbracelet/bubbletea/v2"
12+)
13+
14+type Model struct {
15+ url string
16+ image string
17+ width uint
18+ height uint
19+ err error
20+
21+ cancelAnimation context.CancelFunc
22+}
23+
24+func New(width, height uint, url string) Model {
25+ return Model{
26+ width: width,
27+ height: height,
28+ url: url,
29+ }
30+}
31+
32+func (m Model) Init() tea.Cmd {
33+ return nil
34+}
35+
36+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
37+ switch msg := msg.(type) {
38+ case errMsg:
39+ m.err = msg
40+ return m, nil
41+ case rewdrawMsg:
42+ m.width = msg.width
43+ m.height = msg.height
44+ m.url = msg.url
45+ return m, loadUrl(m.url)
46+ case loadMsg:
47+ return handleLoadMsg(m, msg)
48+ }
49+ return m, nil
50+}
51+
52+func (m Model) View() string {
53+ if m.err != nil {
54+ return fmt.Sprintf("couldn't load image(s): %v", m.err)
55+ }
56+ return m.image
57+}
58+
59+type errMsg struct{ error }
60+
61+func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
62+ return func() tea.Msg {
63+ return rewdrawMsg{
64+ width: width,
65+ height: height,
66+ url: url,
67+ }
68+ }
69+}
70+
71+func (m Model) UpdateUrl(url string) tea.Cmd {
72+ return func() tea.Msg {
73+ return rewdrawMsg{
74+ width: m.width,
75+ height: m.height,
76+ url: url,
77+ }
78+ }
79+}
80+
81+type rewdrawMsg struct {
82+ width uint
83+ height uint
84+ url string
85+}
86+
87+func (m Model) IsLoading() bool {
88+ return m.image == ""
89+}
internal/tui/components/image/load.gonew file+157
@@ -0,0 +1,157 @@
1+// Based on the implementation by @trashhalo at:
2+// https://github.com/trashhalo/imgcat
3+package image
4+
5+import (
6+ "image"
7+ "image/png"
8+ "io"
9+ "io/ioutil"
10+ "net/http"
11+ "os"
12+ "strings"
13+
14+ tea "github.com/charmbracelet/bubbletea/v2"
15+ "github.com/disintegration/imageorient"
16+ "github.com/lucasb-eyer/go-colorful"
17+ "github.com/muesli/termenv"
18+ "github.com/nfnt/resize"
19+ "github.com/srwiley/oksvg"
20+ "github.com/srwiley/rasterx"
21+)
22+
23+type loadMsg struct {
24+ io.ReadCloser
25+}
26+
27+func loadUrl(url string) tea.Cmd {
28+ var r io.ReadCloser
29+ var err error
30+
31+ if strings.HasPrefix(url, "http") {
32+ var resp *http.Response
33+ resp, err = http.Get(url)
34+ r = resp.Body
35+ } else {
36+ r, err = os.Open(url)
37+ }
38+
39+ if err != nil {
40+ return func() tea.Msg {
41+ return errMsg{err}
42+ }
43+ }
44+
45+ return load(r)
46+}
47+
48+func load(r io.ReadCloser) tea.Cmd {
49+ return func() tea.Msg {
50+ return loadMsg{r}
51+ }
52+}
53+
54+func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
55+ if m.cancelAnimation != nil {
56+ m.cancelAnimation()
57+ }
58+
59+ // blank out image so it says "loading..."
60+ m.image = ""
61+
62+ return handleLoadMsgStatic(m, msg)
63+}
64+
65+func handleLoadMsgStatic(m Model, msg loadMsg) (Model, tea.Cmd) {
66+ defer msg.Close()
67+
68+ img, err := readerToimage(m.width, m.height, m.url, msg)
69+ if err != nil {
70+ return m, func() tea.Msg { return errMsg{err} }
71+ }
72+ m.image = img
73+ return m, nil
74+}
75+
76+func imageToString(width, height uint, url string, img image.Image) (string, error) {
77+ img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
78+ b := img.Bounds()
79+ w := b.Max.X
80+ h := b.Max.Y
81+ p := termenv.ColorProfile()
82+ str := strings.Builder{}
83+ for y := 0; y < h; y += 2 {
84+ for x := w; x < int(width); x = x + 2 {
85+ str.WriteString(" ")
86+ }
87+ for x := 0; x < w; x++ {
88+ c1, _ := colorful.MakeColor(img.At(x, y))
89+ color1 := p.Color(c1.Hex())
90+ c2, _ := colorful.MakeColor(img.At(x, y+1))
91+ color2 := p.Color(c2.Hex())
92+ str.WriteString(termenv.String("▀").
93+ Foreground(color1).
94+ Background(color2).
95+ String())
96+ }
97+ str.WriteString("\n")
98+ }
99+ return str.String(), nil
100+}
101+
102+func readerToimage(width uint, height uint, url string, r io.Reader) (string, error) {
103+ if strings.HasSuffix(strings.ToLower(url), ".svg") {
104+ return svgToimage(width, height, url, r)
105+ }
106+
107+ img, _, err := imageorient.Decode(r)
108+ if err != nil {
109+ return "", err
110+ }
111+
112+ return imageToString(width, height, url, img)
113+}
114+
115+func svgToimage(width uint, height uint, url string, r io.Reader) (string, error) {
116+ // Original author: https://stackoverflow.com/users/10826783/usual-human
117+ // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
118+ // Adapted to use size from SVG, and to use temp file.
119+
120+ tmpPngFile, err := ioutil.TempFile("", "imgcat.*.png")
121+ if err != nil {
122+ return "", err
123+ }
124+ tmpPngPath := tmpPngFile.Name()
125+ defer os.Remove(tmpPngPath)
126+ defer tmpPngFile.Close()
127+
128+ // Rasterize the SVG:
129+ icon, err := oksvg.ReadIconStream(r)
130+ if err != nil {
131+ return "", err
132+ }
133+ w := int(icon.ViewBox.W)
134+ h := int(icon.ViewBox.H)
135+ icon.SetTarget(0, 0, float64(w), float64(h))
136+ rgba := image.NewRGBA(image.Rect(0, 0, w, h))
137+ icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1)
138+ // Write rasterized image as PNG:
139+ err = png.Encode(tmpPngFile, rgba)
140+ if err != nil {
141+ tmpPngFile.Close()
142+ return "", err
143+ }
144+ tmpPngFile.Close()
145+
146+ rPng, err := os.Open(tmpPngPath)
147+ if err != nil {
148+ return "", err
149+ }
150+ defer rPng.Close()
151+
152+ img, _, err := imageorient.Decode(rPng)
153+ if err != nil {
154+ return "", err
155+ }
156+ return imageToString(width, height, url, img)
157+}
internal/tui/image/images.godeleted−73
@@ -1,73 +0,0 @@
1−package image
2−
3−import (
4− "fmt"
5− "image"
6− "image/color"
7− "os"
8− "strings"
9−
10− "github.com/charmbracelet/lipgloss/v2"
11− "github.com/disintegration/imaging"
12− "github.com/lucasb-eyer/go-colorful"
13−)
14−
15−func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
16− fileInfo, err := os.Stat(filePath)
17− if err != nil {
18− return false, fmt.Errorf("error getting file info: %w", err)
19− }
20−
21− if fileInfo.Size() > sizeLimit {
22− return true, nil
23− }
24−
25− return false, nil
26−}
27−
28−func ToString(width int, img image.Image) string {
29− img = imaging.Resize(img, width, 0, imaging.Lanczos)
30− b := img.Bounds()
31− imageWidth := b.Max.X
32− h := b.Max.Y
33− str := strings.Builder{}
34−
35− for heightCounter := 0; heightCounter < h; heightCounter += 2 {
36− for x := range imageWidth {
37− c1, _ := colorful.MakeColor(img.At(x, heightCounter))
38− color1 := lipgloss.Color(c1.Hex())
39−
40− var color2 color.Color
41− if heightCounter+1 < h {
42− c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
43− color2 = lipgloss.Color(c2.Hex())
44− } else {
45− color2 = color1
46− }
47−
48− str.WriteString(lipgloss.NewStyle().Foreground(color1).
49− Background(color2).Render("▀"))
50− }
51−
52− str.WriteString("\n")
53− }
54−
55− return str.String()
56−}
57−
58−func ImagePreview(width int, filename string) (string, error) {
59− imageContent, err := os.Open(filename)
60− if err != nil {
61− return "", err
62− }
63− defer imageContent.Close()
64−
65− img, _, err := image.Decode(imageContent)
66− if err != nil {
67− return "", err
68− }
69−
70− imageString := ToString(width, img)
71−
72− return imageString, nil
73−}