Compare commits

...

5 Commits

Author SHA1 Message Date
Aiden Cline 868a303b31 fix(core): move branch watch into Vcs
LocationWatcher only existed to republish git/hg metadata as
filesystem.changed for Vcs. Nothing else consumed those events.
Vcs now owns the Parcel watch, same as skills and config.
2026-08-10 17:46:45 -05:00
Aiden Cline e5b576ce3a fix(core): restore parcel watch for git HEAD
Bun fs.watch misses git's atomic HEAD.lock rename, so checkout never
refreshed the cached branch. Watch the git dir with Parcel like dev,
keeping only HEAD and HEAD.lock.
2026-08-10 17:35:50 -05:00
opencode-agent[bot] 33296e7959 test(app): make offset observer scheduling deterministic (#41602)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-10 17:24:05 -05:00
Simon Klee 283258e95b feat(tui): add clipboard image previews and transcript rendering (#41603)
Use the OpenTUI clipboard service for image input, show image previews in
the prompt, and render transcript images with interactive previews.

Note this includes upgrade of opentui to 0.5.1 +
anomalyco/opentui#1271
2026-08-10 22:51:39 +02:00
opencode-agent[bot] d7a7256bb6 test: stabilize Windows CI timing (#41600)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-10 15:25:55 -05:00
39 changed files with 1068 additions and 710 deletions
+31 -56
View File
@@ -613,9 +613,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -903,7 +903,6 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
@@ -1089,9 +1088,9 @@
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
},
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
@@ -1109,9 +1108,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2169,27 +2168,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
"@opentui/core": ["@opentui/core@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-darwin-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-x64": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y9MKL8LMup4ebW3uZ57RSsPyOv37sIZxuWiHq4XJK9wrzpzTMnVomzxpWOI0S/QtLH2QzZ7znnbF+IuvIQ1hLQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Bn6fUZrwbUojkJG4YHowg/h/u3SPANmEivrl8YVbxBPCYOhZzQWxAVf7Fwg6PD0BpWk5gt9inQrir2DzWh5UpA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "x64" }, "sha512-lrVskzbjcT9yJCUgjOpvPpEq2ipmuDyHTMJ+OebsJFdATdryJLzRAr5BZiFGXEo4CJ2CS/+6vT+ki7dmhpM+0g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-czqIqdnBvyFRSJ0BEdNn7moRN0KQo5gvhSsOSfrzueHxsktgWcmk1cMeCvTkzC3JCBvCwlB1KnSdt4jlwNO1EQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-6nixv6wlgjelUVJVHkJsCpuARb1rE/oxhIIthyLd062u5veoBt83cRGH9KmcZiwowKvso58hXn2KC21qAfylhQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-cxJMfrSsMsrZdU1Ke6awrxz0rdIJuI34l8Le3QlUbM5yLpp09ygJ7CElILMT50g3F+EnRf6BKvVmkbRrDzFQ6g=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-7zVbZ0XgCDi3yImmd9Cj+e0WhSuQzWo1ziSQiFgPCR8cs/dfrpYLgBlX9PP0gFOcJKeRccJJv7rnBetl3CB7tg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "arm64" }, "sha512-K3SNYJDvB7XD5nxi8jt4jWtgmqPG0Qbob0Px9afqMH3g/R5hIoi2P3GDbqn8JNv82c4dPIGJjXXuNhHa0tZQbw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "x64" }, "sha512-3JCpPS8+Gz3PWlQHiFwqYuJ0AM0vKlfbe4+O+0m2EYej/QjN5KjL1nBfVLq5XDj4dVPEIktQKN6e//YzSzdaqA=="],
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@opentui/core": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "@opentui/react": "0.0.0-20260808-9ecf7c0a", "@opentui/solid": "0.0.0-20260808-9ecf7c0a", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-aGxw6P0RJSuUU3y3QtAnKa1B6tQShxR0mjmnN3Cadr4LoduSuWn/l1kE+27KwH0ap9WTAeGVsN7u2oawB9eWKw=="],
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260808-9ecf7c0a", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Q7ouv2KxZKO0/Sh0RbhNd7kEw6E7tlrU+yw36xxlnyXOqlK3TkQ+u1RMSZulYAn8mP20pnYLM9OxY4pOC/wK8Q=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -3539,7 +3538,7 @@
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -3623,8 +3622,6 @@
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
@@ -4041,7 +4038,7 @@
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -4323,7 +4320,7 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
@@ -4467,8 +4464,6 @@
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
@@ -4807,7 +4802,7 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -4933,7 +4928,7 @@
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
@@ -4959,7 +4954,7 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
@@ -5561,7 +5556,7 @@
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -5593,8 +5588,6 @@
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
@@ -6533,8 +6526,6 @@
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
@@ -6719,9 +6710,11 @@
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -6797,8 +6790,6 @@
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
@@ -7565,20 +7556,6 @@
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -8639,8 +8616,6 @@
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+6 -6
View File
@@ -46,9 +46,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -150,9 +150,9 @@
"electron"
],
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { type Virtualizer } from "@tanstack/solid-virtual"
import { Window } from "happy-dom"
import { Node, Window } from "happy-dom"
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
test("matches only the scroll element or an ancestor containing it", () => {
@@ -18,6 +18,7 @@ test("matches only the scroll element or an ancestor containing it", () => {
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const route = targetWindow.document.createElement("section")
const viewport = targetWindow.document.createElement("div")
const unrelated = targetWindow.document.createElement("div")
@@ -40,24 +41,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
targetWindow.document.body.append(unrelated)
unrelated.remove()
await frames(2, targetWindow)
expect(calls).toEqual([])
try {
mutations.append(targetWindow.document.body, unrelated)
mutations.remove(unrelated)
expect(calls).toEqual([])
route.remove()
targetWindow.document.body.append(route)
await waitFor(() => calls.length === 1, targetWindow)
expect(calls).toEqual([[0, false]])
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
await frames(2, targetWindow)
expect(calls).toEqual([[0, false]])
route.remove()
targetWindow.document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3, targetWindow)
expect(calls).toEqual([[0, false]])
cleanup?.()
await targetWindow.happyDOM.close()
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
await frames(2, targetWindow)
expect(calls).toEqual([[0, false]])
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
}
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
@@ -204,7 +205,33 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
}
}
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
const deadline = targetWindow.performance.now() + 1_000
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
function controlledMutations(targetWindow: Window) {
let emit: (record: MutationRecord) => void = () => {
throw new Error("Mutation observer is not active")
}
class ControlledMutationObserver {
constructor(callback: MutationCallback) {
emit = (record) => callback([record], this as unknown as MutationObserver)
}
observe() {}
disconnect() {}
takeRecords() {
return []
}
}
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
return {
append(parent: Node, node: Node) {
parent.appendChild(node)
emit(record(parent, [node], []))
},
remove(node: Node) {
const parent = node.parentNode
if (!parent) throw new Error("Mutation target has no parent")
parent.removeChild(node)
emit(record(parent, [], [node]))
},
}
}
@@ -11,7 +11,9 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
await using fixture = await createAcpFixture()
expect(await fixture.spawn().close()).toBe(0)
const acp = fixture.spawn()
await initialize(acp)
expect(await acp.close()).toBe(0)
}, 60_000)
test("close capability and close request", async () => {
@@ -1,71 +0,0 @@
export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document } from "@opencode-ai/schema/config"
import path from "path"
import { Config } from "../config"
import { Bus } from "../bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
if (!config.includes(".hg") && !config.includes(vcs)) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
)
return Service.of({})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
})
-3
View File
@@ -15,7 +15,6 @@ import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate"
import { Form } from "./form"
import { Image } from "./image"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
@@ -99,8 +98,6 @@ const locationServiceNodes = [
Snapshot.node,
SessionRunnerLLM.node,
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
+28 -20
View File
@@ -3,7 +3,6 @@ export * as Vcs from "./vcs"
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,6 +10,8 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "./bus"
import { Git } from "./git"
import { Watcher } from "./filesystem/watcher"
import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg"
@@ -44,29 +45,36 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const git = yield* Git.Service
const watcher = yield* Watcher.Service
const impl = adapter(proc, fs, location)
const vcs = location.vcs
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
if (vcs && impl) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
const isBranchMetadata =
vcs.type === "git"
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
: (file: string) => path.resolve(file) === path.join(store, "branch")
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.gen(function* () {
const discovered = vcs.type === "git" ? (yield* git.repo.discover(location.directory))?.gitDirectory : undefined
const target = discovered ?? vcs.store
const dir = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
const keep = vcs.type === "git" ? ["HEAD", "HEAD.lock"] : ["branch"]
const ignore = (yield* fs.readDirectoryEntries(dir).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (keep.includes(entry.name) ? [] : [entry.name]),
)
const updates = yield* watcher.subscribe({ path: dir, type: "directory", ignore })
yield* updates.pipe(
Stream.filter((update) => keep.includes(path.basename(update.path))),
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: update.path } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to watch vcs metadata", { cause })))
}
return Service.of({
@@ -88,5 +96,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node, Git.node, Watcher.node],
})
+1
View File
@@ -465,6 +465,7 @@ Use native v2 fields.`,
},
}),
)
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
@@ -185,6 +185,7 @@ Review files`,
},
}),
)
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
+7 -232
View File
@@ -1,28 +1,15 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Config.testLayer()
const it = testEffect(AppNodeBuilder.build(FSUtil.node))
describe("Watcher.testLayer", () => {
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
@@ -40,7 +27,6 @@ describe("Watcher.testLayer", () => {
yield* test.emit({ type: "update", path: "/root/file.md" })
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
// subscriptions() reports acquired watches, so paths come back resolved.
expect(yield* test.subscriptions()).toEqual([{ path: path.resolve("/root"), type: "directory" }])
}).pipe(Effect.provide(Watcher.testLayer)),
)
@@ -126,167 +112,20 @@ describe("Watcher lifecycle", () => {
expect(counts.unsubscribes).toBe(0)
return consumer
}).pipe(withNative(native))
// Closing the layer scope tears the native subscription down while the
// consumer still holds a reference; the consumer's own release as its
// stream ends must not tear it down a second time.
yield* Fiber.join(consumer)
expect(counts.unsubscribes).toBe(1)
})
})
})
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
return Effect.provide(built)
}
function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: {
vcs?: "git" | "hg"
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
},
) {
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(async () => {
const tmp = await tmpdir()
if (options?.vcs === "hg") {
await fs.mkdir(path.join(tmp.path, ".hg"))
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
}
if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
await $`git config user.name Test`.cwd(tmp.path).quiet()
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
await options.init?.(tmp.path)
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationWatcher subscriptions", () => {
it.live("watches only exact Git branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
}),
{ vcs: "git", watcher },
)
})
it.live("watches only exact Hg branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
}),
{ vcs: "hg", watcher },
)
})
})
function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<WatcherEvent>()
const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => {
if (!check(event.data)) return Effect.void
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
}),
Effect.forkScoped,
)
yield* Effect.yieldNow
return { deferred, fiber }
})
}
function maybeNextUpdate<E>(
check: (event: WatcherEvent) => boolean,
trigger: Effect.Effect<void, E>,
timeout: Duration.Input = "5 seconds",
) {
return Effect.acquireUseRelease(
wait(check),
({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
({ fiber }) => Fiber.interrupt(fiber),
)
}
function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
return Effect.gen(function* () {
const result = yield* maybeNextUpdate(check, trigger)
if (Option.isSome(result)) return result.value
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
})
}
function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
return Effect.gen(function* () {
while (true) {
const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
if (Option.isSome(result)) return result.value
}
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
}),
)
}
function ready(file: string, eventFile = file) {
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
yield* eventuallyUpdate(
(event) => event.file === eventFile,
() => fs.writeFileString(file, content),
).pipe(Effect.asVoid)
})
}
describeNative("LocationWatcher", () => {
describeNative("Watcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -333,68 +172,4 @@ describeNative("LocationWatcher", () => {
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
it.live("publishes .git/HEAD events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const head = path.join(directory, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* ready(head)
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toEqual({ file: head, event: "change" })
}),
{ vcs: "git" },
),
)
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
describeSymlink("symlinked .git", () => {
it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
const head = path.join(directory, ".git", "HEAD")
yield* ready(head, path.join(actual, "HEAD"))
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate(
(event) => event.file === path.join(actual, "HEAD"),
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
),
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
}),
{
vcs: "git",
init: async (directory) => {
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
await fs.rename(path.join(directory, ".git"), actual)
await fs.symlink(actual, path.join(directory, ".git"))
},
},
),
)
})
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg" },
),
)
})
+24 -21
View File
@@ -286,27 +286,30 @@ describe("ShellTool", () => {
),
)
it.live("permissions compound commands separately", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
it.live(
"permissions compound commands separately",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live(
+4 -9
View File
@@ -8,7 +8,6 @@ import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
@@ -42,7 +41,9 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
const withHg = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
withTmp((directory) =>
Effect.promise(() => hg(directory, "init")).pipe(Effect.andThen(f(directory).pipe(provide(directory)))),
Effect.promise(() => hg(directory, "init")).pipe(
Effect.andThen(f(directory).pipe(provide(directory))),
),
)
async function hg(directory: string, ...args: string[]) {
@@ -124,13 +125,7 @@ describeHg("Vcs mercurial", () => {
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
yield* bus.publish(FileSystem.Event.Changed, {
file: path.join(directory, ".hg", "branch"),
event: "change",
})
expect(yield* Fiber.join(updated)).toMatchObject({
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
+128 -40
View File
@@ -8,27 +8,66 @@ import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const describeNative = process.env.CI ? describe.skip : describe
const locationLayer = (directory: string, git?: boolean) =>
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
)
const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [[Location.node, locationLayer(directory, input.git)]]),
)
function fakeWatcher() {
const subscriptions: Watcher.WatchInput[] = []
const active = new Set<(update: Watcher.Update) => void>()
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.sync(() => {
subscriptions.push(
input.type === "file"
? { path: input.target, type: "file" }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
active.add(input.publish)
return {
unsubscribe: () => {
active.delete(input.publish)
return Promise.resolve()
},
}
}),
})
return {
subscriptions: () => [...subscriptions],
emit: (update: Watcher.Update) => {
for (const publish of active) publish(update)
},
layer: Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))),
}
}
const provideFake = (directory: string, fake: ReturnType<typeof fakeWatcher>, git = true) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
),
],
[Location.node, locationLayer(directory, git)],
[Watcher.node, fake.layer],
]),
)
@@ -93,35 +132,84 @@ describe("Vcs", () => {
),
)
it.live("caches branch info and publishes HEAD changes", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
}),
),
it.live("watches git branch metadata", () =>
withTmp((directory) => {
const fake = fakeWatcher()
return Effect.promise(() => initRepo(directory)).pipe(
Effect.andThen(
Effect.gen(function* () {
yield* Vcs.Service
expect(fake.subscriptions()).toHaveLength(1)
const git = fake.subscriptions()[0]
if (git?.type !== "directory") throw new Error("expected a directory watch")
expect(git.path).toBe(path.join(directory, ".git"))
expect(git.ignore ?? []).not.toContain("HEAD")
expect(git.ignore ?? []).toContain("objects")
}).pipe(provideFake(directory, fake)),
),
)
}),
)
it.live("caches branch info and publishes HEAD changes", () =>
withTmp((directory) => {
const fake = fakeWatcher()
return Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
fake.emit({ type: "update", path: path.join(directory, ".git", "index.lock") })
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
fake.emit({ type: "update", path: path.join(directory, ".git", "HEAD.lock") })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
}).pipe(provideFake(directory, fake)),
),
)
}),
)
describeNative("native watches", () => {
it.live("publishes branch updates on git checkout", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
_tag: "Some",
value: { data: { branch: "feature" } },
})
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
}),
),
{ timeout: 15_000 },
)
})
it.live("diffs the working copy against HEAD with patches", () =>
withGit((directory) =>
Effect.gen(function* () {
+3 -3
View File
@@ -31,9 +31,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
-1
View File
@@ -93,7 +93,6 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
+15 -3
View File
@@ -97,6 +97,7 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
import { createTuiClipboard } from "./clipboard"
registerOpencodeSpinner()
@@ -251,6 +252,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
)
})
const clipboard = yield* Effect.acquireRelease(
Effect.sync(() => createTuiClipboard(renderer)),
(clipboard) =>
Effect.tryPromise(() => clipboard.dispose()).pipe(
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
),
)
win32DisableProcessedInput()
const finalizers = new Set<() => Promise<void>>()
yield* Effect.addFinalizer(() =>
@@ -287,7 +295,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<TuiAppProvider value={input.app}>
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
fallback={(error, reset) => (
<ClipboardProvider value={clipboard}>
<ErrorComponent error={error} reset={reset} mode={mode} />
</ClipboardProvider>
)}
>
<TuiPathsProvider
value={{
@@ -336,7 +348,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ClipboardProvider value={clipboard}>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
@@ -504,7 +516,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (!text || text.length === 0) return
await clipboard
.write?.(text)
.write(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
+59 -118
View File
@@ -1,125 +1,66 @@
import { execFile, spawn } from "node:child_process"
import { readFile, rm } from "node:fs/promises"
import { platform, release, tmpdir } from "node:os"
import path from "node:path"
import { promisify } from "node:util"
import {
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
decodePasteBytes,
type ClipboardService as CoreClipboardService,
type RendererClipboardBoundary,
} from "@opentui/core"
import type { ClipboardContent, ClipboardService } from "./context/clipboard"
const exec = promisify(execFile)
export type OwnedClipboardService = Required<ClipboardService> & Readonly<{ dispose(): Promise<void> }>
function command(command: string, args: string[] = [], input?: string) {
return new Promise<Buffer>((resolve, reject) => {
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
const output: Buffer[] = []
child.on("error", reject)
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
child.on("close", (code) => {
if (code === 0) return resolve(Buffer.concat(output))
reject(new Error(`${command} exited with code ${code}`))
})
if (input !== undefined) child.stdin?.end(input)
})
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
return createClipboardAdapter(
createClipboard({
host: createHostClipboard(),
terminal: createRendererClipboardAdapter(renderer),
}),
)
}
function writeOsc52(text: string) {
if (!process.stdout.isTTY) return
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
const passthrough = `\x1bPtmux;\x1b${sequence}\x1b\\`
process.stdout.write(process.env.TMUX ? sequence + passthrough : process.env.STY ? passthrough : sequence)
}
export async function read() {
if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
try {
await exec("osascript", [
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${file}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
])
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
} catch {
// Fall through to text clipboard.
} finally {
await rm(file, { force: true }).catch(() => {})
}
}
if (platform() === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
Buffer.alloc(0),
)
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
}
if (platform() === "linux") {
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
Buffer.alloc(0),
)
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
}
const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
}
export function copyCommand(
os: NodeJS.Platform,
wayland: boolean,
has: (name: string) => boolean,
): string[] | undefined {
if (os === "darwin" && has("osascript")) return ["osascript"]
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
if (os === "win32" && has("powershell.exe")) {
return [
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
}
}
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
function getCopyMethod() {
return (copyMethod ??= (async () => {
const { which } = await import("@opencode-ai/core/util/which")
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
if (native?.[0] === "osascript") {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
return {
async read(): Promise<ClipboardContent | undefined> {
const result = await clipboard.read({
preferredTypes: ["image/png", "text/plain"],
selection: "clipboard",
})
if (result.status !== "read") {
if (result.status === "failed") throw result.error
if (result.status === "timed-out") throw new Error("Clipboard read timed out")
if (result.status === "limit-exceeded") {
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
}
return undefined
}
}
if (native) {
return async (text: string) => {
await command(native[0], native.slice(1), text).catch(() => undefined)
}
}
return async (text: string) => {
const { default: clipboardy } = await import("clipboardy")
await clipboardy.write(text).catch(() => undefined)
}
})())
}
export async function write(text: string) {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
if (result.representation.mimeType === "image/png") {
return {
data: Buffer.from(result.representation.bytes).toString("base64"),
mime: result.representation.mimeType,
}
}
if (result.representation.mimeType === "text/plain") {
if (result.representation.bytes.length === 0) return undefined
return {
data: decodePasteBytes(result.representation.bytes),
mime: result.representation.mimeType,
}
}
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
const result = await clipboard.writeText(text, {
destination: "all-available",
selection: "clipboard",
})
if (result.host.status === "written" || result.terminal.status === "attempted") return
if (result.host.status === "failed") throw result.host.error
throw new Error(`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`)
},
dispose() {
return clipboard.dispose()
},
}
}
@@ -84,6 +84,15 @@ export const settings: Setting[] = [
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Transcript images",
category: "Session",
path: ["session", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "images", "tool output"],
},
{
title: "Enabled",
category: "Tabs",
@@ -188,6 +197,15 @@ export const settings: Setting[] = [
values: ["compact", "full"],
keywords: ["paste summary", "clipboard", "pasted content"],
},
{
title: "Image previews",
category: "Input",
path: ["prompt", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "clipboard", "images", "prompt"],
},
{
title: "Leader timeout",
category: "Input",
+1 -1
View File
@@ -39,7 +39,7 @@ export function DialogDebug() {
.map((entry) => `${entry.label}: ${entry.value}`)
.join("\n")
void clipboard
.write?.(text)
.write(text)
.then(() => {
setCopied(true)
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
@@ -0,0 +1,71 @@
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
type ImagePreviewItem = Readonly<{
uri: string
mention?: Readonly<{ text: string }>
}>
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
const [failed, setFailed] = createSignal(false)
const current = createMemo(() => props.images[index()])
const imageHeight = createMemo(() => Math.max(3, dimensions().height - 8))
dialog.setSize("xlarge")
dialog.setCentered(true)
function move(direction: number) {
if (props.images.length < 2) return
setFailed(false)
setIndex((value) => (value + direction + props.images.length) % props.images.length)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "left", title: "Previous image", group: "Dialog", run: () => move(-1) },
{ bind: "right", title: "Next image", group: "Dialog", run: () => move(1) },
],
}))
return (
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Image {index() + 1} of {props.images.length}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<image
id="prompt-image-viewer-image"
source={current().uri}
fit="fit"
protocol="auto"
width="100%"
height={imageHeight()}
onError={() => setFailed(true)}
/>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
{props.images.length > 1 ? "← previous" : ""}
</text>
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
</text>
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
{props.images.length > 1 ? "next →" : ""}
</text>
</box>
</box>
)
}
@@ -492,7 +492,7 @@ function OAuthAuto(props: {
run: () => {
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
clipboard
.write?.(value)
.write(value)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
@@ -172,7 +172,6 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
onMount(() => dialog.setSize("large"))
const copy = () => {
if (!clipboard.write) return
void clipboard
.write(error())
.then(() => setCopied(true))
+25 -8
View File
@@ -12,7 +12,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const exit = useExit()
const clipboard = useClipboard()
const app = useTuiApp()
const [copied, setCopied] = createSignal(false)
const [copyState, setCopyState] = createSignal<"idle" | "copied" | "failed">("idle")
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
// theme context may be the thing that crashed.
@@ -46,11 +46,19 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const issueURL = buildIssueURL(message, stack, app.version)
const copyReport = () => {
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
void clipboard
.write(issueURL.toString())
.then(() => setCopyState("copied"))
.catch(() => setCopyState("failed"))
}
const actions = [
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
{
key: "c",
label: () => ({ idle: "Copy report", copied: "✓ Copied", failed: "Copy failed" })[copyState()],
copy: true,
onUse: copyReport,
},
{ key: "r", label: () => "Restart", onUse: props.reset },
{ key: "q", label: () => "Quit", onUse: () => exit() },
]
@@ -135,13 +143,20 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<For each={actions}>
{(action, index) => {
const isSelected = () => selected() === index()
const isCopied = () => action.copy && copied()
const copyColor = () =>
action.copy
? copyState() === "copied"
? colors.success
: copyState() === "failed"
? colors.error
: undefined
: undefined
return (
<box flexDirection="column" alignItems="center" flexShrink={0}>
<box
onMouseDown={() => setSelected(index())}
onMouseUp={() => action.onUse()}
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
minWidth={15}
alignItems="center"
paddingLeft={2}
@@ -149,7 +164,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
>
<text
attributes={TextAttributes.BOLD}
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
>
{action.label()}
</text>
@@ -189,9 +204,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<Show when={showFooter()}>
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text fg={colors.muted}>
{copied()
{copyState() === "copied"
? "Report copied — paste it into a new GitHub issue."
: "Copy the report and open a GitHub issue to help us fix this."}
: copyState() === "failed"
? "Clipboard write failed. Try again or report the crash manually."
: "Copy the report and open a GitHub issue to help us fix this."}
</text>
<text fg={colors.muted}>OpenCode {app.version}</text>
</box>
+180 -45
View File
@@ -7,9 +7,8 @@ import {
decodePasteBytes,
type KeyEvent,
} from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
@@ -48,13 +47,20 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import {
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = {
sessionID?: string
@@ -72,17 +78,6 @@ export type PromptProps = {
}
}
function pastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
export type PromptRef = {
focused: boolean
current: PromptInfo
@@ -312,6 +307,41 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(),
interrupt: 0,
})
let disposed = false
let pasteQueue = Promise.resolve()
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed) return
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
await run(
() =>
disposed ||
input.isDestroyed ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text,
)
})
.catch((error) => {
if (!disposed) toast.error(error)
})
return pasteQueue
}
const imageAttachments = createMemo(() =>
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
)
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, 3))
function openImagePreview(initial: number) {
const images = imageAttachments()
if (images.length === 0) return
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
}
createEffect(
on(
@@ -391,22 +421,32 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
run: (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
const content = await clipboard.read?.()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
}
return enqueuePaste(async (changed) => {
const content = await clipboard.read()
if (changed()) return
if (content?.mime.startsWith("image/")) {
pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data, changed)
}
})
},
},
{
title: "View image attachments",
name: "prompt.images.view",
category: "Prompt",
enabled: imageAttachments().length > 0,
run: () => openImagePreview(0),
},
{
title: "Interrupt session",
name: "session.interrupt",
@@ -561,6 +601,7 @@ export function Prompt(props: PromptProps) {
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.images.view",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
@@ -614,6 +655,7 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -1262,27 +1304,39 @@ export function Prompt(props: PromptProps) {
return true
}
async function pasteInputText(text: string) {
async function pasteInputText(text: string, changed: () => boolean) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
const filename = path.basename(filepath)
if (attachment?.type === "text") {
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
if (attachment) {
if (changed()) return
pasteLocalAttachment(filepath, attachment)
return
}
if (attachment?.type === "binary") {
await pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
return
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
if (filepaths.length > 1) {
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
for (const candidate of filepaths) {
const next = await readLocalAttachment(candidate, remaining)
if (!next) break
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
attachments.push({ filepath: candidate, attachment: next })
}
if (attachments.length === filepaths.length) {
if (changed()) return
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
return
}
}
}
if (changed()) return
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
@@ -1307,12 +1361,27 @@ export function Prompt(props: PromptProps) {
}, 0)
}
async function pasteAttachment(file: { filename?: string; uri: string }) {
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
const filename = path.basename(filepath)
if (attachment.type === "text") {
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
return
}
pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
}
function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const prefix = pdf ? "data:application/pdf;" : "data:image/"
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
const count = pdf
? (store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
@@ -1344,7 +1413,6 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
@@ -1468,6 +1536,74 @@ export function Prompt(props: PromptProps) {
flexGrow={1}
width="100%"
>
<Show when={config.prompt?.image_preview && visibleImageAttachments().length > 0}>
<box
width="100%"
height={imagePreviewHeight() + 1}
flexDirection="row"
flexShrink={0}
justifyContent="flex-start"
gap={1}
paddingBottom={1}
>
<For each={visibleImageAttachments()}>
{(file, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={imagePreviewWidth()}
height={imagePreviewHeight()}
flexBasis={imagePreviewWidth()}
flexShrink={1}
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(index())
}}
>
<Show
when={!failed()}
fallback={
<box width="100%" height="100%" alignItems="center" justifyContent="center">
<text fg={theme.text.subdued}>No preview</text>
</box>
}
>
<image
id={`prompt-image-preview-${index()}`}
source={file.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={imageAttachments().length > visibleImageAttachments().length}>
<box
width={8}
height={imagePreviewHeight()}
flexBasis={8}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(visibleImageAttachments().length)
}}
>
<text fg={theme.text.subdued} wrapMode="none" truncate>
+{imageAttachments().length - visibleImageAttachments().length} more
</text>
</box>
</Show>
</box>
</Show>
<textarea
width="100%"
placeholder={placeholderText()}
@@ -1496,7 +1632,7 @@ export function Prompt(props: PromptProps) {
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={async (event: PasteEvent) => {
onPaste={(event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
@@ -1506,11 +1642,10 @@ export function Prompt(props: PromptProps) {
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
if (event.bytes.byteLength === 0) {
keymap.dispatch("prompt.paste")
return
}
@@ -1519,7 +1654,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
await pasteInputText(normalizedText)
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
}}
ref={(r: TextareaRenderable) => {
input = r
@@ -1,9 +1,12 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
// Bound filesystem work per terminal paste; the byte budget also bounds staged data.
const MAX_PASTED_FILEPATHS = 32
export const MAX_LOCAL_ATTACHMENT_BYTES = 20 * 1024 * 1024
export type LocalFiles = Readonly<{
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
readText(path: string, maxBytes: number): Promise<string>
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
@@ -11,14 +14,15 @@ export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export function readLocalAttachment(file: string) {
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
return readLocalAttachmentWith(
{
readText: (value) => readFile(value, "utf8"),
readBytes: (value) => readFile(value),
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
readBytes: readFileBounded,
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
},
file,
maxBytes,
)
}
@@ -33,16 +37,108 @@ const mimeTypes: Record<string, string> = {
".webp": "image/webp",
}
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
async function readFileBounded(file: string, maxBytes: number) {
const source = Bun.file(file)
if (!(await source.exists())) throw new Error("Attachment does not exist")
if (source.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
const content = Buffer.from(await source.slice(0, maxBytes + 1).arrayBuffer())
if (content.byteLength > maxBytes) throw new Error("Attachment exceeds the local file limit")
return content
}
export function normalizePastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
const url = decodeFileURL(raw, platform)
if (url) return url
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
function decodeFileURL(value: string, platform: string): string | undefined {
if (!value.startsWith("file://")) return undefined
try {
const url = new URL(value)
if (/%2f|%5c/i.test(url.pathname)) return undefined
const pathname = decodeURIComponent(url.pathname)
if (platform !== "win32") {
if (url.hostname && url.hostname !== "localhost") return undefined
return pathname
}
const local = pathname.replace(/^\/([A-Za-z]:)/, "$1").replaceAll("/", "\\")
if (url.hostname && url.hostname !== "localhost") return `\\\\${url.hostname}${local}`
return local
} catch {
return undefined
}
}
export function parsePastedFilepaths(value: string, platform: string) {
const result: string[] = []
let current = ""
let quote = ""
function push() {
if (!current) return
result.push(decodeFileURL(current, platform) ?? current)
current = ""
}
const input = value.includes("file://")
? value
.split(/\r?\n/)
.filter((line) => !line.trimStart().startsWith("#"))
.join("\n")
: value
for (let index = 0; index < input.length; index++) {
const character = input[index]
if (quote) {
if (character === quote) {
quote = ""
continue
}
if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < input.length) {
current += input[++index]
continue
}
current += character
continue
}
if (character === "'" || character === '"') {
quote = character
continue
}
if (character === "\\" && platform !== "win32" && index + 1 < input.length) {
current += input[++index]
continue
}
if (/\s/.test(character)) {
push()
if (result.length > MAX_PASTED_FILEPATHS) return []
continue
}
current += character
}
if (quote) return []
push()
if (result.length > MAX_PASTED_FILEPATHS) return []
return result
}
export async function readLocalAttachmentWith(
files: LocalFiles,
path: string,
maxBytes = MAX_LOCAL_ATTACHMENT_BYTES,
): Promise<LocalAttachment | undefined> {
const mime = await files.mime(path).catch(() => undefined)
if (!mime) return
if (!mime) return undefined
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
if (mime === "image/svg+xml") {
const content = await files.readText(path).catch(() => undefined)
if (!content) return
const content = await files.readText(path, maxBytes).catch(() => undefined)
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
return { type: "text", mime, content }
}
if (!mime.startsWith("image/") && mime !== "application/pdf") return
const content = await files.readBytes(path).catch(() => undefined)
if (!content) return
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
if (!content || content.byteLength > maxBytes) return undefined
return { type: "binary", mime, content }
}
+6
View File
@@ -114,6 +114,9 @@ export const Info = Schema.Struct({
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
description: "Display large pastes as compact placeholders or full text",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show image attachment previews above the prompt input",
}),
}),
).annotate({ description: "Prompt input behavior" }),
session: Schema.optional(
@@ -128,6 +131,9 @@ export const Info = Schema.Struct({
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
description: "Group related transcript items automatically or render each item separately",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show user attachment and tool-result images in the session transcript",
}),
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
+2
View File
@@ -165,6 +165,7 @@ export const Definitions = {
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_images_view: keybind("<leader>i", "View image attachments"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
@@ -366,6 +367,7 @@ export const CommandMap = {
prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_images_view: "prompt.images.view",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop",
+9 -8
View File
@@ -1,18 +1,19 @@
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
read(): Promise<ClipboardContent | undefined>
write(text: string): Promise<void>
}>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
const ClipboardContext = createContext<ClipboardService>()
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
return useContext(ClipboardContext)
const value = useContext(ClipboardContext)
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
return value
}
@@ -66,8 +66,12 @@ export function DialogMessage(props: {
: "text" in value
? value.text
: ""
await clipboard.write?.(text)
dialog.clear()
try {
await clipboard.write(text)
dialog.clear()
} catch (error) {
toast.error(error)
}
},
},
{
+2 -2
View File
@@ -366,7 +366,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
function copyExternal() {
const current = externalField()
if (!current || !clipboard.write) return
if (!current) return
void clipboard
.write(current.url)
.then(() => {
@@ -996,7 +996,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
</text>
<Show when={externalField() && clipboard.write}>
<Show when={externalField()}>
<text fg={theme.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
+96 -7
View File
@@ -24,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
@@ -54,6 +54,7 @@ import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogImagePreview } from "../../component/dialog-image-preview"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
import { DialogTimeline } from "./dialog-timeline"
@@ -850,7 +851,7 @@ export function Session() {
}
clipboard
.write?.(text)
.write(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
dialog.clear()
@@ -868,7 +869,7 @@ export function Session() {
const sessionData = session()
if (!sessionData) return
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
await clipboard.write?.(transcript)
await clipboard.write(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch {
toast.show({ message: "Failed to copy session transcript", variant: "error" })
@@ -902,7 +903,7 @@ export function Session() {
) + EOL
if (options.action === "copy") {
await clipboard.write?.(content)
await clipboard.write(content)
dialog.clear()
toast.show({ message: "Copied to clipboard", variant: "success" })
return
@@ -1592,8 +1593,9 @@ function SessionGroupView(props: {
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
</Show>
<ToolImages parts={grouped()} />
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
</Show>
</Show>
@@ -1897,6 +1899,11 @@ function UserMessage(props: { message: SessionMessageUser }) {
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const skills = createMemo(() => props.message.skills ?? [])
const images = createMemo(() =>
files().flatMap((file) =>
file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [],
),
)
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
@@ -1918,6 +1925,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<SessionImages images={images()} paddingLeft={2} />
<box
onMouseOver={() => {
setHover(true)
@@ -2210,7 +2218,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool }) {
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
const display = createMemo(() => toolDisplay(props.part.name))
const toolprops = {
@@ -2234,7 +2242,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
},
}
return (
const content = (
<Switch>
<Match when={display() === "shell"}>
<Shell {...toolprops} />
@@ -2280,6 +2288,87 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
</Match>
</Switch>
)
return [
content,
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>,
]
}
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
const images = createMemo(() => props.parts.flatMap(inlineToolImages))
return <SessionImages images={images()} />
}
function SessionImages(props: { images: readonly { uri: string }[]; paddingLeft?: number }) {
const ctx = use()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const visible = createMemo(() => images().slice(0, 3))
return (
<Show when={visible().length > 0}>
<box
flexDirection="row"
flexShrink={0}
paddingTop={1}
paddingLeft={props.paddingLeft ?? 3}
paddingRight={2}
paddingBottom={1}
gap={1}
>
<For each={visible()}>
{(image, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={height() * 2}
height={height()}
flexBasis={height() * 2}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
}}
>
<Show when={!failed()} fallback={<text>No preview</text>}>
<image
source={image.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={images().length > visible().length}>
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
<text wrapMode="none" truncate>
+{images().length - visible().length} more
</text>
</box>
</Show>
</box>
</Show>
)
}
function inlineToolImages(part: SessionMessageAssistantTool) {
return toolDisplayContent(part.state).flatMap((content) =>
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
? [{ uri: content.uri }]
: [],
)
}
type ToolProps = {
+1 -1
View File
@@ -204,7 +204,7 @@ export function DialogProvider(props: ParentProps) {
function copySelection() {
const text = renderer.getSelection()?.getSelectedText()
if (!text || !clipboard.write) return false
if (!text) return false
void clipboard.write(text).then(
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
(error) => toast.error(error),
+1 -1
View File
@@ -35,7 +35,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
clipboard
?.write?.(clipboardText)
.write(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -74,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressArrow("down")
for (const key of "sounds") app.mockInput.pressKey(key)
for (const key of "image preview") app.mockInput.pressKey(key)
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
app.mockInput.pressEnter()
await app.waitFor(() => current.attention?.sound === false)
await app.waitFor(() => current.prompt?.image_preview === true)
} finally {
app.renderer.destroy()
}
+3
View File
@@ -60,6 +60,9 @@ async function mountForm(root: string, width = 80) {
>
<ClipboardProvider
value={{
async read() {
return undefined
},
write(text) {
copied.push(text)
return Promise.resolve()
+121 -11
View File
@@ -1,19 +1,129 @@
import { expect, test } from "bun:test"
import { copyCommand } from "../src/clipboard"
import {
createClipboard,
type ClipboardReadOptions,
type ClipboardReadResult,
type ClipboardService as CoreClipboardService,
type ClipboardWriteOptions,
type ClipboardWriteResult,
type HostClipboardService,
} from "@opentui/core"
import { createClipboardAdapter } from "../src/clipboard"
test("prefers Wayland clipboard when available", () => {
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
function coreClipboard(options: {
read?: ClipboardReadResult
write?: ClipboardWriteResult
onRead?: (input: ClipboardReadOptions) => void
onWrite?: (text: string, input: ClipboardWriteOptions) => void
}): CoreClipboardService {
return {
async read(input) {
options.onRead?.(input)
return options.read ?? { status: "empty" }
},
async writeText(text, input) {
options.onWrite?.(text, input)
return (
options.write ?? {
host: { status: "written" },
terminal: { status: "not-attempted", capability: "unknown" },
}
)
},
async clear() {
return {
host: { status: "cleared" },
terminal: { status: "not-attempted", capability: "unknown" },
}
},
async dispose() {},
}
}
test("adapts OpenTUI image and text reads", async () => {
const requests: ClipboardReadOptions[] = []
const image = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "image/png", bytes: new Uint8Array([0, 1, 2, 255]) },
},
onRead: (input) => requests.push(input),
}),
)
const text = "line 1\r\n\t世界"
const plain = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "text/plain", bytes: new TextEncoder().encode(text) },
},
}),
)
expect(await image.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
expect(await plain.read()).toEqual({ data: text, mime: "text/plain" })
expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }])
})
test("uses osascript on macOS", () => {
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
test("uses all available routes but skips the process host remotely", async () => {
const writes = { host: 0, terminal: 0 }
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return { status: "empty" }
},
async writeText() {
writes.host++
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
const clipboard = createClipboardAdapter(
createClipboard({
host,
terminal: {
remote: true,
writeText() {
writes.terminal++
return { status: "attempted", capability: "supported" }
},
clear() {
return { status: "attempted", capability: "supported" }
},
},
}),
)
expect(await clipboard.write("hello")).toBeUndefined()
expect(writes).toEqual({ host: 0, terminal: 1 })
})
test("falls back through X11 clipboard commands", () => {
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
})
test("rejects only when no clipboard route accepted the write", async () => {
const writes: [string, ClipboardWriteOptions][] = []
const failure = new Error("native clipboard failed")
const fallback = createClipboardAdapter(
coreClipboard({
write: {
host: { status: "written" },
terminal: { status: "local-failure", capability: "supported" },
},
}),
)
const clipboard = createClipboardAdapter(
coreClipboard({
write: {
host: { status: "failed", error: failure },
terminal: { status: "local-failure", capability: "supported" },
},
onWrite: (text, input) => writes.push([text, input]),
}),
)
test("returns undefined when native clipboard is unavailable", () => {
expect(copyCommand("linux", false, () => false)).toBeUndefined()
expect(await fallback.write("hello")).toBeUndefined()
expect(await clipboard.write("hello").then(undefined, (error) => error)).toBe(failure)
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
})
+2
View File
@@ -23,6 +23,8 @@ test("validates the session tabs setting", () => {
})
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
})
test("resolves nested config and keybind defaults", () => {
+12 -1
View File
@@ -7,6 +7,14 @@ import {
} from "../../src/context/runtime"
import type { ParentProps } from "solid-js"
import { LogProvider, type LogSink } from "../../src/context/log"
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write() {},
}
export function TestTuiContexts(
props: ParentProps<{
@@ -14,6 +22,7 @@ export function TestTuiContexts(
directory?: string
paths?: Partial<TuiPaths>
log?: LogSink
clipboard?: ClipboardService
}>,
) {
return (
@@ -28,7 +37,9 @@ export function TestTuiContexts(
}}
>
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
<TuiStartupProvider value={{ skipInitialLoading: false }}>
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</LogProvider>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
@@ -11,6 +11,44 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
}
describe("prompt local attachments", () => {
test("parses multi-file drops from POSIX, URI-list, and Windows terminals", () => {
expect(parsePastedFilepaths("'/tmp/one image.png' /tmp/two\\ image.webp", "linux")).toEqual([
"/tmp/one image.png",
"/tmp/two image.webp",
])
expect(parsePastedFilepaths("file:///tmp/one%20image.png\r\nfile:///tmp/two.webp", "linux")).toEqual([
"/tmp/one image.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths("# dropped files\nfile:///tmp/one.png\nfile:///tmp/two.webp", "linux")).toEqual([
"/tmp/one.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths("/tmp/one\\\\image.png /tmp/two.webp", "linux")).toEqual([
"/tmp/one\\image.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths('"C:\\one image.png" "C:\\two.webp"', "win32")).toEqual([
"C:\\one image.png",
"C:\\two.webp",
])
expect(parsePastedFilepaths("file:///C:/one%20image.png\r\nfile://server/share/two.webp", "win32")).toEqual([
"C:\\one image.png",
"\\\\server\\share\\two.webp",
])
expect(parsePastedFilepaths('"/tmp/O\'Brien.png" /tmp/two.webp', "linux")).toEqual([
"/tmp/O'Brien.png",
"/tmp/two.webp",
])
})
test("rejects unbounded and malformed multi-file drops", () => {
expect(parsePastedFilepaths("'/tmp/one.png /tmp/two.png", "linux")).toEqual([])
expect(
parsePastedFilepaths(Array.from({ length: 33 }, (_, index) => `/tmp/${index}.png`).join(" "), "linux"),
).toEqual([])
})
test("reads SVG attachments as text", async () => {
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
type: "text",
@@ -39,5 +77,8 @@ describe("prompt local attachments", () => {
"/tmp/missing.png",
),
).toBeUndefined()
expect(
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
).toBeUndefined()
})
})