Compare commits

..

45 Commits

Author SHA1 Message Date
Kit Langton bd406b9619 fix(tui): share runtime with external TSX plugins 2026-07-31 21:40:14 -04: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
Kit Langton 0e26116f68 fix(tui): stabilize open picker 2026-07-31 16:38:48 +00:00
152 changed files with 3026 additions and 4707 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,
+84 -10
View File
@@ -4,21 +4,72 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
import { isRecord } from "../protocols/shared"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
const ADAPTER = "openrouter"
type OpenRouterString<Known extends string> = Known | (string & {})
export interface OpenRouterProviderRouting {
readonly [key: string]: unknown
readonly order?: ReadonlyArray<string>
readonly allow_fallbacks?: boolean
readonly require_parameters?: boolean
readonly data_collection?: OpenRouterString<"allow" | "deny">
readonly only?: ReadonlyArray<string>
readonly ignore?: ReadonlyArray<string>
readonly quantizations?: ReadonlyArray<string>
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
readonly max_price?: Readonly<{
prompt?: number | string
completion?: number | string
image?: number | string
audio?: number | string
request?: number | string
}>
readonly zdr?: boolean
}
export type OpenRouterPlugin =
| Readonly<{
id: "web"
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
| Readonly<{ id: "moderation" }>
| Readonly<{ id: "response-healing" }>
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
| Readonly<{ id: string & {}; [key: string]: unknown }>
export interface OpenRouterOptions {
readonly [key: string]: unknown
readonly usage?: boolean | Record<string, unknown>
readonly reasoning?: Record<string, unknown>
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
readonly models?: ReadonlyArray<string>
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{
enabled?: boolean
exclude?: boolean
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
max_tokens?: number
}>
readonly usage?: boolean | Readonly<{ include: boolean }>
readonly user?: string
readonly web_search_options?: Readonly<{
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
}
export type OpenRouterProviderOptionsInput = ProviderOptions & {
@@ -47,7 +98,7 @@ export const protocol = Protocol.make({
body: {
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.protocol.body.from(request).pipe(
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0
@@ -78,16 +129,39 @@ export const protocol = Protocol.make({
stream: OpenAIChat.protocol.stream,
})
const cacheControl = () => {
const breakpoints = newBreakpoints(4)
return (cache: CacheHint | undefined) => {
if (cache === undefined || breakpoints.remaining === 0) return undefined
breakpoints.remaining -= 1
return {
type: "ephemeral" as const,
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
}
}
}
const bodyOptions = (input: unknown) => {
const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
return {
...(openrouter.usage === true
...options,
...(usage === undefined || usage === true
? { usage: { include: true } }
: isRecord(openrouter.usage)
? { usage: openrouter.usage }
: {}),
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
: usage === false
? { usage: { include: false } }
: isRecord(usage)
? { usage }
: {}),
...(Array.isArray(models) ? { models } : {}),
...(isRecord(provider) ? { provider } : {}),
...(Array.isArray(plugins) ? { plugins } : {}),
...(isRecord(web_search_options) ? { web_search_options } : {}),
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
}
}
+2
View File
@@ -124,6 +124,7 @@ export const ToolCallPart = Object.assign(
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.ToolCall" }),
@@ -168,6 +169,7 @@ export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Reasoning" })
@@ -5,6 +5,28 @@ const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
openrouter: {
models: ["google/gemini-3.1-pro"],
provider: {
order: ["anthropic"],
require_parameters: true,
data_collection: "future-policy",
sort: "future-sort",
max_price: { prompt: "0.50" },
},
reasoning: { effort: "future-effort", exclude: false },
plugins: [{ id: "future-plugin", enabled: true }],
web_search_options: { engine: "future-engine" },
debug: { echo_upstream_body: true },
user: "user_123",
},
},
})
LLM.request({
model,
prompt: "Hello",
+177 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message } from "../../src"
import { CacheHint, LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenRouter from "../../src/providers/openrouter"
@@ -27,10 +27,131 @@ describe("OpenRouter", () => {
model: "openai/gpt-4o-mini",
messages: [{ role: "user", content: "Say hello." }],
stream: true,
usage: { include: true },
})
}),
)
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
cache: { tools: true, system: true, messages: { tail: 1 } },
}),
)
expect(prepared.body).toMatchObject({
tools: [{ cache_control: { type: "ephemeral" } }],
messages: [
{
role: "system",
content: [
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
],
},
{
role: "user",
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("lowers manual assistant and tool-result cache hints", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.user("Call the tool"),
Message.assistant([
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
]),
Message.tool({
id: "call_1",
name: "lookup",
result: "Done",
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
}),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Call the tool" },
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
])
}),
)
it.effect("caps manual cache controls at four breakpoints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
prompt: "Hello",
}),
)
const system = prepared.body.messages[0]
expect(system?.role).toBe("system")
expect(
system && Array.isArray(system.content)
? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
: [],
).toHaveLength(4)
}),
)
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: { messages: "latest-assistant" },
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Think" },
{ role: "assistant", cache_control: { type: "ephemeral" } },
])
}),
)
it.effect("allows usage accounting to be disabled explicitly", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: { usage: false } },
}).model("openai/gpt-4o-mini"),
cache: "none",
prompt: "Hello",
}),
)
expect(prepared.body.usage).toEqual({ include: false })
}),
)
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -42,6 +163,13 @@ describe("OpenRouter", () => {
usage: true,
reasoning: { effort: "high" },
promptCacheKey: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
},
},
}).model("anthropic/claude-3.7-sonnet:thinking"),
@@ -53,10 +181,54 @@ describe("OpenRouter", () => {
usage: { include: true },
reasoning: { effort: "high" },
prompt_cache_key: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
})
}),
)
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
Effect.gen(function* () {
const invalid: Record<string, unknown> = {
usage: "yes",
models: "anthropic/claude-sonnet-4.6",
provider: [],
plugins: {},
web_search_options: [],
debug: [],
user: 123,
reasoning: [],
promptCacheKey: 123,
future_option: { enabled: true },
}
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: invalid },
}).model("openai/gpt-4o-mini"),
prompt: "Hello",
}),
)
expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
expect(prepared.body).not.toHaveProperty("usage")
expect(prepared.body).not.toHaveProperty("models")
expect(prepared.body).not.toHaveProperty("provider")
expect(prepared.body).not.toHaveProperty("plugins")
expect(prepared.body).not.toHaveProperty("web_search_options")
expect(prepared.body).not.toHaveProperty("debug")
expect(prepared.body).not.toHaveProperty("user")
expect(prepared.body).not.toHaveProperty("reasoning")
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("preserves the upstream provider finish reason", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
@@ -104,6 +276,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant([
{
@@ -137,6 +310,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -162,6 +336,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -183,6 +358,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
}),
)
-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)
+1
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
@@ -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"],
@@ -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 } }
}
-2
View File
@@ -299,8 +299,6 @@ export const locationLayer = Layer.effect(
}),
)
export const defaultLayer = locationLayer
function modelFromLanguage(info: Info, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
+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)
+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,
+57 -2
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,9 +213,9 @@ 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,
})
-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", {
+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")),
},
}
}
@@ -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": {
+1 -1
View File
@@ -29,7 +29,7 @@ export interface SessionHooks {
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>
}
+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),
+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"
+6 -1
View File
@@ -77,6 +77,11 @@
"bun": "./src/plugin/runtime-plugin-support.bun.ts",
"node": "./src/plugin/runtime-plugin-support.node.ts",
"default": "./src/plugin/runtime-plugin-support.node.ts"
},
"#plugin-loader": {
"bun": "./src/plugin/loader.bun.ts",
"node": "./src/plugin/loader.node.ts",
"default": "./src/plugin/loader.node.ts"
}
},
"dependencies": {
@@ -92,7 +97,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)
}
+18 -6
View File
@@ -37,8 +37,8 @@ export function DialogOpen() {
const dimensions = useTerminalDimensions()
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
data.project.invalidate()
void data.project.sync().catch(() => {})
// One background fetch fills in recent sessions from other projects; the menu renders
@@ -52,8 +52,12 @@ export function DialogOpen() {
{ initialValue: [] },
)
const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID)))
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const openTabs = createMemo(
() => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []),
)
const currentSessionID = createMemo(() =>
route.data.type === "session" ? data.session.root(route.data.sessionID) : undefined,
)
const sessions = createMemo(() => {
const seen = new Set<string>()
return [...data.session.list(), ...fetched()]
@@ -69,7 +73,7 @@ export function DialogOpen() {
const tabs = openTabs()
// With an empty query the menu shows what is not already one keystroke away: open tabs are
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
// matching a tab by name still switches to it.
// matching a loaded tab by name still switches to it.
const recent = filter().trim()
? sessions()
: sessions()
@@ -78,7 +82,9 @@ export function DialogOpen() {
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
const running =
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
value: { type: "session", sessionID: session.id } as OpenTarget,
@@ -98,7 +104,7 @@ export function DialogOpen() {
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
@@ -113,6 +119,10 @@ export function DialogOpen() {
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
category: "Projects",
gutter:
project.canonical === current?.canonical
? () => <text fg={theme.text.formfield.selected}></text>
: undefined,
}
})
@@ -128,6 +138,8 @@ export function DialogOpen() {
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
@@ -89,7 +89,8 @@ export function DialogSessionList() {
const sessions = createMemo(() => {
const query = filter().trim()
const local = localSessions()
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
if (query !== search().trim()) return searchResults.latest?.sessions ?? local
if (searchResults.loading) return searchResults.latest?.sessions ?? []
const result = searchResults()
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
return result.sessions
@@ -154,11 +155,13 @@ export function DialogSessionList() {
footer,
bg: deleting ? theme.background.action.destructive.focused : undefined,
fg: deleting ? theme.text.action.destructive.focused : undefined,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
gutter:
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
}
}
+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()))
-9
View File
@@ -194,10 +194,6 @@ export function resolveZedDbPath() {
return candidates.find((item) => isFile(item))
}
export function isZedTerminal() {
return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
}
function isFile(item: string) {
try {
return statSync(item).isFile()
@@ -220,11 +216,6 @@ function zedWorkspacePaths(value: string | null) {
return value.split(/\r?\n/).filter(Boolean)
}
export function offsetToPosition(text: string, offset: number) {
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
return offsetsToSelection(text, stringOffset, stringOffset).start
}
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
if (byteOffset <= 0) return 0
@@ -1,23 +1,6 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { stringWidth } from "../../util/string-width"
import { FadeFilePath } from "../../ui/fade-file-path"
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const directory = createMemo(() =>
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
)
return (
<FadeFilePath
value={directory()}
maxWidth={props.maxWidth}
fg={props.context.theme.text.subdued}
bg={props.context.theme.background.default}
/>
)
}
function Mcp(props: { context: Plugin.Context }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
@@ -53,34 +36,26 @@ function Mcp(props: { context: Plugin.Context }) {
function View(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
if (list.length === 0) return 0
const count = list.filter((item) => item.status.status === "connected").length
return stringWidth(`${count} MCP /status`) + 2
})
return (
<box
width="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
>
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(props.context.app.version) - mcpWidth())}
/>
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
<Show when={dimensions().height >= 12 && dimensions().width >= 44}>
<box
width="100%"
paddingTop={dimensions().height < 16 ? 0 : 1}
paddingBottom={dimensions().height < 16 ? 0 : 1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
>
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
</box>
</box>
</Show>
)
}
@@ -1,6 +1,7 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { contextUsage, formatContextUsage } from "../../util/session"
import { useTerminalDimensions } from "@opentui/solid"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
@@ -8,6 +9,7 @@ const money = new Intl.NumberFormat("en-US", {
})
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const dimensions = useTerminalDimensions()
const activeSubagents = createMemo(() => {
if (!props.sessionID) return 0
return props.context.data.session
@@ -60,19 +62,24 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
<Show when={status().length > 0}>{status().join(" · ")}</Show>
</text>
</Match>
<Match when={true}>
<Match when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
<Show when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
</Show>
</Match>
<Match when={props.mode === "shell"}>
<text fg={props.context.theme.text.default} flexShrink={0}>
esc <span style={{ fg: props.context.theme.text.subdued }}>exit shell mode</span>
esc{" "}
<span style={{ fg: props.context.theme.text.subdued }}>
{dimensions().width < 44 ? "shell" : "exit shell mode"}
</span>
</text>
</Match>
</Switch>
@@ -1,21 +0,0 @@
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
function View() {
const context = usePlugin()
const theme = context.theme
return (
<box>
<text fg={theme.text.default}>
<b>LSP</b>
</text>
<text fg={theme.text.subdued}>LSP status unavailable</text>
</box>
)
}
export default Plugin.define({
id: "opencode.sidebar-lsp",
setup(context) {
context.ui.slot("sidebar.content", () => <View />)
},
})
@@ -141,22 +141,6 @@ export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], sele
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
}
export function moveFileTreeSelectionToFile(
rows: readonly FileTreeRow[],
selected: number | undefined,
offset: number,
) {
const fileRows = rows.filter((row) => row.fileIndex !== undefined)
if (fileRows.length === 0) return undefined
const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
const next =
offset < 0
? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
: fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
}
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return undefined
@@ -2,13 +2,7 @@
import type { FileDiffInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
import {
TextAttributes,
type BorderSides,
type BoxRenderable,
type DiffRenderable,
type ScrollBoxRenderable,
} from "@opentui/core"
import { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core"
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
import { useTerminalDimensions } from "@opentui/solid"
import path from "path"
@@ -19,6 +13,7 @@ import { DialogSelect } from "../../ui/dialog-select"
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
import { useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@@ -154,7 +149,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
const helpShortcut = shortcut("diff.help")
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const diffNodeByFileIndex = new Map<number, DiffRenderable>()
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>()
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
@@ -270,17 +265,16 @@ function DiffViewer(props: { context: Plugin.Context }) {
if (!patchScroll) return
const hunks = visiblePatchFiles()
.flatMap((entry) => {
const node = diffNodeByFileIndex.get(entry.fileIndex)
if (!node || node.isDestroyed) return []
const contentY = patchScroll.scrollTop + node.y - patchScroll.viewport.y
return node.diff
.split("\n")
.flatMap((line, row) => (line.startsWith("@@") ? [row] : []))
.map((row, hunkIndex) => ({
fileIndex: entry.fileIndex,
hunkIndex,
contentY: contentY + row,
}))
return (
patchDiffByFileIndex
.get(entry.fileIndex)
?.hunks()
.map((node, hunkIndex) => ({
fileIndex: entry.fileIndex,
hunkIndex,
contentY: patchScroll.scrollTop + node.y - patchScroll.viewport.y - (hunkIndex > 0 ? 1 : 0),
})) ?? []
)
})
.sort((left, right) => left.contentY - right.contentY)
const selected = selectedHunk()
@@ -831,9 +825,10 @@ function DiffViewer(props: { context: Plugin.Context }) {
>
{(patch) => (
<box border={patchLeftBorder()} borderColor={theme.border.default}>
<diff
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
<PatchDiff
ref={(component) => patchDiffByFileIndex.set(entry.fileIndex, component)}
diff={patch()}
hunkFg={reviewed() ? theme.text.subdued : theme.diff.text.hunkHeader}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={currentSyntax()}
@@ -847,9 +842,15 @@ function DiffViewer(props: { context: Plugin.Context }) {
removedBg={
reviewed() ? theme.background.surface.overlay : theme.diff.background.removed
}
contextBg={
reviewed() ? theme.background.surface.overlay : theme.diff.background.context
}
addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added}
removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
lineNumberBg={
reviewed() ? theme.background.surface.overlay : theme.diff.background.context
}
addedLineNumberBg={
reviewed()
? theme.background.surface.overlay
-2
View File
@@ -7,5 +7,3 @@ export const go = {
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
}
export const marks = "_^~,"
+3 -1
View File
@@ -32,6 +32,7 @@ import { footerWidthPolicy } from "./footer.width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { MiniPermissionRequest, PermissionReply } from "./types"
import { PatchDiff } from "../component/patch-diff"
function buttons(
list: PermissionOption[],
@@ -405,8 +406,9 @@ export function RunPermissionBody(props: {
</Show>
}
>
<diff
<PatchDiff
diff={info().diff!}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
+3 -1
View File
@@ -13,6 +13,7 @@ import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { toolFiletype, toolStructuredFinal } from "./tool"
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit, TurnSummary } from "./types"
import { PatchDiff } from "../component/patch-diff"
export function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
@@ -178,8 +179,9 @@ export function RunEntryContent(props: {
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<diff
<PatchDiff
diff={item.diff}
hunkFg={theme().block.diffLineNumber}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={syntax()}
+274
View File
@@ -0,0 +1,274 @@
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
import { useRenderer } from "@opentui/solid"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiApp, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useThemes } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { abbreviateHome } from "../util/path-format"
export type Dispose = () => Promise<void>
// The provider's registration store, narrowed to what a plugin context needs:
// route/slot registration lands there, but ordering and lifecycle stay owned
// by the provider.
export type Registry = {
has(kind: "routes" | "slots", name: string): boolean
set(kind: "routes", name: string, page: Page): void
set(kind: "slots", name: string, slot: Slot): void
remove(kind: "routes" | "slots", name: string): void
active(): boolean
}
// The host services a plugin context adapts. Collected once by the provider
// (hooks must run during component setup) and shared by every activation.
export function usePluginHost() {
return {
renderer: useRenderer(),
client: useClient(),
data: useData(),
route: useRoute(),
keymap: Keymap.use(),
shortcuts: Keymap.useShortcuts(),
keymapState: Keymap.useState(),
app: useTuiApp(),
paths: useTuiPaths(),
location: useLocation(),
themes: useThemes(),
dialog: useDialog(),
toast: useToast(),
attention: useAttention(),
storage: useStorage(),
sessionTabs: useSessionTabs(),
}
}
// Build the API surface handed to one plugin activation: host services
// adapted to the plugin contract, with everything registered through it
// unwinding via `owned` when the activation is disposed.
export function createPluginContext(input: {
host: ReturnType<typeof usePluginHost>
id: string
options: Readonly<Record<string, any>> | undefined
owned: Dispose[]
registry: Registry
}): Context {
const host = input.host
let context: Context
// Every dialog and registered render is wrapped so plugin components can
// reach their own context through usePlugin().
const provide = (render: () => JSX.Element) => (
<PluginContextProvider value={context}>{render()}</PluginContextProvider>
)
const dialogApi = createDialogApi(host.dialog, provide)
const toastApi: Toast = {
show(options) {
host.toast.show({ ...options, variant: options.variant ?? "info" })
},
}
// Unregistering after deactivation is a no-op: deactivate already resets
// the registration's routes and slots wholesale.
const registration = (kind: "routes" | "slots", name: string) => {
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!input.registry.active()) return
input.registry.remove(kind, name)
}
input.owned.push(async () => unregister())
return unregister
}
context = {
options: input.options ?? {},
get location() {
return host.location.current
},
app: { version: host.app.version, channel: host.app.channel },
renderer: host.renderer,
client: host.client.api,
data: host.data,
attention: host.attention,
get theme() {
return host.themes.currentTokens()
},
keymap: {
layer: Keymap.createLayer,
dispatch: host.keymap.dispatch,
shortcuts: host.shortcuts.list,
commands: host.keymapState.commands,
pending: host.keymapState.pending,
active: host.keymapState.active,
mode: host.keymap.mode,
},
storage: {
store: (key, options) => host.storage.store(`plugin.${input.id}.${key}`, options),
memory: (key, options) => host.storage.memory(`plugin.${input.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
toast: toastApi,
format: {
path: (value) => abbreviateHome(value, host.paths.home),
},
router: {
register(page) {
if (input.registry.has("routes", page.name)) throw new Error(`Route already registered: ${page.name}`)
input.registry.set("routes", page.name, {
...page,
render: (data) => provide(() => page.render(data)),
})
return registration("routes", page.name)
},
navigate(destination) {
if (destination.type === "plugin") {
host.route.navigate({ ...destination, id: "id" in destination ? destination.id : input.id })
return
}
host.route.navigate(destination)
},
current() {
return host.route.data
},
},
tabs: {
enabled: host.sessionTabs.enabled,
list: () =>
host.sessionTabs.tabs().map((tab) => ({
...tab,
active: host.sessionTabs.current() === tab.sessionID,
...host.sessionTabs.status(tab.sessionID),
})),
open(sessionID) {
if (!host.sessionTabs.enabled()) return false
host.sessionTabs.select(sessionID)
return true
},
focus(sessionID) {
if (!host.sessionTabs.enabled()) return false
if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
host.sessionTabs.select(sessionID)
return true
},
close(sessionID) {
if (!host.sessionTabs.enabled()) return false
const target = sessionID ?? host.sessionTabs.current()
if (!target || !host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
host.sessionTabs.close(target)
return true
},
},
slot(name, render) {
if (input.registry.has("slots", name)) throw new Error(`Slot already registered: ${name}`)
// The registration map erases the slot-specific input type.
input.registry.set("slots", name, ((slotInput: SlotMap[typeof name]) =>
provide(() => render(slotInput))) as Slot)
return registration("slots", name)
},
},
}
return context
}
// A dialog promise must settle exactly once even when confirm and close
// callbacks both fire.
function settle<T>(resolve: (value: T) => void) {
let settled = false
return (value: T) => {
if (settled) return
settled = true
resolve(value)
}
}
function createDialogApi(dialog: ReturnType<typeof useDialog>, provide: (render: () => JSX.Element) => JSX.Element) {
const api: Dialog = {
show(render, onClose) {
dialog.replace(() => provide(render), onClose)
},
set(options) {
dialog.setSize(options.size ?? "medium")
dialog.setCentered(options.centered ?? false)
},
clear() {
dialog.clear()
},
alert(options) {
return new Promise<void>((resolve) => {
const done = settle(resolve)
api.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
})
},
confirm(options) {
return new Promise<boolean | undefined>((resolve) => {
const done = settle(resolve)
api.show(
() => (
<DialogConfirm
title={options.title}
message={options.message}
label={options.label}
onConfirm={() => done(true)}
onCancel={() => done(false)}
/>
),
() => done(undefined),
)
})
},
prompt(options) {
return new Promise<string | undefined>((resolve) => {
const done = settle(resolve)
api.show(
() => (
<DialogPrompt
title={options.title}
description={options.description ? () => <text>{options.description}</text> : undefined}
placeholder={options.placeholder}
value={options.value}
onConfirm={(value) => {
done(value)
api.clear()
}}
/>
),
() => done(undefined),
)
})
},
select(options) {
return new Promise((resolve) => {
const done = settle<(typeof options.options)[number]["value"] | undefined>(resolve)
api.show(
() => (
<DialogSelect
title={options.title}
placeholder={options.placeholder}
options={options.options.map((option) => ({ ...option }))}
current={options.current}
onSelect={(option) => {
done(option.value)
api.clear()
}}
/>
),
() => done(undefined),
)
})
},
}
return api
}
-2
View File
@@ -2,7 +2,6 @@ import HomeFooter from "../feature-plugins/home/footer"
import PromptFooter from "../feature-plugins/prompt/footer"
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
@@ -14,7 +13,6 @@ export const builtins = [
PromptFooter,
SidebarContext,
SidebarMcp,
SidebarLsp,
SidebarFooter,
Notifications,
Plugins,
+279 -373
View File
@@ -1,51 +1,26 @@
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
import {
batch,
createContext,
createEffect,
createMemo,
For,
on,
onCleanup,
onMount,
useContext,
type JSX,
type ParentProps,
} from "solid-js"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { batch, createContext, createEffect, on, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { useRenderer } from "@opentui/solid"
import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
import { preparePlugin } from "#plugin-loader"
import { useConfig } from "../config"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useThemes } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { abbreviateHome } from "../util/path-format"
import { useTuiLifecycle } from "../context/runtime"
import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { discoverTuiPlugins } from "./discovery"
import { createPluginContext, usePluginHost, type Dispose } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
}
type State =
| { readonly target: string; readonly status: "loading" }
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
@@ -61,15 +36,16 @@ type Value = {
readonly list: () => ReadonlyArray<State>
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
}
type Dispose = () => Promise<void>
type Registration = {
plugin: Plugin.Definition
source: RegisteredPlugin["source"]
target?: string
version: string
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -77,27 +53,15 @@ type Registration = {
cleanups: Dispose[]
}
// One entry of the desired plugin generation produced by the resolve phase.
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
const renderer = useRenderer()
const client = useClient()
const data = useData()
const route = useRoute()
const host = usePluginHost()
const config = useConfig()
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const keymapState = Keymap.useState()
const lifecycle = useTuiLifecycle()
const app = useTuiApp()
const paths = useTuiPaths()
const location = useLocation()
const themes = useThemes()
const dialog = useDialog()
const toast = useToast()
const attention = useAttention()
const storage = useStorage()
const sessionTabs = useSessionTabs()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -115,223 +79,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
let context: Context
const dialogApi: Dialog = {
show(render, onClose) {
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
const context = createPluginContext({
host,
id,
options: item.options,
owned,
registry: {
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
set: (kind: "routes" | "slots", name: string, value: Page | Slot) =>
setStore("registrations", id, kind, name, () => value),
remove: (kind, name) =>
setStore(
"registrations",
produce((registrations) => {
if (!registrations[id]) return
delete registrations[id][kind][name]
}),
),
active: () => Boolean(store.registrations[id]?.active),
},
set(options) {
dialog.setSize(options.size ?? "medium")
dialog.setCentered(options.centered ?? false)
},
clear() {
dialog.clear()
},
alert(options) {
return new Promise<void>((resolve) => {
let settled = false
const done = () => {
if (settled) return
settled = true
resolve()
}
dialogApi.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
})
},
confirm(options) {
return new Promise<boolean | undefined>((resolve) => {
let settled = false
const done = (result: boolean | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogConfirm
title={options.title}
message={options.message}
label={options.label}
onConfirm={() => done(true)}
onCancel={() => done(false)}
/>
),
() => done(undefined),
)
})
},
prompt(options) {
return new Promise<string | undefined>((resolve) => {
let settled = false
const done = (result: string | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogPrompt
title={options.title}
description={options.description ? () => <text>{options.description}</text> : undefined}
placeholder={options.placeholder}
value={options.value}
onConfirm={(value) => {
done(value)
dialogApi.clear()
}}
/>
),
() => done(undefined),
)
})
},
select(options) {
return new Promise((resolve) => {
let settled = false
const done = (result: (typeof options.options)[number]["value"] | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogSelect
title={options.title}
placeholder={options.placeholder}
options={options.options.map((option) => ({ ...option }))}
current={options.current}
onSelect={(option) => {
done(option.value)
dialogApi.clear()
}}
/>
),
() => done(undefined),
)
})
},
}
const toastApi: Toast = {
show(options) {
toast.show({ ...options, variant: options.variant ?? "info" })
},
}
context = {
options: item.options ?? {},
get location() {
return location.current
},
app: { version: app.version, channel: app.channel },
renderer,
client: client.api,
data,
attention,
get theme() {
return themes.currentTokens()
},
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
shortcuts: shortcuts.list,
commands: keymapState.commands,
pending: keymapState.pending,
active: keymapState.active,
mode: keymap.mode,
},
storage: {
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
toast: toastApi,
format: {
path: (value) => abbreviateHome(value, paths.home),
},
router: {
register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name])
throw new Error(`Route already registered: ${page.name}`)
setStore("registrations", item.plugin.id, "routes", page.name, {
...page,
render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
})
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!store.registrations[item.plugin.id]?.active) return
setStore(
"registrations",
produce((registrations) => {
if (!registrations[item.plugin.id]) return
delete registrations[item.plugin.id].routes[page.name]
}),
)
}
owned.push(async () => unregister())
return unregister
},
navigate(destination) {
if (destination.type === "plugin") {
route.navigate({ ...destination, id: "id" in destination ? destination.id : item.plugin.id })
return
}
route.navigate(destination)
},
current() {
return route.data
},
},
tabs: {
enabled: sessionTabs.enabled,
list: () =>
sessionTabs.tabs().map((tab) => ({
...tab,
active: sessionTabs.current() === tab.sessionID,
...sessionTabs.status(tab.sessionID),
})),
open(sessionID) {
if (!sessionTabs.enabled()) return false
sessionTabs.select(sessionID)
return true
},
focus(sessionID) {
if (!sessionTabs.enabled()) return false
if (!sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
sessionTabs.select(sessionID)
return true
},
close(sessionID) {
if (!sessionTabs.enabled()) return false
const target = sessionID ?? sessionTabs.current()
if (!target || !sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
sessionTabs.close(target)
return true
},
},
slot(name, render) {
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
<PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
))
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!store.registrations[item.plugin.id]?.active) return
setStore(
"registrations",
produce((registrations) => {
if (!registrations[item.plugin.id]) return
delete registrations[item.plugin.id].slots[name]
}),
)
}
owned.push(async () => unregister())
return unregister
},
},
}
})
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
@@ -374,94 +141,219 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
return true
}
const reconcile = async (configured = config.data.plugins ?? []) => {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
// Cleanup failures must not stop a swap or teardown, but they should not
// vanish either: the old generation may still own listeners or intervals.
const deactivateNoisily = (id: string) =>
deactivate(id).catch((error) =>
host.toast.show({ variant: "error", title: "Plugin", message: `${id}: cleanup failed: ${errorMessage(error)}` }),
)
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
batch(() => {
setStore("registrations", reconcileStore({}))
setStore("states", [])
})
for (const plugin of builtins) {
setStore("registrations", plugin.id, {
plugin,
source: "builtin",
active: false,
routes: {},
slots: {},
cleanups: [],
})
await activate(plugin.id)
}
// Every lifecycle mutation — reconciles, manual dialog toggles, shutdown —
// is serialized through one chain so generations can never interleave.
let loading = Promise.resolve()
const enqueue = <T,>(task: () => Promise<T>) => {
const result = loading.catch(() => undefined).then(task)
loading = result.then(
() => undefined,
() => undefined,
)
return result
}
// Hot-reload local plugin sources: watch the discovery directory and any
// local entrypoints (see watch.ts for the mechanics), debounced into a
// serialized reconcile so bursts of events rebuild the generation once.
let pending: ReturnType<typeof setTimeout> | undefined
const watcher = createSourceWatcher(() => {
clearTimeout(pending)
pending = setTimeout(() => {
// Observe failures immediately: a plugin cleanup that throws would
// otherwise surface as an unhandled rejection until the next trigger.
void enqueue(reconcile).catch(() => undefined)
}, 100)
})
onCleanup(() => {
clearTimeout(pending)
watcher.dispose()
})
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
// core plugin registry: fold the ordered entries into a desired end state
// (importing only new or changed sources, before anything running is
// touched), no-op when the generation is unchanged, and restart only the
// plugins that differ. Membership or order changes rebuild the whole
// generation to preserve slot-order semantics.
// Package resolution failures would otherwise retry a full npm install on
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
const reconcile = async () => {
const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
watcher.add(tuiPluginDirectory(host.paths.cwd))
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
const desired = new Map<string, Desired>()
for (const plugin of builtins) desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
const failures: State[] = []
for (const entry of entries) {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-")) {
for (const id of Object.keys(store.registrations).filter((id) => matches(target.slice(1), id)))
await deactivate(id)
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
continue
}
const selected = Object.keys(store.registrations).filter((id) => matches(target, id))
const selected = [...desired.values()].filter((item) => matches(target, item.plugin.id))
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
for (const id of selected) await activate(id)
for (const item of selected) item.enabled = true
continue
}
const options = typeof entry === "string" ? undefined : entry.options
setStore("states", (items) => [...items, { target, status: "loading" }])
const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
setStore("states", (items) =>
items.map((state) =>
state.target === target
? { target, status: "failed", error: error instanceof Error ? error.message : String(error) }
: state,
),
)
return undefined
})
if (!plugin) {
setStore("states", (items) =>
items.map((state) =>
state.target === target && state.status !== "failed" ? { target, status: "unsupported" } : state,
),
)
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
const local = localSource(target, directory)
if (local) watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages, host.paths.state).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
if (resolved.status === "unsupported") {
failures.push({ target, status: "unsupported" })
continue
}
setStore("registrations", plugin.id, {
plugin,
if (resolved.status === "failed") {
if (!local && !previous) npmFailures.set(target, resolved.error)
failures.push({
target,
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
})
if (previous)
desired.set(previous.plugin.id, {
plugin: previous.plugin,
source: previous.source,
target,
version: previous.version,
options: previous.options,
enabled: previous.active,
})
continue
}
desired.set(resolved.plugin.id, {
plugin: resolved.plugin,
source: "external",
target,
version: resolved.version,
options,
active: false,
routes: {},
slots: {},
cleanups: [],
enabled: true,
})
const error = await activate(plugin.id).then(
() => undefined,
(error) => (error instanceof Error ? error.message : String(error)),
)
setStore("states", (items) => [
...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
error
? { target, status: "failed", error }
: { target, id: plugin.id, status: "active" },
])
}
// Compare: unchanged plugins are never touched, and a fully unchanged
// generation is a no-op, so spurious watch events cost nothing.
const currentIds = Object.keys(store.registrations)
const desiredIds = [...desired.keys()]
const structural = currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
if (structural) {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivateNoisily(id)),
)
setStore("registrations", reconcileStore({}))
}
const changed = structural
? desiredIds
: desiredIds.filter((id) => {
const registration = store.registrations[id]!
const item = desired.get(id)!
// enabled derives from config directives alone, so config wins over
// manual dialog toggles on every reconcile — the same semantics
// config saves had before hot reload existed, just more frequent.
return (
registration.version !== item.version ||
!sameOptions(registration.options, item.options) ||
registration.active !== item.enabled
)
})
// Swap: cleanup failures surface as a toast, never propagate, so one
// broken plugin cannot take the rest of the generation down.
const errors = new Map<string, string>()
for (const id of changed) {
const item = desired.get(id)!
const registration = store.registrations[id]
const replaced =
!registration || registration.version !== item.version || !sameOptions(registration.options, item.options)
// Snapshot the running version before it is overwritten: an import
// failure keeps last-good in the resolve phase, and a setup failure
// must not cost the previous version either.
const fallback: Desired | undefined =
replaced && registration
? {
plugin: registration.plugin,
source: registration.source,
target: registration.target,
version: registration.version,
options: registration.options,
enabled: registration.active,
}
: undefined
if (replaced) {
if (registration) await deactivateNoisily(id)
// In-place replacement keeps the registration's key position, which
// slot ordering (mode "replace" takes the last one) depends on.
setStore("registrations", id, toRegistration(item))
}
if (!item.enabled) {
await deactivateNoisily(id)
continue
}
const error = await activate(id).then(() => undefined, errorMessage)
if (!error) continue
errors.set(id, error)
if (!fallback) continue
setStore("registrations", id, toRegistration(fallback))
if (!fallback.enabled) continue
const restored = await activate(id).then(
() => true,
() => false,
)
if (restored) errors.set(id, `${error} (previous version still active)`)
}
const failedTargets = new Set(failures.map((failure) => failure.target))
const states: State[] = [
...[...desired.values()].flatMap((item): State[] => {
if (item.target === undefined) return []
// A failed reload keeps this item running; the failure entry covers it.
if (failedTargets.has(item.target)) return []
const error = errors.get(item.plugin.id)
if (error) return [{ target: item.target, status: "failed", error }]
const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
return [{ target: item.target, id: item.plugin.id, status }]
}),
...failures,
]
// Surface newly failing plugins; repeated reconciles stay silent.
for (const state of states)
if (
state.status === "failed" &&
!store.states.some((prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error)
)
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
setStore("states", reconcileStore(states))
}
let loading = Promise.resolve()
const slotItems = new WeakMap<Slot, { readonly id: string; readonly render: Slot }>()
createEffect(
on(
() => JSON.stringify(config.data.plugins ?? []),
() => {
const configured = config.data.plugins ?? []
loading = loading.catch(() => undefined).then(() => reconcile(configured))
void loading.then(
npmFailures.clear()
void enqueue(reconcile).then(
() => setStore("ready", true),
() => setStore("ready", true),
)
@@ -478,7 +370,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
.map(([id]) => deactivate(id).catch(() => undefined)),
),
)
.then(() => setStore("registrations", reconcileStore({})))
@@ -500,11 +392,22 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slot: (name) =>
Object.values(store.registrations).flatMap((registration) =>
registration.active && registration.slots[name] ? [registration.slots[name]] : [],
),
activate,
deactivate,
Object.entries(store.registrations).flatMap(([id, registration]) => {
const render = registration.active ? registration.slots[name] : undefined
if (!render) return []
// <For> diffs rows by reference; a stable wrapper per render
// function keeps untouched plugins' slot rows (and their state)
// alive across other plugins' reloads.
const cached = slotItems.get(render)
if (cached) return [cached]
const item = { id, render }
slotItems.set(render, item)
return [item]
}),
// Manual dialog toggles join the same chain as reconciles so a
// toggle mid-reload cannot mix registrations across generations.
activate: (id) => enqueue(() => activate(id)),
deactivate: (id) => enqueue(() => deactivate(id)),
}}
>
{props.children}
@@ -531,17 +434,46 @@ function matches(selector: string, id: string) {
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
}
async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
const local = spec.startsWith("file://")
? new URL(spec)
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
? pathToFileURL(path.resolve(directory, spec))
: undefined
async function resolvePlugin(
spec: string,
local: URL | undefined,
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
state: string,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
if (!entrypoint) return
const mod: { readonly default?: unknown } = await import(entrypoint)
if (!entrypoint) return { status: "unsupported" as const }
// The cache-busted specifier doubles as the version: unique per entrypoint
// and mtime, so equal versions mean an identical module.
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(await preparePlugin(entrypoint, version, state))
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return mod.default
return { status: "loaded" as const, plugin: mod.default, version }
}
function toRegistration(item: Desired): Registration {
return {
plugin: item.plugin,
source: item.source,
target: item.target,
version: item.version,
options: item.options,
active: false,
routes: {},
slots: {},
cleanups: [],
}
}
function sameOptions(a: Registration["options"], b: Registration["options"]) {
return isDeepEqual(a ?? null, b ?? null)
}
async function resolveLocal(url: URL) {
@@ -576,29 +508,3 @@ export function usePlugin() {
if (!value) throw new Error("PluginProvider is missing")
return value
}
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
const plugins = usePlugin()
const route = useRoute()
const content = createMemo(() => {
if (route.data.type !== "plugin") return
const render = plugins.route(route.data.id, route.data.name)
if (!render) return props.fallback(route.data.id, route.data.name)
return render({ data: route.data.data })
})
return <>{content()}</>
}
export function PluginSlot<Name extends SlotName>(props: {
readonly name: Name
readonly input: SlotMap[Name]
readonly mode: "all" | "replace"
}) {
const plugins = usePlugin()
const renderers = createMemo(() => {
const items = plugins.slot(props.name)
if (props.mode === "replace") return items.slice(-1)
return items
})
return <For each={renderers()}>{(render) => render(props.input)}</For>
}
+22 -1
View File
@@ -1,10 +1,15 @@
import { readdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
export function tuiPluginDirectory(cwd: string) {
return path.join(cwd, ".opencode", "plugins", "tui")
}
export async function discoverTuiPlugins(cwd: string) {
const directory = path.join(cwd, ".opencode", "plugins", "tui")
const directory = tuiPluginDirectory(cwd)
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
return Promise.reject(error)
@@ -14,3 +19,19 @@ export async function discoverTuiPlugins(cwd: string) {
.map((entry) => path.join(directory, entry.name))
.sort()
}
export function localSource(spec: string, directory: string) {
if (spec.startsWith("file://")) return new URL(spec)
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
return pathToFileURL(path.resolve(directory, spec))
return undefined
}
// Key local plugin imports by mtime so edited sources re-import fresh instead
// of hitting the ESM cache. Bun ignores query params when caching file:// URL
// imports, so bust with a plain path there; Node keys its cache on the full
// URL. Mirrors the core plugin supervisor's loader.
export function freshSpecifier(entrypoint: string, mtime: number) {
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${mtime}`
return `${entrypoint}?mtime=${mtime}`
}
+44
View File
@@ -0,0 +1,44 @@
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import { isCoreRuntimeModuleSpecifier, runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const runtime = new Set([
"@opentui/solid",
"@opentui/solid/components",
"@opentui/solid/jsx-runtime",
"@opentui/solid/jsx-dev-runtime",
"solid-js",
"solid-js/store",
])
export async function preparePlugin(entrypoint: string, version: string, state: string) {
const source = fileURLToPath(entrypoint)
if (!source.endsWith(".tsx") && !source.endsWith(".jsx")) return version
const result = await Bun.build({
entrypoints: [source],
target: "bun",
format: "esm",
sourcemap: "inline",
plugins: [
createSolidTransformPlugin({
moduleName: runtimeModuleIdForSpecifier("@opentui/solid"),
resolvePath(specifier) {
if (!runtime.has(specifier) && !isCoreRuntimeModuleSpecifier(specifier)) return null
return runtimeModuleIdForSpecifier(specifier)
},
}),
],
external: [...runtime, "@opentui/core", "@opentui/core/testing"].flatMap((specifier) => [
specifier,
runtimeModuleIdForSpecifier(specifier),
]),
})
if (!result.success) throw new Error(result.logs.join("\n"))
const directory = path.join(state, "tui-plugin-cache")
const output = path.join(directory, `${Bun.hash(version).toString(16)}.mjs`)
await mkdir(directory, { recursive: true })
await Bun.write(output, result.outputs[0]!)
return output
}
+3
View File
@@ -0,0 +1,3 @@
export async function preparePlugin(_entrypoint: string, version: string, _state: string) {
return version
}
+84
View File
@@ -0,0 +1,84 @@
import { createComponent, createMemo, ErrorBoundary, For, mergeProps, onMount, Show, type JSX, type ParentProps } from "solid-js"
import type { SlotMap, SlotName } from "@opencode-ai/plugin/tui/context"
import { useRoute } from "../context/route"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
import { usePlugin } from "./context"
// Contain render-time plugin crashes: a throwing slot or route must not take
// down the app or the other plugins. The crash surfaces as one error toast.
function PluginBoundary(props: ParentProps<{ id: string; where: string }>) {
const toast = useToast()
return (
<ErrorBoundary
fallback={(error) => {
// One toast per crash: onMount is untracked, so prop updates while
// the boundary is latched cannot re-toast.
onMount(() =>
toast.show({
variant: "error",
title: "Plugin",
message: `${props.id} crashed in ${props.where}: ${errorMessage(error)}`,
}),
)
return null
}}
>
{props.children}
</ErrorBoundary>
)
}
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
const plugins = usePlugin()
const route = useRoute()
const current = createMemo(() => {
if (route.data.type !== "plugin") return
return {
id: route.data.id,
name: route.data.name,
render: plugins.route(route.data.id, route.data.name),
data: route.data.data,
}
})
return (
// Keyed so navigation or a hot reload recreates the boundary; otherwise
// one crash would latch every future plugin route into the fallback.
<Show keyed when={current()}>
{(item) => (
<PluginBoundary id={item.id} where="route">
{item.render ? createComponent(item.render, { data: item.data }) : props.fallback(item.id, item.name)}
</PluginBoundary>
)}
</Show>
)
}
export function PluginSlot<Name extends SlotName>(props: {
readonly name: Name
readonly input: SlotMap[Name]
readonly mode: "all" | "replace"
}) {
const plugins = usePlugin()
const renderers = createMemo(() => {
const items = plugins.slot(props.name)
if (props.mode === "replace") return items.slice(-1)
return items
})
return (
<For each={renderers()}>
{(item) => (
<PluginBoundary id={item.id} where={`slot ${props.name}`}>
{
// Component semantics: the render body runs once and untracked, so
// signals and intervals created inside are stable, while props stay
// reactive through the merged getter. A bare item.render(props.input)
// call would run inside the host's tracked scope and re-execute the
// whole body (resetting plugin state) on every tracked read.
createComponent(item.render, mergeProps(() => props.input) as SlotMap[Name])
}
</PluginBoundary>
)}
</For>
)
}
+75
View File
@@ -0,0 +1,75 @@
import path from "path"
import { existsSync, watch } from "fs"
import { lstat, realpath, stat } from "fs/promises"
// Watch plugin sources for changes. Files are watched through their parent
// directory (editors that save by rename replace the inode, which silently
// kills a direct file watch) and filtered by basename so bursts in busy
// directories stay quiet. Symlinked files are additionally watched at their
// resolved target, since edits there emit nothing at the link's location.
// Directory targets are watched at their root only: edits to nested helper
// files do not change the entrypoint mtime and are not detected. Watches are
// never torn down individually (a stale watch costs one fs handle and a
// spurious onChange); all die with dispose(). Failed or vanished watches are
// forgotten so a later add() can re-arm once the path exists.
export function createSourceWatcher(onChange: () => void) {
const watchers = new Map<string, ReturnType<typeof watch>>()
const watched = new Map<string, Set<string> | null>()
let disposed = false
const forget = (dir: string) => {
watchers.get(dir)?.close()
watchers.delete(dir)
watched.delete(dir)
}
const arm = (target: string) => {
stat(target)
.then((info) => {
if (disposed) return
const dir = info.isDirectory() ? target : path.dirname(target)
// Directories accept every filename (null); files accept their basename.
const name = info.isDirectory() ? null : path.basename(target)
const existing = watched.get(dir)
if (existing !== undefined) {
if (name === null) watched.set(dir, null)
else existing?.add(name)
return
}
watched.set(dir, name === null ? null : new Set([name]))
const watcher = watch(dir, (_event, filename) => {
// A replaced directory keeps this watcher on the dead inode (Linux
// emits rename, not error); forget it so a later add() re-arms on
// the recreated path, and still schedule so reconcile runs now.
if (!existsSync(dir)) {
forget(dir)
onChange()
return
}
// A null filename (platform-dependent) always schedules.
const accept = watched.get(dir)
if (filename && accept && !accept.has(filename.toString())) return
onChange()
})
// A watched directory can disappear out from under us; without a
// listener the error event would crash the process. Forget the path
// so a later add can re-arm once it exists again.
watcher.on("error", () => forget(dir))
watchers.set(dir, watcher)
})
.catch(() => undefined)
}
const add = (target: string) => {
arm(target)
// A symlinked source receives edits at its resolved target.
lstat(target)
.then((info) => {
if (!info.isSymbolicLink()) return
return realpath(target).then(arm)
})
.catch(() => undefined)
}
const dispose = () => {
disposed = true
for (const watcher of watchers.values()) watcher.close()
}
return { add, dispose }
}

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