Просмотр исходного кода

feat(tui): paste images and pdfs

adamdottv 7 месяцев назад
Родитель
Сommit
662d022a48

+ 1 - 1
package.json

@@ -7,7 +7,7 @@
   "scripts": {
     "dev": "bun run packages/opencode/src/index.ts",
     "typecheck": "bun run --filter='*' typecheck",
-    "stainless": "bun run ./packages/opencode/src/index.ts serve ",
+    "stainless": "./scripts/stainless",
     "postinstall": "./scripts/hooks"
   },
   "workspaces": {

+ 9 - 3
packages/opencode/src/config/config.ts

@@ -58,23 +58,26 @@ export namespace Config {
   export const Keybinds = z
     .object({
       leader: z.string().optional().describe("Leader key for keybind combinations"),
-      help: z.string().optional().describe("Show help dialog"),
+      app_help: z.string().optional().describe("Show help dialog"),
       editor_open: z.string().optional().describe("Open external editor"),
       session_new: z.string().optional().describe("Create a new session"),
       session_list: z.string().optional().describe("List all sessions"),
       session_share: z.string().optional().describe("Share current session"),
+      session_unshare: z.string().optional().describe("Unshare current session"),
       session_interrupt: z.string().optional().describe("Interrupt current session"),
       session_compact: z.string().optional().describe("Toggle compact mode for session"),
       tool_details: z.string().optional().describe("Show tool details"),
       model_list: z.string().optional().describe("List available models"),
       theme_list: z.string().optional().describe("List available themes"),
+      file_list: z.string().optional().describe("List files"),
+      file_close: z.string().optional().describe("Close file"),
+      file_search: z.string().optional().describe("Search file"),
+      file_diff_toggle: z.string().optional().describe("Toggle split/unified diff"),
       project_init: z.string().optional().describe("Initialize project configuration"),
       input_clear: z.string().optional().describe("Clear input field"),
       input_paste: z.string().optional().describe("Paste from clipboard"),
       input_submit: z.string().optional().describe("Submit input"),
       input_newline: z.string().optional().describe("Insert newline in input"),
-      history_previous: z.string().optional().describe("Navigate to previous history item"),
-      history_next: z.string().optional().describe("Navigate to next history item"),
       messages_page_up: z.string().optional().describe("Scroll messages up by one page"),
       messages_page_down: z.string().optional().describe("Scroll messages down by one page"),
       messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"),
@@ -83,6 +86,9 @@ export namespace Config {
       messages_next: z.string().optional().describe("Navigate to next message"),
       messages_first: z.string().optional().describe("Navigate to first message"),
       messages_last: z.string().optional().describe("Navigate to last message"),
+      messages_layout_toggle: z.string().optional().describe("Toggle layout"),
+      messages_copy: z.string().optional().describe("Copy message"),
+      messages_revert: z.string().optional().describe("Revert message"),
       app_exit: z.string().optional().describe("Exit the application"),
     })
     .strict()

+ 8 - 0
packages/tui/cmd/opencode/main.go

@@ -14,6 +14,7 @@ import (
 	"github.com/sst/opencode-sdk-go/option"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/tui"
+	"golang.design/x/clipboard"
 )
 
 var Version = "dev"
@@ -66,6 +67,13 @@ func main() {
 		os.Exit(1)
 	}
 
+	go func() {
+		err = clipboard.Init()
+		if err != nil {
+			slog.Error("Failed to initialize clipboard", "error", err)
+		}
+	}()
+
 	// Create main context for the application
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()

+ 12 - 10
packages/tui/go.mod

@@ -17,6 +17,7 @@ require (
 	github.com/muesli/termenv v0.16.0
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 	github.com/sst/opencode-sdk-go v0.1.0-alpha.8
+	golang.design/x/clipboard v0.7.1
 	rsc.io/qr v0.2.0
 )
 
@@ -54,8 +55,10 @@ require (
 	github.com/tidwall/pretty v1.2.1 // indirect
 	github.com/tidwall/sjson v1.2.5 // indirect
 	github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
-	golang.org/x/mod v0.24.0 // indirect
-	golang.org/x/tools v0.31.0 // indirect
+	golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b // indirect
+	golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect
+	golang.org/x/mod v0.25.0 // indirect
+	golang.org/x/tools v0.34.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 )
 
@@ -66,7 +69,6 @@ require (
 	github.com/charmbracelet/colorprofile v0.3.1 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect
 	github.com/charmbracelet/x/term v0.2.1 // indirect
-	github.com/disintegration/imaging v1.6.2
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
@@ -78,16 +80,16 @@ require (
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/rivo/uniseg v0.4.7
 	github.com/rogpeppe/go-internal v1.14.1 // indirect
-	github.com/spf13/pflag v1.0.6 // indirect
+	github.com/spf13/pflag v1.0.6
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	github.com/yuin/goldmark v1.7.8 // indirect
 	github.com/yuin/goldmark-emoji v1.0.5 // indirect
-	golang.org/x/image v0.26.0
-	golang.org/x/net v0.39.0 // indirect
-	golang.org/x/sync v0.13.0 // indirect
-	golang.org/x/sys v0.32.0 // indirect
-	golang.org/x/term v0.31.0 // indirect
-	golang.org/x/text v0.24.0
+	golang.org/x/image v0.28.0 // indirect
+	golang.org/x/net v0.41.0 // indirect
+	golang.org/x/sync v0.15.0 // indirect
+	golang.org/x/sys v0.33.0 // indirect
+	golang.org/x/term v0.32.0 // indirect
+	golang.org/x/text v0.26.0
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
 

+ 22 - 19
packages/tui/go.sum

@@ -54,8 +54,6 @@ github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2
 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/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.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
@@ -212,20 +210,25 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
 github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
 github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
 github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
+golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c=
+golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
-golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
-golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
+golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b h1:zELBzk+7ERc6m8BxhzU2VYjp03wlEvi+cIgYQR5H3CI=
+golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
+golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
+golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
+golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8=
+golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
-golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -236,15 +239,15 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT
 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
-golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
+golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
+golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
-golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -263,28 +266,28 @@ 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
-golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
-golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
+golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
-golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
-golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 12 - 0
packages/tui/internal/app/app.go

@@ -18,6 +18,7 @@ import (
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
+	"golang.design/x/clipboard"
 )
 
 type App struct {
@@ -146,6 +147,17 @@ func (a *App) Key(commandName commands.CommandName) string {
 	return base(key) + muted(" "+command.Description)
 }
 
+func (a *App) SetClipboard(text string) tea.Cmd {
+	var cmds []tea.Cmd
+	cmds = append(cmds, func() tea.Msg {
+		clipboard.Write(clipboard.FmtText, []byte(text))
+		return nil
+	})
+	// try to set the clipboard using OSC52 for terminals that support it
+	cmds = append(cmds, tea.SetClipboard(text))
+	return tea.Sequence(cmds...)
+}
+
 func (a *App) InitializeProvider() tea.Cmd {
 	providersResponse, err := a.Client.Config.Providers(context.Background())
 	if err != nil {

+ 1 - 1
packages/tui/internal/commands/command.go

@@ -231,7 +231,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 		{
 			Name:        InputPasteCommand,
 			Description: "paste content",
-			Keybindings: parseBindings("ctrl+v"),
+			Keybindings: parseBindings("ctrl+v", "super+v"),
 		},
 		{
 			Name:        InputSubmitCommand,

+ 76 - 17
packages/tui/internal/components/chat/editor.go

@@ -1,9 +1,12 @@
 package chat
 
 import (
+	"encoding/base64"
 	"fmt"
 	"log/slog"
+	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/spinner"
@@ -15,10 +18,10 @@ import (
 	"github.com/sst/opencode/internal/commands"
 	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/components/textarea"
-	"github.com/sst/opencode/internal/image"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
+	"golang.design/x/clipboard"
 )
 
 type EditorComponent interface {
@@ -63,6 +66,57 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 			return m, tea.Batch(cmds...)
 		}
+	case tea.PasteMsg:
+		text := string(msg)
+		text = strings.ReplaceAll(text, "\\", "")
+		text, err := strconv.Unquote(`"` + text + `"`)
+		if err != nil {
+			slog.Error("Failed to unquote text", "error", err)
+			m.textarea.InsertRunesFromUserInput([]rune(msg))
+			return m, nil
+		}
+		if _, err := os.Stat(text); err != nil {
+			slog.Error("Failed to paste file", "error", err)
+			m.textarea.InsertRunesFromUserInput([]rune(msg))
+			return m, nil
+		}
+
+		filePath := text
+		ext := strings.ToLower(filepath.Ext(filePath))
+
+		mediaType := ""
+		switch ext {
+		case ".jpg":
+			mediaType = "image/jpeg"
+		case ".png", ".jpeg", ".gif", ".webp":
+			mediaType = "image/" + ext[1:]
+		case ".pdf":
+			mediaType = "application/pdf"
+		default:
+			mediaType = "text/plain"
+		}
+
+		fileBytes, err := os.ReadFile(filePath)
+		if err != nil {
+			slog.Error("Failed to read file", "error", err)
+			m.textarea.InsertRunesFromUserInput([]rune(msg))
+			return m, nil
+		}
+		base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
+		url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
+
+		attachment := &textarea.Attachment{
+			ID:        uuid.NewString(),
+			Display:   fmt.Sprintf("<%s>", filePath),
+			URL:       url,
+			Filename:  filePath,
+			MediaType: mediaType,
+		}
+		m.textarea.InsertAttachment(attachment)
+		m.textarea.InsertString(" ")
+	case tea.ClipboardMsg:
+		text := string(msg)
+		m.textarea.InsertRunesFromUserInput([]rune(text))
 	case dialog.ThemeSelectedMsg:
 		m.textarea = m.resetTextareaStyles()
 		m.spinner = createSpinner()
@@ -269,24 +323,29 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
 }
 
 func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
-	_, text, err := image.GetImageFromClipboard()
-	if err != nil {
-		slog.Error(err.Error())
+	imageBytes := clipboard.Read(clipboard.FmtImage)
+	if imageBytes != nil {
+		base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
+		attachment := &textarea.Attachment{
+			ID:        uuid.NewString(),
+			Display:   "<clipboard-image>",
+			Filename:  "clipboard-image",
+			MediaType: "image/png",
+			URL:       fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
+		}
+		m.textarea.InsertAttachment(attachment)
+		m.textarea.InsertString(" ")
 		return m, nil
 	}
-	// if len(imageBytes) != 0 {
-	// 	attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
-	// 	attachment := app.Attachment{
-	// 		FilePath: attachmentName,
-	// 		FileName: attachmentName,
-	// 		Content:  imageBytes,
-	// 		MimeType: "image/png",
-	// 	}
-	// 	m.attachments = append(m.attachments, attachment)
-	// } else {
-	m.textarea.InsertString(text)
-	// }
-	return m, nil
+
+	textBytes := clipboard.Read(clipboard.FmtText)
+	if textBytes != nil {
+		m.textarea.InsertRunesFromUserInput([]rune(string(textBytes)))
+		return m, nil
+	}
+
+	// fallback to reading the clipboard using OSC52
+	return m, tea.ReadClipboard
 }
 
 func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {

+ 6 - 20
packages/tui/internal/components/textarea/textarea.go

@@ -11,7 +11,6 @@ import (
 
 	"slices"
 
-	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/bubbles/v2/cursor"
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -653,12 +652,12 @@ func (m *Model) SetValue(s string) {
 
 // InsertString inserts a string at the cursor position.
 func (m *Model) InsertString(s string) {
-	m.insertRunesFromUserInput([]rune(s))
+	m.InsertRunesFromUserInput([]rune(s))
 }
 
 // InsertRune inserts a rune at the cursor position.
 func (m *Model) InsertRune(r rune) {
-	m.insertRunesFromUserInput([]rune{r})
+	m.InsertRunesFromUserInput([]rune{r})
 }
 
 // InsertAttachment inserts an attachment at the cursor position.
@@ -730,8 +729,8 @@ func (m Model) GetAttachments() []*Attachment {
 	return attachments
 }
 
-// insertRunesFromUserInput inserts runes at the current cursor position.
-func (m *Model) insertRunesFromUserInput(runes []rune) {
+// InsertRunesFromUserInput inserts runes at the current cursor position.
+func (m *Model) InsertRunesFromUserInput(runes []rune) {
 	// Clean up any special characters in the input provided by the
 	// clipboard. This avoids bugs due to e.g. tab characters and
 	// whatnot.
@@ -1429,8 +1428,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
 	}
 
 	switch msg := msg.(type) {
-	case tea.PasteMsg:
-		m.insertRunesFromUserInput([]rune(msg))
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
@@ -1490,8 +1487,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
 			m.CursorDown()
 		case key.Matches(msg, m.KeyMap.WordForward):
 			m.wordRight()
-		case key.Matches(msg, m.KeyMap.Paste):
-			return m, Paste
 		case key.Matches(msg, m.KeyMap.CharacterBackward):
 			m.characterLeft(false /* insideLine */)
 		case key.Matches(msg, m.KeyMap.LinePrevious):
@@ -1512,11 +1507,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
 			m.transposeLeft()
 
 		default:
-			m.insertRunesFromUserInput([]rune(msg.Text))
+			m.InsertRunesFromUserInput([]rune(msg.Text))
 		}
 
 	case pasteMsg:
-		m.insertRunesFromUserInput([]rune(msg))
+		m.InsertRunesFromUserInput([]rune(msg))
 
 	case pasteErrMsg:
 		m.Err = msg
@@ -1908,15 +1903,6 @@ func (m *Model) splitLine(row, col int) {
 	m.row++
 }
 
-// Paste is a command for pasting from the clipboard into the text input.
-func Paste() tea.Msg {
-	str, err := clipboard.ReadAll()
-	if err != nil {
-		return pasteErrMsg{err}
-	}
-	return pasteMsg(str)
-}
-
 func wrapInterfaces(content []any, width int) [][]any {
 	if width <= 0 {
 		return [][]any{content}

+ 0 - 46
packages/tui/internal/image/clipboard_unix.go

@@ -1,46 +0,0 @@
-//go:build !windows
-
-package image
-
-import (
-	"bytes"
-	"fmt"
-	"github.com/atotto/clipboard"
-	"image"
-)
-
-func GetImageFromClipboard() ([]byte, string, error) {
-	text, err := clipboard.ReadAll()
-	if err != nil {
-		return nil, "", fmt.Errorf("Error reading clipboard")
-	}
-
-	if text == "" {
-		return nil, "", nil
-	}
-
-	binaryData := []byte(text)
-	imageBytes, err := binaryToImage(binaryData)
-	if err != nil {
-		return nil, text, nil
-	}
-	return imageBytes, "", nil
-
-}
-
-func binaryToImage(data []byte) ([]byte, error) {
-	reader := bytes.NewReader(data)
-	img, _, err := image.Decode(reader)
-	if err != nil {
-		return nil, fmt.Errorf("Unable to covert bytes to image")
-	}
-
-	return ImageToBytes(img)
-}
-
-func min(a, b int) int {
-	if a < b {
-		return a
-	}
-	return b
-}

+ 0 - 192
packages/tui/internal/image/clipboard_windows.go

@@ -1,192 +0,0 @@
-//go:build windows
-
-package image
-
-import (
-	"bytes"
-	"fmt"
-	"image"
-	"image/color"
-	"log/slog"
-	"syscall"
-	"unsafe"
-)
-
-var (
-	user32                     = syscall.NewLazyDLL("user32.dll")
-	kernel32                   = syscall.NewLazyDLL("kernel32.dll")
-	openClipboard              = user32.NewProc("OpenClipboard")
-	closeClipboard             = user32.NewProc("CloseClipboard")
-	getClipboardData           = user32.NewProc("GetClipboardData")
-	isClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
-	globalLock                 = kernel32.NewProc("GlobalLock")
-	globalUnlock               = kernel32.NewProc("GlobalUnlock")
-	globalSize                 = kernel32.NewProc("GlobalSize")
-)
-
-const (
-	CF_TEXT        = 1
-	CF_UNICODETEXT = 13
-	CF_DIB         = 8
-)
-
-type BITMAPINFOHEADER struct {
-	BiSize          uint32
-	BiWidth         int32
-	BiHeight        int32
-	BiPlanes        uint16
-	BiBitCount      uint16
-	BiCompression   uint32
-	BiSizeImage     uint32
-	BiXPelsPerMeter int32
-	BiYPelsPerMeter int32
-	BiClrUsed       uint32
-	BiClrImportant  uint32
-}
-
-func GetImageFromClipboard() ([]byte, string, error) {
-	ret, _, _ := openClipboard.Call(0)
-	if ret == 0 {
-		return nil, "", fmt.Errorf("failed to open clipboard")
-	}
-	defer func(closeClipboard *syscall.LazyProc, a ...uintptr) {
-		_, _, err := closeClipboard.Call(a...)
-		if err != nil {
-			slog.Error("close clipboard failed")
-			return
-		}
-	}(closeClipboard)
-	isTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_TEXT))
-	isUnicodeTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_UNICODETEXT))
-
-	if isTextAvailable != 0 || isUnicodeTextAvailable != 0 {
-		// Get text from clipboard
-		var formatToUse uintptr = CF_TEXT
-		if isUnicodeTextAvailable != 0 {
-			formatToUse = CF_UNICODETEXT
-		}
-
-		hClipboardText, _, _ := getClipboardData.Call(formatToUse)
-		if hClipboardText != 0 {
-			textPtr, _, _ := globalLock.Call(hClipboardText)
-			if textPtr != 0 {
-				defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
-					_, _, err := globalUnlock.Call(a...)
-					if err != nil {
-						slog.Error("Global unlock failed")
-						return
-					}
-				}(globalUnlock, hClipboardText)
-
-				// Get clipboard text
-				var clipboardText string
-				if formatToUse == CF_UNICODETEXT {
-					// Convert wide string to Go string
-					clipboardText = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(textPtr))[:])
-				} else {
-					// Get size of ANSI text
-					size, _, _ := globalSize.Call(hClipboardText)
-					if size > 0 {
-						// Convert ANSI string to Go string
-						textBytes := make([]byte, size)
-						copy(textBytes, (*[1 << 20]byte)(unsafe.Pointer(textPtr))[:size:size])
-						clipboardText = bytesToString(textBytes)
-					}
-				}
-
-				// Check if the text is not empty
-				if clipboardText != "" {
-					return nil, clipboardText, nil
-				}
-			}
-		}
-	}
-	hClipboardData, _, _ := getClipboardData.Call(uintptr(CF_DIB))
-	if hClipboardData == 0 {
-		return nil, "", fmt.Errorf("failed to get clipboard data")
-	}
-
-	dataPtr, _, _ := globalLock.Call(hClipboardData)
-	if dataPtr == 0 {
-		return nil, "", fmt.Errorf("failed to lock clipboard data")
-	}
-	defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
-		_, _, err := globalUnlock.Call(a...)
-		if err != nil {
-			slog.Error("Global unlock failed")
-			return
-		}
-	}(globalUnlock, hClipboardData)
-
-	bmiHeader := (*BITMAPINFOHEADER)(unsafe.Pointer(dataPtr))
-
-	width := int(bmiHeader.BiWidth)
-	height := int(bmiHeader.BiHeight)
-	if height < 0 {
-		height = -height
-	}
-	bitsPerPixel := int(bmiHeader.BiBitCount)
-
-	img := image.NewRGBA(image.Rect(0, 0, width, height))
-
-	var bitsOffset uintptr
-	if bitsPerPixel <= 8 {
-		numColors := uint32(1) << bitsPerPixel
-		if bmiHeader.BiClrUsed > 0 {
-			numColors = bmiHeader.BiClrUsed
-		}
-		bitsOffset = unsafe.Sizeof(*bmiHeader) + uintptr(numColors*4)
-	} else {
-		bitsOffset = unsafe.Sizeof(*bmiHeader)
-	}
-
-	for y := range height {
-		for x := range width {
-
-			srcY := height - y - 1
-			if bmiHeader.BiHeight < 0 {
-				srcY = y
-			}
-
-			var pixelPointer unsafe.Pointer
-			var r, g, b, a uint8
-
-			switch bitsPerPixel {
-			case 24:
-				stride := (width*3 + 3) &^ 3
-				pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*stride+x*3))
-				b = *(*byte)(pixelPointer)
-				g = *(*byte)(unsafe.Add(pixelPointer, 1))
-				r = *(*byte)(unsafe.Add(pixelPointer, 2))
-				a = 255
-			case 32:
-				pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*width*4+x*4))
-				b = *(*byte)(pixelPointer)
-				g = *(*byte)(unsafe.Add(pixelPointer, 1))
-				r = *(*byte)(unsafe.Add(pixelPointer, 2))
-				a = *(*byte)(unsafe.Add(pixelPointer, 3))
-				if a == 0 {
-					a = 255
-				}
-			default:
-				return nil, "", fmt.Errorf("unsupported bit count: %d", bitsPerPixel)
-			}
-
-			img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a})
-		}
-	}
-
-	imageBytes, err := ImageToBytes(img)
-	if err != nil {
-		return nil, "", err
-	}
-	return imageBytes, "", nil
-}
-
-func bytesToString(b []byte) string {
-	i := bytes.IndexByte(b, 0)
-	if i == -1 {
-		return string(b)
-	}
-	return string(b[:i])
-}

+ 0 - 86
packages/tui/internal/image/images.go

@@ -1,86 +0,0 @@
-package image
-
-import (
-	"bytes"
-	"fmt"
-	"image"
-	"image/color"
-	"image/png"
-	"os"
-	"strings"
-
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/disintegration/imaging"
-	"github.com/lucasb-eyer/go-colorful"
-	_ "golang.org/x/image/webp"
-)
-
-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
-}
-
-func ImageToBytes(image image.Image) ([]byte, error) {
-	buf := new(bytes.Buffer)
-	err := png.Encode(buf, image)
-	if err != nil {
-		return nil, err
-	}
-
-	return buf.Bytes(), nil
-}

+ 2 - 2
packages/tui/internal/tui/tui.go

@@ -841,7 +841,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
 			return a, toast.NewErrorToast("Failed to share session")
 		}
 		shareUrl := response.Share.URL
-		cmds = append(cmds, tea.SetClipboard(shareUrl))
+		cmds = append(cmds, a.app.SetClipboard(shareUrl))
 		cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
 	case commands.SessionUnshareCommand:
 		if a.app.Session.ID == "" {
@@ -975,7 +975,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
 	case commands.MessagesCopyCommand:
 		selected := a.messages.Selected()
 		if selected != "" {
-			cmd = tea.SetClipboard(selected)
+			cmd = a.app.SetClipboard(selected)
 			cmds = append(cmds, cmd)
 			cmd = toast.NewSuccessToast("Message copied to clipboard")
 			cmds = append(cmds, cmd)

+ 2 - 2
packages/tui/sdk/.stats.yml

@@ -1,4 +1,4 @@
 configured_endpoints: 20
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-4955370de3d0a21bb41c4e51257210b3284deb5bc3dbace6e7572de0d1635c9e.yml
-openapi_spec_hash: b7591d636977423cd7455aa02caa718f
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-c06a9b8d8284683e8350fdd3eceff0b5756877f7b67e974acd565409b67d32a0.yml
+openapi_spec_hash: 5933bca0c79177065374ac724a6bc986
 config_hash: de53ecf98e1038f2cc2fd273b582f082

+ 27 - 9
packages/tui/sdk/config.go

@@ -397,14 +397,18 @@ func (r configProviderModelsLimitJSON) RawJSON() string {
 type Keybinds struct {
 	// Exit the application
 	AppExit string `json:"app_exit"`
+	// Show help dialog
+	AppHelp string `json:"app_help"`
 	// Open external editor
 	EditorOpen string `json:"editor_open"`
-	// Show help dialog
-	Help string `json:"help"`
-	// Navigate to next history item
-	HistoryNext string `json:"history_next"`
-	// Navigate to previous history item
-	HistoryPrevious string `json:"history_previous"`
+	// Close file
+	FileClose string `json:"file_close"`
+	// Toggle split/unified diff
+	FileDiffToggle string `json:"file_diff_toggle"`
+	// List files
+	FileList string `json:"file_list"`
+	// Search file
+	FileSearch string `json:"file_search"`
 	// Clear input field
 	InputClear string `json:"input_clear"`
 	// Insert newline in input
@@ -415,6 +419,8 @@ type Keybinds struct {
 	InputSubmit string `json:"input_submit"`
 	// Leader key for keybind combinations
 	Leader string `json:"leader"`
+	// Copy message
+	MessagesCopy string `json:"messages_copy"`
 	// Navigate to first message
 	MessagesFirst string `json:"messages_first"`
 	// Scroll messages down by half page
@@ -423,6 +429,8 @@ type Keybinds struct {
 	MessagesHalfPageUp string `json:"messages_half_page_up"`
 	// Navigate to last message
 	MessagesLast string `json:"messages_last"`
+	// Toggle layout
+	MessagesLayoutToggle string `json:"messages_layout_toggle"`
 	// Navigate to next message
 	MessagesNext string `json:"messages_next"`
 	// Scroll messages down by one page
@@ -431,6 +439,8 @@ type Keybinds struct {
 	MessagesPageUp string `json:"messages_page_up"`
 	// Navigate to previous message
 	MessagesPrevious string `json:"messages_previous"`
+	// Revert message
+	MessagesRevert string `json:"messages_revert"`
 	// List available models
 	ModelList string `json:"model_list"`
 	// Initialize project configuration
@@ -445,6 +455,8 @@ type Keybinds struct {
 	SessionNew string `json:"session_new"`
 	// Share current session
 	SessionShare string `json:"session_share"`
+	// Unshare current session
+	SessionUnshare string `json:"session_unshare"`
 	// List available themes
 	ThemeList string `json:"theme_list"`
 	// Show tool details
@@ -455,23 +467,28 @@ type Keybinds struct {
 // keybindsJSON contains the JSON metadata for the struct [Keybinds]
 type keybindsJSON struct {
 	AppExit              apijson.Field
+	AppHelp              apijson.Field
 	EditorOpen           apijson.Field
-	Help                 apijson.Field
-	HistoryNext          apijson.Field
-	HistoryPrevious      apijson.Field
+	FileClose            apijson.Field
+	FileDiffToggle       apijson.Field
+	FileList             apijson.Field
+	FileSearch           apijson.Field
 	InputClear           apijson.Field
 	InputNewline         apijson.Field
 	InputPaste           apijson.Field
 	InputSubmit          apijson.Field
 	Leader               apijson.Field
+	MessagesCopy         apijson.Field
 	MessagesFirst        apijson.Field
 	MessagesHalfPageDown apijson.Field
 	MessagesHalfPageUp   apijson.Field
 	MessagesLast         apijson.Field
+	MessagesLayoutToggle apijson.Field
 	MessagesNext         apijson.Field
 	MessagesPageDown     apijson.Field
 	MessagesPageUp       apijson.Field
 	MessagesPrevious     apijson.Field
+	MessagesRevert       apijson.Field
 	ModelList            apijson.Field
 	ProjectInit          apijson.Field
 	SessionCompact       apijson.Field
@@ -479,6 +496,7 @@ type keybindsJSON struct {
 	SessionList          apijson.Field
 	SessionNew           apijson.Field
 	SessionShare         apijson.Field
+	SessionUnshare       apijson.Field
 	ThemeList            apijson.Field
 	ToolDetails          apijson.Field
 	raw                  string

+ 4 - 1
packages/tui/sdk/scripts/lint

@@ -5,4 +5,7 @@ set -e
 cd "$(dirname "$0")/.."
 
 echo "==> Running Go build"
-go build ./...
+go build .
+
+# Compile the tests but don't run them
+go test -c .