Compare commits

..

47 Commits

Author SHA1 Message Date
Aiden Cline b1f86ee72b feat(plugin): wrap session HTTP requests 2026-08-01 11:44:08 -05:00
Aiden Cline 003b22edda fix: preserve provider error status (#39976) 2026-08-01 11:06:00 -05:00
Kit Langton d5f6c088f0 fix(tui): prevent sidebar scrollbar flash (#40056) 2026-08-01 15:12:37 +00:00
Kit Langton e872bd3c8f feat(tui): prioritize favorite model search results (#40049) 2026-08-01 14:54:46 +00:00
opencode-agent[bot] 85ea15e56d test(tui): wait for mini prompt readiness (#39980)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-01 01:05:18 +00:00
Kit Langton 6a543791b6 fix(tui): persist tab reorder once per drag (#39942) 2026-07-31 20:36:00 -04:00
Kit Langton 47c6840752 fix(tui): harden session tab state hygiene (#39941) 2026-07-31 20:35:07 -04:00
Kit Langton 14e4c3dfd7 fix(tui): ignore hidden tab close hitbox (#39940) 2026-07-31 20:34:13 -04:00
opencode-agent[bot] ee6577936d refactor(tui): remove unused duration formatter (#39964)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:46 -04:00
opencode-agent[bot] 8285a18f18 refactor(tui): remove unused revert diff parser (#39963)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:35 -04:00
opencode-agent[bot] cfcd8de62a refactor(cli): remove unused warning helper (#39962)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:21 -04:00
opencode-agent[bot] c2db18c59b refactor(tui): remove unwired file selection helper (#39961)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:50 -04:00
opencode-agent[bot] 70393a1d18 Remove unused error data serializer (#39960)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:22 -04:00
opencode-agent[bot] eab59ea021 Remove unused locale helpers (#39959)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:06 -04:00
opencode-agent[bot] 4569659b03 Remove unused Zed helpers (#39958)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:53 -04:00
opencode-agent[bot] b5afaed053 Remove unused config optional hook (#39957)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:29 -04:00
opencode-agent[bot] 97f5fb3399 Remove unused subagent retry formatter (#39952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:20 -04:00
opencode-agent[bot] 18843682ad refactor(tui): remove ignored attention kv option (#39956)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:09 -04:00
opencode-agent[bot] 9eb9c1db7d refactor(tui): remove placeholder LSP panel (#39955)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:54 -04:00
opencode-agent[bot] 8846336e11 refactor(tui): remove unused fade file path (#39954)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:34 -04:00
opencode-agent[bot] f6b5f6b6b3 refactor(tui): remove prompt re-export barrels (#39953)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:27 -04:00
opencode-agent[bot] 02df1e9737 refactor(tui): reuse canonical logo (#39951)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:55 -04:00
opencode-agent[bot] 5b84e39f6d refactor(tui): remove stub child cycling commands (#39949)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:23 -04:00
opencode-agent[bot] 6d3f84b4ff refactor(tui): remove dialog show helpers (#39948)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:02 -04:00
opencode-agent[bot] 255e9bbf06 refactor(tui): remove onboarding hint residue (#39947)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:52 -04:00
opencode-agent[bot] 25b6a179e2 refactor(cli): remove no-op migrate command (#39946)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:29 -04:00
opencode-agent[bot] 80874c241f refactor(tui): remove dead assistant renderer (#39945)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:15 -04:00
opencode-agent[bot] f99469bdf8 refactor(core): remove unused layer exports (#39975)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:38 -04:00
opencode-agent[bot] fe0eb4ea8f refactor(core): remove orphaned move service (#39974)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:21 -04:00
opencode-agent[bot] a484f87680 refactor(core): remove unused dependencies (#39973)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:06 -04:00
opencode-agent[bot] 3b6ab392ac refactor(core): remove unused console state model (#39972)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:45 -04:00
opencode-agent[bot] 6c567dc745 refactor(core): remove unreferenced layer map example (#39971)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:27 -04:00
opencode-agent[bot] 92567700f6 refactor(tui): remove unused stop voice operation (#39969)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:17 -04:00
James Long 90c84639e3 feat(theme): export expandTheme (#39967) 2026-07-31 18:32:55 -04:00
opencode-agent[bot] 56c6add5c3 refactor(core): remove unused helpers (#39943)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-31 16:17:14 -05:00
Kit Langton 2cfe8883ea feat(plugin): expose session rename and wait to plugins (#39932) 2026-07-31 21:08:33 +00:00
Kit Langton 102086c50f feat(tui): hot-reload local TUI plugins (#39776) 2026-07-31 16:35:05 -04:00
Kit Langton 06e26d89ad feat(protocol): accept title on session create (#39935) 2026-07-31 19:39:54 +00:00
Aiden Cline 84dd56ed34 fix(core): bound outbound image history (#39929) 2026-07-31 13:57:04 -05: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 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
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
165 changed files with 3192 additions and 4841 deletions
+56 -35
View File
@@ -88,7 +88,6 @@
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -384,7 +383,6 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -405,7 +403,6 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
@@ -427,7 +424,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"drizzle-kit": "catalog:",
@@ -621,9 +617,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 +910,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 +1097,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 +1117,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 +2158,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 +3526,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 +3606,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 +4024,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 +4306,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 +4450,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 +4790,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 +4910,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 +4936,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 +5536,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 +5568,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 +6510,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 +6694,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 +6770,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 +7546,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 +8614,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,
+8 -9
View File
@@ -16,7 +16,6 @@ import {
const patterns = [
/prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
@@ -33,7 +32,6 @@ const patterns = [
/context window exceeds limit/i,
/exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
@@ -44,11 +42,15 @@ const patterns = [
/token limit exceeded/i,
]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
@@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
isContextOverflow(text))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededReason(common)
@@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (
input.status === 400 ||
input.status === 404 ||
input.status === 413 ||
input.status === 422
)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status })
}
+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 } : {}),
}
}
+7 -4
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -96,7 +96,10 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
@@ -150,7 +153,7 @@ export interface Interface {
}
export interface StreamOptions {
readonly transform?: HttpRequestTransform
readonly http?: HttpMiddleware
}
export interface StreamMethod {
@@ -302,7 +305,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
transform: options?.transform,
middleware: options?.http,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
+1 -1
View File
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
+55 -12
View File
@@ -1,11 +1,11 @@
import { Effect, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
import { LLMError, mergeJsonRecords, type LLMRequest } from "../../schema"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -18,7 +18,9 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest
readonly web: Request
readonly framing: Framing.Definition<Frame>
readonly middleware?: HttpMiddleware
}
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -74,21 +76,62 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* (prepareInput.transform?.(request) ?? Effect.void)
const request = ProviderShared.jsonPost({
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
})
return {
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
request,
web: new Request(parts.url, { method: "POST", headers: parts.headers, body: parts.bodyText }),
framing: input.framing,
middleware: prepareInput.middleware,
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request)
Effect.gen(function* () {
const request = prepared.web
const execute = (input: Request) =>
Effect.tryPromise({
try: () => input.text(),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}).pipe(
Effect.flatMap((body) =>
runtime.http.execute(
ProviderShared.jsonPost({
url: input.url,
body,
headers: Headers.fromInput(input.headers),
}),
),
),
Effect.flatMap((response) =>
Stream.toReadableStreamEffect(response.stream).pipe(
Effect.map(
(body) =>
new Response(body, {
status: response.status,
headers: response.headers,
}),
),
),
),
)
return yield* prepared.middleware ? prepared.middleware(request, execute) : execute(request)
})
.pipe(
Effect.mapError((error) =>
error instanceof LLMError
? error
: ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to execute ${request.model.provider}/${request.model.route.id} request`,
ProviderShared.errorText(error),
),
),
Effect.map((response) => HttpClientResponse.fromWeb(prepared.request, response)),
)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
+3 -9
View File
@@ -10,14 +10,8 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export type HttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect<Response, Error>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
@@ -36,7 +30,7 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
readonly middleware?: HttpMiddleware
}
export * as HttpTransport from "./http"
+1 -1
View File
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow")
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
+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" })
+48 -6
View File
@@ -146,12 +146,18 @@ describe("request option precedence", () => {
prompt: "Say hello.",
}),
{
transform: (request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
http: (request, handler) =>
Effect.gen(function* () {
expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
const headers = new Headers(request.headers)
headers.set("x-plugin", "transformed")
return yield* handler(
new Request("https://proxy.test/v1/chat/completions", {
method: request.method,
headers,
body: JSON.stringify({ transformed: true }),
}),
)
}),
},
).pipe(
@@ -171,6 +177,42 @@ describe("request option precedence", () => {
),
)
it.effect("transforms the HTTP response before protocol decoding", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(request)
const body = yield* Effect.promise(() => response.text())
return new Response(body.replace("network", "hooked"), {
status: response.status,
headers: response.headers,
})
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
headers: { "content-type": "text/event-stream" },
}),
),
),
),
)
expect(response.text).toBe("hooked")
}),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
+6 -3
View File
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
),
)
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
it.effect("classifies generic HTTP 413 payload errors", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "payload-too-large",
http: { response: { status: 413 } },
})
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
+22 -4
View File
@@ -6,7 +6,6 @@ describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
@@ -19,6 +18,24 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true)
})
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
expect(failures).toEqual(
failures.map((failure) =>
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => {
const messages = [
"Throttling error: Too many tokens, please wait before trying again.",
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
})
test("classifies transient client statuses as provider internal", () => {
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
"ProviderInternal",
"ProviderInternal",
])
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -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" })],
}),
)
-1
View File
@@ -133,7 +133,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", {
description: "Start the minimal interactive interface",
params: {
@@ -1,5 +0,0 @@
import { Effect } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
-7
View File
@@ -116,13 +116,6 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
},
}),
...(kv.dismissed_getting_started === undefined
? {}
: {
hints: {
onboarding: !kv.dismissed_getting_started,
},
}),
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
}
-1
View File
@@ -35,7 +35,6 @@ const Handlers = Runtime.handlers(Commands, {
plugin: {
list: () => import("./commands/handlers/plugin/list"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
-4
View File
@@ -235,10 +235,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
function warning(message: string) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
+1 -1
View File
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
terminal: { title: false },
prompt: { editor: false, paste: "full" },
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
hints: { onboarding: false },
animations: false,
mouse: false,
})
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
+6 -5
View File
@@ -118,6 +118,7 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined
readonly title?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
@@ -397,7 +398,7 @@ export type Endpoint5_26Output =
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
@@ -523,7 +524,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -685,7 +686,7 @@ export type Endpoint5_26Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly content?:
| readonly [
(
@@ -725,7 +726,7 @@ export type Endpoint5_26Output =
readonly assistantMessageID: SessionMessage.ID
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
@@ -775,7 +776,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly inputID?: SessionMessage.ID | undefined
}
}
@@ -299,7 +299,13 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
payload: {
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -462,6 +462,7 @@ export function make(options: ClientOptions) {
path: `/api/session`,
body: {
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
+12 -1
View File
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionMessageCompactionRunning = {
type: "compaction"
@@ -2662,24 +2662,35 @@ export type SessionListOutput = SessionsResponse
export type SessionCreateInput = {
readonly id?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["id"]
readonly title?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["title"]
readonly agent?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["agent"]
readonly model?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["model"]
readonly location?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
-3
View File
@@ -56,7 +56,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -100,7 +99,6 @@
"@ff-labs/fff-node": "0.10.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -122,7 +120,6 @@
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+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 } }
}
+36 -15
View File
@@ -1,5 +1,6 @@
export * as AISDK from "./aisdk"
import { AsyncLocalStorage } from "node:async_hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type {
JSONSchema7,
@@ -29,7 +30,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { Auth, Endpoint, type AnyRoute, type HttpHandler, type HttpMiddleware } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import type { ID, Info } from "./model"
@@ -103,7 +104,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function prepareOptions(model: Info, pkg: string) {
function prepareOptions(model: Info, pkg: string, requests: AsyncLocalStorage<HttpMiddleware>) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.providerID,
@@ -150,10 +151,20 @@ function prepareOptions(model: Info, pkg: string) {
}
}
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
...opts,
timeout: false,
})
const requestInit: RequestInit = opts
const request =
input instanceof Request
? new Request(input, requestInit)
: input instanceof URL
? new Request(input.href, requestInit)
: new Request(input, requestInit)
const handler: HttpHandler = (input) =>
Effect.tryPromise({
try: () => (typeof customFetch === "function" ? customFetch(input) : fetch(input)),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
const middleware = requests.getStore()
const res = await Effect.runPromise(middleware ? middleware(request, handler) : handler(request))
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
@@ -194,6 +205,7 @@ export const locationLayer = Layer.effect(
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
const languages = new Map<string, LanguageModelV3>()
const sdks = new Map<string, SDK>()
const requests = new AsyncLocalStorage<HttpMiddleware>()
const functionIDs = new WeakMap<object, number>()
let nextFunctionID = 0
const cacheKey = (input: unknown) =>
@@ -267,7 +279,7 @@ export const locationLayer = Layer.effect(
})
const packageName = Provider.packageName(model.package)
const options = prepareOptions(model, packageName)
const options = prepareOptions(model, packageName, requests)
const sdkKey = cacheKey({
providerID: model.providerID,
package: packageName,
@@ -292,16 +304,14 @@ export const locationLayer = Layer.effect(
return language
}),
model: Effect.fn("AISDK.model")(function* (model) {
return modelFromLanguage(model, yield* service.language(model))
return modelFromLanguage(model, yield* service.language(model), requests)
}),
})
return service
}),
)
export const defaultLayer = locationLayer
function modelFromLanguage(info: Info, language: LanguageModelV3) {
function modelFromLanguage(info: Info, language: LanguageModelV3, requests: AsyncLocalStorage<HttpMiddleware>) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
@@ -341,8 +351,11 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
},
with: () => route,
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
prepareTransport: (body, _request, options) => Effect.succeed({ body, middleware: options?.http }),
streamPrepared: (prepared) => {
const input = prepared as { body: LanguageModelV3CallOptions; middleware?: HttpMiddleware }
return streamLanguage(language, input.body, requests, input.middleware)
},
}
return Model.make({
id: info.modelID ?? info.id,
@@ -531,13 +544,21 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
}
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
function streamLanguage(
language: LanguageModelV3,
options: LanguageModelV3CallOptions,
requests?: AsyncLocalStorage<HttpMiddleware>,
middleware?: HttpMiddleware,
) {
const state = { step: 0, toolNames: {} as Record<string, string> }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
Effect.tryPromise({
try: () => language.doStream(options),
try: () =>
requests && middleware
? requests.run(middleware, () => language.doStream(options))
: language.doStream(options),
catch: (error) => llmError("doStream", error),
}).pipe(
Effect.map((result) =>
+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,181 +0,0 @@
export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Global } from "@opencode-ai/util/global"
import { Project } from "../project"
import { Session } from "../session"
import { SessionExecution } from "../session/execution"
import { SessionSchema } from "../session/schema"
import { SessionStore } from "../session/store"
import { AbsolutePath } from "../schema"
import path from "path"
export const Destination = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "MoveSession.Destination" })
export type Destination = typeof Destination.Type
export const Input = Schema.Struct({
sessionID: SessionSchema.ID,
destination: Destination,
moveChanges: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "MoveSession.Input" })
export type Input = typeof Input.Type
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
"MoveSession.DestinationProjectMismatchError",
{
expected: Project.ID,
actual: Project.ID,
},
) {}
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
"MoveSession.DestinationNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
"MoveSession.DestinationNotDirectoryError",
{ directory: AbsolutePath },
) {}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
}) {}
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
"MoveSession.CaptureChangesError",
{
message: Schema.String,
},
) {}
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
"MoveSession.ResetSourceChangesError",
{
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {}
export type Error =
| Session.NotFoundError
| DestinationProjectMismatchError
| DestinationNotFoundError
| DestinationNotDirectoryError
| Session.DestinationNotFoundError
| Session.DestinationNotDirectoryError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
export interface Interface {
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const project = yield* Project.Service
const sessions = yield* SessionStore.Service
const session = yield* Session.Service
const execution = yield* SessionExecution.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
const value = input.destination.directory.trim()
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory) return
const source = yield* project.resolve(current.location.directory)
const destination = yield* project.resolve(directory)
if (input.moveChanges && current.projectID !== destination.id) {
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
// A move must not race active execution: a mid-drain relocation would let
// the source Location dispatch a request assembled under stale instructions
// and history. Serialize like removal does — stop the drain, then move.
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const moveChanges = input.moveChanges && source.directory !== destination.directory
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
if (moveChanges && !sourceRepository)
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
const patch = sourceRepository
? yield* git.change
.capture({ repository: sourceRepository, path: current.location.directory })
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: Git.ChangeSet.make("")
if (patch) {
const repository = yield* git.repo.discover(directory)
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
yield* git.change
.apply({ repository, path: directory, changes: patch })
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
}
yield* session.move({
sessionID: input.sessionID,
directory,
})
if (patch) {
const repository = yield* git.repo.discover(current.location.directory)
if (!repository)
return yield* new ResetSourceChangesError({
directory: current.location.directory,
message: "Source is not a Git repository",
})
yield* git.change
.discard({
repository,
path: current.location.directory,
index: "preserve",
untracked: "remove",
})
.pipe(
Effect.mapError(
(error) =>
new ResetSourceChangesError({
directory: current.location.directory,
message: error.message,
cause: error.cause,
}),
),
)
}
})
return Service.of({ moveSession })
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [
FSUtil.node,
Git.node,
Global.node,
Project.node,
Session.node,
SessionStore.node,
SessionExecution.node,
],
})
@@ -1,4 +1,3 @@
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const imageGenerationArgsSchema = z
@@ -24,91 +23,3 @@ export const imageGenerationArgsSchema = z
export const imageGenerationOutputSchema = z.object({
result: z.string(),
})
type ImageGenerationArgs = {
/**
* Background type for the generated image. Default is 'auto'.
*/
background?: "auto" | "opaque" | "transparent"
/**
* Input fidelity for the generated image. Default is 'low'.
*/
inputFidelity?: "low" | "high"
/**
* Optional mask for inpainting.
* Contains image_url (string, optional) and file_id (string, optional).
*/
inputImageMask?: {
/**
* File ID for the mask image.
*/
fileId?: string
/**
* Base64-encoded mask image.
*/
imageUrl?: string
}
/**
* The image generation model to use. Default: gpt-image-1.
*/
model?: string
/**
* Moderation level for the generated image. Default: auto.
*/
moderation?: "auto"
/**
* Compression level for the output image. Default: 100.
*/
outputCompression?: number
/**
* The output format of the generated image. One of png, webp, or jpeg.
* Default: png
*/
outputFormat?: "png" | "jpeg" | "webp"
/**
* Number of partial images to generate in streaming mode, from 0 (default value) to 3.
*/
partialImages?: number
/**
* The quality of the generated image.
* One of low, medium, high, or auto. Default: auto.
*/
quality?: "auto" | "low" | "medium" | "high"
/**
* The size of the generated image.
* One of 1024x1024, 1024x1536, 1536x1024, or auto.
* Default: auto.
*/
size?: "auto" | "1024x1024" | "1024x1536" | "1536x1024"
}
const imageGenerationToolFactory = createProviderToolFactoryWithOutputSchema<
{},
{
/**
* The generated image encoded in base64.
*/
result: string
},
ImageGenerationArgs
>({
id: "openai.image_generation",
inputSchema: z.object({}),
outputSchema: imageGenerationOutputSchema,
})
export const imageGeneration = (
args: ImageGenerationArgs = {}, // default
) => {
return imageGenerationToolFactory(args)
}
@@ -1,4 +1,3 @@
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const localShellInputSchema = z.object({
@@ -15,50 +14,3 @@ export const localShellInputSchema = z.object({
export const localShellOutputSchema = z.object({
output: z.string(),
})
export const localShell = createProviderToolFactoryWithOutputSchema<
{
/**
* Execute a shell command on the server.
*/
action: {
type: "exec"
/**
* The command to run.
*/
command: string[]
/**
* Optional timeout in milliseconds for the command.
*/
timeoutMs?: number
/**
* Optional user to run the command as.
*/
user?: string
/**
* Optional working directory to run the command in.
*/
workingDirectory?: string
/**
* Environment variables to set for the command.
*/
env?: Record<string, string>
}
},
{
/**
* The output of local shell tool call.
*/
output: string
},
{}
>({
id: "openai.local_shell",
inputSchema: localShellInputSchema,
outputSchema: localShellOutputSchema,
})
@@ -1,4 +1,3 @@
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
// Args validation schema
@@ -39,65 +38,3 @@ export const webSearchPreviewArgsSchema = z.object({
})
.optional(),
})
export const webSearchPreview = createProviderToolFactory<
{
// Web search doesn't take input parameters - it's controlled by the prompt
},
{
/**
* Search context size to use for the web search.
* - high: Most comprehensive context, highest cost, slower response
* - medium: Balanced context, cost, and latency (default)
* - low: Least context, lowest cost, fastest response
*/
searchContextSize?: "low" | "medium" | "high"
/**
* User location information to provide geographically relevant search results.
*/
userLocation?: {
/**
* Type of location (always 'approximate')
*/
type: "approximate"
/**
* Two-letter ISO country code (e.g., 'US', 'GB')
*/
country?: string
/**
* City name (free text, e.g., 'Minneapolis')
*/
city?: string
/**
* Region name (free text, e.g., 'Minnesota')
*/
region?: string
/**
* IANA timezone (e.g., 'America/Chicago')
*/
timezone?: string
}
}
>({
id: "openai.web_search_preview",
inputSchema: z.object({
action: z
.discriminatedUnion("type", [
z.object({
type: z.literal("search"),
query: z.string().nullish(),
}),
z.object({
type: z.literal("open_page"),
url: z.string(),
}),
z.object({
type: z.literal("find"),
url: z.string(),
pattern: z.string(),
}),
])
.nullish(),
}),
})
@@ -1,4 +1,3 @@
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const webSearchArgsSchema = z.object({
@@ -20,83 +19,3 @@ export const webSearchArgsSchema = z.object({
})
.optional(),
})
export const webSearchToolFactory = createProviderToolFactory<
{
// Web search doesn't take input parameters - it's controlled by the prompt
},
{
/**
* Filters for the search.
*/
filters?: {
/**
* Allowed domains for the search.
* If not provided, all domains are allowed.
* Subdomains of the provided domains are allowed as well.
*/
allowedDomains?: string[]
}
/**
* Search context size to use for the web search.
* - high: Most comprehensive context, highest cost, slower response
* - medium: Balanced context, cost, and latency (default)
* - low: Least context, lowest cost, fastest response
*/
searchContextSize?: "low" | "medium" | "high"
/**
* User location information to provide geographically relevant search results.
*/
userLocation?: {
/**
* Type of location (always 'approximate')
*/
type: "approximate"
/**
* Two-letter ISO country code (e.g., 'US', 'GB')
*/
country?: string
/**
* City name (free text, e.g., 'Minneapolis')
*/
city?: string
/**
* Region name (free text, e.g., 'Minnesota')
*/
region?: string
/**
* IANA timezone (e.g., 'America/Chicago')
*/
timezone?: string
}
}
>({
id: "openai.web_search",
inputSchema: z.object({
action: z
.discriminatedUnion("type", [
z.object({
type: z.literal("search"),
query: z.string().nullish(),
}),
z.object({
type: z.literal("open_page"),
url: z.string(),
}),
z.object({
type: z.literal("find"),
url: z.string(),
pattern: z.string(),
}),
])
.nullish(),
}),
})
export const webSearch = (
args: Parameters<typeof webSearchToolFactory>[0] = {}, // default
) => {
return webSearchToolFactory(args)
}
+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
-3
View File
@@ -148,6 +148,3 @@ export function buildLocationServiceMap(
),
)
}
// This is temporary for backwards compatibility
export const locationServiceMapLayer = buildLocationServiceMap()
+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
View File
@@ -341,6 +341,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
create: (input) =>
runtime.session.create({
id: input?.id,
title: input?.title,
agent: input?.agent,
model: input?.model,
location:
@@ -350,8 +351,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
prompt: runtime.session.prompt,
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
command: runtime.session.command,
rename: runtime.session.rename,
synthetic: runtime.session.synthetic,
interrupt: (input) => runtime.session.interrupt(input.sessionID),
wait: (input) => runtime.session.wait(input.sessionID),
},
} satisfies Plugin.Context
})
@@ -1,94 +0,0 @@
export * as LayerMapExample from "./layer-map.example"
import { Context, Effect, Layer, LayerMap } from "effect"
import { Npm } from "@opencode-ai/util/npm"
/**
* Tutorial: split global services from context-specific services.
*
* Use this pattern when part of the app should be constructed once at the app edge,
* while another part should be cached per request/project/workspace key.
*
* In this example:
* - Npm.Service is the global service. It is not keyed by request context and should
* be provided once by the application runtime.
* - ConfigService is context-specific. It is built from a RequestContext key and is
* cached by LayerMap for that key.
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
* request can provide ConfigServiceMap.get(context) to select the right instance.
*
* Lifetime model:
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
* ConfigService for exactly that context key.
* - The cached ConfigService entry stays alive while something is using it. Once idle,
* it remains cached for idleTimeToLive, then its scope is finalized.
* - invalidate(context) removes the cache entry for future lookups. Active users keep
* running on the old instance; the next lookup can create a fresh instance.
*
* Key model:
* - Keys can be strings, structs, classes, arrays, etc.
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
* lookup, so mutating an object after it has been used as a key is a bug.
*/
export type RequestContext = {
readonly directory: string
readonly workspace: string
}
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
"@opencode/example/RequestContextRef",
) {}
export interface ConfigServiceShape {
readonly directory: string
readonly workspace: string
readonly nextUse: () => Effect.Effect<number>
readonly which: Npm.Interface["which"]
}
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
"@opencode/example/ConfigService",
) {}
const configServiceLayer = Layer.effect(
ConfigService,
Effect.gen(function* () {
const context = yield* RequestContextRef
const npm = yield* Npm.Service
let useCount = 0
return ConfigService.of({
directory: context.directory,
workspace: context.workspace,
nextUse: () => Effect.succeed(++useCount),
which: npm.which,
})
}),
)
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
lookup: (context: RequestContext) =>
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
idleTimeToLive: "5 minutes",
}) {}
export const appLayer = ConfigServiceMap.layer
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
const config = yield* ConfigService
return {
directory: config.directory,
workspace: config.workspace,
useCount: yield* config.nextUse(),
}
})
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
})
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
+31 -3
View File
@@ -194,7 +194,9 @@ export function fromPromise(plugin: Plugin) {
),
),
refresh:
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
@@ -263,8 +265,34 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
hook: (name, callback) => {
if (name !== "http")
return register(
host.session.hook(name, (event) =>
Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [event]))),
),
)
return register(
host.session.hook("http", (event) => {
const request = event.request
const output = {
...event,
request: (input: Request) => Effect.runPromiseWith(context)(request(input)),
}
return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.request = (input) =>
Effect.tryPromise({
try: () => output.request(input),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}),
),
)
}),
)
},
create: (input) =>
run(
host.session.create(
+16 -6
View File
@@ -225,15 +225,25 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook("request", (evt) =>
yield* ctx.session.hook("http", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
const request = evt.request
evt.request = (input) => {
const url = new URL(input.url)
const headers = new Headers(input.headers)
headers.set("originator", "opencode")
headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return request(new Request(input, { headers }))
return request(
new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, {
method: input.method,
headers,
body: input.body,
signal: input.signal,
}),
)
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
)
+13 -2
View File
@@ -11,7 +11,17 @@ import { Session } from "../session"
export interface Interface {
readonly session: Pick<
Session.Interface,
"get" | "create" | "messages" | "prompt" | "generate" | "command" | "resume" | "interrupt" | "synthetic"
| "get"
| "create"
| "messages"
| "prompt"
| "generate"
| "command"
| "rename"
| "resume"
| "interrupt"
| "synthetic"
| "wait"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -52,9 +62,11 @@ export const layerWithCell = (cell: Cell) =>
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
},
job: {
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
@@ -108,7 +120,6 @@ export const providerLayerWithCell = (cell: Cell) =>
)
export const layer = layerWithCell(defaultCell)
export const providerLayer = providerLayerWithCell(defaultCell)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+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,
+61 -15
View File
@@ -19,6 +19,11 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -94,6 +99,56 @@ export const unsupportedParts = (messages: LLMRequest["messages"], capabilities:
}),
)
export const boundImages = (messages: LLMRequest["messages"]) => {
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
const size = (data: string | Uint8Array) =>
typeof data === "string" ? Buffer.byteLength(data) : Math.ceil(data.byteLength / 3) * 4
const imageBytes = messages.reduce(
(total, message) =>
total +
message.content.reduce((sum, part) => {
if (part.type === "media" && isImage(part.mediaType)) return sum + size(part.data)
if (part.type !== "tool-result" || part.result.type !== "content") return sum
return (
sum +
part.result.value.reduce(
(bytes: number, item: Content) =>
bytes + (item.type === "file" && isImage(item.mime) ? Buffer.byteLength(item.uri) : 0),
0,
)
)
}, 0),
0,
)
if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
let removed = 0
return messages.map((message) =>
Message.make({
...message,
content: message.content.map((part) => {
if (part.type === "media" && isImage(part.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) {
removed += size(part.data)
return Message.text(IMAGE_REMOVED)
}
if (part.type !== "tool-result" || part.result.type !== "content") return part
return {
...part,
result: {
...part.result,
value: part.result.value.map((item: Content) => {
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
return item
removed += Buffer.byteLength(item.uri)
return { type: "text" as const, text: IMAGE_REMOVED }
}),
},
}
}),
}),
)
}
/**
* Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate
@@ -158,31 +213,22 @@ 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),
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
transform: (request) =>
http: (request, handler) =>
hooks
.trigger("session", "request", {
.trigger("session", "http", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
...request,
request: handler,
})
.pipe(
Effect.tap((event) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
}),
),
Effect.asVoid,
),
.pipe(Effect.flatMap((event) => event.request(request))),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
+16 -10
View File
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return { type: "provider.rate-limit", message: cause.reason.message }
return providerError("provider.rate-limit", cause.reason)
case "Authentication":
return { type: "provider.auth", message: cause.reason.message }
return providerError("provider.auth", cause.reason)
case "QuotaExceeded":
return { type: "provider.quota", message: cause.reason.message }
return providerError("provider.quota", cause.reason)
case "ContentPolicy":
return { type: "provider.content-filter", message: cause.reason.message }
return providerError("provider.content-filter", cause.reason)
case "Transport":
return { type: "provider.transport", message: cause.reason.message }
return providerError("provider.transport", cause.reason)
case "ProviderInternal":
return { type: "provider.internal", message: cause.reason.message }
return providerError("provider.internal", cause.reason)
case "InvalidProviderOutput":
return { type: "provider.invalid-output", message: cause.reason.message }
return providerError("provider.invalid-output", cause.reason)
case "InvalidRequest":
return { type: "provider.invalid-request", message: cause.reason.message }
return providerError("provider.invalid-request", cause.reason)
case "NoRoute":
return { type: "provider.no-route", message: cause.reason.message }
return providerError("provider.no-route", cause.reason)
case "UnknownProvider":
return { type: "provider.unknown", message: cause.reason.message }
return providerError("provider.unknown", cause.reason)
default: {
const exhaustive: never = cause.reason
return exhaustive
@@ -58,3 +58,9 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
const status =
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
}
-10
View File
@@ -1,10 +0,0 @@
import { createRequire } from "node:module"
import path from "node:path"
export namespace Module {
export function resolve(id: string, dir: string) {
try {
return createRequire(path.join(dir, "package.json")).resolve(id)
} catch {}
}
}
-6
View File
@@ -12,12 +12,6 @@ export function getDirectory(path: string | undefined) {
return parts.slice(0, parts.length - 1).join("/") + "/"
}
export function getFileExtension(path: string | undefined) {
if (!path) return ""
const parts = path.split(".")
return parts[parts.length - 1]
}
export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) {
const filename = getFilename(path)
if (filename.length <= maxLength) return filename
@@ -1,16 +0,0 @@
export * as ConfigConsoleStateV1 from "./console-state"
import { Schema } from "effect"
import { NonNegativeInt } from "../../schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: NonNegativeInt,
}) {}
export const emptyConsoleState: ConsoleState = ConsoleState.make({
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
})
+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", {
+44
View File
@@ -51,6 +51,50 @@ const client = LLMClient.layer.pipe(
),
)
it.effect("applies HTTP middleware to AI SDK requests and responses", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
const request = event.options.fetch
event.sdk = {
languageModel: () => ({
...streamModel([]),
doStream: async () => {
const response = await request("https://provider.test/v1/chat", { method: "POST", body: "before" })
const text = await response.text()
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: "text-start", id: "text" })
controller.enqueue({ type: "text-delta", id: "text", delta: text })
controller.enqueue({ type: "text-end", id: "text" })
controller.enqueue({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage })
controller.close()
},
}),
}
},
}),
}
})
const resolved = yield* aisdk.model(
model("middleware-test", {
fetch: async (request: Request) => new Response(await request.text()),
}),
)
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "test" }), {
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(new Request(request, { method: request.method, body: "request-hooked" }))
return new Response(`${yield* Effect.promise(() => response.text())}-response-hooked`)
}),
}).pipe(Effect.provide(client))
expect(response.text).toBe("request-hooked-response-hooked")
}),
)
it.effect("keys language models by package and flattened overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+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(
-291
View File
@@ -1,291 +0,0 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// Records the execution serialization a move must perform before relocating.
const executionCalls: string[] = []
const recordingExecution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)),
awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
MoveSession.node,
Database.node,
Bus.node,
ProjectDirectories.node,
Project.node,
Session.node,
SessionProjector.node,
SessionStore.node,
]),
[[SessionExecution.node, recordingExecution]],
),
)
function abs(input: string) {
return AbsolutePath.make(input)
}
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.autocrlf false`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n")
await $`git add tracked.txt`.cwd(directory).quiet()
await $`git commit -m root`.cwd(directory).quiet()
}
describe("MoveSession", () => {
it.live("moves session changes to another project directory", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-move-destination`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet())
const moved = abs(yield* Effect.promise(() => fs.realpath(destination)))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move",
directory: source,
title: "move",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
executionCalls.length = 0
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
// The move stops active execution before any relocation side effect.
expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`])
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false)
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: moved, path: "" })
}),
)
it.live("moves within a checkout without transferring existing changes", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-nested",
directory: source,
title: "move nested",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
const missing = yield* Session.Service.use((service) =>
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
)
expect(missing._tag).toBe("Session.DestinationNotFoundError")
yield* Effect.promise(() => fs.mkdir(destination))
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n")
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: destination, path: "packages" })
}),
)
it.live("moves a session to another project", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-other-project`)
yield* Effect.acquireRelease(
Effect.promise(() => fs.mkdir(destination, { recursive: true })),
() => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })),
)
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
const sessionID = Session.ID.make("ses_move_project")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-project",
directory: source,
title: "move project",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* Session.Service.use((service) =>
service.move({ sessionID, directory: destination }),
)
expect(
yield* db
.select({ projectID: SessionTable.project_id, directory: SessionTable.directory })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ projectID: destinationProjectID, directory: destination })
}),
)
it.live("moves nested session changes without cleaning unrelated files", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const sourceDirectory = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.mkdir(sourceDirectory))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n"))
yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet())
yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet())
const destination = abs(`${root.path}-move-nested-destination`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet())
const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n"))
yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet())
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested_checkout")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-nested-checkout",
directory: sourceDirectory,
title: "move nested checkout",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe(
"initial\n",
)
expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe(
"staged\n",
)
expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe(
"M packages/staged.txt\n",
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n")
}),
)
})
+2
View File
@@ -106,8 +106,10 @@ export function host(overrides: Overrides = {}): Plugin.Context {
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
rename: overrides.session?.rename ?? (() => Effect.die("unused session.rename")),
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
},
}
}
+33
View File
@@ -221,6 +221,39 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
const request = event.request
event.request = async (input) => {
const response = await request(new Request(input, { headers: { "x-hook": "promise" } }))
return new Response(`${await response.text()}-response`)
}
})
},
}),
).effect(host)
const event: SessionHooks["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
request: (input) => Effect.succeed(new Response(input.headers.get("x-hook") ?? "missing")),
}
yield* hooks.trigger("session", "http", event)
const response = yield* event.request(new Request("https://provider.test"))
expect(yield* Effect.promise(() => response.text())).toBe("promise-response")
}),
)
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -29,6 +29,21 @@ function required<T>(value: T | undefined): T {
return value
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
request: (input) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
})
const response = yield* event.request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
})
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -100,33 +115,9 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
@@ -134,7 +125,7 @@ describe("OpenAIPlugin", () => {
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).toEqual({})
expect(custom.headers).not.toHaveProperty("originator")
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
@@ -184,21 +175,13 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).toEqual({})
expect(request.headers).not.toHaveProperty("originator")
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
+20
View File
@@ -14,6 +14,9 @@ import {
TransportReason,
UnknownProviderReason,
ToolFailure,
HttpContext,
HttpRequestDetails,
HttpResponseDetails,
} from "@opencode-ai/ai"
import { Permission } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/schema/tool"
@@ -71,6 +74,23 @@ describe("toSessionError", () => {
})
})
test("preserves provider HTTP status", () => {
const http = new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
response: new HttpResponseDetails({ status: 413, headers: {} }),
})
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
type: "provider.invalid-request",
message: "too large",
status: 413,
})
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
type: "provider.internal",
message: "bad gateway",
status: 502,
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart } from "@opencode-ai/ai"
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
@@ -62,3 +62,51 @@ describe("SessionModelRequest.unsupportedParts", () => {
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
})
})
describe("SessionModelRequest.boundImages", () => {
test("preserves images below the trigger", () => {
const messages = [Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })]
expect(boundImages(messages)).toBe(messages)
})
test("replaces oldest images until the retained payload reaches the target", () => {
const image = "a".repeat(9 * 1024 * 1024)
const messages = [
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "first.png" }),
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "second.png" }),
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "third.png" }),
]
const result = boundImages(messages)
expect(result[0]?.content[0]).toMatchObject({ type: "text" })
expect(result[1]?.content[0]).toMatchObject({ type: "text" })
expect(result[2]?.content[0]).toMatchObject({ type: "media", filename: "third.png" })
})
test("replaces images nested in tool results", () => {
const image = "a".repeat(13 * 1024 * 1024)
const result = boundImages([
Message.tool(
ToolResultPart.make({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "first.png" },
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "second.png" },
],
},
}),
),
])
expect(result[0]?.content[0]).toMatchObject({
type: "tool-result",
result: {
type: "content",
value: [{ type: "text" }, { type: "file", name: "second.png" }],
},
})
})
})
+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": {
+5 -5
View File
@@ -1,10 +1,9 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Effect, JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -16,20 +15,21 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Effect.Effect<Response, Error>
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
readonly http: SessionHttp
}
export type SessionDomain = Pick<
SessionApi<unknown>,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly hook: Hooks<SessionHooks>
}
+3 -3
View File
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -16,15 +15,16 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Promise<Response>
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
readonly http: SessionHttp
}
export type SessionDomain = Pick<
+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
@@ -149,6 +149,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
HttpApiEndpoint.post("session.create", "/api/session", {
payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional),
title: Schema.String.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
+2
View File
@@ -1,9 +1,11 @@
export * as SessionError from "./session-error.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
export interface Error extends Schema.Schema.Type<typeof Error> {}
export const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
}).annotate({ identifier: "Session.StructuredError" })
+1
View File
@@ -77,6 +77,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
data: yield* session
.create({
id: ctx.payload.id,
title: ctx.payload.title,
agent: ctx.payload.agent,
model: ctx.payload.model,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
+1
View File
@@ -45,6 +45,7 @@ export type {
StatefulColor,
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { expandTheme } from "./expand.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
+1 -1
View File
@@ -92,7 +92,7 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"diff": "catalog:",
"clipboardy": "4.0.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
+57 -66
View File
@@ -72,9 +72,9 @@ import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { PromptHistoryProvider } from "./prompt/history"
import { FrecencyProvider } from "./prompt/frecency"
import { PromptStashProvider } from "./prompt/stash"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import * as Model from "./util/model"
@@ -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()
-3
View File
@@ -5,7 +5,6 @@ import type {
TuiAttentionNotifyResult,
TuiAttentionNotifySkipReason,
TuiAttentionWhen,
TuiKV,
TuiAttentionSoundName,
TuiAttentionSoundPack,
TuiAttentionSoundPackInfo,
@@ -115,8 +114,6 @@ export function createTuiAttention(input: {
renderer: AttentionRenderer
config: Pick<Config.Resolved, "attention">
update?: Config.Interface["update"]
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
kv?: TuiKV
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
}): TuiAttentionHost {
let focus: FocusState = "unknown"
+1 -5
View File
@@ -1,4 +1,4 @@
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound } from "@opentui/core"
import { readFile } from "node:fs/promises"
let audio: Audio | null | undefined
@@ -42,10 +42,6 @@ export function play(sound: AudioSound, options?: AudioPlayOptions) {
return current.play(sound, options)
}
export function stopVoice(voice: AudioVoice) {
return audio?.stopVoice(voice) ?? false
}
export function dispose() {
audio?.dispose()
audio = undefined
+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)
}
@@ -43,15 +43,6 @@ export const settings: Setting[] = [
labels: ["off", "on"],
keywords: ["motion", "effects"],
},
{
title: "Onboarding",
category: "Appearance",
path: ["hints", "onboarding"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["hints", "getting started", "guidance"],
},
{
title: "Sidebar",
category: "Session",
@@ -93,15 +84,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 +180,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)
}
+10 -4
View File
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => {
const provider = providers().get(model.providerID)
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
return {
value: { providerID: model.providerID, modelID: model.id },
providerID: model.providerID,
providerName: provider?.name ?? model.providerID,
title: model.name,
releaseDate: model.time.released,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
? "(Favorite)"
: undefined,
favorite,
description: favorite ? "(Favorite)" : undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
onSelect() {
@@ -96,7 +96,9 @@ export function DialogModel(props: { providerID?: string }) {
)
if (needle) {
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
)
}
return [...favoriteOptions, ...recentOptions, ...modelOptions]
@@ -160,6 +162,10 @@ export function DialogModel(props: { providerID?: string }) {
)
}
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
}
export function sortModelOptions<
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
>(options: T[]) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash"
import { usePromptStash, type StashEntry } from "../prompt/stash"
function getRelativeTime(timestamp: number): string {
const now = Date.now()
+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>
)
}
@@ -1 +0,0 @@
export * from "../../prompt/frecency"
@@ -1 +0,0 @@
export * from "../../prompt/history"
+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 }
}
@@ -1 +0,0 @@
export * from "../../prompt/stash"
+24 -2
View File
@@ -6,6 +6,7 @@ import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
seedSessionTabMotion,
@@ -48,6 +49,10 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
// per slot crossing; the preview holds after release until the store reflects the move,
// so the strip never flashes the pre-drag order while the write is in flight.
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
let strip: { screenX: number } | undefined
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
@@ -55,7 +60,18 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs()))
const ordered = createMemo(() => {
const pending = preview()
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
createEffect(() => {
const pending = preview()
if (!pending || dragging()) return
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
})
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
)
@@ -279,6 +295,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab === NEW_SESSION_TAB) return
tabs.select(tab.sessionID)
}
@@ -295,7 +313,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
if (slot !== undefined && slot !== tabNumber() - 1)
setPreview({ sessionID: tab.sessionID, index: slot })
}}
onMouseDragEnd={release}
>
@@ -341,6 +360,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
fg={closeColor()}
selectable={false}
onMouseUp={(event) => {
// The close mark only renders while hovered; without motion events a click can
// land here first, and must select the tab instead of closing it invisibly.
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
}}
-15
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",
}),
@@ -168,11 +162,6 @@ export const Info = Schema.Struct({
}),
}),
).annotate({ description: "Mini transcript presentation settings" }),
hints: Schema.optional(
Schema.Struct({
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
}),
).annotate({ description: "In-product guidance settings" }),
debug: Schema.optional(
Schema.Struct({
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
@@ -273,7 +262,3 @@ export function useConfig() {
if (!value) throw new Error("ConfigProvider is missing")
return value
}
export function useConfigOptional() {
return useContext(ConfigContext)
}
-6
View File
@@ -105,8 +105,6 @@ export const Definitions = {
session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View pending work"),
session_child_first: keybind("down", "Toggle subagent picker"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
@@ -164,7 +162,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"),
@@ -309,8 +306,6 @@ export const CommandMap = {
session_compact: "session.compact",
session_queued_prompts: "session.queued_prompts",
session_child_first: "session.child.first",
session_child_cycle: "session.child.next",
session_child_cycle_reverse: "session.child.previous",
session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle",
session_quick_switch_1: "session.quick_switch.1",
@@ -365,7 +360,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)
}
@@ -29,9 +29,11 @@ export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
}
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
export function closeSessionTab(tabs: SessionTab[], sessionID: string) {
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
if (index === -1) return { tabs: [...tabs], next: undefined }
// Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can
// detect it by identity.
if (index === -1) return { tabs, next: undefined }
return {
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
@@ -87,9 +89,13 @@ export function cycleSessionTab(tabs: readonly SessionTab[], active: string | un
return tabs[(start + direction + tabs.length) % tabs.length]
}
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
// session switch forever; the oldest entries fall off first.
const SESSION_TAB_HISTORY_LIMIT = 100
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
if (history.entries[history.index] === sessionID) return history
const entries = [...history.entries.slice(0, history.index + 1), sessionID]
const entries = [...history.entries.slice(0, history.index + 1), sessionID].slice(-SESSION_TAB_HISTORY_LIMIT)
return { entries, index: entries.length - 1 }
}
+3 -2
View File
@@ -73,7 +73,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs?.scope ?? "global"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
() => {},
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
(error) => console.error("Failed to persist session tabs", error),
)
}
@@ -222,7 +223,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
const closed = closeSessionTab(state().tabs, target)
if (closed.tabs.length === state().tabs.length) return
if (closed.tabs === state().tabs) return
const selected = navigate && current() === target
const previous = selected
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
+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()))

Some files were not shown because too many files have changed in this diff Show More