Compare commits

..

25 Commits

Author SHA1 Message Date
Kit Langton fb9e9b2b44 fix(tui): keep-last-good on setup failure, serialized lifecycle, watcher hardening 2026-07-31 14:55:54 -04:00
Kit Langton 02b434d755 Merge remote-tracking branch 'origin/v2' into tui-plugin-reload
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/tui/src/routes/home.tsx
2026-07-31 14:50:35 -04:00
opencode-agent[bot] f92e490bb6 fix(tui): preserve diff hunk boundaries (#39693)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-31 14:47:36 -04:00
Kit Langton 0481dba88e fix(tui): improve narrow layouts (#39918) 2026-07-31 14:44:13 -04:00
Kit Langton 1fda757fc6 Merge remote-tracking branch 'origin/v2' into tui-plugin-reload
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/tui/src/plugin/context.tsx
2026-07-31 14:32:24 -04:00
Kit Langton dd0e41c633 fix(tui): render plugin slots and routes with component semantics (#39917) 2026-07-31 14:30:57 -04:00
opencode-agent[bot] b69e51d835 refactor(ai): simplify provider options (#39924)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-31 13:27:32 -05:00
Kit Langton ce23d8ae65 Merge remote-tracking branch 'origin/v2' into tui-plugin-reload
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/tui/src/routes/session/sidebar.tsx
2026-07-31 14:27:09 -04:00
opencode-agent[bot] e60e8d9387 refactor(core): rename attachments config to media (#39927)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-31 13:26:53 -05:00
Kit Langton 3bfce3fd2d feat(tui): expand pasted text (#39920) 2026-07-31 18:17:35 +00:00
Aiden Cline b498a5c6c4 feat(ai): expand OpenRouter native support (#39907) 2026-07-31 12:57:18 -05:00
Kit Langton db9e942398 fix(core): resize large image attachments (#39919) 2026-07-31 13:24:05 -04:00
Kit Langton 33275995d4 refactor(tui): move plugin render components to render.tsx 2026-07-31 12:51:59 -04:00
Kit Langton e79364fe00 refactor(tui): extract plugin source watcher 2026-07-31 12:50:26 -04:00
Kit Langton 93a915c18f refactor(tui): extract plugin api construction 2026-07-31 12:49:27 -04:00
Kit Langton 0e26116f68 fix(tui): stabilize open picker 2026-07-31 16:38:48 +00:00
Kit Langton 80532b9c76 fix(tui): render plugin slots and routes with component semantics 2026-07-31 10:59:16 -04:00
Kit Langton 49256c8851 fix(tui): plugin reload review fixes 2026-07-31 10:20:06 -04:00
Kit Langton 8add0d6226 feat(tui): ephemeral memory storage for plugins 2026-07-31 10:20:06 -04:00
Kit Langton 8fe47cefbd fix(tui): contain plugin render crashes 2026-07-30 21:27:08 -04:00
Kit Langton dbd29054ed refactor(tui): simplify plugin reconcile 2026-07-30 21:24:00 -04:00
Kit Langton 53632dfcc1 feat(tui): diff plugin generations on reconcile 2026-07-30 21:15:53 -04:00
Kit Langton 3f9945b98d test(tui): make plugin source assertions platform-neutral 2026-07-30 20:58:28 -04:00
Kit Langton 7ca3fc44b8 fix(tui): harden plugin source watches 2026-07-30 20:56:00 -04:00
Kit Langton 7a9cc15296 feat(tui): hot-reload local TUI plugins 2026-07-30 20:39:32 -04:00
85 changed files with 2635 additions and 3195 deletions
+56 -31
View File
@@ -621,9 +621,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -914,6 +914,7 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
@@ -1100,9 +1101,9 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
},
"overrides": {
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
@@ -1120,9 +1121,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2161,27 +2162,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.0.0-20260731-8e554196", "", { "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-20260731-8e554196", "@opentui/core-darwin-x64": "0.0.0-20260731-8e554196", "@opentui/core-linux-arm64": "0.0.0-20260731-8e554196", "@opentui/core-linux-arm64-musl": "0.0.0-20260731-8e554196", "@opentui/core-linux-x64": "0.0.0-20260731-8e554196", "@opentui/core-linux-x64-musl": "0.0.0-20260731-8e554196", "@opentui/core-win32-arm64": "0.0.0-20260731-8e554196", "@opentui/core-win32-x64": "0.0.0-20260731-8e554196" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-xXX9wN3NkXwrzNw6yfnbpRkoA5AAhgO8N8p1SNwdXdMxJajyMi5oXB8As/xHkubawEJmPRGF/0LFcRjz0p6riA=="],
"@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260731-8e554196", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9+Yeyh1rXHjYP0TY+1rmmYZ3gn89to16jVO11Yn9M9ggiC8htHSOT/tg2RWwXhl2Jm4nSpIk9wKF3TdKtU6BFQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260731-8e554196", "", { "os": "darwin", "cpu": "x64" }, "sha512-DQ9fseLrrygk4WC9FI/8/tx/wVy+6yU8TbhP37wn80HZsuSNvzIwsmifEFwL3fe4l+odkEJ6YCyr8UvRswdbQw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260731-8e554196", "", { "os": "linux", "cpu": "arm64" }, "sha512-uS7Ej6HdNPB7YGbXtf3JFfjG9dTWEi/YuD+ejGIxLho/zNQUdhnfT9SsrPOIeDWqT0uO5BjXFffcUKD7fG+GdA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260731-8e554196", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/q4Etu8AK0+0oHcmE4XkpfZCFWIYht7C5bIDeYCa6yQNCVMZeemDx4WEHH7DQDSsev208l01ly+CB0MlhnnOw=="],
"@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-x64": ["@opentui/core-linux-x64@0.0.0-20260731-8e554196", "", { "os": "linux", "cpu": "x64" }, "sha512-YB1WokWhHdFm7ytBZkSHyMIuUdLbXeguz9fdt8N1TkI1FMWeVPRANVhk7XrH9zlI/IRqLLvTKUgWKyhbYGuP7A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260731-8e554196", "", { "os": "linux", "cpu": "x64" }, "sha512-U84Pu0yD3Iuhwmj1SJQMQID69J+v8fykYb/H3acrmKKmCWBf9XBQ0PXP4T2X/GeyXCZdI+I+3rUtlsa/QIyqDg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260731-8e554196", "", { "os": "win32", "cpu": "arm64" }, "sha512-Kw9jMghKVArQ13bohhgvvkrvEFLWwtAMLrrZLNH1WUH+j5QF4mpnGb+p1K/S/6Gri44HZBANB2gKDCtk0b3g8A=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260731-8e554196", "", { "os": "win32", "cpu": "x64" }, "sha512-qNbF9a+Om1XmYt6xpKrZmKftYovX+QFwpH3ORT2AfguESkSfzj/fqLivOm0lEZDuDTnXQvM/pwqJOk/UNwCMug=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260731-8e554196", "", { "dependencies": { "@opentui/core": "0.0.0-20260731-8e554196" }, "peerDependencies": { "@opentui/react": "0.0.0-20260731-8e554196", "@opentui/solid": "0.0.0-20260731-8e554196", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-lOD/FqN5y/ydobbzlD9llBK3/mfckfZcK7RRoBuelyxtWCvyASVrj93j1DdiQp4T8hh5Gy0gocZ94egxaVHrNA=="],
"@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/solid": ["@opentui/solid@0.0.0-20260731-8e554196", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260731-8e554196", "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-xk3FYM5+wASJ6vdHpTKjgt9gVghLKujzfAGszbdpXOMmmVYwxN/BcV/JtmGwORg4EW2Xk+zQqziI4fmeAaz57g=="],
"@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=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -3529,7 +3530,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.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -3609,6 +3610,8 @@
"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=="],
@@ -4025,7 +4028,7 @@
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"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=="],
"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=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -4307,7 +4310,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@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
@@ -4451,6 +4454,8 @@
"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=="],
@@ -4789,7 +4794,7 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -4909,7 +4914,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@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
@@ -4935,7 +4940,7 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
@@ -5535,7 +5540,7 @@
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -5567,6 +5572,8 @@
"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=="],
@@ -6507,6 +6514,8 @@
"@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=="],
@@ -6689,11 +6698,9 @@
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"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=="],
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -6767,6 +6774,8 @@
"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=="],
@@ -7541,6 +7550,20 @@
"@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=="],
@@ -8595,6 +8618,8 @@
"@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
@@ -45,9 +45,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -148,9 +148,9 @@
"electron"
],
"overrides": {
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
+2 -1
View File
@@ -38,7 +38,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -133,6 +133,7 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
+120 -25
View File
@@ -11,6 +11,7 @@ import {
Usage,
type FinishReason,
type FinishReasonDetails,
type CacheHint,
type JsonSchema,
type LLMRequest,
type MediaPart,
@@ -38,6 +39,11 @@ export const PATH = "/chat/completions"
// The body schema is the provider-native JSON body. `fromRequest` below builds
// this shape from the common `LLMRequest`, then `Route.make` validates and
// JSON-encodes it before transport.
const OpenAIChatCacheControl = Schema.Struct({
type: Schema.Literal("ephemeral"),
ttl: Schema.optional(Schema.String),
})
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
description: Schema.String,
@@ -47,6 +53,7 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -61,7 +68,11 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
Schema.Struct({
type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }),
@@ -69,7 +80,10 @@ const OpenAIChatUserContent = Schema.Union([
])
const OpenAIChatMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
}),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
@@ -83,10 +97,16 @@ const OpenAIChatMessage = Schema.Union([
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown),
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
Schema.Struct({
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
content: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -210,13 +230,20 @@ export interface ParserState {
// Lowering is the only place that knows how common LLM messages map onto the
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`.
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
},
cache_control: options.cacheControl?.(tool.cache),
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -258,11 +285,14 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown)
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text })
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
continue
}
if (part.type === "media") {
@@ -271,14 +301,18 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
}
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
}
if (content.every((part) => part.type === "text"))
return { role: "user" as const, content: content.map((part) => part.text).join("") }
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
return {
role: "user" as const,
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
}
return { role: "user" as const, content }
})
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField?: string,
options: LoweringOptions = {},
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -316,29 +350,44 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (reasoning.length === 0) return nativeReasoning
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const result = {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_details: details,
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
})
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const messages: OpenAIChatMessage[] = []
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
if (part.result.type !== "content") {
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
messages.push({
role: "tool",
tool_call_id: part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
messages.push({
role: "tool",
tool_call_id: part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
const files = content.filter((item) => item.type === "file")
images.push(
...(yield* Effect.forEach(files, (item) =>
@@ -352,15 +401,29 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField?: string,
options: LoweringOptions = {},
) {
if (message.role === "user") return [yield* lowerUserMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
return (yield* lowerToolMessages(message)).messages
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
return (yield* lowerToolMessages(message, options)).messages
})
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
const system: OpenAIChatMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
request.system.length === 0
? []
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
? [
{
role: "system",
content: request.system.map((part) => ({
type: "text",
text: part.text,
cache_control: options.cacheControl?.(part.cache),
})),
},
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
@@ -371,28 +434,53 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
messages.push({
role: "user",
content: [
...pendingImages.splice(0),
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
})
continue
}
const previous = messages.at(-1)
if (previous?.role === "user" && typeof previous.content === "string")
messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
messages[messages.length - 1] = options.cacheControl?.(part.cache)
? {
role: "user",
content: [
{ type: "text", text: previous.content },
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
],
}
: { role: "user", content: `${previous.content}\n${part.text}` }
else if (previous?.role === "user" && Array.isArray(previous.content))
messages[messages.length - 1] = {
role: "user",
content: [...previous.content, { type: "text", text: part.text }],
content: [
...previous.content,
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
}
else messages.push({ role: "user", content: part.text })
else
messages.push(
options.cacheControl?.(part.cache)
? {
role: "user",
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
}
: { role: "user", content: part.text },
)
continue
}
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message)
const lowered = yield* lowerToolMessages(message, options)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
}
flushImages()
return messages
@@ -406,7 +494,10 @@ const lowerOptions = (request: LLMRequest) => {
}
}
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
request: LLMRequest,
options: LoweringOptions = {},
) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`.
const reasoningField = request.model.compatibility?.reasoningField
@@ -419,12 +510,16 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
return {
model: request.model.id,
messages: yield* lowerMessages(request),
messages: yield* lowerMessages(request, options),
tools:
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
+84 -10
View File
@@ -4,21 +4,72 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
import { isRecord } from "../protocols/shared"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
const ADAPTER = "openrouter"
type OpenRouterString<Known extends string> = Known | (string & {})
export interface OpenRouterProviderRouting {
readonly [key: string]: unknown
readonly order?: ReadonlyArray<string>
readonly allow_fallbacks?: boolean
readonly require_parameters?: boolean
readonly data_collection?: OpenRouterString<"allow" | "deny">
readonly only?: ReadonlyArray<string>
readonly ignore?: ReadonlyArray<string>
readonly quantizations?: ReadonlyArray<string>
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
readonly max_price?: Readonly<{
prompt?: number | string
completion?: number | string
image?: number | string
audio?: number | string
request?: number | string
}>
readonly zdr?: boolean
}
export type OpenRouterPlugin =
| Readonly<{
id: "web"
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
| Readonly<{ id: "moderation" }>
| Readonly<{ id: "response-healing" }>
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
| Readonly<{ id: string & {}; [key: string]: unknown }>
export interface OpenRouterOptions {
readonly [key: string]: unknown
readonly usage?: boolean | Record<string, unknown>
readonly reasoning?: Record<string, unknown>
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
readonly models?: ReadonlyArray<string>
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{
enabled?: boolean
exclude?: boolean
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
max_tokens?: number
}>
readonly usage?: boolean | Readonly<{ include: boolean }>
readonly user?: string
readonly web_search_options?: Readonly<{
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
}
export type OpenRouterProviderOptionsInput = ProviderOptions & {
@@ -47,7 +98,7 @@ export const protocol = Protocol.make({
body: {
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.protocol.body.from(request).pipe(
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0
@@ -78,16 +129,39 @@ export const protocol = Protocol.make({
stream: OpenAIChat.protocol.stream,
})
const cacheControl = () => {
const breakpoints = newBreakpoints(4)
return (cache: CacheHint | undefined) => {
if (cache === undefined || breakpoints.remaining === 0) return undefined
breakpoints.remaining -= 1
return {
type: "ephemeral" as const,
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
}
}
}
const bodyOptions = (input: unknown) => {
const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
return {
...(openrouter.usage === true
...options,
...(usage === undefined || usage === true
? { usage: { include: true } }
: isRecord(openrouter.usage)
? { usage: openrouter.usage }
: {}),
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
: usage === false
? { usage: { include: false } }
: isRecord(usage)
? { usage }
: {}),
...(Array.isArray(models) ? { models } : {}),
...(isRecord(provider) ? { provider } : {}),
...(Array.isArray(plugins) ? { plugins } : {}),
...(isRecord(web_search_options) ? { web_search_options } : {}),
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
}
}
+2
View File
@@ -124,6 +124,7 @@ export const ToolCallPart = Object.assign(
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.ToolCall" }),
@@ -168,6 +169,7 @@ export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Reasoning" })
@@ -5,6 +5,28 @@ const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
openrouter: {
models: ["google/gemini-3.1-pro"],
provider: {
order: ["anthropic"],
require_parameters: true,
data_collection: "future-policy",
sort: "future-sort",
max_price: { prompt: "0.50" },
},
reasoning: { effort: "future-effort", exclude: false },
plugins: [{ id: "future-plugin", enabled: true }],
web_search_options: { engine: "future-engine" },
debug: { echo_upstream_body: true },
user: "user_123",
},
},
})
LLM.request({
model,
prompt: "Hello",
+177 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message } from "../../src"
import { CacheHint, LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenRouter from "../../src/providers/openrouter"
@@ -27,10 +27,131 @@ describe("OpenRouter", () => {
model: "openai/gpt-4o-mini",
messages: [{ role: "user", content: "Say hello." }],
stream: true,
usage: { include: true },
})
}),
)
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
cache: { tools: true, system: true, messages: { tail: 1 } },
}),
)
expect(prepared.body).toMatchObject({
tools: [{ cache_control: { type: "ephemeral" } }],
messages: [
{
role: "system",
content: [
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
],
},
{
role: "user",
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("lowers manual assistant and tool-result cache hints", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.user("Call the tool"),
Message.assistant([
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
]),
Message.tool({
id: "call_1",
name: "lookup",
result: "Done",
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
}),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Call the tool" },
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
])
}),
)
it.effect("caps manual cache controls at four breakpoints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
prompt: "Hello",
}),
)
const system = prepared.body.messages[0]
expect(system?.role).toBe("system")
expect(
system && Array.isArray(system.content)
? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
: [],
).toHaveLength(4)
}),
)
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: { messages: "latest-assistant" },
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Think" },
{ role: "assistant", cache_control: { type: "ephemeral" } },
])
}),
)
it.effect("allows usage accounting to be disabled explicitly", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: { usage: false } },
}).model("openai/gpt-4o-mini"),
cache: "none",
prompt: "Hello",
}),
)
expect(prepared.body.usage).toEqual({ include: false })
}),
)
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -42,6 +163,13 @@ describe("OpenRouter", () => {
usage: true,
reasoning: { effort: "high" },
promptCacheKey: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
},
},
}).model("anthropic/claude-3.7-sonnet:thinking"),
@@ -53,10 +181,54 @@ describe("OpenRouter", () => {
usage: { include: true },
reasoning: { effort: "high" },
prompt_cache_key: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
})
}),
)
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
Effect.gen(function* () {
const invalid: Record<string, unknown> = {
usage: "yes",
models: "anthropic/claude-sonnet-4.6",
provider: [],
plugins: {},
web_search_options: [],
debug: [],
user: 123,
reasoning: [],
promptCacheKey: 123,
future_option: { enabled: true },
}
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: invalid },
}).model("openai/gpt-4o-mini"),
prompt: "Hello",
}),
)
expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
expect(prepared.body).not.toHaveProperty("usage")
expect(prepared.body).not.toHaveProperty("models")
expect(prepared.body).not.toHaveProperty("provider")
expect(prepared.body).not.toHaveProperty("plugins")
expect(prepared.body).not.toHaveProperty("web_search_options")
expect(prepared.body).not.toHaveProperty("debug")
expect(prepared.body).not.toHaveProperty("user")
expect(prepared.body).not.toHaveProperty("reasoning")
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("preserves the upstream provider finish reason", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
@@ -104,6 +276,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant([
{
@@ -137,6 +310,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -162,6 +336,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -183,6 +358,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
}),
)
+59 -18
View File
@@ -1,10 +1,13 @@
export * as AISDKNative from "./aisdk-native"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Provider } from "./provider"
export interface Mapping {
readonly package: string
readonly settings: Readonly<Record<string, unknown>>
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
}
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
@@ -20,14 +23,7 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
},
}
case "@openrouter/ai-sdk-provider":
return {
package: "@opencode-ai/ai/providers/openrouter",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapProviderOptions("openrouter", settings),
},
}
return mapOpenRouter(settings, baseSettings)
case "@ai-sdk/xai":
return {
package: "@opencode-ai/ai/providers/xai",
@@ -69,6 +65,61 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
return { providerOptions: { gemini: options } }
}
function mapOpenRouter(
settings: Readonly<Record<string, unknown>>,
baseSettings: Readonly<Record<string, unknown>>,
): Mapping {
const headers =
Provider.mergeHeaders(
{
...(typeof settings.appName === "string" ? { "X-OpenRouter-Title": settings.appName } : {}),
...(typeof settings.appUrl === "string" ? { "HTTP-Referer": settings.appUrl } : {}),
...(isStringRecord(settings.api_keys) && Object.keys(settings.api_keys).length > 0
? { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }
: {}),
},
isStringRecord(settings.headers) ? settings.headers : undefined,
) ?? {}
return {
package: "@opencode-ai/ai/providers/openrouter",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapOpenRouterOptions(settings),
},
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(isRecord(settings.extraBody) ? { body: settings.extraBody } : {}),
}
}
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
const options = Object.fromEntries(
Object.entries(settings).filter(
([key]) =>
![
"apiKey",
"api_keys",
"appName",
"appUrl",
"authToken",
"baseURL",
"chunkTimeout",
"compatibility",
"extraBody",
"fetch",
"headers",
"timeout",
].includes(key),
),
)
if (Object.keys(options).length === 0) return {}
return { providerOptions: { openrouter: options } }
}
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string")
}
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
@@ -78,13 +129,3 @@ function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
if (Object.keys(options).length === 0) return {}
return { providerOptions: { xai: options } }
}
function mapProviderOptions(namespace: string, settings: Readonly<Record<string, unknown>>) {
const values = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
if (Object.keys(values).length === 0) return {}
return { providerOptions: { [namespace]: values } }
}
+3 -3
View File
@@ -16,7 +16,7 @@ import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments"
import { ConfigMedia } from "./config/media"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
@@ -85,8 +85,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
description: "Attachment processing configuration",
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
description: "Media processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
@@ -1,15 +1,15 @@
export * as ConfigAttachments from "./attachments"
export * as ConfigMedia from "./media"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
export class Image extends Schema.Class<Image>("Config.Attachments.Image")({
export class Image extends Schema.Class<Image>("Config.Media.Image")({
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Attachments")({
export class Info extends Schema.Class<Info>("Config.Media")({
image: Image.pipe(Schema.optional),
}) {}
+1 -1
View File
@@ -61,7 +61,7 @@ const layer = Layer.effect(
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
),
)
const normalize = yield* loadAdapter
+4 -9
View File
@@ -96,9 +96,7 @@ const providerHeaders = (model: Info) => {
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (
model: Info,
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
@@ -202,8 +200,8 @@ export const fromCatalogModel = (
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
@@ -266,10 +264,7 @@ export const layer = Layer.effect(
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const load = Effect.fn("ModelResolver.resolveModel")(function* (
selected: Info,
variant?: VariantID,
) {
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
+3 -3
View File
@@ -938,7 +938,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
: resolved.bytes
const normalized = yield* normalizeImageAttachment(
input,
Base64.make(Buffer.from(content).toString("base64")),
Buffer.from(content).toString("base64"),
mime,
image,
)
@@ -954,11 +954,11 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
input: PromptInput.FileAttachment,
data: Base64,
data: string,
mime: string,
image: Effect.Effect<Image.Interface>,
) {
if (!mime.startsWith("image/")) return { data, mime }
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
const service = yield* image
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
+1 -1
View File
@@ -71,7 +71,7 @@ export const layer = Layer.effect(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, app) },
providerOptions: { openai: { promptCacheKey } },
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: hookedTools,
+1 -1
View File
@@ -158,7 +158,7 @@ export const layer = Layer.effect(
http: {
headers: SessionModelHeaders.make(session, app),
},
providerOptions: { openai: { promptCacheKey } },
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
system: contextEvent.system,
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
tools: hookedTools,
+1 -1
View File
@@ -63,7 +63,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
attachments: info.attachment,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
+36
View File
@@ -2,6 +2,42 @@ import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
describe("AISDKNative", () => {
test("maps OpenRouter settings to native destinations", () => {
expect(
AISDKNative.map("@openrouter/ai-sdk-provider", {
appName: "OpenCode",
appUrl: "https://opencode.ai",
headers: { "x-openrouter-title": "Configured", "x-provider-api-keys": "Configured BYOK" },
api_keys: { anthropic: "provider-key" },
extraBody: { transforms: ["middle-out"] },
models: ["anthropic/claude-sonnet-4.6"],
provider: { only: ["anthropic"], require_parameters: true },
reasoning: { effort: "high" },
promptCacheKey: "session_123",
future_option: { enabled: true },
}),
).toEqual({
package: "@opencode-ai/ai/providers/openrouter",
settings: {
providerOptions: {
openrouter: {
models: ["anthropic/claude-sonnet-4.6"],
provider: { only: ["anthropic"], require_parameters: true },
reasoning: { effort: "high" },
promptCacheKey: "session_123",
future_option: { enabled: true },
},
},
},
headers: {
"x-openrouter-title": "Configured",
"HTTP-Referer": "https://opencode.ai",
"x-provider-api-keys": "Configured BYOK",
},
body: { transforms: ["middle-out"] },
})
})
test("maps every Google thinking setting", () => {
expect(
AISDKNative.map("@ai-sdk/google", {
+3 -3
View File
@@ -803,7 +803,7 @@ describe("Config", () => {
custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
},
lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
attachments: {
media: {
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
},
tool_output: { max_lines: 1000, max_bytes: 32768 },
@@ -890,7 +890,7 @@ describe("Config", () => {
typescript: { disabled: true },
custom: { command: ["custom-lsp"], extensions: [".foo"] },
})
expect(documents[0]?.info.attachments).toEqual({
expect(documents[0]?.info.media).toEqual({
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
})
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
@@ -1097,7 +1097,7 @@ describe("Config", () => {
expect(documents[0]?.info.references).toEqual({
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
})
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
expect(documents[0]?.info.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
expect(documents[0]?.info.providers?.custom).toMatchObject({
settings: { apiKey: "secret" },
models: {
+32
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { LLM, Model } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
@@ -546,6 +547,37 @@ describe("ModelResolver", () => {
}),
)
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
settings: {
appName: "OpenCode",
appUrl: "https://opencode.ai",
extraBody: { transforms: ["middle-out"], provider: { sort: "price" } },
},
headers: { "X-OpenRouter-Title": "Custom" },
body: { provider: { only: ["anthropic"] } },
}),
undefined,
{
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
expect(settings.headers).toEqual({
"HTTP-Referer": "https://opencode.ai",
"X-OpenRouter-Title": "Custom",
})
expect(settings.body).toEqual({
transforms: ["middle-out"],
provider: { sort: "price", only: ["anthropic"] },
})
return Model.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
},
}),
},
),
)
it.effect("loads supported AISDK catalog packages as native routes", () =>
Effect.gen(function* () {
const google = yield* ModelResolver.fromCatalogModel(
+34 -2
View File
@@ -26,8 +26,8 @@ import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencod
import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
const executionCalls: Session.ID[] = []
const interruptCalls: Session.ID[] = []
@@ -58,7 +58,10 @@ const locations = Layer.effect(
() =>
// Attachment admission only needs the location-scoped Image service.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
imagePassthrough as unknown as Layer.Layer<LocationServices>,
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
}) as unknown as Layer.Layer<LocationServices>,
),
)
const it = testEffect(
@@ -386,6 +389,35 @@ describe("Session.prompt", () => {
}),
)
it.effect("normalizes large image content before validating persisted Base64", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const pixel = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
)
const bytes = Buffer.concat([pixel, Buffer.alloc(4_323_030 - pixel.length)])
const data = bytes.toString("base64")
expect(data).toHaveLength(5_764_040)
const message = yield* session.prompt({
sessionID,
text: "Inspect this image",
files: [{ uri: `data:image/png;base64,${data}` }],
resume: false,
})
expect(message.data.files).toEqual([
{
data: "AA==",
mime: "image/png",
source: { type: "inline" },
},
])
}),
)
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
Effect.gen(function* () {
yield* setup
+6 -6
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { ConfigMedia } from "@opencode-ai/core/config/media"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
@@ -432,8 +432,8 @@ describe("ReadTool", () => {
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
}),
}),
}),
@@ -475,7 +475,7 @@ describe("ReadTool", () => {
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
}),
}),
])
@@ -514,8 +514,8 @@ describe("ReadTool", () => {
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
}),
}),
}),
+3 -3
View File
@@ -31,9 +31,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+18
View File
@@ -26,12 +26,27 @@ import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
export interface Storage {
/**
* Durable JSON state: persisted to disk, survives hot reloads and TUI
* restarts, and stays live-synced across running TUI instances.
*/
store<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-memory state: survives plugin hot reloads (old and new
* generations share the same live store) and is gone when the TUI exits.
* Updates are synchronous and values need not be JSON-serializable.
*/
memory<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
}
interface LocationCollection<Value> {
@@ -136,6 +151,9 @@ export interface SlotMap {
readonly sessionID?: string
readonly mode: "normal" | "shell"
}
readonly "session.composer.top": {
readonly sessionID: string
}
readonly "sidebar.content": {
readonly sessionID: string
}
+1
View File
@@ -92,6 +92,7 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
+54 -63
View File
@@ -82,7 +82,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { PluginRoute, PluginSlot } from "./plugin/render"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -92,7 +93,6 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
import { createTuiClipboard, formatClipboardWriteNotification } from "./clipboard"
registerOpencodeSpinner()
@@ -259,13 +259,6 @@ 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(() =>
@@ -301,57 +294,57 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<TuiAppProvider value={input.app}>
<ClipboardProvider value={clipboard}>
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
>
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<StorageProvider>
<TuiLifecycleProvider
<StorageProvider>
<TuiLifecycleProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiTerminalEnvironmentProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
}}
>
<TuiTerminalEnvironmentProvider
<TuiStartupProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
initialRoute: process.env.OPENCODE_STORY
? {
type: "plugin",
id: "opencode.storybook",
name: "storybook",
// OPENCODE_STORY=1 opens the index; any other value opens that story.
data:
process.env.OPENCODE_STORY === "1"
? undefined
: { story: process.env.OPENCODE_STORY },
}
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_STORY
? {
type: "plugin",
id: "opencode.storybook",
name: "storybook",
// OPENCODE_STORY=1 opens the index; any other value opens that story.
data:
process.env.OPENCODE_STORY === "1"
? undefined
: { story: process.env.OPENCODE_STORY },
}
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
@@ -416,13 +409,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</StorageProvider>
</TuiPathsProvider>
</ErrorBoundary>
</ClipboardProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</StorageProvider>
</TuiPathsProvider>
</ErrorBoundary>
</TuiAppProvider>
</EpilogueProvider>
</ExitProvider>
@@ -517,10 +510,8 @@ function App(props: { pair?: DialogPairCredentials }) {
if (!text || text.length === 0) return
await clipboard
.write(text)
.then((outcome) =>
toast.show(formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" })),
)
.write?.(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
+109 -121
View File
@@ -1,136 +1,124 @@
import {
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
decodePasteBytes,
type ClipboardService as CoreClipboardService,
type ClipboardWriteResult,
type RendererClipboardBoundary,
} from "@opentui/core"
import type { ClipboardService, ClipboardWriteOutcome } from "./context/clipboard"
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"
const timeoutMs = 1_000
const maxReadBytes = 8 * 1024 * 1024
const exec = promisify(execFile)
export type ClipboardNotification = Readonly<{
message: string
variant: "info" | "success" | "warning"
}>
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 type ClipboardCopyState =
| "idle"
| "confirmed"
| "confirmed-partial"
| "attempted"
| "attempted-partial"
| "failed"
function writeOsc52(text: string) {
if (!process.stdout.isTTY) return
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
}
export type OwnedClipboardService = ClipboardService & Readonly<{ dispose(): Promise<void> }>
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(() => {})
}
}
export class ClipboardWriteError extends Error {
readonly result: ClipboardWriteResult
constructor(result: ClipboardWriteResult) {
super(
`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`,
result.host.status === "failed" ? { cause: result.host.error } : undefined,
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),
)
this.name = "ClipboardWriteError"
this.result = result
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())",
]
}
}
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
return createClipboardAdapter(
createClipboard({
host: createHostClipboard({
timeoutMs,
maxReadBytes,
maxWriteBytes: 8 * 1024 * 1024,
maxImagePixels: 64 * 1024 * 1024,
maxConversionBytes: 512 * 1024 * 1024,
maxConcurrentOperations: 16,
maxProviderTransfers: 16,
maxWorkUnitsPerDrain: 64,
}),
terminal: createRendererClipboardAdapter(renderer),
}),
)
}
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
return {
async read() {
const result = await clipboard.read({
preferredTypes: ["image/png", "text/plain"],
selection: "clipboard",
})
if (result.status !== "read") {
if (result.status === "empty" || result.status === "unsupported" || result.status === "cancelled") return
if (result.status === "failed") throw result.error
if (result.status === "timed-out") throw new Error(`Clipboard read timed out after ${timeoutMs}ms`)
if (result.status === "limit-exceeded") {
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
}
throw new Error(`Unexpected clipboard read status: ${result.status}`)
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)
}
if (result.representation.mimeType === "image/png") {
return {
data: Buffer.from(result.representation.bytes).toString("base64"),
mime: result.representation.mimeType,
}
}
if (native) {
return async (text: string) => {
await command(native[0], native.slice(1), text).catch(() => undefined)
}
if (result.representation.mimeType === "text/plain") {
if (result.representation.bytes.length === 0) return
return {
data: decodePasteBytes(result.representation.bytes),
mime: result.representation.mimeType,
}
}
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
return classifyClipboardWriteResult(
await clipboard.writeText(text, {
destination: "all-available",
selection: "clipboard",
}),
)
},
dispose() {
return clipboard.dispose()
},
}
}
return async (text: string) => {
const { default: clipboardy } = await import("clipboardy")
await clipboardy.write(text).catch(() => undefined)
}
})())
}
export function classifyClipboardWriteResult(result: ClipboardWriteResult): ClipboardWriteOutcome {
const partial =
result.host.status === "failed" ||
result.host.status === "timed-out" ||
result.host.status === "cancelled" ||
result.terminal.status === "local-failure"
if (result.host.status === "written") return { delivery: "confirmed", partial, result }
if (result.terminal.status === "attempted") return { delivery: "attempted", partial, result }
throw new ClipboardWriteError(result)
}
export function formatClipboardWriteNotification(
outcome: ClipboardWriteOutcome,
confirmed: ClipboardNotification,
): ClipboardNotification {
if (outcome.delivery === "confirmed" && !outcome.partial) return confirmed
if (outcome.delivery === "attempted" && !outcome.partial) {
return { message: "Sent to terminal clipboard (acceptance unconfirmed)", variant: "info" }
}
if (outcome.delivery === "confirmed") {
return { message: "Copied to host clipboard; terminal clipboard dispatch failed", variant: "warning" }
}
return { message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" }
}
export function clipboardCopyState(outcome: ClipboardWriteOutcome): ClipboardCopyState {
if (!outcome.partial) return outcome.delivery
return `${outcome.delivery}-partial`
export async function write(text: string) {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
}
@@ -93,15 +93,6 @@ 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",
@@ -198,15 +189,6 @@ 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",
+5 -15
View File
@@ -9,7 +9,6 @@ import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { describeOS, describeTerminal } from "../util/system"
import { useTuiApp } from "../context/runtime"
import { formatClipboardWriteNotification } from "../clipboard"
export function DialogDebug() {
const theme = useTheme()
@@ -39,22 +38,13 @@ export function DialogDebug() {
const text = entries()
.map((entry) => `${entry.label}: ${entry.value}`)
.join("\n")
setCopied(false)
void clipboard
.write(text)
.then((outcome) => {
setCopied(outcome.delivery === "confirmed")
toast.show(
formatClipboardWriteNotification(outcome, {
message: "Debug info copied to clipboard",
variant: "info",
}),
)
})
.catch((error) => {
setCopied(false)
toast.error(error)
.write?.(text)
.then(() => {
setCopied(true)
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
})
.catch(toast.error)
}
Keymap.createLayer(() => ({
@@ -1,77 +0,0 @@
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, on } 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
setIndex((value) => (value + direction + props.images.length) % props.images.length)
}
createEffect(
on(
() => current()?.uri,
() => setFailed(false),
),
)
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="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="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>
)
}
@@ -8,7 +8,6 @@ import type {
} from "@opencode-ai/client"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { formatClipboardWriteNotification } from "../clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
@@ -453,12 +452,8 @@ 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)
.then((outcome) =>
toast.show(
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
),
)
.write?.(value)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
},
+2 -7
View File
@@ -9,7 +9,6 @@ import { useTheme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
import { formatClipboardWriteNotification } from "../clipboard"
import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
@@ -147,14 +146,10 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
onMount(() => dialog.setSize("large"))
const copy = () => {
if (!clipboard.write) return
void clipboard
.write(error())
.then((outcome) => {
setCopied(outcome.delivery === "confirmed")
toast.show(
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
)
})
.then(() => setCopied(true))
.catch(toast.error)
}
+18 -6
View File
@@ -37,8 +37,8 @@ export function DialogOpen() {
const dimensions = useTerminalDimensions()
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
data.project.invalidate()
void data.project.sync().catch(() => {})
// One background fetch fills in recent sessions from other projects; the menu renders
@@ -52,8 +52,12 @@ export function DialogOpen() {
{ initialValue: [] },
)
const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID)))
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const openTabs = createMemo(
() => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []),
)
const currentSessionID = createMemo(() =>
route.data.type === "session" ? data.session.root(route.data.sessionID) : undefined,
)
const sessions = createMemo(() => {
const seen = new Set<string>()
return [...data.session.list(), ...fetched()]
@@ -69,7 +73,7 @@ export function DialogOpen() {
const tabs = openTabs()
// With an empty query the menu shows what is not already one keystroke away: open tabs are
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
// matching a tab by name still switches to it.
// matching a loaded tab by name still switches to it.
const recent = filter().trim()
? sessions()
: sessions()
@@ -78,7 +82,9 @@ export function DialogOpen() {
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
const running =
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
value: { type: "session", sessionID: session.id } as OpenTarget,
@@ -98,7 +104,7 @@ export function DialogOpen() {
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
@@ -113,6 +119,10 @@ export function DialogOpen() {
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
category: "Projects",
gutter:
project.canonical === current?.canonical
? () => <text fg={theme.text.formfield.selected}></text>
: undefined,
}
})
@@ -128,6 +138,8 @@ export function DialogOpen() {
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
@@ -89,7 +89,8 @@ export function DialogSessionList() {
const sessions = createMemo(() => {
const query = filter().trim()
const local = localSessions()
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
if (query !== search().trim()) return searchResults.latest?.sessions ?? local
if (searchResults.loading) return searchResults.latest?.sessions ?? []
const result = searchResults()
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
return result.sessions
@@ -154,11 +155,13 @@ export function DialogSessionList() {
footer,
bg: deleting ? theme.background.action.destructive.focused : undefined,
fg: deleting ? theme.text.action.destructive.focused : undefined,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
gutter:
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
}
}
+9 -38
View File
@@ -6,14 +6,13 @@ import { useClipboard } from "../context/clipboard"
import { useExit } from "../context/exit"
import { useTuiApp } from "../context/runtime"
import { describeOS, describeTerminal } from "../util/system"
import { clipboardCopyState, type ClipboardCopyState } from "../clipboard"
export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
const term = useTerminalDimensions()
const exit = useExit()
const clipboard = useClipboard()
const app = useTuiApp()
const [copyState, setCopyState] = createSignal<ClipboardCopyState>("idle")
const [copied, setCopied] = createSignal(false)
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
// theme context may be the thing that crashed.
@@ -29,7 +28,6 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
onPrimary: "#ffffff",
error: "#d1383d",
success: "#3d9a57",
warning: "#c18401",
}
: {
bg: "#0a0a0a",
@@ -41,7 +39,6 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
onPrimary: "#0a0a0a",
error: "#e06c75",
success: "#7fd88f",
warning: "#e5c07b",
}
const message = props.error.message || "An unknown error occurred."
@@ -49,27 +46,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const issueURL = buildIssueURL(message, stack, app.version)
const copyReport = () => {
void clipboard
.write(issueURL.toString())
.then((outcome) => setCopyState(clipboardCopyState(outcome)))
.catch(() => setCopyState("failed"))
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
}
const actions = [
{
key: "c",
label: () =>
({
idle: "Copy report",
confirmed: "Copied",
"confirmed-partial": "Copied (terminal failed)",
attempted: "Sent",
"attempted-partial": "Sent (host failed)",
failed: "Copy failed",
})[copyState()],
copy: true,
onUse: copyReport,
},
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
{ key: "r", label: () => "Restart", onUse: props.reset },
{ key: "q", label: () => "Quit", onUse: () => exit() },
]
@@ -154,18 +135,13 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<For each={actions}>
{(action, index) => {
const isSelected = () => selected() === index()
const copyColor = () => {
if (!action.copy) return
if (copyState() === "confirmed") return colors.success
if (copyState() === "confirmed-partial" || copyState() === "attempted-partial") return colors.warning
if (copyState() === "failed") return colors.error
}
const isCopied = () => action.copy && copied()
return (
<box flexDirection="column" alignItems="center" flexShrink={0}>
<box
onMouseDown={() => setSelected(index())}
onMouseUp={() => action.onUse()}
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
minWidth={15}
alignItems="center"
paddingLeft={2}
@@ -173,7 +149,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
>
<text
attributes={TextAttributes.BOLD}
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
>
{action.label()}
</text>
@@ -213,14 +189,9 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<Show when={showFooter()}>
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text fg={colors.muted}>
{{
idle: "Copy the report and open a GitHub issue to help us fix this.",
confirmed: "Report copied. Paste it into a new GitHub issue.",
"confirmed-partial": "Report copied to host clipboard; terminal dispatch failed.",
attempted: "Report sent to terminal clipboard; acceptance is unconfirmed.",
"attempted-partial": "Report sent to terminal clipboard; host clipboard write failed.",
failed: "Clipboard write failed. Try again or report the crash manually.",
}[copyState()]}
{copied()
? "Report copied — paste it into a new GitHub issue."
: "Copy the report and open a GitHub issue to help us fix this."}
</text>
<text fg={colors.muted}>OpenCode {app.version}</text>
</box>
+26 -9
View File
@@ -1,11 +1,13 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { logo } from "../logo"
import { go, logo } from "../logo"
export function Logo() {
const theme = useTheme()
const dimensions = useTerminalDimensions()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background.default, fg, 0.25)
@@ -49,14 +51,29 @@ export function Logo() {
return (
<box>
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
</box>
)}
</For>
{dimensions().height < 12 ? null : dimensions().width < 22 ? (
<For each={go.right.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
</For>
) : dimensions().width < 44 ? (
<>
<For each={logo.left.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>}
</For>
<For each={logo.right}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
</For>
</>
) : (
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
</box>
)}
</For>
)}
</box>
)
}
+83
View File
@@ -0,0 +1,83 @@
/** @jsxImportSource @opentui/solid */
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { createMemo, For, Show, splitProps } from "solid-js"
import { splitPatchHunks } from "../util/diff"
import { stringWidth } from "../util/string-width"
export interface PatchDiffRef {
readonly hunks: () => readonly DiffRenderable[]
}
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref"> & {
diff: string
hunkFg: ColorInput
lineNumberBg: ColorInput
ref?: (value: PatchDiffRef) => void
}
export function PatchDiff(props: Props) {
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
const hunks = createMemo(() => splitPatchHunks(local.diff))
const nodes = new Map<number, DiffRenderable>()
local.ref?.({
hunks: () =>
[...nodes.entries()]
.sort(([left], [right]) => left - right)
.map(([, node]) => node)
.filter((node) => !node.isDestroyed),
})
const syncGutters = (attempt = 0) => {
requestAnimationFrame(() => {
const sides = [...nodes.values()]
.filter((item) => !item.isDestroyed)
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
const lineNumbers = sides.map((side) => new Map([...side.getLineNumbers()].filter(([line]) => line >= 0)))
const digits = lineNumbers.map((numbers) => Math.max(0, ...numbers.values()).toString().length)
const after = sides.map((side) =>
Math.max(
0,
...[...side.getLineSigns()].filter(([line]) => line >= 0).map(([, sign]) => stringWidth(sign.after ?? "")),
),
)
const maxDigits = Math.max(...digits)
const maxAfter = Math.max(...after)
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
if (!maxDigits) return
sides.forEach((side) => {
const index = sides.indexOf(side)
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
side.setLineNumbers(lineNumbers[index])
side.setLineSigns(signs)
})
})
}
const register = (index: number, node: DiffRenderable) => {
nodes.set(index, node)
syncGutters()
}
return (
<For each={hunks()}>
{(hunk, index) => (
<>
<Show when={index() > 0}>
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
<text fg={local.hunkFg} bg={local.lineNumberBg}>
{` ${hunk.header ?? ""}`}
</text>
</box>
</Show>
<diff
{...diffProps}
ref={(node: DiffRenderable) => register(index(), node)}
diff={hunk.patch}
minHeight={hunk.rows}
lineNumberBg={local.lineNumberBg}
/>
</>
)}
</For>
)
}
+120 -289
View File
@@ -7,9 +7,10 @@ import {
decodePasteBytes,
type KeyEvent,
} from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import { registerOpencodeSpinner } from "../register-spinner"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
@@ -47,19 +48,12 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import {
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { readLocalAttachment } 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/context"
import { DialogImagePreview } from "../dialog-image-preview"
import { PluginSlot } from "../../plugin/render"
registerOpencodeSpinner()
@@ -78,6 +72,17 @@ 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
@@ -305,114 +310,6 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(),
interrupt: 0,
})
let disposed = false
let pasteQueue = Promise.resolve()
let pasteEpoch = 0
let pasteMutating = false
let pasteMutation = 0
function capturePrompt() {
return {
epoch: pasteEpoch,
sessionID: props.sessionID,
mode: store.mode,
text: input.plainText,
cursor: input.cursorOffset,
files: store.prompt.files && unwrap(store.prompt.files),
agents: store.prompt.agents && unwrap(store.prompt.agents),
pasted: unwrap(store.prompt.pasted),
}
}
function promptChanged(before: ReturnType<typeof capturePrompt>) {
if (disposed || input.isDestroyed) return true
return (
pasteEpoch !== before.epoch ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text ||
input.cursorOffset !== before.cursor ||
(store.prompt.files && unwrap(store.prompt.files)) !== before.files ||
(store.prompt.agents && unwrap(store.prompt.agents)) !== before.agents ||
unwrap(store.prompt.pasted) !== before.pasted
)
}
function cancelChangedPrompt(before: ReturnType<typeof capturePrompt>) {
if (!promptChanged(before)) return false
pasteEpoch = Math.max(pasteEpoch, before.epoch + 1)
if (!disposed && !input.isDestroyed) {
toast.show({ message: "Attachment paste canceled because the prompt changed", variant: "warning" })
}
return true
}
function enqueuePaste(run: (before: ReturnType<typeof capturePrompt>) => Promise<void>) {
const epoch = pasteEpoch
pasteQueue = pasteQueue
.then(async () => {
if (disposed || epoch !== pasteEpoch) return
await run(capturePrompt())
})
.catch((error) => {
if (!disposed) toast.error(error)
})
return pasteQueue
}
function setPromptMode(mode: "normal" | "shell") {
if (store.mode === mode) return
pasteEpoch++
setStore("mode", mode)
}
function applyPaste(run: () => void) {
const mutation = ++pasteMutation
pasteMutating = true
try {
run()
} finally {
queueMicrotask(() => {
if (pasteMutation === mutation) pasteMutating = false
})
}
}
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 imagePreviewAvailableWidth = createMemo(() => Math.min(70, Math.max(0, dimensions().width - 9)))
const imagePreviewLimit = createMemo(() =>
Math.max(1, Math.min(3, Math.floor((imagePreviewAvailableWidth() - 8) / (imagePreviewWidth() + 1)))),
)
const visibleImageCount = createMemo(() => Math.min(imagePreviewLimit(), imageAttachments().length))
const hiddenImageAttachmentCount = createMemo(() => imageAttachments().length - visibleImageCount())
const imagePreviewsVisible = createMemo(
() => imageAttachments().length > 0 && imagePreviewAvailableWidth() >= imagePreviewWidth(),
)
const imageOverflowVisible = createMemo(
() => hiddenImageAttachmentCount() > 0 && imagePreviewAvailableWidth() >= imagePreviewWidth() + 9,
)
function openImagePreview(initial: number) {
const images = imageAttachments()
if (images.length === 0) return
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
}
function imagePreviewMouseIndex(event: MouseEvent): number | undefined {
if (!config.prompt?.image_preview || !imagePreviewsVisible()) return undefined
const x = event.x - anchor.x - 3
const y = event.y - anchor.y - 1
if (x < 0 || y < 0 || y >= imagePreviewHeight()) return undefined
const stride = imagePreviewWidth() + 1
const index = Math.floor(x / stride)
if (index < visibleImageCount() && x % stride < imagePreviewWidth()) return index
if (index === visibleImageCount() && imageOverflowVisible() && x % stride < 8) return visibleImageCount()
return undefined
}
createEffect(
on(
@@ -482,32 +379,22 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste",
category: "Prompt",
palette: undefined,
run: (_input: string | undefined, event?: KeyEvent) => {
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
return enqueuePaste(async (before) => {
const content = await clipboard.read()
if (cancelChangedPrompt(before)) 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, before)
}
})
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)
}
},
},
{
title: "View image attachments",
name: "prompt.images.view",
category: "Prompt",
enabled: imageAttachments().length > 0,
run: () => openImagePreview(0),
},
{
title: "Interrupt session",
name: "session.interrupt",
@@ -519,7 +406,7 @@ export function Prompt(props: PromptProps) {
if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
setPromptMode("normal")
setStore("mode", "normal")
return
}
if (!props.sessionID) return
@@ -640,7 +527,6 @@ export function Prompt(props: PromptProps) {
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.images.view",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
@@ -665,14 +551,12 @@ export function Prompt(props: PromptProps) {
input.blur()
},
set(prompt) {
pasteEpoch++
input.setText(prompt.text)
setStore("prompt", prompt)
restoreExtmarksFromPrompt(prompt)
input.gotoBufferEnd()
},
reset() {
pasteEpoch++
input.clear()
input.extmarks.clear()
setStore("prompt", emptyPrompt())
@@ -696,7 +580,6 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -920,7 +803,7 @@ export function Prompt(props: PromptProps) {
group: "Prompt",
run: () => {
setStore("placeholder", randomIndex(shell().length))
setPromptMode("shell")
setStore("mode", "shell")
},
},
],
@@ -931,7 +814,7 @@ export function Prompt(props: PromptProps) {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setPromptMode("normal") }],
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }],
}
})
@@ -942,7 +825,9 @@ export function Prompt(props: PromptProps) {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
commands: [{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setPromptMode("normal") }],
commands: [
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
],
}
})
@@ -973,7 +858,7 @@ export function Prompt(props: PromptProps) {
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
setPromptMode(item.mode ?? "normal")
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromPrompt(item)
input.cursorOffset = 0
},
@@ -1012,7 +897,7 @@ export function Prompt(props: PromptProps) {
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
setPromptMode(item.mode ?? "normal")
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromPrompt(item)
input.cursorOffset = input.plainText.length
},
@@ -1130,7 +1015,7 @@ export function Prompt(props: PromptProps) {
sessionID,
command: inputText,
})
setPromptMode("normal")
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
@@ -1261,7 +1146,7 @@ export function Prompt(props: PromptProps) {
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText)
applyPaste(() => input.insertText(virtualText + " "))
input.insertText(virtualText + " ")
const extmarkId = input.extmarks.create({
start: extmarkStart,
@@ -1283,46 +1168,56 @@ export function Prompt(props: PromptProps) {
)
}
async function pasteInputText(text: string, before: ReturnType<typeof capturePrompt>) {
function expandPastedText(extmarkId: number) {
const extmark = input.extmarks.get(extmarkId)
const ref = store.extmarkToPart.get(extmarkId)
if (!extmark || ref?.type !== "pasted") return false
const part = store.prompt.pasted[ref.index]
if (!part) return false
input.extmarks.delete(extmarkId)
input.setSelection(extmark.start, extmark.end)
input.insertText(part.text)
return true
}
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
if (attachment) {
if (cancelChangedPrompt(before)) return
pasteLocalAttachment(filepath, attachment)
const filename = path.basename(filepath)
if (attachment?.type === "text") {
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
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 (cancelChangedPrompt(before)) return
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
return
}
if (attachment?.type === "binary") {
await pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
return
}
}
if (cancelChangedPrompt(before)) 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) => {
const ref = store.extmarkToPart.get(extmark.id)
return (
(extmark.end === input.cursorOffset || extmark.end + 1 === input.cursorOffset) &&
ref?.type === "pasted" &&
store.prompt.pasted[ref.index]?.text === pastedContent
)
})
if (extmark && expandPastedText(extmark.id)) return
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
applyPaste(() => input.insertText(normalizedText))
input.insertText(normalizedText)
setTimeout(() => {
if (!input || input.isDestroyed) return
@@ -1331,32 +1226,17 @@ export function Prompt(props: PromptProps) {
}, 0)
}
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 }) {
async function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const count = pdf
? (store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const prefix = pdf ? "data:application/pdf;" : "data:image/"
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
applyPaste(() => input.insertText(textToInsert))
input.insertText(textToInsert)
const extmarkId = input.extmarks.create({
start: extmarkStart,
@@ -1383,6 +1263,7 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
@@ -1433,13 +1314,20 @@ export function Prompt(props: PromptProps) {
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
if (store.mode === "shell") {
if (!shell().length) return undefined
const example = shell()[store.placeholder % shell().length]
return `Run a command... "${example}"`
}
if (!list().length) return undefined
return `Ask anything... "${list()[store.placeholder % list().length]}"`
const value = (() => {
if (store.mode === "shell") {
if (!shell().length) return undefined
return `Run a command... "${shell()[store.placeholder % shell().length]}"`
}
if (!list().length) return undefined
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})()
if (!value) return undefined
const width =
dimensions().width < 44
? dimensions().width - 5
: Math.min(75, dimensions().width - 4) - 5
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
})
const locationLabel = createMemo(() => {
if (!props.sessionID) {
@@ -1489,83 +1377,14 @@ export function Prompt(props: PromptProps) {
}}
>
<box
paddingLeft={2}
paddingRight={2}
paddingLeft={dimensions().width < 44 ? 1 : 2}
paddingRight={dimensions().width < 44 ? 1 : 2}
paddingTop={1}
flexShrink={0}
backgroundColor={promptBg()}
flexGrow={1}
width="100%"
onMouseDown={(event: MouseEvent) => {
if (event.button !== 0 || imagePreviewMouseIndex(event) === undefined) return
event.preventDefault()
}}
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
const index = imagePreviewMouseIndex(event)
if (index === undefined) return
event.preventDefault()
openImagePreview(index)
}}
>
<Show when={config.prompt?.image_preview && imagePreviewsVisible()}>
<box
width="100%"
height={imagePreviewHeight() + 1}
flexDirection="row"
flexShrink={0}
justifyContent="flex-start"
gap={1}
paddingBottom={1}
>
<For each={imageAttachments().slice(0, visibleImageCount())}>
{(file, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={imagePreviewWidth()}
height={imagePreviewHeight()}
flexBasis={imagePreviewWidth()}
flexShrink={1}
>
<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={imageOverflowVisible()}>
<box
width={8}
height={imagePreviewHeight()}
flexBasis={8}
flexShrink={1}
alignItems="center"
justifyContent="center"
>
<text fg={theme.text.subdued} wrapMode="none" truncate>
+{hiddenImageAttachmentCount()} more
</text>
</box>
</Show>
</box>
</Show>
<textarea
width="100%"
placeholder={placeholderText()}
@@ -1575,17 +1394,13 @@ export function Prompt(props: PromptProps) {
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
if (!pasteMutating) pasteEpoch++
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
onCursorChange={() => {
if (!pasteMutating) pasteEpoch++
setCursorVersion((value) => value + 1)
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
e.preventDefault()
@@ -1597,7 +1412,7 @@ export function Prompt(props: PromptProps) {
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={(event: PasteEvent) => {
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
@@ -1607,10 +1422,11 @@ 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 (event.bytes.byteLength === 0) {
if (!pastedContent) {
keymap.dispatch("prompt.paste")
return
}
@@ -1619,7 +1435,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
void enqueuePaste((before) => pasteInputText(normalizedText, before))
await pasteInputText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
@@ -1638,33 +1454,48 @@ export function Prompt(props: PromptProps) {
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled) return
if (props.disabled || r.button !== 0) return
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
.find((item) => store.extmarkToPart.get(item.id)?.type === "pasted")
if (!extmark || !expandPastedText(extmark.id)) return
r.preventDefault()
r.stopPropagation()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
<Show when={agentLabel()} fallback={<box height={1} />}>
{(label) => (
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
<Show
when={store.mode === "normal" && local.permission.mode === "auto" && dimensions().width >= 44}
>
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
</Show>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<Show when={store.mode === "normal" && dimensions().width >= 28}>
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
flexShrink={1}
minWidth={0}
wrapMode="none"
truncate
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<Show when={dimensions().width >= 50}>
<text flexShrink={0} fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>
{currentProviderLabel()}
</text>
</Show>
<Show when={showVariant() && dimensions().width >= 70}>
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
<text>
<span
@@ -1,15 +1,9 @@
import { constants } from "node:fs"
import { open } from "node:fs/promises"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
// 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, maxBytes: number): Promise<string>
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
@@ -17,15 +11,14 @@ export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
export function readLocalAttachment(file: string) {
return readLocalAttachmentWith(
{
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
readBytes: readFileBounded,
readText: (value) => readFile(value, "utf8"),
readBytes: (value) => readFile(value),
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
},
file,
maxBytes,
)
}
@@ -40,109 +33,16 @@ const mimeTypes: Record<string, string> = {
".webp": "image/webp",
}
async function readFileBounded(file: string, maxBytes: number) {
const handle = await open(file, constants.O_RDONLY | constants.O_NONBLOCK)
try {
const info = await handle.stat()
if (!info.isFile() || info.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
const content = Buffer.allocUnsafe(info.size + 1)
let offset = 0
while (offset < content.byteLength) {
const { bytesRead } = await handle.read(content, offset, content.byteLength - offset, offset)
if (bytesRead === 0) break
offset += bytesRead
}
if (offset !== info.size) throw new Error("Attachment changed while being read")
return content.subarray(0, offset)
} finally {
await handle.close()
}
}
export function normalizePastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
const url = decodeFileURL(raw)
if (url) return url
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
function decodeFileURL(value: string): string | undefined {
if (!value.startsWith("file://")) return undefined
try {
return fileURLToPath(value)
} 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) ?? 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> {
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
const mime = await files.mime(path).catch(() => undefined)
if (!mime) return undefined
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
if (!mime) return
if (mime === "image/svg+xml") {
const content = await files.readText(path, maxBytes).catch(() => undefined)
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
const content = await files.readText(path).catch(() => undefined)
if (!content) return
return { type: "text", mime, content }
}
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
if (!content || content.byteLength > maxBytes) return undefined
if (!mime.startsWith("image/") && mime !== "application/pdf") return
const content = await files.readBytes(path).catch(() => undefined)
if (!content) return
return { type: "binary", mime, content }
}
-6
View File
@@ -105,9 +105,6 @@ 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(
@@ -122,9 +119,6 @@ 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
@@ -164,7 +164,6 @@ export const Definitions = {
prompt_submit: keybind("none", "Submit 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"),
@@ -365,7 +364,6 @@ export const CommandMap = {
display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit",
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",
+8 -15
View File
@@ -1,25 +1,18 @@
import type { ClipboardWriteResult } from "@opentui/core"
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardWriteOutcome = Readonly<{
delivery: "confirmed" | "attempted"
partial: boolean
result: ClipboardWriteResult
}>
export type ClipboardService = Readonly<{
read(): Promise<ClipboardContent | undefined>
write(text: string): Promise<ClipboardWriteOutcome>
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
}>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
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 ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
const value = useContext(ClipboardContext)
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
return value
return useContext(ClipboardContext)
}
+18 -1
View File
@@ -1,5 +1,5 @@
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
import { createStore, reconcile, type Store } from "solid-js/store"
import { createStore, produce, reconcile, type Store } from "solid-js/store"
import path from "path"
import { mkdirSync, readFileSync, watch } from "fs"
import { Flock } from "@opencode-ai/util/flock"
@@ -13,12 +13,20 @@ type Options<Value extends object> = {
}
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
type MemoryEntry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
export interface Storage {
store<Value extends object>(
key: string,
options: Options<Value>,
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-process state. Entries are memoized here, above consumer
* lifecycles, so the same live store survives plugin hot reloads; it is
* gone when the TUI exits. Updates are synchronous and values need not be
* JSON-serializable.
*/
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
}
function clone<Value extends object>(value: Value) {
@@ -37,6 +45,7 @@ function segment(value: string) {
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
@@ -73,6 +82,14 @@ function createStorage(root: string, channel: string) {
entries.set(file, { value: entry as Entry<object>, reload })
return entry
},
memory<Value extends object>(key: string, options: { readonly initial: Value }) {
const existing = memories.get(key)
if (existing) return existing as MemoryEntry<Value>
const [store, setStore] = createStore(options.initial)
const entry = [store, (mutation: (draft: Value) => void) => setStore(produce(mutation))] as const
memories.set(key, entry as MemoryEntry<object>)
return entry
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
@@ -1,23 +1,6 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { stringWidth } from "../../util/string-width"
import { FadeFilePath } from "../../ui/fade-file-path"
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const directory = createMemo(() =>
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
)
return (
<FadeFilePath
value={directory()}
maxWidth={props.maxWidth}
fg={props.context.theme.text.subdued}
bg={props.context.theme.background.default}
/>
)
}
function Mcp(props: { context: Plugin.Context }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
@@ -53,34 +36,26 @@ function Mcp(props: { context: Plugin.Context }) {
function View(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
if (list.length === 0) return 0
const count = list.filter((item) => item.status.status === "connected").length
return stringWidth(`${count} MCP /status`) + 2
})
return (
<box
width="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
>
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(props.context.app.version) - mcpWidth())}
/>
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
<Show when={dimensions().height >= 12 && dimensions().width >= 44}>
<box
width="100%"
paddingTop={dimensions().height < 16 ? 0 : 1}
paddingBottom={dimensions().height < 16 ? 0 : 1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
>
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
</box>
</box>
</Show>
)
}
@@ -1,6 +1,7 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { contextUsage, formatContextUsage } from "../../util/session"
import { useTerminalDimensions } from "@opentui/solid"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
@@ -8,6 +9,7 @@ const money = new Intl.NumberFormat("en-US", {
})
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const dimensions = useTerminalDimensions()
const activeSubagents = createMemo(() => {
if (!props.sessionID) return 0
return props.context.data.session
@@ -60,19 +62,24 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
<Show when={status().length > 0}>{status().join(" · ")}</Show>
</text>
</Match>
<Match when={true}>
<Match when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
<Show when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
</Show>
</Match>
<Match when={props.mode === "shell"}>
<text fg={props.context.theme.text.default} flexShrink={0}>
esc <span style={{ fg: props.context.theme.text.subdued }}>exit shell mode</span>
esc{" "}
<span style={{ fg: props.context.theme.text.subdued }}>
{dimensions().width < 44 ? "shell" : "exit shell mode"}
</span>
</text>
</Match>
</Switch>
@@ -2,13 +2,7 @@
import type { FileDiffInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
import {
TextAttributes,
type BorderSides,
type BoxRenderable,
type DiffRenderable,
type ScrollBoxRenderable,
} from "@opentui/core"
import { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core"
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
import { useTerminalDimensions } from "@opentui/solid"
import path from "path"
@@ -19,6 +13,7 @@ import { DialogSelect } from "../../ui/dialog-select"
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
import { useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@@ -154,7 +149,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
const helpShortcut = shortcut("diff.help")
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const diffNodeByFileIndex = new Map<number, DiffRenderable>()
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>()
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
@@ -270,17 +265,16 @@ function DiffViewer(props: { context: Plugin.Context }) {
if (!patchScroll) return
const hunks = visiblePatchFiles()
.flatMap((entry) => {
const node = diffNodeByFileIndex.get(entry.fileIndex)
if (!node || node.isDestroyed) return []
const contentY = patchScroll.scrollTop + node.y - patchScroll.viewport.y
return node.diff
.split("\n")
.flatMap((line, row) => (line.startsWith("@@") ? [row] : []))
.map((row, hunkIndex) => ({
fileIndex: entry.fileIndex,
hunkIndex,
contentY: contentY + row,
}))
return (
patchDiffByFileIndex
.get(entry.fileIndex)
?.hunks()
.map((node, hunkIndex) => ({
fileIndex: entry.fileIndex,
hunkIndex,
contentY: patchScroll.scrollTop + node.y - patchScroll.viewport.y - (hunkIndex > 0 ? 1 : 0),
})) ?? []
)
})
.sort((left, right) => left.contentY - right.contentY)
const selected = selectedHunk()
@@ -831,9 +825,10 @@ function DiffViewer(props: { context: Plugin.Context }) {
>
{(patch) => (
<box border={patchLeftBorder()} borderColor={theme.border.default}>
<diff
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
<PatchDiff
ref={(component) => patchDiffByFileIndex.set(entry.fileIndex, component)}
diff={patch()}
hunkFg={reviewed() ? theme.text.subdued : theme.diff.text.hunkHeader}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={currentSyntax()}
@@ -847,9 +842,15 @@ function DiffViewer(props: { context: Plugin.Context }) {
removedBg={
reviewed() ? theme.background.surface.overlay : theme.diff.background.removed
}
contextBg={
reviewed() ? theme.background.surface.overlay : theme.diff.background.context
}
addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added}
removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
lineNumberBg={
reviewed() ? theme.background.surface.overlay : theme.diff.background.context
}
addedLineNumberBg={
reviewed()
? theme.background.surface.overlay
+3 -1
View File
@@ -32,6 +32,7 @@ import { footerWidthPolicy } from "./footer.width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { MiniPermissionRequest, PermissionReply } from "./types"
import { PatchDiff } from "../component/patch-diff"
function buttons(
list: PermissionOption[],
@@ -405,8 +406,9 @@ export function RunPermissionBody(props: {
</Show>
}
>
<diff
<PatchDiff
diff={info().diff!}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
+3 -1
View File
@@ -13,6 +13,7 @@ import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { toolFiletype, toolStructuredFinal } from "./tool"
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit, TurnSummary } from "./types"
import { PatchDiff } from "../component/patch-diff"
export function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
@@ -178,8 +179,9 @@ export function RunEntryContent(props: {
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<diff
<PatchDiff
diff={item.diff}
hunkFg={theme().block.diffLineNumber}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={syntax()}
+274
View File
@@ -0,0 +1,274 @@
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
import { useRenderer } from "@opentui/solid"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiApp, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useThemes } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { abbreviateHome } from "../util/path-format"
export type Dispose = () => Promise<void>
// The provider's registration store, narrowed to what a plugin context needs:
// route/slot registration lands there, but ordering and lifecycle stay owned
// by the provider.
export type Registry = {
has(kind: "routes" | "slots", name: string): boolean
set(kind: "routes", name: string, page: Page): void
set(kind: "slots", name: string, slot: Slot): void
remove(kind: "routes" | "slots", name: string): void
active(): boolean
}
// The host services a plugin context adapts. Collected once by the provider
// (hooks must run during component setup) and shared by every activation.
export function usePluginHost() {
return {
renderer: useRenderer(),
client: useClient(),
data: useData(),
route: useRoute(),
keymap: Keymap.use(),
shortcuts: Keymap.useShortcuts(),
keymapState: Keymap.useState(),
app: useTuiApp(),
paths: useTuiPaths(),
location: useLocation(),
themes: useThemes(),
dialog: useDialog(),
toast: useToast(),
attention: useAttention(),
storage: useStorage(),
sessionTabs: useSessionTabs(),
}
}
// Build the API surface handed to one plugin activation: host services
// adapted to the plugin contract, with everything registered through it
// unwinding via `owned` when the activation is disposed.
export function createPluginContext(input: {
host: ReturnType<typeof usePluginHost>
id: string
options: Readonly<Record<string, any>> | undefined
owned: Dispose[]
registry: Registry
}): Context {
const host = input.host
let context: Context
// Every dialog and registered render is wrapped so plugin components can
// reach their own context through usePlugin().
const provide = (render: () => JSX.Element) => (
<PluginContextProvider value={context}>{render()}</PluginContextProvider>
)
const dialogApi = createDialogApi(host.dialog, provide)
const toastApi: Toast = {
show(options) {
host.toast.show({ ...options, variant: options.variant ?? "info" })
},
}
// Unregistering after deactivation is a no-op: deactivate already resets
// the registration's routes and slots wholesale.
const registration = (kind: "routes" | "slots", name: string) => {
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!input.registry.active()) return
input.registry.remove(kind, name)
}
input.owned.push(async () => unregister())
return unregister
}
context = {
options: input.options ?? {},
get location() {
return host.location.current
},
app: { version: host.app.version, channel: host.app.channel },
renderer: host.renderer,
client: host.client.api,
data: host.data,
attention: host.attention,
get theme() {
return host.themes.currentTokens()
},
keymap: {
layer: Keymap.createLayer,
dispatch: host.keymap.dispatch,
shortcuts: host.shortcuts.list,
commands: host.keymapState.commands,
pending: host.keymapState.pending,
active: host.keymapState.active,
mode: host.keymap.mode,
},
storage: {
store: (key, options) => host.storage.store(`plugin.${input.id}.${key}`, options),
memory: (key, options) => host.storage.memory(`plugin.${input.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
toast: toastApi,
format: {
path: (value) => abbreviateHome(value, host.paths.home),
},
router: {
register(page) {
if (input.registry.has("routes", page.name)) throw new Error(`Route already registered: ${page.name}`)
input.registry.set("routes", page.name, {
...page,
render: (data) => provide(() => page.render(data)),
})
return registration("routes", page.name)
},
navigate(destination) {
if (destination.type === "plugin") {
host.route.navigate({ ...destination, id: "id" in destination ? destination.id : input.id })
return
}
host.route.navigate(destination)
},
current() {
return host.route.data
},
},
tabs: {
enabled: host.sessionTabs.enabled,
list: () =>
host.sessionTabs.tabs().map((tab) => ({
...tab,
active: host.sessionTabs.current() === tab.sessionID,
...host.sessionTabs.status(tab.sessionID),
})),
open(sessionID) {
if (!host.sessionTabs.enabled()) return false
host.sessionTabs.select(sessionID)
return true
},
focus(sessionID) {
if (!host.sessionTabs.enabled()) return false
if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
host.sessionTabs.select(sessionID)
return true
},
close(sessionID) {
if (!host.sessionTabs.enabled()) return false
const target = sessionID ?? host.sessionTabs.current()
if (!target || !host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
host.sessionTabs.close(target)
return true
},
},
slot(name, render) {
if (input.registry.has("slots", name)) throw new Error(`Slot already registered: ${name}`)
// The registration map erases the slot-specific input type.
input.registry.set("slots", name, ((slotInput: SlotMap[typeof name]) =>
provide(() => render(slotInput))) as Slot)
return registration("slots", name)
},
},
}
return context
}
// A dialog promise must settle exactly once even when confirm and close
// callbacks both fire.
function settle<T>(resolve: (value: T) => void) {
let settled = false
return (value: T) => {
if (settled) return
settled = true
resolve(value)
}
}
function createDialogApi(dialog: ReturnType<typeof useDialog>, provide: (render: () => JSX.Element) => JSX.Element) {
const api: Dialog = {
show(render, onClose) {
dialog.replace(() => provide(render), onClose)
},
set(options) {
dialog.setSize(options.size ?? "medium")
dialog.setCentered(options.centered ?? false)
},
clear() {
dialog.clear()
},
alert(options) {
return new Promise<void>((resolve) => {
const done = settle(resolve)
api.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
})
},
confirm(options) {
return new Promise<boolean | undefined>((resolve) => {
const done = settle(resolve)
api.show(
() => (
<DialogConfirm
title={options.title}
message={options.message}
label={options.label}
onConfirm={() => done(true)}
onCancel={() => done(false)}
/>
),
() => done(undefined),
)
})
},
prompt(options) {
return new Promise<string | undefined>((resolve) => {
const done = settle(resolve)
api.show(
() => (
<DialogPrompt
title={options.title}
description={options.description ? () => <text>{options.description}</text> : undefined}
placeholder={options.placeholder}
value={options.value}
onConfirm={(value) => {
done(value)
api.clear()
}}
/>
),
() => done(undefined),
)
})
},
select(options) {
return new Promise((resolve) => {
const done = settle<(typeof options.options)[number]["value"] | undefined>(resolve)
api.show(
() => (
<DialogSelect
title={options.title}
placeholder={options.placeholder}
options={options.options.map((option) => ({ ...option }))}
current={options.current}
onSelect={(option) => {
done(option.value)
api.clear()
}}
/>
),
() => done(undefined),
)
})
},
}
return api
}
+277 -373
View File
@@ -1,51 +1,25 @@
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
import {
batch,
createContext,
createEffect,
createMemo,
For,
on,
onCleanup,
onMount,
useContext,
type JSX,
type ParentProps,
} from "solid-js"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { batch, createContext, createEffect, on, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { useRenderer } from "@opentui/solid"
import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
import { useConfig } from "../config"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useThemes } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { abbreviateHome } from "../util/path-format"
import { useTuiLifecycle } from "../context/runtime"
import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { discoverTuiPlugins } from "./discovery"
import { createPluginContext, usePluginHost, type Dispose } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
}
type State =
| { readonly target: string; readonly status: "loading" }
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
@@ -61,15 +35,16 @@ type Value = {
readonly list: () => ReadonlyArray<State>
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
}
type Dispose = () => Promise<void>
type Registration = {
plugin: Plugin.Definition
source: RegisteredPlugin["source"]
target?: string
version: string
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -77,27 +52,15 @@ type Registration = {
cleanups: Dispose[]
}
// One entry of the desired plugin generation produced by the resolve phase.
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
const renderer = useRenderer()
const client = useClient()
const data = useData()
const route = useRoute()
const host = usePluginHost()
const config = useConfig()
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const keymapState = Keymap.useState()
const lifecycle = useTuiLifecycle()
const app = useTuiApp()
const paths = useTuiPaths()
const location = useLocation()
const themes = useThemes()
const dialog = useDialog()
const toast = useToast()
const attention = useAttention()
const storage = useStorage()
const sessionTabs = useSessionTabs()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -115,223 +78,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
let context: Context
const dialogApi: Dialog = {
show(render, onClose) {
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
const context = createPluginContext({
host,
id,
options: item.options,
owned,
registry: {
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
set: (kind: "routes" | "slots", name: string, value: Page | Slot) =>
setStore("registrations", id, kind, name, () => value),
remove: (kind, name) =>
setStore(
"registrations",
produce((registrations) => {
if (!registrations[id]) return
delete registrations[id][kind][name]
}),
),
active: () => Boolean(store.registrations[id]?.active),
},
set(options) {
dialog.setSize(options.size ?? "medium")
dialog.setCentered(options.centered ?? false)
},
clear() {
dialog.clear()
},
alert(options) {
return new Promise<void>((resolve) => {
let settled = false
const done = () => {
if (settled) return
settled = true
resolve()
}
dialogApi.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
})
},
confirm(options) {
return new Promise<boolean | undefined>((resolve) => {
let settled = false
const done = (result: boolean | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogConfirm
title={options.title}
message={options.message}
label={options.label}
onConfirm={() => done(true)}
onCancel={() => done(false)}
/>
),
() => done(undefined),
)
})
},
prompt(options) {
return new Promise<string | undefined>((resolve) => {
let settled = false
const done = (result: string | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogPrompt
title={options.title}
description={options.description ? () => <text>{options.description}</text> : undefined}
placeholder={options.placeholder}
value={options.value}
onConfirm={(value) => {
done(value)
dialogApi.clear()
}}
/>
),
() => done(undefined),
)
})
},
select(options) {
return new Promise((resolve) => {
let settled = false
const done = (result: (typeof options.options)[number]["value"] | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogSelect
title={options.title}
placeholder={options.placeholder}
options={options.options.map((option) => ({ ...option }))}
current={options.current}
onSelect={(option) => {
done(option.value)
dialogApi.clear()
}}
/>
),
() => done(undefined),
)
})
},
}
const toastApi: Toast = {
show(options) {
toast.show({ ...options, variant: options.variant ?? "info" })
},
}
context = {
options: item.options ?? {},
get location() {
return location.current
},
app: { version: app.version, channel: app.channel },
renderer,
client: client.api,
data,
attention,
get theme() {
return themes.currentTokens()
},
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
shortcuts: shortcuts.list,
commands: keymapState.commands,
pending: keymapState.pending,
active: keymapState.active,
mode: keymap.mode,
},
storage: {
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
toast: toastApi,
format: {
path: (value) => abbreviateHome(value, paths.home),
},
router: {
register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name])
throw new Error(`Route already registered: ${page.name}`)
setStore("registrations", item.plugin.id, "routes", page.name, {
...page,
render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
})
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!store.registrations[item.plugin.id]?.active) return
setStore(
"registrations",
produce((registrations) => {
if (!registrations[item.plugin.id]) return
delete registrations[item.plugin.id].routes[page.name]
}),
)
}
owned.push(async () => unregister())
return unregister
},
navigate(destination) {
if (destination.type === "plugin") {
route.navigate({ ...destination, id: "id" in destination ? destination.id : item.plugin.id })
return
}
route.navigate(destination)
},
current() {
return route.data
},
},
tabs: {
enabled: sessionTabs.enabled,
list: () =>
sessionTabs.tabs().map((tab) => ({
...tab,
active: sessionTabs.current() === tab.sessionID,
...sessionTabs.status(tab.sessionID),
})),
open(sessionID) {
if (!sessionTabs.enabled()) return false
sessionTabs.select(sessionID)
return true
},
focus(sessionID) {
if (!sessionTabs.enabled()) return false
if (!sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
sessionTabs.select(sessionID)
return true
},
close(sessionID) {
if (!sessionTabs.enabled()) return false
const target = sessionID ?? sessionTabs.current()
if (!target || !sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
sessionTabs.close(target)
return true
},
},
slot(name, render) {
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
<PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
))
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!store.registrations[item.plugin.id]?.active) return
setStore(
"registrations",
produce((registrations) => {
if (!registrations[item.plugin.id]) return
delete registrations[item.plugin.id].slots[name]
}),
)
}
owned.push(async () => unregister())
return unregister
},
},
}
})
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
@@ -374,94 +140,219 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
return true
}
const reconcile = async (configured = config.data.plugins ?? []) => {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
// Cleanup failures must not stop a swap or teardown, but they should not
// vanish either: the old generation may still own listeners or intervals.
const deactivateNoisily = (id: string) =>
deactivate(id).catch((error) =>
host.toast.show({ variant: "error", title: "Plugin", message: `${id}: cleanup failed: ${errorMessage(error)}` }),
)
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
batch(() => {
setStore("registrations", reconcileStore({}))
setStore("states", [])
})
for (const plugin of builtins) {
setStore("registrations", plugin.id, {
plugin,
source: "builtin",
active: false,
routes: {},
slots: {},
cleanups: [],
})
await activate(plugin.id)
}
// Every lifecycle mutation — reconciles, manual dialog toggles, shutdown —
// is serialized through one chain so generations can never interleave.
let loading = Promise.resolve()
const enqueue = <T,>(task: () => Promise<T>) => {
const result = loading.catch(() => undefined).then(task)
loading = result.then(
() => undefined,
() => undefined,
)
return result
}
// Hot-reload local plugin sources: watch the discovery directory and any
// local entrypoints (see watch.ts for the mechanics), debounced into a
// serialized reconcile so bursts of events rebuild the generation once.
let pending: ReturnType<typeof setTimeout> | undefined
const watcher = createSourceWatcher(() => {
clearTimeout(pending)
pending = setTimeout(() => {
// Observe failures immediately: a plugin cleanup that throws would
// otherwise surface as an unhandled rejection until the next trigger.
void enqueue(reconcile).catch(() => undefined)
}, 100)
})
onCleanup(() => {
clearTimeout(pending)
watcher.dispose()
})
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
// core plugin registry: fold the ordered entries into a desired end state
// (importing only new or changed sources, before anything running is
// touched), no-op when the generation is unchanged, and restart only the
// plugins that differ. Membership or order changes rebuild the whole
// generation to preserve slot-order semantics.
// Package resolution failures would otherwise retry a full npm install on
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
const reconcile = async () => {
const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
watcher.add(tuiPluginDirectory(host.paths.cwd))
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
const desired = new Map<string, Desired>()
for (const plugin of builtins) desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
const failures: State[] = []
for (const entry of entries) {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-")) {
for (const id of Object.keys(store.registrations).filter((id) => matches(target.slice(1), id)))
await deactivate(id)
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
continue
}
const selected = Object.keys(store.registrations).filter((id) => matches(target, id))
const selected = [...desired.values()].filter((item) => matches(target, item.plugin.id))
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
for (const id of selected) await activate(id)
for (const item of selected) item.enabled = true
continue
}
const options = typeof entry === "string" ? undefined : entry.options
setStore("states", (items) => [...items, { target, status: "loading" }])
const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
setStore("states", (items) =>
items.map((state) =>
state.target === target
? { target, status: "failed", error: error instanceof Error ? error.message : String(error) }
: state,
),
)
return undefined
})
if (!plugin) {
setStore("states", (items) =>
items.map((state) =>
state.target === target && state.status !== "failed" ? { target, status: "unsupported" } : state,
),
)
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
const local = localSource(target, directory)
if (local) watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
if (resolved.status === "unsupported") {
failures.push({ target, status: "unsupported" })
continue
}
setStore("registrations", plugin.id, {
plugin,
if (resolved.status === "failed") {
if (!local && !previous) npmFailures.set(target, resolved.error)
failures.push({
target,
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
})
if (previous)
desired.set(previous.plugin.id, {
plugin: previous.plugin,
source: previous.source,
target,
version: previous.version,
options: previous.options,
enabled: previous.active,
})
continue
}
desired.set(resolved.plugin.id, {
plugin: resolved.plugin,
source: "external",
target,
version: resolved.version,
options,
active: false,
routes: {},
slots: {},
cleanups: [],
enabled: true,
})
const error = await activate(plugin.id).then(
() => undefined,
(error) => (error instanceof Error ? error.message : String(error)),
)
setStore("states", (items) => [
...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
error
? { target, status: "failed", error }
: { target, id: plugin.id, status: "active" },
])
}
// Compare: unchanged plugins are never touched, and a fully unchanged
// generation is a no-op, so spurious watch events cost nothing.
const currentIds = Object.keys(store.registrations)
const desiredIds = [...desired.keys()]
const structural = currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
if (structural) {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivateNoisily(id)),
)
setStore("registrations", reconcileStore({}))
}
const changed = structural
? desiredIds
: desiredIds.filter((id) => {
const registration = store.registrations[id]!
const item = desired.get(id)!
// enabled derives from config directives alone, so config wins over
// manual dialog toggles on every reconcile — the same semantics
// config saves had before hot reload existed, just more frequent.
return (
registration.version !== item.version ||
!sameOptions(registration.options, item.options) ||
registration.active !== item.enabled
)
})
// Swap: cleanup failures surface as a toast, never propagate, so one
// broken plugin cannot take the rest of the generation down.
const errors = new Map<string, string>()
for (const id of changed) {
const item = desired.get(id)!
const registration = store.registrations[id]
const replaced =
!registration || registration.version !== item.version || !sameOptions(registration.options, item.options)
// Snapshot the running version before it is overwritten: an import
// failure keeps last-good in the resolve phase, and a setup failure
// must not cost the previous version either.
const fallback: Desired | undefined =
replaced && registration
? {
plugin: registration.plugin,
source: registration.source,
target: registration.target,
version: registration.version,
options: registration.options,
enabled: registration.active,
}
: undefined
if (replaced) {
if (registration) await deactivateNoisily(id)
// In-place replacement keeps the registration's key position, which
// slot ordering (mode "replace" takes the last one) depends on.
setStore("registrations", id, toRegistration(item))
}
if (!item.enabled) {
await deactivateNoisily(id)
continue
}
const error = await activate(id).then(() => undefined, errorMessage)
if (!error) continue
errors.set(id, error)
if (!fallback) continue
setStore("registrations", id, toRegistration(fallback))
if (!fallback.enabled) continue
const restored = await activate(id).then(
() => true,
() => false,
)
if (restored) errors.set(id, `${error} (previous version still active)`)
}
const failedTargets = new Set(failures.map((failure) => failure.target))
const states: State[] = [
...[...desired.values()].flatMap((item): State[] => {
if (item.target === undefined) return []
// A failed reload keeps this item running; the failure entry covers it.
if (failedTargets.has(item.target)) return []
const error = errors.get(item.plugin.id)
if (error) return [{ target: item.target, status: "failed", error }]
const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
return [{ target: item.target, id: item.plugin.id, status }]
}),
...failures,
]
// Surface newly failing plugins; repeated reconciles stay silent.
for (const state of states)
if (
state.status === "failed" &&
!store.states.some((prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error)
)
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
setStore("states", reconcileStore(states))
}
let loading = Promise.resolve()
const slotItems = new WeakMap<Slot, { readonly id: string; readonly render: Slot }>()
createEffect(
on(
() => JSON.stringify(config.data.plugins ?? []),
() => {
const configured = config.data.plugins ?? []
loading = loading.catch(() => undefined).then(() => reconcile(configured))
void loading.then(
npmFailures.clear()
void enqueue(reconcile).then(
() => setStore("ready", true),
() => setStore("ready", true),
)
@@ -478,7 +369,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
.map(([id]) => deactivate(id).catch(() => undefined)),
),
)
.then(() => setStore("registrations", reconcileStore({})))
@@ -500,11 +391,22 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slot: (name) =>
Object.values(store.registrations).flatMap((registration) =>
registration.active && registration.slots[name] ? [registration.slots[name]] : [],
),
activate,
deactivate,
Object.entries(store.registrations).flatMap(([id, registration]) => {
const render = registration.active ? registration.slots[name] : undefined
if (!render) return []
// <For> diffs rows by reference; a stable wrapper per render
// function keeps untouched plugins' slot rows (and their state)
// alive across other plugins' reloads.
const cached = slotItems.get(render)
if (cached) return [cached]
const item = { id, render }
slotItems.set(render, item)
return [item]
}),
// Manual dialog toggles join the same chain as reconciles so a
// toggle mid-reload cannot mix registrations across generations.
activate: (id) => enqueue(() => activate(id)),
deactivate: (id) => enqueue(() => deactivate(id)),
}}
>
{props.children}
@@ -531,17 +433,45 @@ function matches(selector: string, id: string) {
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
}
async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
const local = spec.startsWith("file://")
? new URL(spec)
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
? pathToFileURL(path.resolve(directory, spec))
: undefined
async function resolvePlugin(
spec: string,
local: URL | undefined,
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
if (!entrypoint) return
const mod: { readonly default?: unknown } = await import(entrypoint)
if (!entrypoint) return { status: "unsupported" as const }
// The cache-busted specifier doubles as the version: unique per entrypoint
// and mtime, so equal versions mean an identical module.
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return mod.default
return { status: "loaded" as const, plugin: mod.default, version }
}
function toRegistration(item: Desired): Registration {
return {
plugin: item.plugin,
source: item.source,
target: item.target,
version: item.version,
options: item.options,
active: false,
routes: {},
slots: {},
cleanups: [],
}
}
function sameOptions(a: Registration["options"], b: Registration["options"]) {
return isDeepEqual(a ?? null, b ?? null)
}
async function resolveLocal(url: URL) {
@@ -576,29 +506,3 @@ export function usePlugin() {
if (!value) throw new Error("PluginProvider is missing")
return value
}
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
const plugins = usePlugin()
const route = useRoute()
const content = createMemo(() => {
if (route.data.type !== "plugin") return
const render = plugins.route(route.data.id, route.data.name)
if (!render) return props.fallback(route.data.id, route.data.name)
return render({ data: route.data.data })
})
return <>{content()}</>
}
export function PluginSlot<Name extends SlotName>(props: {
readonly name: Name
readonly input: SlotMap[Name]
readonly mode: "all" | "replace"
}) {
const plugins = usePlugin()
const renderers = createMemo(() => {
const items = plugins.slot(props.name)
if (props.mode === "replace") return items.slice(-1)
return items
})
return <For each={renderers()}>{(render) => render(props.input)}</For>
}
+22 -1
View File
@@ -1,10 +1,15 @@
import { readdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
export function tuiPluginDirectory(cwd: string) {
return path.join(cwd, ".opencode", "plugins", "tui")
}
export async function discoverTuiPlugins(cwd: string) {
const directory = path.join(cwd, ".opencode", "plugins", "tui")
const directory = tuiPluginDirectory(cwd)
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
return Promise.reject(error)
@@ -14,3 +19,19 @@ export async function discoverTuiPlugins(cwd: string) {
.map((entry) => path.join(directory, entry.name))
.sort()
}
export function localSource(spec: string, directory: string) {
if (spec.startsWith("file://")) return new URL(spec)
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
return pathToFileURL(path.resolve(directory, spec))
return undefined
}
// Key local plugin imports by mtime so edited sources re-import fresh instead
// of hitting the ESM cache. Bun ignores query params when caching file:// URL
// imports, so bust with a plain path there; Node keys its cache on the full
// URL. Mirrors the core plugin supervisor's loader.
export function freshSpecifier(entrypoint: string, mtime: number) {
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${mtime}`
return `${entrypoint}?mtime=${mtime}`
}
+84
View File
@@ -0,0 +1,84 @@
import { createComponent, createMemo, ErrorBoundary, For, mergeProps, onMount, Show, type JSX, type ParentProps } from "solid-js"
import type { SlotMap, SlotName } from "@opencode-ai/plugin/tui/context"
import { useRoute } from "../context/route"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
import { usePlugin } from "./context"
// Contain render-time plugin crashes: a throwing slot or route must not take
// down the app or the other plugins. The crash surfaces as one error toast.
function PluginBoundary(props: ParentProps<{ id: string; where: string }>) {
const toast = useToast()
return (
<ErrorBoundary
fallback={(error) => {
// One toast per crash: onMount is untracked, so prop updates while
// the boundary is latched cannot re-toast.
onMount(() =>
toast.show({
variant: "error",
title: "Plugin",
message: `${props.id} crashed in ${props.where}: ${errorMessage(error)}`,
}),
)
return null
}}
>
{props.children}
</ErrorBoundary>
)
}
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
const plugins = usePlugin()
const route = useRoute()
const current = createMemo(() => {
if (route.data.type !== "plugin") return
return {
id: route.data.id,
name: route.data.name,
render: plugins.route(route.data.id, route.data.name),
data: route.data.data,
}
})
return (
// Keyed so navigation or a hot reload recreates the boundary; otherwise
// one crash would latch every future plugin route into the fallback.
<Show keyed when={current()}>
{(item) => (
<PluginBoundary id={item.id} where="route">
{item.render ? createComponent(item.render, { data: item.data }) : props.fallback(item.id, item.name)}
</PluginBoundary>
)}
</Show>
)
}
export function PluginSlot<Name extends SlotName>(props: {
readonly name: Name
readonly input: SlotMap[Name]
readonly mode: "all" | "replace"
}) {
const plugins = usePlugin()
const renderers = createMemo(() => {
const items = plugins.slot(props.name)
if (props.mode === "replace") return items.slice(-1)
return items
})
return (
<For each={renderers()}>
{(item) => (
<PluginBoundary id={item.id} where={`slot ${props.name}`}>
{
// Component semantics: the render body runs once and untracked, so
// signals and intervals created inside are stable, while props stay
// reactive through the merged getter. A bare item.render(props.input)
// call would run inside the host's tracked scope and re-execute the
// whole body (resetting plugin state) on every tracked read.
createComponent(item.render, mergeProps(() => props.input) as SlotMap[Name])
}
</PluginBoundary>
)}
</For>
)
}
+75
View File
@@ -0,0 +1,75 @@
import path from "path"
import { existsSync, watch } from "fs"
import { lstat, realpath, stat } from "fs/promises"
// Watch plugin sources for changes. Files are watched through their parent
// directory (editors that save by rename replace the inode, which silently
// kills a direct file watch) and filtered by basename so bursts in busy
// directories stay quiet. Symlinked files are additionally watched at their
// resolved target, since edits there emit nothing at the link's location.
// Directory targets are watched at their root only: edits to nested helper
// files do not change the entrypoint mtime and are not detected. Watches are
// never torn down individually (a stale watch costs one fs handle and a
// spurious onChange); all die with dispose(). Failed or vanished watches are
// forgotten so a later add() can re-arm once the path exists.
export function createSourceWatcher(onChange: () => void) {
const watchers = new Map<string, ReturnType<typeof watch>>()
const watched = new Map<string, Set<string> | null>()
let disposed = false
const forget = (dir: string) => {
watchers.get(dir)?.close()
watchers.delete(dir)
watched.delete(dir)
}
const arm = (target: string) => {
stat(target)
.then((info) => {
if (disposed) return
const dir = info.isDirectory() ? target : path.dirname(target)
// Directories accept every filename (null); files accept their basename.
const name = info.isDirectory() ? null : path.basename(target)
const existing = watched.get(dir)
if (existing !== undefined) {
if (name === null) watched.set(dir, null)
else existing?.add(name)
return
}
watched.set(dir, name === null ? null : new Set([name]))
const watcher = watch(dir, (_event, filename) => {
// A replaced directory keeps this watcher on the dead inode (Linux
// emits rename, not error); forget it so a later add() re-arms on
// the recreated path, and still schedule so reconcile runs now.
if (!existsSync(dir)) {
forget(dir)
onChange()
return
}
// A null filename (platform-dependent) always schedules.
const accept = watched.get(dir)
if (filename && accept && !accept.has(filename.toString())) return
onChange()
})
// A watched directory can disappear out from under us; without a
// listener the error event would crash the process. Forget the path
// so a later add can re-arm once it exists again.
watcher.on("error", () => forget(dir))
watchers.set(dir, watcher)
})
.catch(() => undefined)
}
const add = (target: string) => {
arm(target)
// A symlinked source receives edits at its resolved target.
lstat(target)
.then((info) => {
if (!info.isSymbolicLink()) return
return realpath(target).then(arm)
})
.catch(() => undefined)
}
const dispose = () => {
disposed = true
for (const watcher of watchers.values()) watcher.close()
}
return { add, dispose }
}
+9 -2
View File
@@ -9,7 +9,8 @@ import { useEditorContext } from "../context/editor"
import { useData } from "../context/data"
import { useLocation } from "../context/location"
import { FormPrompt } from "./session/form"
import { PluginSlot } from "../plugin/context"
import { PluginSlot } from "../plugin/render"
import { useTerminalDimensions } from "@opentui/solid"
let once = false
const placeholder = {
@@ -26,6 +27,7 @@ export function Home() {
const editor = useEditorContext()
const data = useData()
const location = useLocation()
const dimensions = useTerminalDimensions()
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
const currentLocation = () => route.location ?? data.location.default()
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
@@ -71,7 +73,12 @@ export function Home() {
return (
<>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box
flexGrow={1}
alignItems="center"
paddingLeft={dimensions().width < 44 ? 1 : 2}
paddingRight={dimensions().width < 44 ? 1 : 2}
>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
@@ -8,7 +8,6 @@ import { errorMessage } from "../../util/error"
import { DialogFork } from "./dialog-fork"
import type { PromptInfo } from "../../prompt/history"
import { projectedPromptInput } from "../../prompt/codec"
import { formatClipboardWriteNotification } from "../../clipboard"
export function DialogMessage(props: {
messageID: string
@@ -67,19 +66,8 @@ export function DialogMessage(props: {
: "text" in value
? value.text
: ""
if (!text) {
toast.show({ message: "No text content found in message", variant: "error" })
return
}
try {
const outcome = await clipboard.write(text)
toast.show(
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
)
dialog.clear()
} catch (error) {
toast.error(error)
}
await clipboard.write?.(text)
dialog.clear()
},
},
{
+4 -7
View File
@@ -8,7 +8,6 @@ import type { FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useClient } from "../../context/client"
import { useClipboard } from "../../context/clipboard"
import { formatClipboardWriteNotification } from "../../clipboard"
import { SplitBorder } from "../../ui/border"
import { useToast } from "../../ui/toast"
import { Keymap } from "../../context/keymap"
@@ -365,14 +364,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
function copyExternal() {
const current = externalField()
if (!current) return
if (!current || !clipboard.write) return
void clipboard
.write(current.url)
.then((outcome) => {
.then(() => {
setStore("externalReady", { ...store.externalReady, [current.key]: true })
toast.show(
formatClipboardWriteNotification(outcome, { message: "Copied URL to clipboard", variant: "info" }),
)
toast.show({ message: "Copied URL to clipboard", variant: "info" })
})
.catch(toast.error)
}
@@ -995,7 +992,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
</text>
<Show when={externalField()}>
<Show when={externalField() && clipboard.write}>
<text fg={theme.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
+87 -194
View File
@@ -22,8 +22,9 @@ import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
@@ -52,7 +53,6 @@ import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
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"
@@ -74,13 +74,13 @@ import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation"
import { useConfig } from "../../config"
import { useClipboard } from "../../context/clipboard"
import { formatClipboardWriteNotification } from "../../clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { PluginSlot } from "../../plugin/render"
import {
cacheReuseDrop,
createSessionRows,
@@ -107,7 +107,6 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120
const SESSION_IMAGE_LIMIT = 6
const context = createContext<{
width: number
@@ -117,7 +116,6 @@ const context = createContext<{
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
imageKeys: () => ReadonlySet<string>
models: () => ModelInfo[]
config: ReturnType<typeof useConfig>["data"]
}>()
@@ -147,9 +145,6 @@ export function Session() {
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
const imageKeys = createMemo(() =>
config.session?.image_preview ? sessionImageKeys(messages(), session()?.revert?.messageID) : new Set<string>(),
)
const currentLocation = useLocation()
const location = createMemo(() => session()?.location ?? currentLocation.ref)
@@ -776,15 +771,8 @@ export function Session() {
}
clipboard
.write(text)
.then((outcome) =>
toast.show(
formatClipboardWriteNotification(outcome, {
message: "Message copied to clipboard!",
variant: "success",
}),
),
)
.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()
},
@@ -801,13 +789,8 @@ export function Session() {
const sessionData = session()
if (!sessionData) return
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
const outcome = await clipboard.write(transcript)
toast.show(
formatClipboardWriteNotification(outcome, {
message: "Session transcript copied to clipboard!",
variant: "success",
}),
)
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" })
}
@@ -849,11 +832,9 @@ export function Session() {
})()
if (options.action === "copy") {
const outcome = await clipboard.write(content)
await clipboard.write?.(content)
dialog.clear()
toast.show(
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "success" }),
)
toast.show({ message: "Copied to clipboard", variant: "success" })
return
}
@@ -974,13 +955,19 @@ export function Session() {
markdownMode,
groupExploration,
diffWrapMode,
imageKeys,
models,
config,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<box
flexGrow={1}
minHeight={0}
paddingBottom={1}
paddingLeft={dimensions().width < 44 ? 1 : 2}
paddingRight={dimensions().width < 44 ? 1 : 2}
gap={1}
>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
@@ -1025,6 +1012,7 @@ export function Session() {
</Show>
</scrollbox>
<box flexShrink={0}>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
@@ -1138,7 +1126,11 @@ function SessionRowView(props: SessionRowViewProps) {
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
<TurnTokenUsage
messageIDs={row().messageIDs}
previousCache={row().previousCache}
message={props.message}
/>
)}
</Match>
</Switch>
@@ -1245,10 +1237,21 @@ function TurnTokenToolCalls(props: { tools: SessionMessageAssistantTool[] }) {
<For each={props.tools}>
{(tool) => (
<box flexDirection="row">
<text width={nameWidth()} flexShrink={0} fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
<text
width={nameWidth()}
flexShrink={0}
fg={theme.text.subdued}
attributes={TextAttributes.BOLD}
>
{tool.name}
</text>
<text fg={theme.text.subdued} attributes={TextAttributes.DIM} wrapMode="word" flexGrow={1} minWidth={0}>
<text
fg={theme.text.subdued}
attributes={TextAttributes.DIM}
wrapMode="word"
flexGrow={1}
minWidth={0}
>
{turnTokenToolSummary(tool)}
</text>
</box>
@@ -1265,7 +1268,9 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
const primaryKey = ["command", "id", "pattern", "url", "query", "path", "description", "code"].find(
(key) => key in data,
)
const input = Object.entries(data).filter(([, value]) => ["string", "number", "boolean"].includes(typeof value))
const input = Object.entries(data).filter(([, value]) =>
["string", "number", "boolean"].includes(typeof value),
)
const primary = input.find(([key]) => key === primaryKey)?.[1]
const details = input.filter(([key]) => key !== primaryKey).map(([key, value]) => `${key}: ${String(value)}`)
return [primary === undefined ? "" : String(primary), ...details].filter(Boolean).join(" ")
@@ -1347,7 +1352,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
/>
</Match>
<Match when={item().type === "tool"}>
<ToolPartWithImages messageID={props.partRef.messageID} part={item() as SessionMessageAssistantTool} />
<ToolPart part={item() as SessionMessageAssistantTool} />
</Match>
</Switch>
)}
@@ -1485,7 +1490,6 @@ function SessionGroupView(props: {
}) {
const theme = useTheme()
const ctx = use()
const dialog = useDialog()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
@@ -1495,13 +1499,13 @@ function SessionGroupView(props: {
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
if (part?.type !== "tool") return []
return [{ messageID: ref.messageID, part }]
return [part]
})
const grouped = createMemo(() => parts(props.refs))
const pending = createMemo(() => parts(props.pending))
const label = createMemo(() => {
const counts = grouped().reduce<Record<string, number>>((result, item) => {
const tool = toolDisplay(item.part.name)
const counts = grouped().reduce<Record<string, number>>((result, part) => {
const tool = toolDisplay(part.name)
const name = tool === "grep" || tool === "glob" ? "search" : tool
result[name] = (result[name] ?? 0) + 1
return result
@@ -1515,11 +1519,7 @@ function SessionGroupView(props: {
<Show when={grouped().length > 0 || pending().length > 0}>
<Show
when={ctx.groupExploration()}
fallback={
<For each={[...grouped(), ...pending()]}>
{(item) => <ToolPartWithImages messageID={item.messageID} part={item.part} />}
</For>
}
fallback={<For each={[...grouped(), ...pending()]}>{(part) => <ToolPart part={part} />}</For>}
>
<Show when={grouped().length > 0}>
<InlineToolRow
@@ -1539,14 +1539,9 @@ function SessionGroupView(props: {
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(item) => <ToolPart part={item.part} />}</For>
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
</Show>
<ToolImages
parts={grouped()}
visible={ctx.imageKeys()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
<For each={pending()}>{(item) => <ToolPartWithImages messageID={item.messageID} part={item.part} />}</For>
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
</Show>
</Show>
)
@@ -1555,6 +1550,7 @@ function SessionGroupView(props: {
function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const local = useLocal()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const model = createMemo(
() =>
@@ -1588,8 +1584,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
<Show when={duration()}>
<Show when={dimensions().width >= 28}>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
</Show>
<Show when={duration() && (dimensions().width < 28 || dimensions().width >= 36)}>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={interrupted()}>
@@ -1680,7 +1678,8 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
const text = () =>
props.message.status === "failed" ? (cancelled() ? "" : props.message.error.message) : props.message.summary
const content = createMemo(() => text().trim())
const color = () => (status() === "failed" && !cancelled() ? theme.text.feedback.error.default : theme.text.subdued)
const color = () =>
status() === "failed" && !cancelled() ? theme.text.feedback.error.default : theme.text.subdued
return (
<box>
<box flexDirection="row" alignItems="center">
@@ -1851,7 +1850,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const images = createMemo(() => sessionMessageImages(props.message))
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
@@ -1870,26 +1868,30 @@ function UserMessage(props: { message: SessionMessageUser }) {
border={["left"]}
borderColor={queued() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
}}
>
<SessionImages
images={images()}
visible={ctx.imageKeys()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} flexShrink={0}>
<box
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
}}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
flexShrink={0}
>
<text fg={theme.text.default}>{props.message.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
@@ -2312,122 +2314,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
)
}
function ToolPartWithImages(props: { messageID: string; part: SessionMessageAssistantTool }) {
const ctx = use()
const dialog = useDialog()
return (
<>
<ToolPart part={props.part} />
<ToolImages
parts={[props]}
visible={ctx.imageKeys()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
</>
)
}
export function ToolImages(props: {
parts: readonly { messageID: string; part: SessionMessageAssistantTool }[]
visible: ReadonlySet<string>
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void
}) {
const images = createMemo(() => props.parts.flatMap((item) => inlineToolImages(item.messageID, item.part)))
return <SessionImages images={images()} visible={props.visible} onOpen={props.onOpen} />
}
export function SessionImages(props: {
images: readonly { key: string; uri: string }[]
visible: ReadonlySet<string>
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void
}) {
const dimensions = useTerminalDimensions()
const images = createMemo(() => props.images.filter((image) => props.visible.has(image.key)))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const width = createMemo(() => height() * 2)
const limit = createMemo(() =>
Math.max(0, Math.min(3, Math.floor((Math.max(0, dimensions().width - 6) + 1) / (width() + 1)))),
)
const count = createMemo(() => Math.min(limit(), images().length))
return (
<Show when={count() > 0}>
<box flexDirection="row" flexShrink={0} paddingTop={1} paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<For each={images().slice(0, count())}>
{(image, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={width()}
height={height()}
flexBasis={width()}
flexShrink={0}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
props.onOpen?.(images(), index())
}}
>
<Show when={!failed()} fallback={<text>No preview</text>}>
<image
id={`session-image-${image.key}`}
source={image.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={images().length > count()}>
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
<text wrapMode="none" truncate>
+{images().length - count()} more
</text>
</box>
</Show>
</box>
</Show>
)
}
export function sessionImageKeys(messages: readonly SessionMessageInfo[], revertBoundary?: string) {
return new Set(
messages
.filter((message) => !revertBoundary || message.id < revertBoundary)
.flatMap(sessionMessageImages)
.map((image) => image.key)
.slice(-SESSION_IMAGE_LIMIT),
)
}
export function sessionMessageImages(message: SessionMessageInfo) {
if (message.type === "user") {
return (message.files ?? []).flatMap((file, index) =>
file.mime.startsWith("image/")
? [{ key: `${message.id}:file:${index}`, uri: `data:${file.mime};base64,${file.data}` }]
: [],
)
}
if (message.type !== "assistant") return []
return message.content.flatMap((part) => (part.type === "tool" ? inlineToolImages(message.id, part) : []))
}
function inlineToolImages(messageID: string, part: SessionMessageAssistantTool) {
return toolDisplayContent(part.state).flatMap((content, index) =>
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
? [{ key: `${messageID}:${part.id}:${index}`, uri: content.uri }]
: [],
)
}
type ToolProps = {
input: Record<string, unknown>
metadata: Record<string, unknown>
@@ -2473,7 +2359,10 @@ function GenericTool(props: ToolProps) {
{(value) => (
<box gap={1}>
<text>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}> Output </span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
{" "}
Output{" "}
</span>
</text>
<box paddingLeft={1}>
<text fg={theme.text.default} wrapMode="word">
@@ -2725,7 +2614,9 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>{title()}</text>
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
{title()}
</text>
}
>
<Spinner color={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
@@ -3150,8 +3041,9 @@ function Edit(props: ToolProps) {
{(item) => (
<BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
<box paddingLeft={1}>
<diff
<PatchDiff
diff={item().patch}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={filetype(path())}
syntaxStyle={syntax()}
@@ -3239,8 +3131,9 @@ function ApplyPatch(props: ToolProps) {
}
>
<box paddingLeft={1}>
<diff
<PatchDiff
diff={file.patch}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={filetype(file.relativePath)}
syntaxStyle={syntax()}
@@ -14,6 +14,7 @@ import { useConfig } from "../../config"
import { Keymap } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { SimulationSemantics } from "../../simulation/semantics"
import { PatchDiff } from "../../component/patch-diff"
type PermissionStage = "permission" | "always" | "reject"
@@ -50,8 +51,9 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
},
}}
>
<diff
<PatchDiff
diff={diff()}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
+1 -1
View File
@@ -2,7 +2,7 @@ import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { PluginSlot } from "../../plugin/context"
import { PluginSlot } from "../../plugin/render"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { getScrollAcceleration } from "../../util/scroll"
+6 -2
View File
@@ -245,7 +245,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.options,
() => {
if (!props.preserveSelection && props.current === undefined) {
if (!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) {
const count = flat().length
if (count === 0) return
const next = reconcileSelection(store.selected, count)
@@ -281,7 +281,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("selected", index)
selection = option
if (!moved) return
if ((!props.preserveSelection && props.current === undefined) || store.filter.length > 0) return
if (
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
store.filter.length > 0
)
return
scrollAfterLayout(false, option.value)
return
}
+2 -4
View File
@@ -7,7 +7,6 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { useClipboard } from "../context/clipboard"
import { useConfig } from "../config"
import { formatClipboardWriteNotification } from "../clipboard"
export type DialogSize = "medium" | "large" | "xlarge"
@@ -205,10 +204,9 @@ export function DialogProvider(props: ParentProps) {
function copySelection() {
const text = renderer.getSelection()?.getSelectedText()
if (!text) return false
if (!text || !clipboard.write) return false
void clipboard.write(text).then(
(outcome) =>
toast.show(formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" })),
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
(error) => toast.error(error),
)
renderer.clearSelection()
+56
View File
@@ -0,0 +1,56 @@
export interface PatchHunk {
readonly patch: string
readonly header?: string
readonly rows?: number
}
export function splitPatchHunks(patch: string): PatchHunk[] {
const starts = [
...patch.matchAll(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@.*$/gm),
].map((match) => match.index)
if (starts.length <= 1) return [{ patch }]
const prefix = patch.slice(0, starts[0])
return starts.map((start, index) => {
const end = starts[index + 1] ?? patch.length
const lineEnd = patch.indexOf("\n", start)
return {
header: patch.slice(start, lineEnd === -1 ? end : lineEnd),
patch: prefix + patch.slice(start, end),
rows: splitRows(patch.slice(start, end)),
}
})
}
function splitRows(hunk: string) {
const lines = hunk.replace(/\n$/, "").split("\n").slice(1)
let rows = 0
let index = 0
while (index < lines.length) {
const prefix = lines[index][0]
if (prefix === " " || !prefix) {
rows++
index++
continue
}
if (prefix === "\\") {
index++
continue
}
let additions = 0
let deletions = 0
while (
index < lines.length &&
(lines[index][0] === "+" || lines[index][0] === "-")
) {
if (lines[index][0] === "+") additions++
if (lines[index][0] === "-") deletions++
index++
}
rows += Math.max(additions, deletions)
}
return rows
}
+2 -5
View File
@@ -1,5 +1,4 @@
import type { ClipboardService } from "../context/clipboard"
import { formatClipboardWriteNotification } from "../clipboard"
type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
@@ -36,10 +35,8 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
clipboard
.write(clipboardText)
.then((outcome) =>
toast.show(formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" })),
)
?.write?.(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
+8 -258
View File
@@ -1,89 +1,14 @@
import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import type {
ClipboardOptions,
ClipboardService,
HostClipboardOptions,
HostClipboardService,
RendererClipboardBoundary,
} from "@opentui/core"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
const openTui = { ...(await import("@opentui/core")) }
function restoreOpenTui() {
mock.restore()
mock.module("@opentui/core", () => openTui)
}
async function mockOpenTuiClipboard(
renderer: RendererClipboardBoundary,
options: {
dispose?: () => Promise<void>
constructionError?: Error
} = {},
) {
const calls = {
host: [] as (HostClipboardOptions | undefined)[],
adapter: [] as RendererClipboardBoundary[],
service: [] as ClipboardOptions[],
dispose: 0,
hostDispose: 0,
hostWrite: 0,
}
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return { status: "empty" }
},
async writeText() {
calls.hostWrite++
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {
calls.hostDispose++
await options.dispose?.()
},
}
mock.module("@opentui/core", () => ({
...openTui,
createCliRenderer: async () => renderer,
createHostClipboard: (input?: HostClipboardOptions) => {
if (options.constructionError) throw options.constructionError
calls.host.push(input)
return host
},
createRendererClipboardAdapter: (input: RendererClipboardBoundary) => {
calls.adapter.push(input)
return openTui.createRendererClipboardAdapter(input)
},
createClipboard: (input: ClipboardOptions) => {
calls.service.push(input)
const service = openTui.createClipboard(input)
return {
read: service.read,
writeText: service.writeText,
clear: service.clear,
async dispose() {
calls.dispose++
await service.dispose()
},
} satisfies ClipboardService
},
}))
return calls
}
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const clipboard = await mockOpenTuiClipboard(setup.renderer)
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const titles: string[] = []
let started!: () => void
const ready = new Promise<void>((resolve) => {
@@ -117,46 +42,18 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
expect(setup.renderer.isDestroyed).toBe(true)
expect(titles.at(-1)).toBe("")
expect(clipboard.host).toEqual([
{
timeoutMs: 1_000,
maxReadBytes: 8 * 1024 * 1024,
maxWriteBytes: 8 * 1024 * 1024,
maxImagePixels: 64 * 1024 * 1024,
maxConversionBytes: 512 * 1024 * 1024,
maxConcurrentOperations: 16,
maxProviderTransfers: 16,
maxWorkUnitsPerDrain: 64,
},
])
expect(clipboard.adapter).toEqual([setup.renderer])
expect(clipboard.service).toHaveLength(1)
expect(clipboard.dispose).toBe(1)
expect(clipboard.hostDispose).toBe(1)
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
restoreOpenTui()
mock.restore()
}
})
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
let releaseDispose!: () => void
let startDispose!: () => void
const disposeStarted = new Promise<void>((resolve) => {
startDispose = resolve
})
const disposeReady = new Promise<void>((resolve) => {
releaseDispose = resolve
})
const clipboard = await mockOpenTuiClipboard(setup.renderer, {
dispose: async () => {
startDispose()
await disposeReady
},
})
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
let initialTitle!: () => void
const initialTitleSet = new Promise<void>((resolve) => {
initialTitle = resolve
@@ -227,164 +124,17 @@ test("session lifecycle updates the terminal title and prints the epilogue after
data: { sessionID: "dummy", title: "Renamed session" },
})
await renamedTitleSet
let settled = false
void task.then(
() => (settled = true),
() => (settled = true),
)
events.emit({
id: "evt_exit",
created: 2,
type: "tui.command.execute",
data: { command: "app.exit" },
})
await disposeStarted
expect(settled).toBe(false)
expect(stdout).toBe("")
releaseDispose()
setup.renderer.destroy()
await task
expect(stdout).toContain("Renamed session")
expect(stdout).toContain("opencode2 -s dummy")
expect(promptRequests).toBe(0)
expect(clipboard.dispose).toBe(1)
expect(clipboard.hostDispose).toBe(1)
} finally {
releaseDispose()
process.stdout.write = originalWrite
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
restoreOpenTui()
}
})
test("direct renderer destruction disposes the clipboard once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const clipboard = await mockOpenTuiClipboard(setup.renderer)
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
if (title === "OpenCode") started()
setTitle(title)
}
const events = createEventStream()
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await ready
const staleCopy = setup.renderer.console.onCopySelection
setup.renderer.destroy()
await task
expect(clipboard.dispose).toBe(1)
expect(clipboard.hostDispose).toBe(1)
await staleCopy?.("stale")
expect(clipboard.hostWrite).toBe(0)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
restoreOpenTui()
}
})
test("clipboard construction failure releases the renderer", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const failure = new Error("clipboard construction failed")
await mockOpenTuiClipboard(setup.renderer, { constructionError: failure })
const events = createEventStream()
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
await expect(
Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
),
).rejects.toThrow("clipboard construction failed")
expect(setup.renderer.isDestroyed).toBe(true)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
restoreOpenTui()
}
})
test("clipboard disposal failure is logged without failing remaining cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const clipboard = await mockOpenTuiClipboard(setup.renderer, {
dispose: async () => {
throw new Error("clipboard disposal failed")
},
})
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
if (title === "OpenCode") started()
setTitle(title)
}
const events = createEventStream()
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const logs: unknown[] = []
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: (_level, message) => void logs.push(message),
}).pipe(
Effect.provide(AppNodeBuilder.build(Global.node)),
Effect.provide(FileSystem.layerNoop({})),
),
)
await ready
setup.renderer.destroy()
await task
expect(clipboard.dispose).toBe(1)
expect(clipboard.hostDispose).toBe(1)
expect(titles.at(-1)).toBe("")
expect(
logs.some((message) =>
(Array.isArray(message) ? message : [message]).some((value) => value === "Failed to dispose TUI clipboard"),
),
).toBe(true)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
restoreOpenTui()
mock.restore()
}
})
@@ -5,7 +5,6 @@ import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { ConfigProvider, resolve, type Info, type Interface } from "../../../src/config"
import { CommandPaletteDialog } from "../../../src/component/command-palette"
import { settingID, settings } from "../../../src/component/dialog-config"
import { Keymap } from "../../../src/context/keymap"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
@@ -14,10 +13,6 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
test("searches settings globally and opens the matching setting", async () => {
let current: Info = {}
expect(settings.find((setting) => settingID(setting) === "session.image_preview")).toMatchObject({
title: "Transcript images",
default: false,
})
const service: Interface = {
get: async () => current,
update: async (update) => {
@@ -79,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 "image preview") app.mockInput.pressKey(key)
for (const key of "sounds") app.mockInput.pressKey(key)
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
app.mockInput.pressEnter()
await app.waitFor(() => current.prompt?.image_preview === true)
await app.waitFor(() => current.attention?.sound === false)
} finally {
app.renderer.destroy()
}
+15 -1
View File
@@ -106,11 +106,18 @@ test("does not preload session summaries into the data context", async () => {
}
})
test("proactively syncs project metadata", async () => {
test("proactively syncs project metadata newest first", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/project") return
return json([
{
id: "proj_old",
canonical: "/old/project",
name: "Old project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
{
id: "proj_test",
canonical: worktree,
@@ -149,6 +156,13 @@ test("proactively syncs project metadata", async () => {
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_old",
canonical: "/old/project",
name: "Old project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
} finally {
app.renderer.destroy()
+145 -20
View File
@@ -18,16 +18,14 @@ import { StorageProvider } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("selecting an unhydrated session preserves its location", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
const events = createEventStream()
const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" }
const calls = createFetch((url) => {
if (url.pathname !== "/api/session") return
const fixture = await renderOpen((url) => {
if (url.pathname !== "/api/session") return undefined
return json({
data: [
{
@@ -42,7 +40,129 @@ test("selecting an unhydrated session preserves its location", async () => {
],
cursor: {},
})
}, events)
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Remote session"))
expect(fixture.data.session.get("ses_remote")).toBeUndefined()
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" })
expect(fixture.location.ref).toEqual(remote)
} finally {
fixture.dispose()
}
})
test("shows the current project and opens its root", async () => {
const root = "/tmp/opencode/project"
const subfolder = `${root}/packages/tui`
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/project")
return json([
{
id: "proj_current",
canonical: root,
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
if (url.pathname === "/api/location")
return json({
directory: subfolder,
project: { id: "proj_current", directory: root, canonical: root },
})
return undefined
},
async ({ data, location }) => {
await data.location.sync({ directory: subfolder })
location.set({ directory: subfolder })
},
)
try {
const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode") && value.includes("●"))
expect(frame).toContain(root)
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } })
expect(fixture.location.ref).toEqual({ directory: root })
} finally {
fixture.dispose()
}
})
test("preserves a moved project when sessions arrive", async () => {
let resolveSessions!: (response: Response) => void
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions
if (url.pathname === "/api/project")
return json([
{
id: "proj_first",
canonical: "/tmp/opencode/first",
name: "First project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_second",
canonical: "/tmp/opencode/second",
name: "Second project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Second project"))
fixture.app.mockInput.pressArrow("down")
resolveSessions(
json({
data: [
{
id: "ses_recent",
projectID: "proj_first",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 2, updated: 3 },
title: "Recent session",
location: { directory: "/tmp/opencode/first" },
},
],
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Recent session"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } })
} finally {
fixture.dispose()
}
})
async function renderOpen(
handler: FetchHandler,
beforeOpen?: (contexts: {
data: ReturnType<typeof useData>
location: ReturnType<typeof useLocation>
}) => void | Promise<void>,
) {
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
const events = createEventStream()
const calls = createFetch(handler, events)
let route!: ReturnType<typeof useRoute>
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
@@ -52,7 +172,9 @@ test("selecting an unhydrated session preserves its location", async () => {
route = useRoute()
location = useLocation()
data = useData()
onMount(() => dialog.replace(() => <DialogOpen />))
onMount(
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
)
return null
}
@@ -90,17 +212,20 @@ test("selecting an unhydrated session preserves its location", async () => {
)
app.renderer.start()
try {
await app.waitForFrame((frame) => frame.includes("Remote session"))
expect(data.session.get("ses_remote")).toBeUndefined()
app.mockInput.pressEnter()
await app.waitFor(() => route.data.type === "session")
expect(route.data).toEqual({ type: "session", sessionID: "ses_remote" })
expect(location.ref).toEqual(remote)
} finally {
app.renderer.destroy()
rmSync(state, { recursive: true, force: true })
return {
app,
get route() {
return route
},
get location() {
return location
},
get data() {
return data
},
dispose() {
app.renderer.destroy()
rmSync(state, { recursive: true, force: true })
},
}
})
}
@@ -82,7 +82,12 @@ async function renderSelect(
return app
}
async function mountSelect(root: string, initial: DialogSelectOption<string>[], current?: string) {
async function mountSelect(
root: string,
initial: DialogSelectOption<string>[],
current?: string,
focusCurrent?: boolean,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
@@ -118,6 +123,7 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[],
title="Mutable options"
options={options()}
current={current}
focusCurrent={focusCurrent}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
/>
@@ -352,3 +358,24 @@ test("keeps the current option selected when options reorder", async () => {
select.app.renderer.destroy()
}
})
test("keeps the first row selected when current is only a marker", async () => {
await using tmp = await tmpdir()
const project = { title: "project", value: "project" }
const select = await mountSelect(tmp.path, [project], "current", false)
try {
select.replaceOptions([
{ title: "recent session", value: "recent" },
project,
{ title: "current session", value: "current" },
])
await select.app.waitForFrame((frame) => frame.includes("recent session"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["recent"])
} finally {
select.app.renderer.destroy()
}
})
@@ -103,6 +103,8 @@ test("brackets navigate diff hunks", async () => {
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("@@ -20,3 +20,3 @@")
expect(countDiffs(viewer.app.renderer.root)).toBe(3)
const scroll = findScrollBox(viewer.app.renderer.root)!
const initial = scroll.scrollTop
@@ -256,6 +258,12 @@ function containsDiff(root: Renderable): boolean {
return root.getChildren().some(containsDiff)
}
function countDiffs(root: Renderable): number {
return (
(root instanceof DiffRenderable ? 1 : 0) + root.getChildren().reduce((total, child) => total + countDiffs(child), 0)
)
}
const session = {
id: "session-1",
projectID: "project-1",
+1 -11
View File
@@ -60,19 +60,9 @@ async function mountForm(root: string, width = 80) {
>
<ClipboardProvider
value={{
async read() {
return undefined
},
write(text) {
copied.push(text)
return Promise.resolve({
delivery: "confirmed" as const,
partial: false,
result: {
host: { status: "written" as const },
terminal: { status: "not-attempted" as const, capability: "unknown" as const },
},
})
return Promise.resolve()
},
}}
>
@@ -1,147 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import { ImageRenderable } from "@opentui/core"
import { MouseButtons } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageUser } from "@opencode-ai/client"
import { sessionImageKeys, sessionMessageImages, SessionImages, ToolImages } from "../../../src/routes/session"
const PNG_1X1_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg=="
const image = { type: "file" as const, uri: `data:image/png;base64,${PNG_1X1_BASE64}`, mime: "image/png" }
let setup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => {
setup?.renderer.destroy()
setup = undefined
})
test("renders bounded inline images from completed tool content", async () => {
let opened = -1
setup = await testRender(
() => toolImages([image, image, image, image, image, image, image], (_, index) => (opened = index)),
{
width: 80,
height: 70,
},
)
await setup.renderOnce()
const first = setup.renderer.root.findDescendantById("session-image-message-1:call-1:1")
const second = setup.renderer.root.findDescendantById("session-image-message-1:call-1:2")
if (!(first instanceof ImageRenderable)) throw new Error("Tool image did not render")
if (!(second instanceof ImageRenderable)) throw new Error("Second tool image did not render")
await first.loadPromise
expect(first.fit).toBe("cover")
expect(first.protocol).toBe("auto")
expect(first.width).toBe(16)
expect(first.height).toBe(8)
expect(second.y).toBe(first.y)
expect(second.x).toBe(first.x + first.width + 1)
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:3")).toBeInstanceOf(ImageRenderable)
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")).toBeUndefined()
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:4")).toBeUndefined()
expect(setup.captureCharFrame()).toContain("+3 more")
await setup.mockMouse.click(first.x, first.y, MouseButtons.LEFT)
expect(opened).toBe(0)
})
test("does not expose external image tool content to the renderer", () => {
expect(
sessionMessageImages(
assistant("message-1", [
tool([
{ type: "file", uri: "https://example.test/image.png", mime: "image/png" },
{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" },
{ type: "file", uri: "data:text/plain;base64,SGVsbG8=", mime: "text/plain" },
]),
]),
),
).toEqual([])
})
test("does not render session images without the opt-in setting", async () => {
const part = tool([image])
setup = await testRender(() => <ToolImages parts={[{ messageID: "message-1", part }]} visible={new Set()} />, {
width: 80,
height: 24,
})
await setup.renderOnce()
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")).toBeUndefined()
})
test("renders images submitted in user prompts", async () => {
const message: SessionMessageUser = {
type: "user",
id: "message-user",
text: "What is in this image?",
files: [{ data: PNG_1X1_BASE64, mime: "image/png", source: { type: "inline" }, name: "prompt.png" }],
time: { created: 1 },
}
setup = await testRender(
() => <SessionImages images={sessionMessageImages(message)} visible={sessionImageKeys([message])} />,
{ width: 80, height: 24 },
)
await setup.renderOnce()
const preview = setup.renderer.root.findDescendantById("session-image-message-user:file:0")
if (!(preview instanceof ImageRenderable)) throw new Error("User image did not render")
await preview.loadPromise
expect(preview.fit).toBe("cover")
})
test("does not reserve image slots for reverted messages", () => {
const visible = assistant("message-1", [tool([image])])
const reverted = assistant("message-2", [tool([image, image, image, image, image, image])])
expect([...sessionImageKeys([visible, reverted], reverted.id)]).toEqual(["message-1:call-1:0"])
})
test("falls back when inline image content is malformed", async () => {
setup = await testRender(
() => toolImages([{ type: "file", uri: "data:image/png;base64,aW52YWxpZA==", mime: "image/png" }]),
{ width: 80, height: 24 },
)
await setup.renderOnce()
const preview = setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")
if (!(preview instanceof ImageRenderable)) throw new Error("Tool image did not render")
await preview.loadPromise
expect(await setup.waitForFrame((frame) => frame.includes("No preview"))).toContain("No preview")
})
function toolImages(
content: Extract<SessionMessageAssistantTool["state"], { status: "completed" }>["content"],
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void,
) {
const part = tool(content)
const message = assistant("message-1", [part])
return <ToolImages parts={[{ messageID: message.id, part }]} visible={sessionImageKeys([message])} onOpen={onOpen} />
}
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
type: "assistant",
id,
agent: "build",
model: { id: "model", providerID: "provider" },
content,
time: { created: 1 },
}
}
function tool(
content: Extract<SessionMessageAssistantTool["state"], { status: "completed" }>["content"],
): SessionMessageAssistantTool {
return {
type: "tool",
id: "call-1",
name: "image",
state: { status: "completed", input: {}, content },
time: { created: 0, completed: 1 },
}
}
+10 -262
View File
@@ -1,271 +1,19 @@
import { expect, test } from "bun:test"
import {
createClipboard,
type ClipboardReadOptions,
type ClipboardReadResult,
type ClipboardService as CoreClipboardService,
type ClipboardWriteOptions,
type ClipboardWriteResult,
type HostClipboardService,
} from "@opentui/core"
import {
ClipboardWriteError,
classifyClipboardWriteResult,
createClipboardAdapter,
formatClipboardWriteNotification,
} from "../src/clipboard"
import { copyCommand } from "../src/clipboard"
type OpenTuiFixture = {
read?: ClipboardReadResult
remote?: boolean
onCoreRead?: (options: ClipboardReadOptions) => void
onCoreWrite?: (text: string, options: ClipboardWriteOptions) => void
onHostWrite?: () => void
onTerminalWrite?: () => void
}
function openTuiClipboard(options: OpenTuiFixture = {}) {
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return options.read ?? { status: "empty" }
},
async writeText() {
options.onHostWrite?.()
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
const clipboard = createClipboard({
host,
terminal: {
remote: options.remote ?? false,
writeText() {
options.onTerminalWrite?.()
return { status: "attempted", capability: "supported" }
},
clear() {
return { status: "attempted", capability: "supported" }
},
},
})
return {
read(input) {
options.onCoreRead?.(input)
return clipboard.read(input)
},
writeText(text, input) {
options.onCoreWrite?.(text, input)
return clipboard.writeText(text, input)
},
clear: clipboard.clear,
dispose: clipboard.dispose,
} satisfies CoreClipboardService
}
function writeResult(
host: ClipboardWriteResult["host"]["status"],
terminal: ClipboardWriteResult["terminal"]["status"],
error = new Error("host failed"),
): ClipboardWriteResult {
return {
host: host === "failed" ? { status: host, error } : { status: host },
terminal: { status: terminal, capability: "supported" },
}
}
function writeError(result: ClipboardWriteResult) {
try {
classifyClipboardWriteResult(result)
} catch (error) {
if (error instanceof ClipboardWriteError) return error
throw error
}
throw new Error("Expected clipboard classification to fail")
}
test("requests the standard clipboard with image-first preferences and adapts PNG bytes", async () => {
const requests: ClipboardReadOptions[] = []
const bytes = new Uint8Array([0, 1, 2, 255])
const clipboard = createClipboardAdapter(
openTuiClipboard({
read: { status: "read", representation: { mimeType: "image/png", bytes } },
onCoreRead: (input) => requests.push(input),
}),
)
expect(await clipboard.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
expect(bytes).toEqual(new Uint8Array([0, 1, 2, 255]))
expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }])
test("prefers Wayland clipboard when available", () => {
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
})
test("decodes text through OpenTUI without normalizing its contents", async () => {
const text = "line 1\r\n\t\u001b[31m世界"
const bytes = new TextEncoder().encode(text)
const clipboard = createClipboardAdapter(
openTuiClipboard({ read: { status: "read", representation: { mimeType: "text/plain", bytes } } }),
)
expect(await clipboard.read()).toEqual({ data: text, mime: "text/plain" })
test("uses osascript on macOS", () => {
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
})
test("maps empty host results and zero-byte text to no content", async () => {
await Promise.all(
(["empty", "unsupported", "cancelled"] as const).map(async (status) => {
expect(await createClipboardAdapter(openTuiClipboard({ read: { status } })).read()).toBeUndefined()
}),
)
expect(
await createClipboardAdapter(
openTuiClipboard({
read: { status: "read", representation: { mimeType: "text/plain", bytes: new Uint8Array() } },
}),
).read(),
).toBeUndefined()
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("preserves backend read failures and synthesizes operational errors", async () => {
const failure = new Error("read failed")
const failed = createClipboardAdapter(openTuiClipboard({ read: { status: "failed", error: failure } }))
expect(await failed.read().then(undefined, (error) => error)).toBe(failure)
const timedOut = createClipboardAdapter(openTuiClipboard({ read: { status: "timed-out" } }))
await expect(timedOut.read()).rejects.toThrow("Clipboard read timed out after 1000ms")
const limited = createClipboardAdapter(openTuiClipboard({ read: { status: "limit-exceeded" } }))
const limitError = await limited.read().then(undefined, (error) => error)
expect(limitError).toBeInstanceOf(RangeError)
if (!(limitError instanceof RangeError)) throw limitError
expect(limitError.message).toBe("Clipboard content exceeded configured read or image conversion limits")
const unexpected = createClipboardAdapter(
openTuiClipboard({
read: { status: "read", representation: { mimeType: "text/html", bytes: new Uint8Array([1]) } },
}),
)
await expect(unexpected.read()).rejects.toThrow("Unexpected clipboard MIME type: text/html")
})
test("uses OpenTUI all-available composition for local writes", async () => {
let hostWrites = 0
let terminalWrites = 0
const writes: [string, ClipboardWriteOptions][] = []
const clipboard = createClipboardAdapter(
openTuiClipboard({
onHostWrite: () => hostWrites++,
onTerminalWrite: () => terminalWrites++,
onCoreWrite: (text, options) => writes.push([text, options]),
}),
)
expect(await clipboard.write("hello")).toMatchObject({ delivery: "confirmed", partial: false })
expect(hostWrites).toBe(1)
expect(terminalWrites).toBe(1)
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
})
test("does not authorize a process-host write for remote renderers", async () => {
let hostWrites = 0
let terminalWrites = 0
const clipboard = createClipboardAdapter(
openTuiClipboard({
remote: true,
onHostWrite: () => hostWrites++,
onTerminalWrite: () => terminalWrites++,
}),
)
expect(await clipboard.write("hello")).toMatchObject({
delivery: "attempted",
partial: false,
result: { host: { status: "not-attempted" }, terminal: { status: "attempted" } },
})
expect(hostWrites).toBe(0)
expect(terminalWrites).toBe(1)
})
test("classifies confirmed, attempted, and partial delivery", () => {
const scenarios = [
{ result: writeResult("written", "attempted"), delivery: "confirmed", partial: false },
{ result: writeResult("written", "not-attempted"), delivery: "confirmed", partial: false },
{ result: writeResult("unsupported", "attempted"), delivery: "attempted", partial: false },
{ result: writeResult("failed", "attempted"), delivery: "attempted", partial: true },
{ result: writeResult("timed-out", "attempted"), delivery: "attempted", partial: true },
{ result: writeResult("cancelled", "attempted"), delivery: "attempted", partial: true },
{ result: writeResult("written", "local-failure"), delivery: "confirmed", partial: true },
] as const
scenarios.forEach((scenario) => {
expect(classifyClipboardWriteResult(scenario.result)).toMatchObject({
delivery: scenario.delivery,
partial: scenario.partial,
result: scenario.result,
})
})
})
test("rejects every unavailable total-failure combination", () => {
const hosts = ["failed", "unsupported", "cancelled", "timed-out", "not-attempted"] as const
const terminals = ["local-failure", "not-attempted"] as const
hosts.flatMap((host) => terminals.map((terminal) => [host, terminal] as const)).forEach(([host, terminal]) => {
const failure = new Error("native write failed")
const result = writeResult(host, terminal, failure)
const error = writeError(result)
expect(error.message).toBe(`Clipboard write failed (host: ${host}, terminal: ${terminal})`)
expect(error.result).toBe(result)
expect(error.cause).toBe(host === "failed" ? failure : undefined)
})
})
test("formats truthful notifications for every delivered outcome", () => {
const confirmed = { message: "Copied value", variant: "success" as const }
expect(
formatClipboardWriteNotification(
{ delivery: "confirmed", partial: false, result: writeResult("written", "attempted") },
confirmed,
),
).toBe(confirmed)
expect(
formatClipboardWriteNotification(
{ delivery: "attempted", partial: false, result: writeResult("unsupported", "attempted") },
confirmed,
),
).toEqual({ message: "Sent to terminal clipboard (acceptance unconfirmed)", variant: "info" })
expect(
formatClipboardWriteNotification(
{ delivery: "confirmed", partial: true, result: writeResult("written", "local-failure") },
confirmed,
),
).toEqual({ message: "Copied to host clipboard; terminal clipboard dispatch failed", variant: "warning" })
expect(
formatClipboardWriteNotification(
{ delivery: "attempted", partial: true, result: writeResult("failed", "attempted") },
confirmed,
),
).toEqual({ message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" })
})
test("retains OpenTUI text validation and the configured 8 MiB UTF-8 bound", async () => {
let hostWrites = 0
let terminalWrites = 0
const clipboard = createClipboardAdapter(
openTuiClipboard({
onHostWrite: () => hostWrites++,
onTerminalWrite: () => terminalWrites++,
}),
)
const boundary = "é".repeat(4 * 1024 * 1024)
await expect(clipboard.write(boundary)).resolves.toMatchObject({ delivery: "confirmed" })
await expect(clipboard.write("")).rejects.toThrow("writeText requires non-empty text")
await expect(clipboard.write("before\0after")).rejects.toThrow("writeText does not support NUL characters")
await expect(clipboard.write(boundary + "a")).rejects.toThrow(
"writeText exceeds the configured 8388608 byte limit",
)
expect(hostWrites).toBe(1)
expect(terminalWrites).toBe(1)
test("returns undefined when native clipboard is unavailable", () => {
expect(copyCommand("linux", false, () => false)).toBeUndefined()
})
@@ -1,131 +0,0 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { ErrorComponent } from "../../src/component/error-component"
import { ClipboardProvider, type ClipboardService, type ClipboardWriteOutcome } from "../../src/context/clipboard"
import { ExitProvider } from "../../src/context/exit"
import { TuiAppProvider } from "../../src/context/runtime"
const outcomes = [
{
label: "Copied",
absent: "Copied (terminal failed)",
outcome: {
delivery: "confirmed",
partial: false,
result: {
host: { status: "written" },
terminal: { status: "attempted", capability: "supported" },
},
},
},
{
label: "Copied (terminal failed)",
absent: undefined,
outcome: {
delivery: "confirmed",
partial: true,
result: {
host: { status: "written" },
terminal: { status: "local-failure", capability: "supported" },
},
},
},
{
label: "Sent",
absent: "Sent (host failed)",
outcome: {
delivery: "attempted",
partial: false,
result: {
host: { status: "unsupported" },
terminal: { status: "attempted", capability: "supported" },
},
},
},
{
label: "Sent (host failed)",
absent: undefined,
outcome: {
delivery: "attempted",
partial: true,
result: {
host: { status: "failed", error: new Error("host failed") },
terminal: { status: "attempted", capability: "supported" },
},
},
},
] as const satisfies readonly { label: string; absent: string | undefined; outcome: ClipboardWriteOutcome }[]
function clipboard(write: () => Promise<ClipboardWriteOutcome>): ClipboardService {
return {
async read() {
return undefined
},
write,
}
}
async function waitForFrame(
app: { renderOnce(): Promise<void>; captureCharFrame(): string },
match: (frame: string) => boolean,
) {
for (let attempts = 0; attempts < 20; attempts++) {
await app.renderOnce()
const frame = app.captureCharFrame()
if (match(frame)) return frame
await Bun.sleep(1)
}
throw new Error("Timed out waiting for clipboard state")
}
test("crash report distinguishes every delivered clipboard state", async () => {
for (const scenario of outcomes) {
const app = await testRender(
() => (
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<ExitProvider exit={() => {}}>
<ClipboardProvider value={clipboard(async () => scenario.outcome)}>
<ErrorComponent error={new Error("boom")} reset={() => {}} />
</ClipboardProvider>
</ExitProvider>
</TuiAppProvider>
),
{ width: 100, height: 24 },
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Copy report")
app.mockInput.pressKey("c")
const frame = await waitForFrame(app, (frame) => frame.includes(scenario.label))
if (scenario.absent) expect(frame).not.toContain(scenario.absent)
} finally {
app.renderer.destroy()
}
}
})
test("crash report catches clipboard rejection", async () => {
const app = await testRender(
() => (
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<ExitProvider exit={() => {}}>
<ClipboardProvider
value={clipboard(async () => {
throw new Error("copy failed")
})}
>
<ErrorComponent error={new Error("boom")} reset={() => {}} />
</ClipboardProvider>
</ExitProvider>
</TuiAppProvider>
),
{ width: 100, height: 24 },
)
try {
await app.renderOnce()
app.mockInput.pressKey("c")
expect(await waitForFrame(app, (frame) => frame.includes("Copy failed"))).toContain("Copy failed")
} finally {
app.renderer.destroy()
}
})
+1 -4
View File
@@ -14,14 +14,11 @@ test("validates mini replay settings", () => {
expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
})
test("validates boolean settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(() => decode({ prompt: { image_preview: "on" } })).toThrow()
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
})
test("resolves nested config and keybind defaults", () => {
-8
View File
@@ -86,14 +86,6 @@ test("resolves a session move keybind", () => {
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
})
test("resolves the image viewer keybind", () => {
const defaults = resolve({}, { terminalSuspend: true })
const overridden = resolve({ keybinds: { prompt_images_view: "ctrl+shift+i" } }, { terminalSuspend: true })
expect(defaults.keybinds.get("prompt.images.view")).toMatchObject([{ key: "<leader>i" }])
expect(overridden.keybinds.get("prompt.images.view")).toMatchObject([{ key: "ctrl+shift+i" }])
})
test("resolves message navigation defaults", () => {
const config = resolve({}, { terminalSuspend: true })
@@ -1,50 +0,0 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { ErrorBoundary, type JSX } from "solid-js"
import { ClipboardProvider, type ClipboardService, useClipboard } from "../../src/context/clipboard"
const clipboard: ClipboardService = {
async read() {
return { data: "text", mime: "text/plain" }
},
async write() {
return {
delivery: "confirmed",
partial: false,
result: {
host: { status: "written" },
terminal: { status: "not-attempted", capability: "unknown" },
},
}
},
}
test("requires explicit provider injection", () => {
expect(() => useClipboard()).toThrow("useClipboard must be used within a ClipboardProvider")
})
test("keeps clipboard access available to an error boundary fallback", async () => {
let value: ClipboardService | undefined
function Crash(): JSX.Element {
throw new Error("crash")
}
function Fallback() {
value = useClipboard()
return <text>fallback</text>
}
const app = await testRender(() => (
<ClipboardProvider value={clipboard}>
<ErrorBoundary fallback={() => <Fallback />}>
<Crash />
</ErrorBoundary>
</ClipboardProvider>
))
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("fallback")
expect(value).toBe(clipboard)
} finally {
app.renderer.destroy()
}
})
+1 -21
View File
@@ -7,23 +7,6 @@ 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() {
return {
delivery: "confirmed",
partial: false,
result: {
host: { status: "written" },
terminal: { status: "not-attempted", capability: "unknown" },
},
}
},
}
export function TestTuiContexts(
props: ParentProps<{
@@ -31,7 +14,6 @@ export function TestTuiContexts(
directory?: string
paths?: Partial<TuiPaths>
log?: LogSink
clipboard?: ClipboardService
}>,
) {
return (
@@ -46,9 +28,7 @@ export function TestTuiContexts(
}}
>
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}>
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
</TuiStartupProvider>
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</LogProvider>
@@ -0,0 +1,263 @@
import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
import path from "node:path"
import { createEventStream, createFetch } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
function lifecycleSource(marker: string, id: string, version: string) {
return `
import { appendFile } from "node:fs/promises"
export default {
id: ${JSON.stringify(id)},
setup: async () => {
await appendFile(${JSON.stringify(marker)}, "${version}:setup\\n")
return () => appendFile(${JSON.stringify(marker)}, "${version}:cleanup\\n")
},
}
`
}
async function until(read: () => Promise<string>, expected: (value: string | undefined) => boolean) {
let value: string | undefined
for (let attempt = 0; attempt < 200; attempt++) {
value = await read().catch(() => undefined)
if (expected(value)) return value
await Bun.sleep(50)
}
return value
}
async function bootApp(directory: string) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream()
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const cwd = process.cwd()
process.chdir(directory)
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
return {
task,
async [Symbol.asyncDispose]() {
process.chdir(cwd)
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
},
}
}
test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "marker.txt")
const source = path.join(directory, "hot.ts")
await writeFile(source, lifecycleSource(marker, "test.hot", "v1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n")
await writeFile(source, lifecycleSource(marker, "test.hot", "v2"))
expect(await until(read, (value) => value?.includes("v2:setup") ?? false)).toBe("v1:setup\nv1:cleanup\nv2:setup\n")
process.emit("SIGHUP")
await app.task
})
test("a plugin whose slot render throws does not take down the TUI", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const markerA = path.join(tmp.path, "a.txt")
const markerCrash = path.join(tmp.path, "crash.txt")
const sourceA = path.join(directory, "a.ts")
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
await writeFile(
path.join(directory, "crash.ts"),
`
import { appendFile } from "node:fs/promises"
export default {
id: "test.crash",
setup: async (context: any) => {
context.ui.slot("home.footer", () => {
throw new Error("boom")
})
await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
},
}
`,
)
await using app = await bootApp(tmp.path)
const readA = () => readFile(markerA, "utf8")
expect(await until(readA, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
// The crashing plugin genuinely loaded and registered its slot; without
// this the rest of the test would pass even if it never imported.
expect(await until(() => readFile(markerCrash, "utf8"), (value) => value === "setup\n")).toBe("setup\n")
// The app survives the crashing slot: hot reload still works for others.
// The render-time boundary itself (fallback + toast) is not exercisable
// here: the test renderer never executes slot render bodies, so render
// containment is verified in the real TUI (see PluginBoundary in
// src/plugin/render.tsx and the demo runs on the PR).
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
process.emit("SIGHUP")
await app.task
})
test("editing one plugin leaves others untouched and a broken save keeps the last good version", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const markerA = path.join(tmp.path, "a.txt")
const markerB = path.join(tmp.path, "b.txt")
const sourceA = path.join(directory, "a.ts")
const sourceB = path.join(directory, "b.ts")
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
await using app = await bootApp(tmp.path)
const readA = () => readFile(markerA, "utf8")
const readB = () => readFile(markerB, "utf8")
await until(readA, (value) => value === "a1:setup\n")
await until(readB, (value) => value === "b1:setup\n")
// Editing B restarts only B: A sees no cleanup and no second setup.
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
expect(await readA()).toBe("a1:setup\n")
// A broken save keeps the last good version running: b2 is never cleaned
// up. Editing A afterwards provides a positive completion signal — once
// A's swap lands, the serialized reconcile has processed the broken save.
await writeFile(sourceB, "export default {")
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
expect(await readB()).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
// Fixing the file replaces the kept version and leaves A alone.
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b3"))
expect(await until(readB, (value) => value?.includes("b3:setup") ?? false)).toBe(
"b1:setup\nb1:cleanup\nb2:setup\nb2:cleanup\nb3:setup\n",
)
expect(await readA()).toBe("a1:setup\na1:cleanup\na2:setup\n")
process.emit("SIGHUP")
await app.task
})
test("a save whose setup throws restores the previous version", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "a.txt")
const source = path.join(directory, "a.ts")
await writeFile(source, lifecycleSource(marker, "test.a", "a1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
// The module imports fine but its setup throws — unlike an import failure,
// the swap has already torn down a1, so keep-last-good means restoring it.
await writeFile(
source,
`
export default {
id: "test.a",
setup: async () => {
throw new Error("setup boom")
},
}
`,
)
expect(await until(read, (value) => value === "a1:setup\na1:cleanup\na1:setup\n")).toBe(
"a1:setup\na1:cleanup\na1:setup\n",
)
// Fixing the file swaps out the restored version normally.
await writeFile(source, lifecycleSource(marker, "test.a", "a2"))
expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe(
"a1:setup\na1:cleanup\na1:setup\na1:cleanup\na2:setup\n",
)
process.emit("SIGHUP")
await app.task
})
test("editing a symlinked plugin's target hot-reloads it", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "a.txt")
// The real source lives outside the discovery directory; only a symlink
// is discovered. Edits land at the target, which emits no event in the
// plugin directory itself.
const target = path.join(tmp.path, "elsewhere", "a.ts")
await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, lifecycleSource(marker, "test.a", "a1"))
await symlink(target, path.join(directory, "a.ts"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
await writeFile(target, lifecycleSource(marker, "test.a", "a2"))
expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
process.emit("SIGHUP")
await app.task
})
test("memory storage survives hot reload while disk storage persists", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "counter.txt")
const source = path.join(directory, "counter.ts")
const counterSource = (note: string) => `
import { appendFile } from "node:fs/promises"
// ${note}
export default {
id: "test.counter",
setup: async (context: any) => {
const [state, update] = context.storage.memory("counter", { initial: { count: 0 } })
update((draft: any) => {
draft.count += 1
})
await appendFile(${JSON.stringify(marker)}, "count:" + state.count + "\\n")
},
}
`
await writeFile(source, counterSource("v1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value === "count:1\n")).toBe("count:1\n")
// The reloaded generation shares the same live store: the count continues.
await writeFile(source, counterSource("v2"))
expect(await until(read, (value) => value?.includes("count:2") ?? false)).toBe("count:1\ncount:2\n")
process.emit("SIGHUP")
await app.task
})
+30
View File
@@ -0,0 +1,30 @@
import { writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect, test } from "bun:test"
import { freshSpecifier, localSource } from "../src/plugin/discovery"
import { tmpdir } from "./fixture/fixture"
test("localSource resolves file URLs and local paths but not package specs", () => {
const base = process.cwd()
const absolute = path.resolve(base, "abs", "plugin.ts")
expect(localSource("file:///tmp/plugin.ts", base)?.href).toBe("file:///tmp/plugin.ts")
expect(localSource("./plugin.ts", base)?.href).toBe(pathToFileURL(path.join(base, "plugin.ts")).href)
expect(localSource("../plugin.ts", path.join(base, "nested"))?.href).toBe(
pathToFileURL(path.join(base, "plugin.ts")).href,
)
expect(localSource(absolute, base)?.href).toBe(pathToFileURL(absolute).href)
expect(localSource("some-package", base)).toBeUndefined()
expect(localSource("@scope/some-package", base)).toBeUndefined()
})
test("freshSpecifier re-imports a plugin source after it changes", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "plugin.ts")
await writeFile(file, "export default 1")
const first: { readonly default?: unknown } = await import(freshSpecifier(pathToFileURL(file).href, 1))
await writeFile(file, "export default 2")
const second: { readonly default?: unknown } = await import(freshSpecifier(pathToFileURL(file).href, 2))
expect(first.default).toBe(1)
expect(second.default).toBe(2)
})
-534
View File
@@ -1,534 +0,0 @@
import { afterAll, expect, mock, test } from "bun:test"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { ImageRenderable, TextareaRenderable, type ClipboardReadResult, type HostClipboardService } from "@opentui/core"
import { createTestRenderer, MouseButtons } from "@opentui/core/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { createComponent } from "solid-js"
import { Prompt, type PromptRef } from "../../src/component/prompt"
import { parsePromptInfo } from "../../src/prompt/history"
import { createEventStream, createFetch } from "../fixture/tui-client"
const openTui = { ...(await import("@opentui/core")) }
const PNG_1X1_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg=="
const PNG_1X1 = Buffer.from(PNG_1X1_BASE64, "base64")
const readPngClipboard = async (): Promise<ClipboardReadResult> => ({
status: "read",
representation: { mimeType: "image/png", bytes: PNG_1X1 },
})
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
let activeHost: HostClipboardService | undefined
let activePromptRef: PromptRef | undefined
await mock.module("@opentui/core", () => ({
...openTui,
createCliRenderer: async () => {
if (!activeSetup) throw new Error("Prompt renderer is not mounted")
return activeSetup.renderer
},
createHostClipboard: () => {
if (!activeHost) throw new Error("Prompt clipboard is not mounted")
return activeHost
},
}))
await mock.module("../../src/routes/home", () => ({
Home: () =>
createComponent(Prompt, {
ref: (value) => (activePromptRef = value),
showPlaceholder: false,
}),
}))
const { run } = await import("../../src/app")
afterAll(() => mock.restore())
async function mountPrompt(read: () => Promise<ClipboardReadResult>, imagePreview = false, width = 80) {
const setup = await createTestRenderer({ width, height: 24, useThread: false })
let reads = 0
let ready!: () => void
const mounted = new Promise<void>((resolve) => (ready = resolve))
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
if (title === "OpenCode") ready()
setTitle(title)
}
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
reads++
return read()
},
async writeText() {
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
activeSetup = setup
activeHost = host
activePromptRef = undefined
const events = createEventStream()
const calls = createFetch(undefined, events)
let preloaded!: () => void
const preload = new Promise<void>((resolve) => (preloaded = resolve))
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const response = await calls.fetch(request)
const url = new URL(request.url)
if (url.pathname === "/api/session/active") preloaded()
return response
},
})
let task: Promise<unknown> | undefined
try {
task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ prompt: { paste: "full" as const, image_preview: imagePreview } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await mounted
await setup.waitFor(() => activePromptRef?.focused === true)
await preload
await Bun.sleep(0)
} catch (error) {
setup.renderer.destroy()
await task?.catch(() => undefined)
await server.stop()
activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
throw error
}
return {
setup,
get input() {
const input = setup.renderer.currentFocusedEditor
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
return input
},
get reads() {
return reads
},
get prompt() {
if (!activePromptRef) throw new Error("Prompt ref is not mounted")
return activePromptRef
},
async dispose() {
activePromptRef?.reset()
setup.renderer.destroy()
await task
await server.stop()
activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
},
}
}
async function pasteImages(prompt: Awaited<ReturnType<typeof mountPrompt>>, count: number) {
for (let index = 0; index < count; index++) {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === index + 1)
}
}
test("distinguishes whitespace-only terminal paste from empty clipboard fallback", async () => {
const prompt = await mountPrompt(async () => ({ status: "empty" }))
try {
await prompt.setup.mockInput.pasteBracketedText(" \t\n")
await prompt.setup.waitFor(() => prompt.input.plainText === " \t\n")
expect(prompt.reads).toBe(0)
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
expect(prompt.input.plainText).toBe(" \t\n")
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("normalizes host clipboard text once before inserting it", async () => {
const bytes = new TextEncoder().encode("first\r\nsecond\rthird")
const prompt = await mountPrompt(async () => ({ status: "read", representation: { mimeType: "text/plain", bytes } }))
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.input.plainText === "first\nsecond\nthird")
expect(prompt.input.plainText).toBe("first\nsecond\nthird")
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("serializes overlapping local image pastes", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "opencode-overlap-"))
const file = path.join(directory, "image.png")
await writeFile(file, PNG_1X1)
const prompt = await mountPrompt(async () => ({ status: "empty" }))
try {
await Promise.all([
prompt.setup.mockInput.pasteBracketedText(`'${file}'`),
prompt.setup.mockInput.pasteBracketedText(`'${file}'`),
])
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2)
expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ")
} finally {
await prompt.dispose()
await rm(directory, { recursive: true, force: true })
}
})
test("continues queued paste work after a clipboard failure", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "opencode-recovery-"))
const file = path.join(directory, "image.png")
await writeFile(file, PNG_1X1)
const result = Promise.withResolvers<ClipboardReadResult>()
const prompt = await mountPrompt(() => result.promise)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
await prompt.setup.mockInput.pasteBracketedText(`'${file}'`)
result.reject(new Error("clipboard failed"))
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 1)
expect(prompt.input.plainText).toBe("[Image 1] ")
} finally {
await prompt.dispose()
await rm(directory, { recursive: true, force: true })
}
})
test("cancels delayed and queued pastes after a reversible prompt change", async () => {
const result = Promise.withResolvers<ClipboardReadResult>()
const prompt = await mountPrompt(() => result.promise)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
await prompt.setup.mockInput.pasteBracketedText("queued")
prompt.setup.mockInput.pressKey("x")
await prompt.setup.waitFor(() => prompt.input.plainText === "x")
prompt.setup.mockInput.pressBackspace()
await prompt.setup.waitFor(() => prompt.input.plainText === "")
prompt.prompt.set({ text: "temporary", files: [], agents: [], pasted: [] })
prompt.prompt.reset()
await prompt.setup.renderOnce()
await prompt.setup.mockInput.pasteBracketedText("new")
result.resolve({
status: "read",
representation: { mimeType: "text/plain", bytes: new TextEncoder().encode("/missing-one.png /missing-two.png") },
})
await prompt.setup.waitForFrame((frame) => frame.includes("Attachment paste canceled"))
await prompt.setup.waitFor(() => prompt.input.plainText === "new")
expect(prompt.prompt.current.files).toEqual([])
} finally {
await prompt.dispose()
}
})
test("does not insert a delayed image after entering shell mode", async () => {
const result = Promise.withResolvers<ClipboardReadResult>()
const prompt = await mountPrompt(() => result.promise)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
prompt.setup.mockInput.pressKey("!")
await prompt.setup.waitForFrame((frame) => frame.includes("Shell"))
prompt.setup.mockInput.pressBackspace()
await prompt.setup.waitForFrame((frame) => !frame.includes("Shell"))
result.resolve(await readPngClipboard())
await prompt.setup.waitForFrame((frame) => frame.includes("Attachment paste canceled"))
expect(prompt.input.plainText).toBe("")
expect(prompt.prompt.current.files).toEqual([])
} finally {
await prompt.dispose()
}
})
test("ignores a clipboard read that completes after prompt teardown", async () => {
const result = Promise.withResolvers<ClipboardReadResult>()
const prompt = await mountPrompt(() => result.promise)
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
prompt.setup.renderer.destroy()
result.resolve(await readPngClipboard())
await Bun.sleep(0)
await prompt.dispose()
})
test("creates one image mention from PNG clipboard bytes", async () => {
const prompt = await mountPrompt(readPngClipboard)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.input.plainText === "[Image 1] ")
expect(prompt.input.plainText).toBe("[Image 1] ")
expect(prompt.input.extmarks.getVirtual()).toHaveLength(1)
expect(prompt.prompt.current.files).toEqual([
{
uri: `data:image/png;base64,${PNG_1X1_BASE64}`,
name: "clipboard",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
])
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("renders at most three left-aligned cropped thumbnails", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
await pasteImages(prompt, 4)
const first = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")
if (!(first instanceof ImageRenderable)) throw new Error("Image preview did not render")
await first.loadPromise
expect(first.fit).toBe("cover")
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeInstanceOf(ImageRenderable)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-2")).toBeInstanceOf(ImageRenderable)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-3")).toBeUndefined()
const frame = await prompt.setup.waitForFrame((frame) => frame.includes("+1 more"))
expect(frame).toMatch(/^┃ █/m)
expect(frame.match(/\[Image 1\]/g)).toHaveLength(1)
expect(frame.match(/\[Image 2\]/g)).toHaveLength(1)
expect(frame.match(/\[Image 3\]/g)).toHaveLength(1)
await prompt.setup.mockMouse.click(49, 1, MouseButtons.LEFT)
await prompt.setup.waitForFrame((frame) => frame.includes("Image 4 of 4"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 4 of 4"))
await prompt.setup.mockMouse.click(50, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 4 of 4")
} finally {
await prompt.dispose()
}
})
test("opens image attachments by keyboard, mouse, and command palette", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
await pasteImages(prompt, 2)
const thumbnail = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")
if (!(thumbnail instanceof ImageRenderable)) throw new Error("Second image thumbnail did not render")
prompt.setup.mockInput.pressKey("x", { ctrl: true })
prompt.setup.mockInput.pressKey("i")
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
await prompt.setup.mockMouse.click(14, 1, MouseButtons.LEFT)
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
await prompt.setup.mockMouse.click(15, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2")
await prompt.setup.mockMouse.click(29, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2")
await prompt.setup.mockMouse.click(16, 1, MouseButtons.LEFT)
await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2"))
const large = prompt.setup.renderer.root.findDescendantById("image-viewer-image")
if (!(large instanceof ImageRenderable)) throw new Error("Large image preview did not render")
expect(large.fit).toBe("fit")
expect(large.height).toBeGreaterThan(thumbnail.height)
prompt.setup.mockInput.pressArrow("left")
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
expect(large.isDestroyed).toBe(true)
await prompt.setup.waitFor(() => prompt.setup.renderer.currentFocusedEditor === prompt.input)
prompt.setup.mockInput.pressKey("p", { ctrl: true })
await prompt.setup.waitForFrame((frame) => frame.includes("Commands"))
for (const key of "view image attachments") prompt.setup.mockInput.pressKey(key)
const palette = await prompt.setup.waitForFrame((frame) => frame.includes("View image attachments"))
expect(palette).toContain("ctrl+x i")
prompt.setup.mockInput.pressEnter()
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
} finally {
await prompt.dispose()
}
})
test("attaches multiple images from one terminal drop", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "opencode-drop-"))
const first = path.join(directory, "one image.png")
const second = path.join(directory, "two image.png")
await Promise.all([writeFile(first, PNG_1X1), writeFile(second, PNG_1X1)])
const prompt = await mountPrompt(async () => ({ status: "empty" }), true)
try {
await prompt.setup.mockInput.pasteBracketedText(`'${first}' '${second}'`)
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2)
expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ")
expect(prompt.prompt.current.files?.map((file) => file.name)).toEqual(["one image.png", "two image.png"])
} finally {
await prompt.dispose()
await rm(directory, { recursive: true, force: true })
}
})
test("reduces the preview count to fit a narrow terminal", async () => {
const prompt = await mountPrompt(readPngClipboard, true, 32)
try {
await pasteImages(prompt, 4)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeInstanceOf(ImageRenderable)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeUndefined()
await prompt.setup.waitForFrame((frame) => frame.includes("+3 more"))
} finally {
await prompt.dispose()
}
})
test("does not click a hidden overflow control", async () => {
const prompt = await mountPrompt(readPngClipboard, true, 22)
try {
await pasteImages(prompt, 2)
await prompt.setup.mockMouse.click(16, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2")
} finally {
await prompt.dispose()
}
})
test("hides thumbnails when their minimum width does not fit", async () => {
const prompt = await mountPrompt(readPngClipboard, true, 16)
try {
await pasteImages(prompt, 1)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
await prompt.setup.mockMouse.click(12, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 1 of 1")
} finally {
await prompt.dispose()
}
})
test("removes an image preview when its mention is deleted", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(
() => prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0") instanceof ImageRenderable,
)
const preview = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")
if (!(preview instanceof ImageRenderable)) throw new Error("Image preview did not render")
await preview.loadPromise
const image = preview.image
expect(image).not.toBeNull()
prompt.input.cursorOffset = prompt.input.plainText.length
prompt.setup.mockInput.pressBackspace()
prompt.setup.mockInput.pressBackspace()
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 0)
expect(prompt.input.plainText).toBe("")
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
expect(() => image!.info()).toThrow("NativeImage is disposed")
} finally {
await prompt.dispose()
}
})
test("keeps an attachment when its opt-in preview cannot be decoded", async () => {
const bytes = new Uint8Array([137, 80, 78, 71])
const prompt = await mountPrompt(
async () => ({ status: "read", representation: { mimeType: "image/png", bytes } }),
true,
)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitForFrame((frame) => frame.includes("No preview"))
expect(prompt.input.plainText).toBe("[Image 1] ")
expect(prompt.prompt.current.files).toEqual([
{
uri: "data:image/png;base64,iVBORw==",
name: "clipboard",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
])
} finally {
await prompt.dispose()
}
})
test("ignores malformed attachment URIs restored into the prompt", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
const restored = parsePromptInfo({
text: "[Image 1] ",
files: [{ uri: 42 }],
agents: [],
pasted: [],
})
if (!restored) throw new Error("Malformed prompt fixture was not restored")
prompt.prompt.set(restored)
await prompt.setup.renderOnce()
expect(prompt.prompt.current.text).toBe("[Image 1] ")
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1 && prompt.prompt.current.files?.length === 1)
expect(prompt.prompt.current.files?.[0]?.uri).toBe(`data:image/png;base64,${PNG_1X1_BASE64}`)
} finally {
await prompt.dispose()
}
})
test("shows clipboard read failures without changing prompt state", async () => {
const prompt = await mountPrompt(async () => ({ status: "failed", error: new Error("clipboard read failed") }))
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitForFrame((frame) => frame.includes("clipboard read failed"))
expect(prompt.input.plainText).toBe("")
expect(prompt.input.extmarks.getAll()).toHaveLength(0)
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import { 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,36 +11,6 @@ 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",
])
})
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",
@@ -69,8 +39,5 @@ describe("prompt local attachments", () => {
"/tmp/missing.png",
),
).toBeUndefined()
expect(
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
).toBeUndefined()
})
})
-104
View File
@@ -1,104 +0,0 @@
import { expect, test } from "bun:test"
import type { ClipboardService } from "../../src/context/clipboard"
import { Selection } from "../../src/util/selection"
test("copies semantic selection text, reports attempted partial delivery, and clears immediately", async () => {
const notifications: { message: string; variant: "info" | "success" | "warning" | "error" }[] = []
const errors: unknown[] = []
const writes: string[] = []
const events: string[] = []
let notify!: () => void
const notified = new Promise<void>((resolve) => {
notify = resolve
})
const focus = {
hasSelection: () => true,
getClipboardText: (text: string) => text.replace("[Pasted ~2 lines]", "first\nsecond"),
}
const renderer = {
getSelection: () => ({
getSelectedText: () => "before [Pasted ~2 lines] after",
selectedRenderables: [focus],
}),
clearSelection: () => events.push("clear"),
currentFocusedRenderable: focus,
}
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write(text) {
events.push("write")
writes.push(text)
return {
delivery: "attempted",
partial: true,
result: {
host: { status: "failed", error: new Error("host failed") },
terminal: { status: "attempted", capability: "supported" },
},
}
},
}
expect(
Selection.copy(
renderer,
{
show: (notification) => {
notifications.push(notification)
notify()
},
error: (error) => errors.push(error),
},
clipboard,
),
).toBe(true)
expect(writes).toEqual(["before first\nsecond after"])
expect(events).toEqual(["write", "clear"])
await notified
expect(notifications).toEqual([
{ message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" },
])
expect(errors).toEqual([])
})
test("reports total selection-copy failure without delaying selection clearing", async () => {
const failure = new Error("copy failed")
const errors: unknown[] = []
const notifications: unknown[] = []
let clears = 0
let report!: () => void
const reported = new Promise<void>((resolve) => {
report = resolve
})
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write() {
throw failure
},
}
expect(
Selection.copy(
{
getSelection: () => ({ getSelectedText: () => "text", selectedRenderables: [] }),
clearSelection: () => void clears++,
},
{
show: (notification) => notifications.push(notification),
error: (error) => {
errors.push(error)
report()
},
},
clipboard,
),
).toBe(true)
expect(clears).toBe(1)
await reported
expect(notifications).toEqual([])
expect(errors).toEqual([failure])
})
@@ -111,7 +111,7 @@ Configure image normalization in `opencode.json` or `opencode.jsonc`:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"attachments": {
"media": {
"image": {
"auto_resize": true,
"max_width": 2000,
+2 -2
View File
@@ -267,14 +267,14 @@ this field, but it does not start language servers yet.
See the [LSP guide](/lsp) for accepted fields and current limitations.
### Attachments
### Media
Control how oversized images loaded by the `read` tool are resized or rejected
before they are sent to a model.
```jsonc
{
"attachments": {
"media": {
"image": {
"auto_resize": true,
"max_width": 2000,
+3 -3
View File
@@ -175,16 +175,16 @@ Rename the singular `snapshot` field to `snapshots`. Its boolean value does not
{ "snapshots": false }
```
### Attachments
### Media
Rename the singular `attachment` object to `attachments`. Nested image settings keep the same names:
Rename the singular `attachment` object to `media`. Nested image settings keep the same names:
```jsonc
// V1
{ "attachment": { "image": { "auto_resize": true } } }
// V2
{ "attachments": { "image": { "auto_resize": true } } }
{ "media": { "image": { "auto_resize": true } } }
```
### MCP servers