Compare commits

...
Author SHA1 Message Date
Kit Langton a474e92433 test: migrate config template fixtures 2026-05-18 19:13:47 -04:00
Kit LangtonandGitHub ee5cf45ef9 Migrate simple config tests to instance fixtures (#28210) 2026-05-18 19:13:16 -04:00
opencode-agent[bot] 2e1593dea5 chore: generate 2026-05-18 22:33:32 +00:00
Kit LangtonandGitHub 0f3d168fdd test(cli): subprocess integration test harness + regression suite for opencode run (#28230) 2026-05-18 18:32:20 -04:00
opencode-agent[bot] ce09fc8356 chore: generate 2026-05-18 22:03:00 +00:00
Luke ParkerandGitHub 44a35c5895 test(app): add session timeline smoke coverage (#26619) 2026-05-18 22:01:29 +00:00
Kit LangtonandGitHub 8a321c4536 fix(native-llm): prefer console opencode token (#28237) 2026-05-18 17:23:57 -04:00
opencode ef9e567e5f sync release versions for v1.15.5 2026-05-18 20:57:13 +00:00
Kit LangtonandGitHub b396b71c6f fix(ui): guard reasoning renderer against undefined text (#28222) 2026-05-18 16:04:07 -04:00
Aiden ClineandGitHub d8efc575fa refactor(session): extract prompt tool resolution (#28204) 2026-05-18 14:50:31 -05:00
opencode-agent[bot] e1fbed8fb6 chore: update nix node_modules hashes 2026-05-18 19:12:18 +00:00
James LongandGitHub 12ae22378f fix(plugin): ask in tools from plugins returns promise instead of effect (#28217) 2026-05-18 18:57:29 +00:00
opencode-agent[bot] a88b436eec chore: generate 2026-05-18 18:42:55 +00:00
Kit LangtonandGitHub dbe36851bc Preview native LLM runtime stack (#27114) 2026-05-18 14:41:36 -04:00
James LongandGitHub ff9d7cab5c fix(core): fix file references in workspaces (#28209) 2026-05-18 18:23:35 +00:00
Kit LangtonandGitHub 88681d389b Migrate provider lookup tests to instance fixtures
Migrate provider env precedence, model lookup, and default model tests to Effect-aware instance fixtures while keeping behavior unchanged.
2026-05-18 18:01:53 +00:00
Kit LangtonandGitHub ae2ecd1ed3 Migrate custom provider tests to instance fixtures
Migrate the next custom provider/model config tests to Effect-aware instance fixtures while keeping timing neutral and behavior unchanged.
2026-05-18 17:49:26 +00:00
Kit LangtonandGitHub 159d271e1e refactor(sync): publish via EffectBridge.fork for codebase consistency (#28187) 2026-05-18 13:30:41 -04:00
Kit LangtonandGitHub 762850dfe5 Migrate provider config tests to instance fixtures
Convert the first provider env/config/filtering tests to Effect-aware instance fixtures while keeping behavior unchanged and documenting neutral timing.
2026-05-18 13:22:59 -04:00
Kit LangtonandGitHub b039702e8c feat(tui): add syntax highlighting for elixir, fsharp, r, make, vim, xml, agda (#28198) 2026-05-18 13:08:08 -04:00
opencode-agent[bot] 094886c848 chore: generate 2026-05-18 17:03:44 +00:00
Shoubhit DashandGitHub dc0297f4ac refactor(session): extract reference prompt helpers (#28197) 2026-05-18 22:32:18 +05:30
Kit LangtonandGitHub 3ab67f3280 Stabilize watcher test readiness (#28194) 2026-05-18 12:51:46 -04:00
Shoubhit DashandGitHub a2e6bd503b refactor(reference): split materialization state (#28190) 2026-05-18 22:14:33 +05:30
Aiden ClineandGitHub 05ac345696 refactor(session): move prompt reminders out of core loop (#28082) 2026-05-18 11:36:38 -05:00
opencode-agent[bot] e3feca09b3 chore: generate 2026-05-18 16:20:46 +00:00
Kit LangtonandGitHub 896ad7b884 Speed up targeted opencode tests
Reduce avoidable setup costs in slow opencode tests while preserving reviewed coverage and recording the benchmark evidence for follow-up test-suite work.
2026-05-18 16:18:29 +00:00
opencode-agent[bot] 0a945219a9 chore: generate 2026-05-18 16:17:46 +00:00
Shoubhit DashandGitHub 94828eb44b refactor(repository): type cache failures (#28188) 2026-05-18 21:46:18 +05:30
Shoubhit DashandGitHub 96192495ae refactor(repository): add cache service (#28184) 2026-05-18 21:25:38 +05:30
opencode-agent[bot] f7b5576bcc chore: generate 2026-05-18 15:39:33 +00:00
Kit LangtonandGitHub cb35493242 fix(bus): acquire PubSub subscription eagerly to close /event race (#27959) 2026-05-18 11:38:05 -04:00
Shoubhit DashandGitHub 5bfd7fd16c refactor(repository): clarify reference domain (#28182) 2026-05-18 21:04:44 +05:30
opencode-agent[bot] 2932b41e64 chore: generate 2026-05-18 15:14:13 +00:00
Shoubhit DashandGitHub eb389c58eb refactor(reference): normalize config entries (#28178) 2026-05-18 20:42:41 +05:30
Shoubhit DashandGitHub 54ff0a669b test(reference): cover configured reference contracts (#28170) 2026-05-18 20:09:27 +05:30
opencode-agent[bot] e56999fd36 chore: generate 2026-05-18 12:31:37 +00:00
Simon KleeandGitHub 1124315267 run: refresh prompt layout after paste (#28164)
Pasting into the prompt textarea left its layout stale until the next edit, so the visible content did not reflect the pasted text. Mark the layout dirty on paste and notify the content-change handler once the renderer is idle so the prompt updates immediately.
2026-05-18 12:30:20 +00:00
opencode-agent[bot] 2bf3f3041f chore: update nix node_modules hashes 2026-05-18 12:03:51 +00:00
SebastianandGitHub 6e4db5666a upgrade opentui to 0.2.14 (#28090) 2026-05-18 13:44:08 +02:00
Shoubhit DashandGitHub 564cde393e fix(tui): copy pasted prompt content (#28156) 2026-05-18 17:02:34 +05:30
opencode-agent[bot] c813927bb6 chore: generate 2026-05-18 11:07:52 +00:00
Simon KleeandGitHub 5970c12d90 run: replay session history on interactive resume (#26880) 2026-05-18 13:06:27 +02:00
opencode-agent[bot] 116a4e33ba chore: generate 2026-05-18 11:06:05 +00:00
Shoubhit DashandGitHub 611e48c4ac fix(tui): collapse long tool output lines (#28148) 2026-05-18 16:34:34 +05:30
Brendan AllanandGitHub 836a33198e fix(ui): fix question dock overflow and message part flex layout (#28142) 2026-05-18 17:55:27 +08:00
opencode-agent[bot] 7afd477d1a chore: generate 2026-05-18 09:28:20 +00:00
fe143df151 fix(ui): fallback to execCommand for clipboard copy when navigator.clipboard fails (#27993)
Co-authored-by: SpiritChen51 <spiritchen51@users.noreply.github.com>
2026-05-18 17:26:45 +08:00
e94aecaa08 fix(tui): use contrast-aware foreground for paste summary badge (#27969)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-05-18 08:38:40 +00:00
Brendan AllanandGitHub 418c0ea04a feat(desktop): add notification permission for renderer (#28119) 2026-05-18 15:43:28 +08:00
opencode-agent[bot] 4312e5df05 chore: generate 2026-05-18 07:21:25 +00:00
Brendan AllanandGitHub f80f3e33ec desktop: add free limit + go usage exceeded dialogs to match tui (#27677) 2026-05-18 15:20:08 +08:00
opencode-agent[bot] 479206449e chore: generate 2026-05-18 06:41:17 +00:00
6849059b00 fix(app): hide prompt placeholder for whitespace input (#28101)
Co-authored-by: ShrootBuck <ShrootBuck@users.noreply.github.com>
2026-05-18 14:39:51 +08:00
opencode-agent[bot] 43df145cd2 chore: update nix node_modules hashes 2026-05-18 03:59:28 +00:00
Frank 28a0bf6c8f sync 2026-05-17 23:43:23 -04:00
opencode-agent[bot] 30e4edffd5 chore: generate 2026-05-18 03:42:06 +00:00
5452ab6db7 perf(app): virtualize session timeline rows (#26949)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-05-18 03:40:52 +00:00
DaxandGitHub e85119aa64 Load models.dev snapshot from build global (#28077) 2026-05-17 21:59:44 -04:00
黑墨水鱼andGitHub 71b27a1b0f fix: sync PWA status bar theme-color with app color scheme (#28006) 2026-05-18 00:12:02 +00:00
opencode-agent[bot] 32f37d86f0 chore: update nix node_modules hashes 2026-05-17 23:40:17 +00:00
opencode-agent[bot] b14ea406ba chore: generate 2026-05-17 23:25:50 +00:00
Luke ParkerandGitHub de846ca57d chore: Upgrade Bun to the final non-rust version (#27648) 2026-05-18 09:24:26 +10:00
Luke ParkerandGitHub f06b78751e fix(desktop): install the latest available update (#27953) 2026-05-18 09:23:32 +10:00
opencode-agent[bot] 969d0f48b2 chore: generate 2026-05-17 23:03:12 +00:00
Luke ParkerandGitHub fc19dcc70c fix: sort v2 session list by updated time (#27954) 2026-05-18 09:01:55 +10:00
opencode-agent[bot] 49c6b46afc chore: update nix node_modules hashes 2026-05-17 22:39:36 +00:00
SebastianandGitHub f97e115ee2 dialog prompt submit keybind + opentui event sink (#27945) 2026-05-18 00:23:19 +02:00
Dax Raad e92b1fe7d7 core: let models layer infer its own type so layer composition no longer requires matching explicit requirements 2026-05-17 17:37:11 -04:00
opencode f98449c9b5 sync release versions for v1.15.4 2026-05-17 17:44:50 +00:00
193 changed files with 11042 additions and 75643 deletions
+39 -59
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.3",
"version": "1.15.5",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -307,7 +307,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.3",
"version": "1.15.5",
"bin": {
"opencode": "./bin/opencode",
},
@@ -421,6 +421,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -489,6 +490,7 @@
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -520,7 +522,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -536,9 +538,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.11",
"@opentui/keymap": ">=0.2.11",
"@opentui/solid": ">=0.2.11",
"@opentui/core": ">=0.2.14",
"@opentui/keymap": ">=0.2.14",
"@opentui/solid": ">=0.2.14",
},
"optionalPeers": [
"@opentui/core",
@@ -558,7 +560,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -573,7 +575,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -608,7 +610,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -657,7 +659,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -721,9 +723,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.11",
"@opentui/keymap": "0.2.11",
"@opentui/solid": "0.2.11",
"@opentui/core": "0.2.14",
"@opentui/keymap": "0.2.14",
"@opentui/solid": "0.2.14",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -735,7 +737,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.12",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
@@ -764,7 +766,7 @@
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
"ulid": "3.0.1",
"virtua": "0.42.3",
"virtua": "0.49.1",
"vite": "7.1.4",
"vite-plugin-solid": "2.11.10",
"zod": "4.1.8",
@@ -1590,23 +1592,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.11", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.11", "@opentui/core-darwin-x64": "0.2.11", "@opentui/core-linux-arm64": "0.2.11", "@opentui/core-linux-x64": "0.2.11", "@opentui/core-win32-arm64": "0.2.11", "@opentui/core-win32-x64": "0.2.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-X0zLmcDEvMrPzWYp769I7VEVb+og38vaete9tGZXu9HnJgu/paPUUplUT+6denBQccr2qx1rBYV6EtgbBpLEyw=="],
"@opentui/core": ["@opentui/core@0.2.14", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.14", "@opentui/core-darwin-x64": "0.2.14", "@opentui/core-linux-arm64": "0.2.14", "@opentui/core-linux-x64": "0.2.14", "@opentui/core-win32-arm64": "0.2.14", "@opentui/core-win32-x64": "0.2.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-17YCr3BqM9mhi/DdNVM+omgmrKQNIl0G5RzoaTFOHe4+OAhG+W3iooYi+WdsekJWSUOEwZqDRz0QBTZhOtgZsQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h2MXtE2Cu3XlKVoQMXthnbhleO68zGXkoh/r1Q5pCoZh6RuXqns5/94D/aZThXBWwzPuEoyarMlxxR9OqrpvHw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iS4NZQkOKX2EP5rsNjDcU7inDLcKhPaSBn8ENjDXKx2smOh7p/rgM2qlEaiLI3njtL784QoF+nxTzSXbEI6+Jw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y0jbPClnOBTPSIy+2THG86MTqIG/jGFlOOKuw4JfCDqEjPBM3pLWIHnJb3WxHRi2LlvfyBxvrUTXWlW6JpI0QQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-ft4ZwYHCV0VtRMwQtHH5mAgwqRLHEXP26DWcwtCZWDEHDvghClBR0cj9UZLH5JAKn/j7ds5hZDCCZz+nUiEHYA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-blQyyuTaW4q/OQ3whs7Kt7GCXhBUR5EQHHDdjOqQAr0HYpohUa6sbHMbiBcX2Ehc9ZWwtiaOoWiyZ5YXy2SAvg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-t/EKD4+rlzWuwYAa6NzGCmiBOHvF+hzjNwExj+dnSqX5wK7TU+VHl+N2iYUl4VhhJK94kPP6BnrF5GcHnZGFLg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-0nEB5+MgzQRYiVcQd1vHXPWNPWGh4JEmQTJKyG3OHnTzPaJ1FVSQ/V71ECyRSl3ymY3F+U0eW9cFgw1hCieK2w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Lvqmd92UZ+KZVnr0xU0jYj4XqnCSsBQJHS/FpYkJgZSAN7/4NmPlgMvQXIGW8a3BcFaGRKe8LuGpqM2E4oaX0Q=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-+KKH77fzm0qF8py9G2pU32DzB1bAgDMfBajrs7gKL5NtSEnknrwfh7hIs/tq41aF6j9zvIzgtykByh26tcjFog=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jnuud29daaEoZNEp80dxUDLyUcwLr+g6SruHPyyWerOe7J10JE1ihJNkDlXLT7T49xdBGYRQlRkuNGwRfZWx5A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-dMmb9DX0W0HWadLdgciMbonqIc1xdcKiVmaQSYxw5eGCzFRPZIOrKHByesP+2ipkMuLx85W/MJUFal/lW8XSNg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-2ZUNh7yaAMUwAOK8oFEO28qXqPFrWPGGD0KHK1Gp97Th9XuVZniMLtbkbrFtDRh15+PVj7MyrG0N967W7rLr1A=="],
"@opentui/keymap": ["@opentui/keymap@0.2.11", "", { "dependencies": { "@opentui/core": "0.2.11" }, "peerDependencies": { "@opentui/react": "0.2.11", "@opentui/solid": "0.2.11", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-pCrJrY3mTuXdDaaRneId1JsJCtGE+7prTtWihzOLZzVJTJYyYtT38gMI7MpyAoloVDfEL5cTe8C+v7wv+IYREw=="],
"@opentui/keymap": ["@opentui/keymap@0.2.14", "", { "dependencies": { "@opentui/core": "0.2.14" }, "peerDependencies": { "@opentui/react": "0.2.14", "@opentui/solid": "0.2.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Jd4F3S98D8bJcr41jk7KcsFwwQTA0GCNKb+LoDMkPwv00k+NV6XcrXPB3QlNeM/JrVbe55pyGaT/ynZklGYHRw=="],
"@opentui/solid": ["@opentui/solid@0.2.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.11", "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-M3WHxBFORHVE0yqMJYpi9PfjXWlnRTw/LYuBhZaJv0HTo+zTs60P/ukGcwnHDWnMpTGf3BH9x0Yi2dIqjHRY6Q=="],
"@opentui/solid": ["@opentui/solid@0.2.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.14", "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-tSWiiwdh/J+crkjwHgd26HXAlJHYocwkN/eUoqSH3+Y/uO3c3qRnbzQz7zzds+j4XDQU/8e9QcZshYLvQT9c7w=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2282,7 +2284,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
@@ -2694,7 +2696,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -4904,7 +4906,7 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"virtua": ["virtua@0.42.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-5FoAKcEvh05qsUF97Yz42SWJ7bwnPExjUYHGuoxz1EUtfWtaOgXaRwnylJbDpA0QcH1rKvJ2qsGRi9MK1fpQbg=="],
"virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
@@ -5834,10 +5836,6 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.2.7", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.7", "@opentui/core-darwin-x64": "0.2.7", "@opentui/core-linux-arm64": "0.2.7", "@opentui/core-linux-x64": "0.2.7", "@opentui/core-win32-arm64": "0.2.7", "@opentui/core-win32-x64": "0.2.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-cnN6JcaGC7SeQzobBy/CHzqUAQFtypazuw1CjQBo7WwoOiLMGubt9W5FXeF0zIrSxH2Ed6NLWhPYRg7SD4629Q=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.2.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.7", "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-nlkx9HvuWaHtc5A8eUEAPNi+5+37LZS3ln73WRmtT5xin8LnQf+yhwopqGgPSnLq1ODLwhkKRdr/9JCDr2j7Bg=="],
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6608,22 +6606,6 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CAy6cL3byz2Xf6gFiJHBpcnsp/2ADEWLLOUokVypOyPLcy8GY3sPzlA4pkAjVGQMYQhDj+Y3+SXz4uTLt4AETg=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-K06h333rMkC9cyMJr/VvcRK3ik81Admd8ZsES5uf5YXWPdYhXGf75I1T8mKIThhUmoFLb8R5xqfuPmoocsjM7Q=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-iYWGTztbdG9yYSB5Alxuo0dWAmkWQR0+/paNWUyPOocjigmKgMmACDtHgYqa7sxkIcWgmXljt/f8rgXDG4wdMg=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-tymBCfYbsDRfHQNXsolkFfaTEIDhemD4+1ZovUztQd7i+0Ggnu9WbPN1SNCiRz6PjrlaNeQzZE3Wl8FfVdw/cw=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-XLPJWdT8QOukrYDkpIng6+uNUlF66ByXcQlC3qA9JbrUTBetZhgXs8Q2jEjRfc+Ty3uh1iRSA6PgJGbbOK/f4Q=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-CzVGEfqysVk8Hxcj0RDv/DtXIM6iZmbmr23kW7y8CJMPtmV1gmKI4D9abVjynWJnGbaSBnDi43mgZnGMgOdyEg=="],
"opentui-spinner/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -6974,8 +6956,6 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
-3
View File
@@ -223,8 +223,6 @@ const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret },
})
const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
////////////////
// CONSOLE
////////////////
@@ -274,7 +272,6 @@ new sst.cloudflare.x.SolidStart("Console", {
new sst.Secret("CLOUDFLARE_API_TOKEN", process.env.CLOUDFLARE_API_TOKEN!),
]
: []),
gatewayKv,
],
environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!),
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Ucvyzyq+oYvWglkeowSvb0LgDzkAvaSdq0CdA6jgN6U=",
"aarch64-linux": "sha256-SERwZvvN6P8/OwNolHmC0KU9H5laVQm+FD/NNKauZA8=",
"aarch64-darwin": "sha256-I1ABwMHkTAntlYyg43w0cW8iPYfZa9MT0In2C7plB5g=",
"x86_64-darwin": "sha256-degJTL0RG7QQO8/USgIF//ya7oNmwChTmAoJcpXbIp0="
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
}
}
+6 -6
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.13",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev",
@@ -31,13 +31,13 @@
"@effect/opentelemetry": "4.0.0-beta.65",
"@effect/platform-node": "4.0.0-beta.65",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.12",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.11",
"@opentui/keymap": "0.2.11",
"@opentui/solid": "0.2.11",
"@opentui/core": "0.2.14",
"@opentui/keymap": "0.2.14",
"@opentui/solid": "0.2.14",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -74,7 +74,7 @@
"shiki": "3.20.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"virtua": "0.49.1",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
@@ -0,0 +1,314 @@
const words = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
"metro",
"nova",
"orbit",
"pixel",
"quartz",
"river",
"signal",
"vector",
]
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
type Message = { info: MessageInfo; parts: MessagePart[] }
function lorem(seed: number, length: number) {
let out = ""
let i = seed
while (out.length < length) {
const word = words[i % words.length]
out += (out ? " " : "") + word
if (i % 17 === 0) out += ".\n\n"
i += 7
}
return out.slice(0, length)
}
function id(prefix: string, value: number) {
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
}
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
const messageID = id("msg_user", index)
return {
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs },
agent: "build",
model,
},
parts: [
{
id: id("prt_user_text", index),
sessionID,
messageID,
type: "text",
text: lorem(index, textLength),
},
],
}
}
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
const messageID = id("msg_assistant", index)
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
parentID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: parts.map((part) => ({
...part,
sessionID,
messageID,
})),
}
}
function textPart(index: number, partIndex: number, length: number): MessagePart {
return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
}
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
return {
id: id(`prt_reasoning_${partIndex}`, index),
type: "reasoning",
text: lorem(index * 19 + partIndex, length),
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
}
}
function toolPart(
index: number,
partIndex: number,
tool: string,
input: Record<string, unknown>,
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
function fileDiff(file: string, seed: number) {
return {
file,
additions: (seed % 9) + 1,
deletions: seed % 4,
before: code(seed, 32),
after: code(seed + 1, 38),
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number) {
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
"\n",
)
}
function turn(index: number): Message[] {
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
const parts = [
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
? [
toolPart(
index,
11,
"question",
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
120,
),
]
: []),
...(index % 17 === 0
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.info.id, parts)]
}
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "smoke-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
{
id: sourceID,
slug: "source",
projectID,
directory,
title: "Uncommitted changes inquiry",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
{
id: targetID,
slug: "target",
projectID,
directory,
title: "Example Game: sample jump movement & sample physics analysis",
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const end = before
? Math.max(
0,
messages.findIndex((message) => message.info.id === before),
)
: messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
}
@@ -0,0 +1,412 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server"
const forbiddenText = ["Load details", "Show earlier steps"]
type SmokeState = {
ids: string[]
visibleIds: string[]
messageIds: string[]
visibleMessageIds: string[]
topVisibleId?: string
signature: string
scrollTop: number
scrollHeight: number
clientHeight: number
errorToasts: string[]
forbiddenText: string[]
}
type SmokeWindow = Window & {
__timelineSmokeState?: () => SmokeState
__timelineSmokeErrorToasts?: string[]
__timelineSmokeForbiddenText?: string[]
}
test.describe("smoke: session timeline", () => {
test.setTimeout(240_000)
test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page)
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await configureSmokePage(page)
await openProject(page, "SmokeProject")
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page, "smoke-project")
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
const expectedPartIDs = fixture.expected.targetPartIDs
const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
})
})
async function configureSmokePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
const smoke = window as SmokeWindow
smoke.__timelineSmokeErrorToasts = []
smoke.__timelineSmokeForbiddenText = []
const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
const idsOf = (el: HTMLElement) =>
[el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
smoke.__timelineSmokeState = () => {
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
el.querySelector("[data-timeline-row], [data-session-title]"),
)
if (!scroller) {
return {
ids: [],
visibleIds: [],
messageIds: [],
visibleMessageIds: [],
topVisibleId: undefined,
signature: "",
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
}
}
const ids: string[] = []
const visibleIds: string[] = []
const scrollerRect = scroller.getBoundingClientRect()
let topVisibleId: string | undefined
for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
const next = idsOf(el)
ids.push(...next)
const rect = el.getBoundingClientRect()
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
if (!topVisibleId) topVisibleId = next[0]
visibleIds.push(...next)
}
}
const messageIds: string[] = []
const visibleMessageIds: string[] = []
const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
const rect = el.getBoundingClientRect()
const id = el.dataset.messageId
if (id) {
messageIds.push(id)
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
}
return {
id,
top: Math.round(rect.top),
bottom: Math.round(rect.bottom),
}
})
const signature = JSON.stringify({
top: Math.round(scroller.scrollTop),
height: Math.round(scroller.scrollHeight),
rows,
ids,
})
return {
ids,
visibleIds,
messageIds,
visibleMessageIds,
topVisibleId,
signature,
scrollTop: Math.round(scroller.scrollTop),
scrollHeight: Math.round(scroller.scrollHeight),
clientHeight: Math.round(scroller.clientHeight),
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
}
}
let recordFrame: number | undefined
const record = () => {
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
const text = toast.textContent?.trim()
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
}
const text = document.body?.textContent ?? ""
for (const value of ["Load details", "Show earlier steps"]) {
if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
smoke.__timelineSmokeForbiddenText!.push(value)
}
}
}
const start = () => {
const root = document.documentElement ?? document.body
if (!root) return
new MutationObserver(() => {
if (recordFrame) return
recordFrame = requestAnimationFrame(() => {
recordFrame = undefined
record()
})
}).observe(root, { childList: true, subtree: true })
record()
}
if (document.documentElement ?? document.body) start()
else document.addEventListener("DOMContentLoaded", start, { once: true })
})
}
async function expectCanScrollToStart(
page: Page,
expectedPartIDs: string[],
expectedMessageIDs: string[],
errors: string[],
) {
await pointAtTimeline(page)
const seenParts = new Set<string>()
const seenMessages = new Set<string>()
const samples: TraversalSample[] = []
let current = await timelineState(page)
let unchangedAtTop = 0
for (let attempt = 0; attempt < 600; attempt++) {
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
if (
current.scrollTop <= 1 &&
seenParts.size === expectedPartIDs.length &&
seenMessages.size === expectedMessageIDs.length
) {
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
return
}
const before = current
const changed = await scrollTimelineUp(page, current)
current = await timelineState(page)
if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
else unchangedAtTop = 0
if (unchangedAtTop >= 2) break
}
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
}
async function timelineState(page: Page) {
return page.evaluate(
() =>
(window as SmokeWindow).__timelineSmokeState?.() ?? {
ids: [],
visibleIds: [],
messageIds: [],
visibleMessageIds: [],
topVisibleId: undefined,
signature: "",
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
errorToasts: [],
forbiddenText: [],
},
)
}
function timelineScroller(page: Page) {
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
}
async function pointAtTimeline(page: Page) {
const box = await timelineScroller(page).boundingBox()
if (!box) throw new Error("Timeline scroller is not visible")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
}
async function scrollTimelineUp(page: Page, before: SmokeState) {
return page.evaluate(
(prev) =>
new Promise<boolean>((resolve) => {
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
el.querySelector("[data-timeline-row], [data-session-title]"),
)
if (!scroller) {
resolve(false)
return
}
scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
let frames = 0
let stableFrames = 0
let last = ""
let changed = false
const check = () => {
const current = read()
if (current !== prev) changed = true
if (current === last) stableFrames++
else {
stableFrames = 0
last = current
}
if (changed && stableFrames >= 2) {
resolve(true)
return
}
frames++
if (frames >= 30) {
resolve(changed)
return
}
requestAnimationFrame(check)
}
requestAnimationFrame(check)
}),
before.signature,
)
}
function expectOrderedIDs(expected: string[], actual: string[], label: string) {
expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
const actualSet = new Set(actual)
expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
}
function unique(values: string[]) {
return values.filter((value, index) => values.indexOf(value) === index)
}
function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
for (const id of state.ids) seenParts.add(id)
for (const id of state.visibleIds) seenParts.add(id)
for (const id of state.messageIds) seenMessages.add(id)
for (const id of state.visibleMessageIds) seenMessages.add(id)
}
type TraversalSample = ReturnType<typeof sampleTraversal>
function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
return {
seenParts,
seenMessages,
mounted: state.ids.length,
visible: state.visibleIds.length,
mountedMessages: unique(state.messageIds).length,
visibleMessages: unique(state.visibleMessageIds).length,
top: state.scrollTop,
height: state.scrollHeight,
first: state.ids[0],
last: state.ids.at(-1),
topVisible: state.topVisibleId,
visibleFirst: state.visibleIds[0],
visibleLast: state.visibleIds.at(-1),
}
}
function sampleSummary(samples: TraversalSample[]) {
return samples
.filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
.map(
(sample, index) =>
`${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
)
.join("\n")
}
async function waitForTimelineStable(page: Page) {
await page.waitForFunction(
() =>
new Promise<boolean>((resolve) => {
requestAnimationFrame(() => {
const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
requestAnimationFrame(() => {
const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
requestAnimationFrame(() =>
resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
)
})
})
}),
)
}
async function expectSessionTimelineReady(
page: Page,
expectedPartIDs: string[],
expectedMessageIDs: string[],
errors: string[],
) {
await waitForTimelineStable(page)
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
const currentState = await timelineState(page)
expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
}
function expectCompleteScroll(
state: SmokeState,
expectedPartIDs: string[],
expectedMessageIDs: string[],
seenParts: Set<string>,
seenMessages: Set<string>,
samples: TraversalSample[],
) {
expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
expect(
expectedPartIDs.filter((id) => !seenParts.has(id)),
`missing visible timeline parts\n${sampleSummary(samples)}`,
).toEqual([])
expect(
expectedMessageIDs.filter((id) => !seenMessages.has(id)),
`missing visible messages\n${sampleSummary(samples)}`,
).toEqual([])
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
expect(expectedPartIDs.length).toBe(331)
}
async function openProject(page: Page, projectName: string) {
await page.goto("/")
await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
}
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
await page
.locator(`a[href*="${sessionId}"]`)
.first()
.evaluate((el) => (el as HTMLElement).click())
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
}
async function expectSessionReady(page: Page, projectName: string) {
await expect(page.getByText(projectName).first()).toBeVisible()
await expect(page.getByText("Ask anything...")).toBeVisible()
}
-11
View File
@@ -1,11 +0,0 @@
import { test } from "@playwright/test"
test(
"test something cool",
{
annotation: { type: "todo" },
},
async () => {
test.fixme()
},
)
+18
View File
@@ -0,0 +1,18 @@
import { expect, type Page } from "@playwright/test"
export function trackPageErrors(page: Page) {
const errors: string[] = []
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text())
})
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
return errors
}
export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) {
expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({
consoleErrors: [],
toastErrors: [],
forbiddenText: [],
})
}
+86
View File
@@ -0,0 +1,86 @@
import type { Page, Route } from "@playwright/test"
const emptyList = new Set([
"/skill",
"/command",
"/lsp",
"/formatter",
"/permission",
"/question",
"/vcs/status",
"/vcs/diff",
])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
export interface MockServerConfig {
provider: unknown
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
},
"/project": [config.project],
"/project/current": config.project,
"/agent": [{ name: "build", mode: "primary" }],
"/vcs": { branch: "main", default_branch: "main" },
"/session": config.sessions,
}
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route)
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1])
return json(route, session ?? {})
}
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const limit = Number(url.searchParams.get("limit") ?? 80)
const before = url.searchParams.get("before") ?? undefined
const pageData = config.pageMessages(messagesMatch[1], limit, before)
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
}
return json(route, {})
})
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
-1
View File
@@ -10,7 +10,6 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.3",
"version": "1.15.5",
"description": "",
"type": "module",
"exports": {
+4
View File
@@ -16,6 +16,10 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
// Update theme-color meta tag to match app color scheme
var metas = document.querySelectorAll("meta[name='theme-color']")
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
if (themeId === "oc-2") return
var css = localStorage.getItem("opencode-theme-css-" + mode)
@@ -0,0 +1,44 @@
import { usePlatform } from "@/context/platform"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { JSX } from "solid-js"
export type DialogGoUpsellProps = {
title: string
description: JSX.Element
link?: string
actionLabel: string
onClose?: (dontShowAgain?: boolean) => void
}
export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const dialog = useDialog()
const platform = usePlatform()
const runAction = () => {
if (props.link) platform.openLink(props.link)
props.onClose?.()
dialog.close()
}
const dismiss = () => {
props.onClose?.(true)
dialog.close()
}
return (
<Dialog title={props.title} description={props.description} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}>
Don't show again
</Button>
<Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel}
</Button>
</div>
</div>
</Dialog>
)
}
+3 -3
View File
@@ -99,8 +99,6 @@ const EXAMPLES = [
"prompt.example.25",
] as const
const NON_EMPTY_TEXT = /[^\s\u200B]/
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const queryOptions = useQueryOptions()
@@ -860,7 +858,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
? rawParts[0].content
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
const textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "")
const shouldReset =
textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !hasNonText && images.length === 0
if (shouldReset) {
closePopover()
@@ -41,6 +41,7 @@ export function trimSessions(
.filter((s) => !s.time?.archived)
.sort((a, b) => cmp(a.id, b.id))
const roots = all.filter((s) => !s.parentID)
roots.sort(compareSessionRecent)
const children = all.filter((s) => !!s.parentID)
const base = roots.slice(0, limit)
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
+66 -201
View File
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { useSearchParams } from "@solidjs/router"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
@@ -64,6 +64,7 @@ import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
const emptyUserMessages: UserMessage[] = []
type FollowupItem = FollowupDraft & { id: string }
@@ -75,7 +76,6 @@ type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
loaded: () => number
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
@@ -85,205 +85,74 @@ type SessionHistoryWindowInput = {
scroller: () => HTMLDivElement | undefined
}
/**
* Maintains the rendered history window for a session timeline.
*
* It keeps initial paint bounded to recent turns, reveals cached turns in
* small batches while scrolling upward, and prefetches older history near top.
*/
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const turnInit = 10
const turnBatch = 8
const turnScrollThreshold = 200
const turnPrefetchBuffer = 16
const prefetchCooldownMs = 400
const prefetchNoGrowthLimit = 2
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
const historyScrollThreshold = 200
let shiftFrame: number | undefined
const [state, setState] = createStore({
turnID: undefined as string | undefined,
turnStart: 0,
prefetchUntil: 0,
prefetchNoGrowth: 0,
shift: false,
})
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
const turnStart = createMemo(() => {
const id = input.sessionID()
const len = input.visibleUserMessages().length
if (!id || len <= 0) return 0
if (state.turnID !== id) return initialTurnStart(len)
if (state.turnStart <= 0) return 0
if (state.turnStart >= len) return initialTurnStart(len)
return state.turnStart
const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
equals: same,
})
const setTurnStart = (start: number) => {
const id = input.sessionID()
const next = start > 0 ? start : 0
if (!id) {
setState({ turnID: undefined, turnStart: next })
return
}
setState({ turnID: id, turnStart: next })
const cancelShiftReset = () => {
if (shiftFrame === undefined) return
cancelAnimationFrame(shiftFrame)
shiftFrame = undefined
}
const renderedUserMessages = createMemo(
() => {
const msgs = input.visibleUserMessages()
const start = turnStart()
if (start <= 0) return msgs
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const preserveScroll = (fn: () => void) => {
const el = input.scroller()
if (!el) {
fn()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
const scheduleShiftReset = () => {
cancelShiftReset()
shiftFrame = requestAnimationFrame(() => {
shiftFrame = undefined
setState("shift", false)
})
}
const backfillTurns = () => {
const start = turnStart()
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
preserveScroll(() => setTurnStart(nextStart))
}
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
const loadAndReveal = async () => {
const id = input.sessionID()
if (!id) return
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
let loaded = input.loaded()
if (start > 0) setTurnStart(0)
if (!input.historyMore() || input.historyLoading()) return
let afterVisible = beforeVisible
let added = 0
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
afterVisible = input.visibleUserMessages().length
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
if (afterVisible > beforeVisible) break
if (raw <= 0) break
if (!input.historyMore()) break
}
if (added <= 0) return
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
const growth = afterVisible - beforeVisible
if (growth <= 0) return
if (turnStart() !== 0) return
const target = Math.min(afterVisible, beforeVisible + turnBatch)
setTurnStart(Math.max(0, afterVisible - target))
}
/** Scroll/prefetch path: fetch older history from server. */
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
const fetchOlderMessages = async () => {
const id = input.sessionID()
if (!id) return
if (!input.historyMore() || input.historyLoading()) return
if (opts?.prefetch) {
const now = Date.now()
if (state.prefetchUntil > now) return
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
setState("prefetchUntil", now + prefetchCooldownMs)
}
const start = turnStart()
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
const beforeVisible = input.visibleUserMessages().length
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
let loaded = input.loaded()
let added = 0
let growth = 0
cancelShiftReset()
setState("shift", true)
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
growth = input.visibleUserMessages().length - beforeVisible
if (growth > 0) break
if (raw <= 0) break
if (opts?.prefetch) break
if (!input.historyMore()) break
}
const afterVisible = input.visibleUserMessages().length
if (opts?.prefetch) {
setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (added > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", 0)
}
if (added <= 0) return
if (growth <= 0) return
if (opts?.prefetch) {
const current = turnStart()
preserveScroll(() => setTurnStart(current + growth))
if (growth > 0) {
scheduleShiftReset()
return
}
if (turnStart() !== start) return
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = Math.min(afterVisible, base + turnBatch)
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
setState("shift", false)
}
const loadAndReveal = () => fetchOlderMessages()
const onScrollerScroll = () => {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
if (start <= turnPrefetchBuffer) {
void fetchOlderMessages({ prefetch: true })
}
backfillTurns()
return
}
if (el.scrollTop >= historyScrollThreshold) return
void fetchOlderMessages()
}
@@ -292,27 +161,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
on(
input.sessionID,
() => {
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
cancelShiftReset()
setState({ shift: false })
},
{ defer: true },
),
)
createEffect(
on(
() => [input.sessionID(), input.messagesReady()] as const,
([id, ready]) => {
if (!id || !ready) return
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
},
{ defer: true },
),
)
onCleanup(cancelShiftReset)
return {
turnStart,
setTurnStart,
renderedUserMessages,
userMessages,
shift: () => state.shift,
loadAndReveal,
onScrollerScroll,
}
@@ -333,6 +193,7 @@ export default function Page() {
const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => {
@@ -737,6 +598,7 @@ export default function Page() {
let dockHeight = 0
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let revealMessage = (_id: string) => {}
let scrollMark = 0
let messageMark = 0
@@ -1403,9 +1265,8 @@ export default function Page() {
},
)
const historyWindow = createSessionHistoryWindow({
const historyLoader = createSessionHistoryLoader({
sessionID: () => params.id,
messagesReady,
loaded: () => messages().length,
visibleUserMessages,
historyMore,
@@ -1427,9 +1288,9 @@ export default function Page() {
const el = scroller
if (!el) return
if (el.scrollHeight > el.clientHeight + 1) return
if (historyWindow.turnStart() <= 0 && !historyMore()) return
if (!historyMore()) return
void historyWindow.loadAndReveal()
void historyLoader.loadAndReveal()
})
}
@@ -1439,15 +1300,14 @@ export default function Page() {
[
params.id,
messagesReady(),
historyWindow.turnStart(),
historyMore(),
historyLoading(),
autoScroll.userScrolled(),
visibleUserMessages().length,
] as const,
([id, ready, start, more, loading, scrolled]) => {
([id, ready, more, loading, scrolled]) => {
if (!id || !ready || loading || scrolled) return
if (start <= 0 && !more) return
if (!more) return
fill()
},
{ defer: true },
@@ -1749,15 +1609,14 @@ export default function Page() {
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage,
setTurnStart: historyWindow.setTurnStart,
autoScroll,
scroller: () => scroller,
anchor,
revealMessage: (id) => revealMessage(id),
scheduleScrollState,
consumePendingMessage: layout.pendingMessage.consume,
})
@@ -1787,6 +1646,8 @@ export default function Page() {
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
})
useUsageExceededDialogs()
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""}
@@ -1830,20 +1691,23 @@ export default function Page() {
>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -1853,8 +1717,11 @@ export default function Page() {
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1863,14 +1730,12 @@ export default function Page() {
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>
@@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
@@ -0,0 +1,368 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, PartGroup, renderable } from "@opencode-ai/ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export type TimelineRowMap = {
CommentStrip: {
userMessageID: string
previousUserMessage: boolean
}
UserMessage: {
userMessageID: string
anchor: boolean
previousUserMessage: boolean
}
TurnDivider: {
userMessageID: string
label: "compaction" | "interrupted"
}
AssistantPart: {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string }
BottomSpacer: {}
}
export namespace TimelineRow {
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
previousUserMessage: boolean
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
anchor: boolean
previousUserMessage: boolean
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
}> {}
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
export type TimelineRow =
| CommentStrip
| UserMessage
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
| BottomSpacer
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
case "BottomSpacer":
return "bottom-spacer"
}
}
export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b)
}
}
export namespace Timeline {
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
assistantMessages: AssistantMessage[],
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
const previousUserMessage = index > 0
const userParts = getMessageParts(userMessage.id)
const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? [])
const compaction = userParts.some((p) => p.type === "compaction")
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
const interrupted = interruptedMessageIndex !== -1
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
)
const assistantItems =
interrupted && !compaction
? [
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map(
(group) => ({
type: "part" as const,
group,
}),
),
{ type: "interrupted" as const },
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map(
(group) => ({
type: "part" as const,
group,
}),
),
]
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length
if (comments.length > 0)
rows.push(
new TimelineRow.CommentStrip({
userMessageID: userMessage.id,
previousUserMessage,
}),
)
rows.push(
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
anchor: comments.length === 0,
previousUserMessage: comments.length === 0 && previousUserMessage,
}),
)
if (compaction) {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "compaction",
}),
)
}
let assistantGroupIndex = 0
assistantItems.forEach((item) => {
if (item.type === "interrupted") {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "interrupted",
}),
)
return
}
rows.push(
new TimelineRow.AssistantPart({
userMessageID: userMessage.id,
group: item.group,
previousAssistantPart: assistantGroupIndex > 0,
lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1,
}),
)
assistantGroupIndex += 1
})
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
.flatMap((message) => getMessageParts(message.id))
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
.find((value): value is string => !!value)
rows.push(
new TimelineRow.Thinking({
userMessageID: userMessage.id,
reasoningHeading: heading,
}),
)
}
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
const diffs = (userMessage.summary?.diffs ?? [])
.reduceRight<SummaryDiff[]>((result, diff) => {
if (!isSummaryDiff(diff)) return result
if (result.some((item) => item.file === diff.file)) return result
result.push(diff)
return result
}, [])
.reverse()
if (diffs.length > 0 && (status === "idle" || !isActive)) {
rows.push(
new TimelineRow.DiffSummary({
userMessageID: userMessage.id,
diffs,
}),
)
}
if (error) {
const data = error.data?.message
rows.push(
new TimelineRow.Error({
userMessageID: userMessage.id,
text: unwrapErrorMessage(
typeof data === "string" ? data : data === undefined || data === null ? "" : String(data),
),
}),
)
}
return rows
}
function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
return typeof value.file === "string"
}
function reasoningHeading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
if (html?.[1]) {
const value = cleanHeading(html[1].replace(/<[^>]+>/g, " "))
if (value) return value
}
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
if (atx?.[1]) {
const value = cleanHeading(atx[1])
if (value) return value
}
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
if (setext?.[1]) {
const value = cleanHeading(setext[1])
if (value) return value
}
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
if (strong?.[1]) {
const value = cleanHeading(strong[1])
if (value) return value
}
}
function cleanHeading(value: string) {
return value
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_~]+/g, "")
.trim()
}
function unwrapErrorMessage(message: string) {
const text = message.replace(/^Error:\s*/, "").trim()
const parse = (value: string) => {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
const read = (value: string) => {
const first = parse(value)
if (typeof first !== "string") return first
return parse(first.trim())
}
let json = read(text)
if (json === undefined) {
const start = text.indexOf("{")
const end = text.lastIndexOf("}")
if (start !== -1 && end > start) json = read(text.slice(start, end + 1))
}
if (!record(json)) return message
const err = record(json.error) ? json.error : undefined
if (err) {
const type = typeof err.type === "string" ? err.type : undefined
const msg = typeof err.message === "string" ? err.message : undefined
if (type && msg) return `${type}: ${msg}`
if (msg) return msg
if (type) return type
const code = typeof err.code === "string" ? err.code : undefined
if (code) return code
}
const msg = typeof json.message === "string" ? json.message : undefined
if (msg) return msg
const reason = typeof json.error === "string" ? json.error : undefined
if (reason) return reason
return message
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
}
export namespace MessageComment {
export type MessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
export const fromPart = (part: Part): MessageComment | undefined => {
if (part.type !== "text" || !part.synthetic) return
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
if (!next) return
return {
path: next.path,
comment: next.comment,
selection: next.selection
? {
startLine: next.selection.startLine,
endLine: next.selection.endLine,
}
: undefined,
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import { useSDK } from "@/context/sdk"
import { Persist, persisted } from "@/utils/persist"
import { SessionStatus } from "@opencode-ai/sdk/v2"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSessionLayout } from "./session-layout"
import { useDialog } from "@opencode-ai/ui/context"
import { DialogUsageExceeded } from "@/components/dialog-usage-exceeded"
import { useI18n } from "@opencode-ai/ui/context"
const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
function goUpsellKeys(status: SessionStatus) {
if (status.type !== "retry" || !status.action) return
const { action } = status
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
if (action.reason === "free_tier_limit") {
return {
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
} as const
}
if (action.reason === "account_rate_limit") {
return {
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
} as const
}
}
export function useUsageExceededDialogs() {
const sdk = useSDK()
const dialog = useDialog()
const { params } = useSessionLayout()
const { t, locale } = useI18n()
const isEnglish = () => locale() === "en"
const [goUpsellState, setGoUpsellState] = persisted(
Persist.global("go-upsell"),
createStore({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_FREE_TIER_DONT_SHOW]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null as null | number,
}),
)
onCleanup(
sdk.event.on("session.status", (evt) => {
if (evt.properties.sessionID !== params.id) return
if (evt.properties.status.type !== "retry") return
const { action } = evt.properties.status
if (!action) return
if (dialog.active) return
const keys = goUpsellKeys(evt.properties.status)
if (!keys) return
const seen = goUpsellState[keys.lastSeenAt]
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
if (goUpsellState[keys.dontShow]) return
if (action.reason === "free_tier_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
else {
void import("../../components/dialog-connect-provider").then((x) =>
dialog.show(() => <x.DialogConnectProvider provider="opencode-go" />),
)
}
}}
/>
))
} else if (action.reason === "account_rate_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.accountRateLimit.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.accountRateLimit.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.accountRateLimit.actionLabel")}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
}}
/>
))
}
}),
)
}
@@ -11,21 +11,19 @@ export const useSessionHashScroll = (input: {
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
turnStart: () => number
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
revealMessage?: (id: string) => void
scheduleScrollState: (el: HTMLDivElement) => void
consumePendingMessage: (key: string) => string | undefined
}) => {
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
let clearing = false
@@ -77,6 +75,7 @@ export const useSessionHashScroll = (input: {
}
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
input.revealMessage?.(id)
const el = document.getElementById(input.anchor(id))
if (el) return scrollToElement(el, behavior)
if (left <= 0) return false
@@ -89,18 +88,7 @@ export const useSessionHashScroll = (input: {
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
if (index !== -1 && index < input.turnStart()) {
input.setTurnStart(index)
queue(() => {
seek(message.id, behavior)
})
updateHash(message.id)
return
}
input.revealMessage?.(message.id)
if (seek(message.id, behavior)) {
updateHash(message.id)
@@ -154,7 +142,6 @@ export const useSessionHashScroll = (input: {
if (!input.sessionID() || !input.messagesReady()) return
visibleUserMessages()
input.turnStart()
let targetId = input.pendingMessage()
if (!targetId) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.3",
"version": "1.15.5",
"type": "module",
"license": "MIT",
"scripts": {
@@ -1,6 +1,7 @@
import { Resource, waitUntil } from "@opencode-ai/console-resource"
export function createDataDumper(sessionId: string, requestId: string, projectId: string) {
return
if (Resource.App.stage !== "production") return
if (sessionId === "") return
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.3",
"version": "1.15.5",
"private": true,
"type": "module",
"license": "MIT",
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.3",
"version": "1.15.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.3",
"version": "1.15.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+1 -1
View File
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
ARG BUN_VERSION=1.3.13
ARG BUN_VERSION=1.3.14
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.3",
"version": "1.15.5",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -105,6 +105,8 @@ export const Provider = Schema.Struct({
export type Provider = Schema.Schema.Type<typeof Provider>
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
export interface Interface {
readonly get: () => Effect.Effect<Record<string, Provider>>
readonly refresh: (force?: boolean) => Effect.Effect<void>
@@ -112,9 +114,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
type Requirements = AppFileSystem.Service | HttpClient.HttpClient
export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
@@ -157,12 +157,9 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
Effect.map((v) => v as Record<string, Provider> | undefined),
)
// Bundled at build time; absent in dev — `tryPromise` covers both.
const loadSnapshot = Effect.tryPromise({
// @ts-ignore — generated at build time, may not exist in dev
try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record<string, Provider> | undefined),
catch: () => undefined,
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
)
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
@@ -223,4 +220,4 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
)
export * as ModelsDev from "./models"
export * as ModelsDev from "./models-dev"
-2
View File
@@ -1,2 +0,0 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
import { DateTime, Effect } from "effect"
import { Catalog } from "../catalog"
import { ModelV2 } from "../model"
import { ModelsDev } from "../models"
import { ModelsDev } from "../models-dev"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
+3 -3
View File
@@ -4,7 +4,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { it } from "./lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
@@ -136,14 +136,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns bundled snapshot when disk empty and fetch disabled", () =>
it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () =>
Effect.gen(function* () {
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
ModelsDev.Service.use((s) => s.get()),
)
expect(Object.keys(result).length).toBeGreaterThan(0)
expect(result).toEqual({})
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.3",
"version": "1.15.5",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+4 -12
View File
@@ -6,8 +6,6 @@ import { initLogging } from "./logging"
const logger = initLogging()
const { autoUpdater } = pkg
let downloadedUpdateVersion: string | undefined
export function setupAutoUpdater() {
if (!UPDATER_ENABLED) return
autoUpdater.logger = logger
@@ -26,12 +24,6 @@ export function setupAutoUpdater() {
export async function checkUpdate() {
if (!UPDATER_ENABLED) return { updateAvailable: false }
if (downloadedUpdateVersion) {
logger.log("returning cached downloaded update", {
version: downloadedUpdateVersion,
})
return { updateAvailable: true, version: downloadedUpdateVersion }
}
logger.log("checking for updates", {
currentVersion: app.getVersion(),
channel: autoUpdater.channel,
@@ -57,7 +49,6 @@ export async function checkUpdate() {
logger.log("update available", { version })
await autoUpdater.downloadUpdate()
logger.log("update download completed", { version })
downloadedUpdateVersion = version
return { updateAvailable: true, version }
} catch (error) {
logger.error("update check failed", error)
@@ -66,14 +57,15 @@ export async function checkUpdate() {
}
export async function installUpdate(killSidecar: () => Promise<void>) {
if (!downloadedUpdateVersion) {
const result = await checkUpdate()
if (!result.updateAvailable) {
logger.log("install update skipped", {
reason: "no downloaded update ready",
reason: result.failed ? "update check failed" : "no update available",
})
return
}
logger.log("installing downloaded update", {
version: downloadedUpdateVersion,
version: result.version ?? null,
})
await killSidecar()
autoUpdater.quitAndInstall()
+7 -5
View File
@@ -9,6 +9,8 @@ const rendererRoot = join(root, "../renderer")
const rendererProtocol = "oc"
const rendererHost = "renderer"
const clipboardWritePermission = "clipboard-sanitized-write"
const notificationPermission = "notifications"
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
protocol.registerSchemesAsPrivileged([
{
@@ -109,7 +111,7 @@ export function createMainWindow() {
},
})
allowClipboardWrite(win)
allowRendererPermissions(win)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
@@ -162,7 +164,7 @@ export function createLoadingWindow() {
},
})
allowClipboardWrite(win)
allowRendererPermissions(win)
loadWindow(win, "loading.html")
@@ -199,16 +201,16 @@ function loadWindow(win: BrowserWindow, html: string) {
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
function allowClipboardWrite(win: BrowserWindow) {
function allowRendererPermissions(win: BrowserWindow) {
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(
permission === clipboardWritePermission &&
rendererPermissions.has(permission) &&
isTrustedRendererUrl(details.requestingUrl) &&
webContents.id === win.webContents.id,
)
})
win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
if (permission !== clipboardWritePermission) return false
if (!rendererPermissions.has(permission)) return false
if (webContents && webContents.id !== win.webContents.id) return false
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
})
-1
View File
@@ -9,7 +9,6 @@
<link rel="shortcut icon" href="./favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="./social-share.png" />
<meta property="twitter:image" content="./social-share.png" />
<script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
@@ -9,7 +9,6 @@
<link rel="shortcut icon" href="./favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="./social-share.png" />
<meta property="twitter:image" content="./social-share.png" />
<script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.3",
"version": "1.15.5",
"private": true,
"type": "module",
"license": "MIT",
-1
View File
@@ -25,7 +25,6 @@ export default createHandler(() => (
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
{assets}
</head>
<body class="antialiased overscroll-none text-12-regular">
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.3"
version = "1.15.5"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.3",
"version": "1.15.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.3",
"version": "1.15.5",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.3",
"version": "1.15.5",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+2
View File
@@ -91,6 +91,7 @@ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"),
id: ContentBlockID,
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -112,6 +113,7 @@ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"),
id: ContentBlockID,
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
-2
View File
@@ -3,8 +3,6 @@ dist
dist-*
gen
app.log
src/provider/models-snapshot.js
src/provider/models-snapshot.d.ts
script/build-*.ts
temporary-*.md
.artifacts
+6 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.3",
"version": "1.15.5",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -10,6 +10,8 @@
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
"bench:test": "bun run script/bench-test-suite.ts",
"profile:test": "bun run script/profile-test-files.ts",
"build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
@@ -37,8 +39,9 @@
"devDependencies": {
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -99,6 +102,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+76
View File
@@ -296,5 +296,81 @@ export default {
],
},
},
{
filetype: "elixir",
wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.5/tree-sitter-elixir.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/locals.scm",
],
},
},
{
filetype: "fsharp",
wasm: "https://github.com/ionide/tree-sitter-fsharp/releases/download/0.3.0/tree-sitter-fsharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/fsharp/highlights.scm",
],
},
},
{
filetype: "r",
wasm: "https://github.com/r-lib/tree-sitter-r/releases/download/v1.2.0/tree-sitter-r.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/locals.scm",
],
},
},
{
filetype: "make",
aliases: ["makefile"],
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-make/releases/download/v1.1.1/tree-sitter-make.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/make/highlights.scm",
],
},
},
{
filetype: "vim",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-vim/releases/download/v0.8.1/tree-sitter-vim.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/locals.scm",
],
},
},
{
filetype: "xml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-xml/releases/download/v0.7.0/tree-sitter-xml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/locals.scm",
],
},
},
{
filetype: "agda",
wasm: "https://github.com/tree-sitter/tree-sitter-agda/releases/download/v1.3.3/tree-sitter-agda.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/agda/highlights.scm",
],
},
},
],
}
@@ -0,0 +1,52 @@
// Full-suite timing harness for the test-speed research in ../../perf/test-suite.md.
// Use this for periodic sanity checks; use profile-test-files.ts for discovery.
// Env: BENCH_WARMUPS=0 BENCH_RUNS=1 bun run bench:test
const warmups = Number(Bun.env.BENCH_WARMUPS ?? 0)
const runs = Number(Bun.env.BENCH_RUNS ?? 1)
const timings: number[] = []
if (!Number.isInteger(warmups) || warmups < 0) {
console.error("BENCH_WARMUPS must be a non-negative integer")
process.exit(1)
}
if (!Number.isInteger(runs) || runs < 1) {
console.error("BENCH_RUNS must be a positive integer")
process.exit(1)
}
for (const index of Array.from({ length: warmups + runs }, (_, index) => index)) {
const measured = index >= warmups
const label = measured ? `run ${index - warmups + 1}/${runs}` : `warmup ${index + 1}/${warmups}`
const start = performance.now()
console.log(`bench:test ${label}`)
const proc = Bun.spawn(["bun", "test", "--timeout", "30000"], {
cwd: import.meta.dir + "/..",
stdout: "inherit",
stderr: "inherit",
env: Bun.env,
})
const exitCode = await proc.exited
if (exitCode !== 0) {
console.error(`bench:test failed during ${label} with exit code ${exitCode}`)
process.exit(exitCode)
}
const seconds = (performance.now() - start) / 1000
console.log(`bench:test ${label} ${seconds.toFixed(3)}s`)
if (measured) timings.push(seconds)
}
const sorted = timings.toSorted((a, b) => a - b)
const median = sorted[Math.floor(sorted.length / 2)]
const mean = timings.reduce((sum, timing) => sum + timing, 0) / timings.length
const best = sorted[0] ?? median
const worst = sorted.at(-1) ?? median
console.log(
`bench:test median=${median.toFixed(3)}s mean=${mean.toFixed(3)}s best=${best.toFixed(3)}s worst=${worst.toFixed(3)}s`,
)
console.log(`METRIC test_suite_seconds=${median.toFixed(3)}`)
console.log(`METRIC test_suite_best_seconds=${best.toFixed(3)}`)
console.log(`METRIC test_suite_worst_seconds=${worst.toFixed(3)}`)
+2 -1
View File
@@ -11,7 +11,7 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir)
await import("./generate.ts")
const generated = await import("./generate.ts")
// Load migrations from migration directories
const migrationDirs = (
@@ -52,6 +52,7 @@ await Bun.build({
external: ["jsonc-parser", "@lydell/node-pty"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OPENCODE_MODELS_DEV: generated.modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
},
files: {
+2 -1
View File
@@ -12,7 +12,7 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir)
await import("./generate.ts")
const generated = await import("./generate.ts")
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
@@ -218,6 +218,7 @@ for (const item of targets) {
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OPENCODE_MODELS_DEV: generated.modelsData,
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
OPENCODE_WORKER_PATH: workerPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
+2 -11
View File
@@ -8,16 +8,7 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir)
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "../core/src/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "../core/src/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
console.log("Loaded models.dev snapshot")
@@ -0,0 +1,42 @@
// Per-file profiler for finding candidate test-speed work; see ../../perf/test-suite.md
// for the benchmark notes, kept wins, and discarded experiments.
// Example: TEST_PROFILE_GLOB='test/server/**/*.test.ts' TEST_PROFILE_TOP=15 bun run profile:test
const pattern = Bun.env.TEST_PROFILE_GLOB ?? "test/**/*.test.{ts,tsx}"
const limit = Number(Bun.env.TEST_PROFILE_LIMIT ?? 0)
const timeout = Bun.env.TEST_PROFILE_TIMEOUT ?? "30000"
const files = Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: import.meta.dir + "/..", onlyFiles: true }))
.then((files) => files.toSorted())
.then((files) => (limit > 0 ? files.slice(0, limit) : files))
const results = []
for (const file of await files) {
const start = performance.now()
const proc = Bun.spawn(["bun", "test", "--timeout", timeout, file], {
cwd: import.meta.dir + "/..",
stdout: "pipe",
stderr: "pipe",
env: Bun.env,
})
const [output, error, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
const seconds = (performance.now() - start) / 1000
results.push({ file, seconds, exitCode })
console.log(`${exitCode === 0 ? "PASS" : "FAIL"} ${seconds.toFixed(3)}s ${file}`)
if (exitCode !== 0) console.log((output + error).trim())
}
const sorted = results.toSorted((a, b) => b.seconds - a.seconds)
console.log("\nSlowest test files:")
for (const result of sorted.slice(0, Number(Bun.env.TEST_PROFILE_TOP ?? 20))) {
console.log(`${result.seconds.toFixed(3)}s ${result.exitCode === 0 ? "PASS" : "FAIL"} ${result.file}`)
}
if (sorted[0]) {
console.log(`METRIC slowest_test_file_seconds=${sorted[0].seconds.toFixed(3)}`)
console.log(`METRIC profiled_test_files=${results.length}`)
}
if (results.some((result) => result.exitCode !== 0)) process.exit(1)
+29 -21
View File
@@ -37,8 +37,16 @@ export interface Interface {
properties: BusProperties<D>,
options?: { id?: string },
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
// subscribe / subscribeAll are eager: the underlying PubSub subscription is
// acquired in the caller's Scope at `yield*` time. Any publish after the
// yield is delivered, even if stream consumption starts later. The previous
// Stream-returning shape acquired the subscription lazily on first pull,
// opening a race window during which publishes were lost — see
// test/bus/bus-effect.test.ts RACE tests.
readonly subscribe: <D extends BusEvent.Definition>(
def: D,
) => Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope>
readonly subscribeAll: () => Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
@@ -109,26 +117,26 @@ export const layer = Layer.effect(
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
const subscribe = <D extends BusEvent.Definition>(
def: D,
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: def.type })
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
const subscription = yield* PubSub.subscribe(ps)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type })))
return Stream.fromSubscription(subscription)
})
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
const subscribeAll = (): Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: "*" })
const s = yield* InstanceState.get(state)
const subscription = yield* PubSub.subscribe(s.wildcard)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" })))
return Stream.fromSubscription(subscription)
})
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
+1 -1
View File
@@ -19,7 +19,7 @@ import type {
import { UI } from "../ui"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { InstanceRef } from "@/effect/instance-ref"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
+1 -1
View File
@@ -2,7 +2,7 @@ import { EOL } from "os"
import { Effect } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
+1 -1
View File
@@ -3,7 +3,7 @@ import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import * as Prompt from "../effect/prompt"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
+30
View File
@@ -218,6 +218,15 @@ export const RunCommand = effectCmd({
type: "boolean",
describe: "show thinking blocks",
})
.option("replay", {
type: "boolean",
default: false,
describe: "replay visible session history on interactive resume",
})
.option("replay-limit", {
type: "number",
describe: "cap visible interactive replay to the newest N messages",
})
.option("interactive", {
alias: ["i"],
type: "boolean",
@@ -269,6 +278,21 @@ export const RunCommand = effectCmd({
die("--interactive cannot be used with --format json")
}
if (args.replay && !args.interactive) {
die("--replay requires --interactive")
}
if (args["replay-limit"] !== undefined && !args.interactive) {
die("--replay-limit requires --interactive")
}
if (
args["replay-limit"] !== undefined &&
(!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0)
) {
die("--replay-limit must be a positive integer")
}
if (args.interactive && !process.stdout.isTTY) {
die("--interactive requires a TTY stdout")
}
@@ -281,6 +305,8 @@ export const RunCommand = effectCmd({
}
}
const replay = args.replay || args["replay-limit"] !== undefined
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const directory = (() => {
if (!args.dir) return args.attach ? undefined : root
@@ -786,6 +812,8 @@ export const RunCommand = effectCmd({
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session || args.continue) && !args.fork,
replay,
replayLimit: args["replay-limit"],
agent,
model,
variant: args.variant,
@@ -821,6 +849,8 @@ export const RunCommand = effectCmd({
agent: args.agent,
model,
variant: args.variant,
replay,
replayLimit: args["replay-limit"],
files,
initialInput,
thinking,
@@ -7,7 +7,7 @@
/** @jsxImportSource @opentui/solid */
import { pathToFileURL } from "bun"
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { useKeyboard, useRenderer } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -197,13 +197,45 @@ export function RunPromptBody(props: {
onContentChange: () => void
bind: (area?: TextareaRenderable) => void
}) {
const renderer = useRenderer()
let area: TextareaRenderable | undefined
let pasteTick: ReturnType<typeof setTimeout> | undefined
const refreshPasteLayout = () => {
if (pasteTick) {
clearTimeout(pasteTick)
}
pasteTick = setTimeout(() => {
pasteTick = undefined
if (!area || area.isDestroyed) {
return
}
// Paste can leave the textarea layout stale until the next edit.
area.getLayoutNode().markDirty()
renderer.requestRender()
void renderer
.idle()
.then(() => {
if (!area || area.isDestroyed) {
return
}
props.onContentChange()
})
.catch(() => {})
}, 0)
}
onMount(() => {
props.bind(area)
})
onCleanup(() => {
if (pasteTick) {
clearTimeout(pasteTick)
}
props.bind(undefined)
})
@@ -226,6 +258,9 @@ export function RunPromptBody(props: {
keyBindings={props.bindings()}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={() => {
refreshPasteLayout()
}}
onContentChange={props.onContentChange}
ref={(next) => {
area = next
@@ -51,6 +51,8 @@ type RunRuntimeInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
}
@@ -67,6 +69,8 @@ type RunLocalInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
}
@@ -490,6 +494,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
directory: ctx.directory,
sessionID: state.sessionID,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
limits: () => state.limits,
footer,
trace: log,
@@ -722,6 +728,8 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
resolveSession: () => {
if (session) {
@@ -774,6 +782,8 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
boot: async () => ({
sdk: input.sdk,
@@ -0,0 +1,188 @@
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { messagePrompt, type SessionMessages } from "./session.shared"
import type { FooterPatch, StreamCommit } from "./types"
type ReplayInput = {
messages: SessionMessages
permissions: PermissionRequest[]
questions: QuestionRequest[]
thinking: boolean
limits: Record<string, number>
}
export type SessionReplay = {
data: SessionData
commits: StreamCommit[]
patch?: FooterPatch
}
type ReplayMessage = {
commits: StreamCommit[]
patch?: FooterPatch
}
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
return reduceSessionData({
data,
event,
sessionID,
thinking,
limits,
})
}
function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) {
if (!left) {
return right
}
if (!right) {
return left
}
return {
...left,
...right,
}
}
function active(data: SessionData) {
return data.part.size > 0 || data.tools.size > 0
}
function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
if (active(data)) {
if (!patch) {
return {
phase: "running",
} satisfies FooterPatch
}
return {
...patch,
phase: "running",
} satisfies FooterPatch
}
if (data.permissions.length > 0 || data.questions.length > 0) {
if (!patch) {
return {
phase: "idle",
} satisfies FooterPatch
}
return {
...patch,
phase: "idle",
} satisfies FooterPatch
}
if (!patch) {
return undefined
}
return {
...patch,
phase: "idle",
status: "",
} satisfies FooterPatch
}
function replayMessage(
data: SessionData,
message: SessionMessages[number],
thinking: boolean,
limits: Record<string, number>,
): ReplayMessage {
if (message.info.role === "user") {
const prompt = messagePrompt(message)
if (!prompt.text.trim()) {
return {
commits: [],
}
}
return {
commits: [
{
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: message.info.id,
},
],
}
}
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
const info = apply(
data,
{
id: `bootstrap:message:${message.info.id}`,
type: "message.updated",
properties: {
sessionID: message.info.sessionID,
info: message.info,
},
},
message.info.sessionID,
thinking,
limits,
)
commits.push(...info.commits)
patch = mergePatch(patch, info.footer?.patch)
for (const part of message.parts) {
const next = apply(
data,
{
id: `bootstrap:part:${part.id}`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
part,
time: 0,
},
},
message.info.sessionID,
thinking,
limits,
)
patch = mergePatch(patch, next.footer?.patch)
commits.push(...next.commits)
}
return {
commits,
patch,
}
}
export function replaySession(input: ReplayInput): SessionReplay {
const data = createSessionData()
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
bootstrapSessionData({
data,
messages: input.messages,
permissions: input.permissions,
questions: input.questions,
})
for (const message of input.messages) {
const next = replayMessage(data, message, input.thinking, input.limits)
commits.push(...next.commits)
patch = mergePatch(patch, next.patch)
}
return {
data,
commits,
patch: replayPatch(data, patch),
}
}
@@ -60,7 +60,7 @@ function fileSource(
}
}
function prompt(msg: SessionMessages[number]): RunPrompt {
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
const parts: RunPrompt["parts"] = []
let text = msg.parts
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
@@ -135,7 +135,7 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
}
return {
prompt: prompt(msg),
prompt: messagePrompt(msg),
provider: msg.info.model.providerID,
model: msg.info.model.modelID,
variant: msg.info.model.variant,
@@ -27,6 +27,7 @@ import {
reduceSessionData,
type SessionData,
} from "./session-data"
import { replaySession } from "./session-replay"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
@@ -66,6 +67,8 @@ type StreamInput = {
directory?: string
sessionID: string
thinking: boolean
replay?: boolean
replayLimit?: number
limits: () => Record<string, number>
footer: FooterApi
trace?: Trace
@@ -432,7 +435,12 @@ function createLayer(input: StreamInput) {
blockerTick: 0,
blockers: new Map(),
}
let booting = true
const buffered: Event[] = []
const replayedParts = new Set<string>()
const recovering = new Set<string>()
const tracked = (sessionID: string | undefined) =>
sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID))
const currentSubagentState = () => {
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
state.selectedSubagent = undefined
@@ -550,11 +558,11 @@ function createLayer(input: StreamInput) {
}
})
const messages = (sessionID: string, limit: number) =>
const messages = (sessionID: string, limit?: number) =>
Effect.promise(() =>
input.sdk.session.messages({
sessionID,
limit,
...(typeof limit === "number" ? { limit } : {}),
}),
).pipe(
Effect.map((item) => item.data ?? []),
@@ -596,7 +604,14 @@ function createLayer(input: StreamInput) {
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
const [messagesList, children, permissions, questions] = yield* Effect.all(
[
messages(input.sessionID, SUBAGENT_BOOTSTRAP_LIMIT),
messages(
input.sessionID,
input.replay
? input.replayLimit === undefined
? undefined
: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT)
: SUBAGENT_BOOTSTRAP_LIMIT,
),
Effect.promise(() =>
input.sdk.session.children({
sessionID: input.sessionID,
@@ -619,12 +634,52 @@ function createLayer(input: StreamInput) {
},
)
bootstrapSessionData({
data: state.data,
messages: messagesList,
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
questions: questions.filter((item) => item.sessionID === input.sessionID),
})
const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
const history = input.replay
? replaySession({
messages: messagesList,
permissions: sessionPermissions,
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
})
: undefined
const replay =
history && input.replayLimit !== undefined && messagesList.length > input.replayLimit
? replaySession({
messages: messagesList.slice(-input.replayLimit),
permissions: sessionPermissions,
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
})
: history
replayedParts.clear()
if (history) {
state.data = history.data
}
if (!history) {
bootstrapSessionData({
data: state.data,
messages: messagesList,
permissions: sessionPermissions,
questions: sessionQuestions,
})
}
if (replay) {
for (const [partID] of replay.data.text) {
if (!replay.data.part.has(partID)) {
continue
}
replayedParts.add(partID)
}
}
bootstrapSubagentData({
data: state.subagent,
messages: messagesList,
@@ -632,6 +687,7 @@ function createLayer(input: StreamInput) {
permissions,
questions,
})
clearFinishedSubagents(state.subagent)
for (const request of [
...state.data.permissions,
@@ -642,9 +698,29 @@ function createLayer(input: StreamInput) {
seedBlocker(request.id)
}
if (replay) {
const activeCommitIDs = new Set([...state.data.part.keys(), ...state.data.tools])
for (const commit of replay.commits) {
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
if (commit.partID && activeCommitIDs.has(commit.partID)) {
continue
}
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
}
}
const snapshot = currentSubagentState()
traceTabs(input.trace, [], snapshot.tabs)
syncFooter([], undefined, snapshot)
syncFooter([], replay?.patch, snapshot)
if (replay) {
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
}
booting = false
yield* drainBuffered()
const sessions = [...state.subagent.tabs.keys()]
if (sessions.length === 0) {
@@ -738,6 +814,86 @@ function createLayer(input: StreamInput) {
})
}
const applyEvent = Effect.fn("RunStreamTransport.applyEvent")(function* (event: Event) {
if (event.type === "message.part.delta" && event.properties.sessionID === input.sessionID) {
if (replayedParts.has(event.properties.partID)) {
const seen = state.data.text.get(event.properties.partID) ?? ""
if (seen.endsWith(event.properties.delta)) {
return
}
replayedParts.delete(event.properties.partID)
}
}
trackBlocker(event)
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
const next = reduceSessionData({
data: state.data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
state.data = next.data
if (
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
event.properties.part.tool === "question" &&
event.properties.part.state.status === "running" &&
state.data.questions.length === 0
) {
yield* recoverQuestion(event.properties.part.id).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
}
const changed = reduceSubagentData({
data: state.subagent,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
if (changed && prev) {
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
}
releaseBlocker(event)
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
touch(event)
yield* mark(event)
})
const drainBuffered = Effect.fn("RunStreamTransport.drainBuffered")(function* () {
let pending = buffered.splice(0)
while (pending.length > 0) {
const next: Event[] = []
let changed = false
for (const event of pending) {
if (!tracked(sid(event))) {
next.push(event)
continue
}
changed = true
yield* applyEvent(event)
}
if (!changed) {
buffered.push(...next)
return
}
pending = next
}
})
const watch = Effect.fn("RunStreamTransport.watch")(() =>
Stream.fromAsyncIterable(events.stream, (error) =>
error instanceof Error ? error : new Error(String(error)),
@@ -762,53 +918,25 @@ function createLayer(input: StreamInput) {
}
const sessionID = sid(event)
if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) {
if (booting) {
if (sessionID) {
input.trace?.write("recv.event", event)
buffered.push(event)
}
return
}
if (!tracked(sessionID)) {
if (sessionID) {
input.trace?.write("recv.event", event)
buffered.push(event)
}
return
}
input.trace?.write("recv.event", event)
trackBlocker(event)
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
const next = reduceSessionData({
data: state.data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
state.data = next.data
if (
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
event.properties.part.tool === "question" &&
event.properties.part.state.status === "running" &&
state.data.questions.length === 0
) {
yield* recoverQuestion(event.properties.part.id).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
}
const changed = reduceSubagentData({
data: state.subagent,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
if (changed && prev) {
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
}
releaseBlocker(event)
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
touch(event)
yield* mark(event)
yield* applyEvent(event)
yield* drainBuffered()
}),
),
Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
@@ -823,8 +951,8 @@ function createLayer(input: StreamInput) {
),
)
yield* bootstrap()
yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
yield* bootstrap()
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
if (closed || next.signal?.aborted || input.footer.isClosed) {
@@ -420,9 +420,26 @@ function ensureBlockerTab(
title: string | undefined,
kind: "permission" | "question",
) {
if (data.tabs.has(sessionID)) {
const current = data.tabs.get(sessionID)
if (current) {
ensureDetail(data, sessionID)
return false
if (current.status !== "running") {
return false
}
const next = {
...current,
description: kind === "permission" ? "Pending permission" : "Pending question",
status: "running" as const,
title: current.title ?? title,
lastUpdatedAt: Date.now(),
}
if (sameSubagentTab(current, next)) {
return false
}
data.tabs.set(sessionID, next)
return true
}
data.tabs.set(sessionID, {
@@ -52,6 +52,8 @@ export type RunInput = {
sessionID: string
sessionTitle?: string
resume?: boolean
replay?: boolean
replayLimit?: number
agent: string | undefined
model: PromptModel | undefined
variant: string | undefined
@@ -1,12 +1,14 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useProject } from "@tui/context/project"
import { useSDK } from "@tui/context/sdk"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const dialog = useDialog()
const project = useProject()
const [store] = createStore({
filter: "",
@@ -17,6 +19,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
async () => {
const result = await sdk.client.find.files({
query: store.filter,
workspace: project.workspace.current(),
})
if (result.error) return []
const sliced = (result.data ?? []).slice(0, 5)
@@ -6,6 +6,7 @@ import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useEditorContext } from "@tui/context/editor"
import { useProject } from "@tui/context/project"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
@@ -19,7 +20,7 @@ import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
import { Reference } from "@/reference/reference"
import type { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
function removeLineRange(input: string) {
@@ -85,6 +86,7 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const project = useProject()
const command = useCommandPalette()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
@@ -310,7 +312,7 @@ export function Autocomplete(props: {
`Referenced configured reference @${reference.name}.`,
...(reference.kind === "local" ? ["Kind: local directory"] : []),
...(reference.kind === "git" ? ["Kind: git repository"] : []),
...(reference.kind === "invalid" ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "invalid" && reference.repository ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
@@ -324,7 +326,7 @@ export function Autocomplete(props: {
const references = createMemo(() =>
Reference.resolveAll({
references: (sync.data.config.reference ?? {}) as NonNullable<Config.Info["reference"]>,
references: ConfigReference.normalize(sync.data.config.reference ?? {}),
directory: sync.path.directory || process.cwd(),
worktree: sync.path.worktree || sync.path.directory || process.cwd(),
}),
@@ -382,6 +384,7 @@ export function Autocomplete(props: {
// Get files from SDK
const result = await sdk.client.find.files({
query: baseQuery,
workspace: project.workspace.current(),
})
const options: AutocompleteOption[] = []
@@ -28,7 +28,7 @@ import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign } from "./part"
import { assign, expandPastedTextPlaceholders } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -1544,6 +1544,9 @@ export function Prompt(props: PromptProps) {
}}
ref={(r: TextareaRenderable) => {
input = r
Object.assign(r, {
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
})
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
@@ -14,3 +14,10 @@ export function assign(part: Item): Item & { id: PartID } {
id: PartID.ascending(),
}
}
export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["parts"]) {
return parts.reduce((result, part) => {
if (part.type !== "text" || !part.source?.text) return result
return result.replace(part.source.text.value, part.text)
}, text)
}
@@ -188,6 +188,7 @@ export const Definitions = {
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
@@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) {
{
scope: ["extmark.paste"],
style: {
foreground: theme.background,
foreground: selectedForeground(theme, theme.warning),
background: theme.warning,
bold: true,
},
@@ -30,6 +30,7 @@ import type {
ToolTextContent,
} from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { collapseToolOutput } from "../../util/collapse-tool-output"
const id = "internal:session-v2-debug"
const route = "session.v2.messages"
@@ -198,26 +199,28 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
function ShellMessage(props: { message: SessionMessageShell }) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi(props.message.output.trim()))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<BlockTool
title="# Shell"
spinner={!props.message.time.completed}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -518,14 +521,15 @@ type ToolProps = {
function GenericTool(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Show
@@ -539,11 +543,11 @@ function GenericTool(props: ToolProps) {
<BlockTool
title={`# ${props.part.name} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -702,15 +706,17 @@ function BlockTool(props: {
function Bash(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Switch>
@@ -719,12 +725,12 @@ function Bash(props: ToolProps) {
title={title()}
part={props.part}
spinner={props.part.state.status === "running"}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {command()}</text>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -114,6 +114,7 @@ type RuntimeState = {
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
pending: Map<string, ConfigPlugin.Origin>
dispose_timeout_ms: number
}
const log = Log.create({ service: "tui.plugin" })
@@ -394,7 +395,7 @@ async function syncPluginThemes(plugin: PluginEntry) {
}
}
function createPluginScope(load: PluginLoad, id: string) {
function createPluginScope(load: PluginLoad, id: string, disposeTimeoutMs: number) {
const ctrl = new AbortController()
let list: { key: symbol; fn: TuiDispose }[] = []
let done = false
@@ -436,14 +437,14 @@ function createPluginScope(load: PluginLoad, id: string) {
ctrl.abort()
const queue = [...list].reverse()
list = []
const until = Date.now() + DISPOSE_TIMEOUT_MS
const until = Date.now() + disposeTimeoutMs
for (const item of queue) {
const left = until - Date.now()
if (left <= 0) {
fail("timed out cleaning up tui plugin", {
path: load.spec,
id,
timeout: DISPOSE_TIMEOUT_MS,
timeout: disposeTimeoutMs,
})
break
}
@@ -454,7 +455,7 @@ function createPluginScope(load: PluginLoad, id: string) {
fail("timed out cleaning up tui plugin", {
path: load.spec,
id,
timeout: DISPOSE_TIMEOUT_MS,
timeout: disposeTimeoutMs,
})
break
}
@@ -523,7 +524,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
if (persist) writePluginEnabledState(state.api, plugin.id, true)
if (plugin.scope) return true
const scope = createPluginScope(plugin.load, plugin.id)
const scope = createPluginScope(plugin.load, plugin.id, state.dispose_timeout_ms)
const api = pluginApi(state, plugin, scope, plugin.id)
const ok = await Promise.resolve()
.then(async () => {
@@ -1002,7 +1003,12 @@ let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved; dispose?: () => void }) {
export async function init(input: {
api: HostPluginApi
config: TuiConfig.Resolved
dispose?: () => void
disposeTimeoutMs?: number
}) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
@@ -1052,7 +1058,7 @@ export async function dispose() {
state.dispose?.()
}
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void }) {
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void; disposeTimeoutMs?: number }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
@@ -1064,6 +1070,7 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
plugins: [],
plugins_by_id: new Map(),
pending: new Map(),
dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS,
}
runtime = next
try {
@@ -84,6 +84,7 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { SessionRetry } from "@/session/retry"
@@ -1696,12 +1697,12 @@ function GenericTool(props: ToolProps<any>) {
const ctx = use()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
@@ -1716,11 +1717,11 @@ function GenericTool(props: ToolProps<any>) {
<BlockTool
title={`# ${props.tool} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1871,14 +1872,16 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const ctx = use()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
const workdirDisplay = createMemo(() => {
@@ -1902,14 +1905,14 @@ function Shell(props: ToolProps<typeof ShellTool>) {
title={title()}
part={props.part}
spinner={isRunning()}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1,8 +1,10 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, onMount, type JSX } from "solid-js"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
import { useTuiConfig } from "../context/tui-config"
import { useBindings, useCommandShortcut } from "../keymap"
export type DialogPromptProps = {
title: string
@@ -18,8 +20,32 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable
function confirm() {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}
useBindings(() => ({
target: textareaTarget,
enabled: textareaTarget() !== undefined && !props.busy,
// Dialog form semantics must win over the global managed textarea input layer.
priority: 1,
commands: [
{
name: "dialog.prompt.submit",
title: "Submit dialog prompt",
category: "Dialog",
run: confirm,
},
],
bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
}))
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
@@ -59,13 +85,10 @@ export function DialogPrompt(props: DialogPromptProps) {
<box gap={1}>
{props.description}
<textarea
onSubmit={() => {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}}
height={3}
ref={(val: TextareaRenderable) => {
textarea = val
setTextareaTarget(val)
}}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
@@ -80,9 +103,11 @@ export function DialogPrompt(props: DialogPromptProps) {
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<Show when={submitShortcut()}>
<text fg={theme.text}>
{submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</Show>
</box>
</box>
@@ -0,0 +1,19 @@
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
return { output, overflow: false }
}
const preview = lines.slice(0, maxLines).join("\n")
if (Array.from(preview).length > maxChars) {
return {
output:
Array.from(preview)
.slice(0, Math.max(0, maxChars - 1))
.join("") + "…",
overflow: true,
}
}
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
}
@@ -7,6 +7,7 @@ type Toast = {
type FocusableSelectionTarget = {
hasSelection: () => boolean
getClipboardText?: (text: string) => string
}
type Renderer = {
@@ -23,10 +24,17 @@ type SelectionKeyEvent = {
}
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
const selection = renderer.getSelection()
if (!selection) return false
const text = selection.getSelectedText()
if (!text) return false
Clipboard.copy(text)
const focus = renderer.currentFocusedRenderable
const clipboardText =
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
Clipboard.copy(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
+46
View File
@@ -18,6 +18,52 @@ const Local = Schema.Struct({
})
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
export type Entry = Schema.Schema.Type<typeof Entry>
export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" })
export type Info = Schema.Schema.Type<typeof Info>
export type NormalizedEntry =
| {
kind: "local"
path: string
}
| {
kind: "git"
repository: string
branch?: string
}
| {
kind: "invalid"
message: string
}
export type NormalizedInfo = Record<string, NormalizedEntry>
export function validateAlias(name: string) {
if (name.length === 0) return "Reference alias must not be empty"
if (/[\/\s`,]/.test(name)) {
return "Reference alias must not contain /, whitespace, comma, or backtick"
}
}
export function normalizeEntry(entry: Entry): NormalizedEntry {
if (typeof entry === "string") {
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) {
return { kind: "local", path: entry }
}
return { kind: "git", repository: entry }
}
if ("path" in entry) return { kind: "local", path: entry.path }
return { kind: "git", repository: entry.repository, branch: entry.branch }
}
export function normalize(info: Info): NormalizedInfo {
return Object.fromEntries(
Object.entries(info).map(([name, entry]) => {
const aliasError = validateAlias(name)
return [name, aliasError ? { kind: "invalid" as const, message: aliasError } : normalizeEntry(entry)] as const
}),
)
}
@@ -160,6 +160,7 @@ export interface Interface {
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
timeout?: number,
) => Effect.Effect<void, WaitForSyncError>
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
}
@@ -946,12 +947,13 @@ export const layer = Layer.effect(
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
timeout = TIMEOUT,
) {
if (synced(state)) return
yield* Effect.catch(
waitEvent({
timeout: TIMEOUT,
timeout,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
+1 -1
View File
@@ -14,7 +14,7 @@ import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
@@ -50,6 +50,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: enabledByExperimental("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}
+1 -1
View File
@@ -243,7 +243,7 @@ export const layer = Layer.effect(
}
// Subscribe to bus events, fiber interrupted when scope closes
yield* bus.subscribeAll().pipe(
yield* (yield* bus.subscribeAll()).pipe(
Stream.runForEach((input) =>
Effect.sync(() => {
for (const hook of hooks) {
+1 -1
View File
@@ -425,7 +425,7 @@ export const layer: Layer.Layer<
const initState = yield* InstanceState.make(
Effect.fn("Project.initState")(function* (ctx) {
yield* bus.subscribe(Command.Event.Executed).pipe(
yield* (yield* bus.subscribe(Command.Event.Executed)).pipe(
Stream.runForEach((payload) =>
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
),
+1 -1
View File
@@ -298,7 +298,7 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
yield* (yield* bus.subscribe(FileWatcher.Event.Updated)).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach((_evt) =>
Effect.gen(function* () {
@@ -1,6 +1,6 @@
import { Schema } from "effect"
export { CatalogModelStatus } from "@opencode-ai/core/models"
export { CatalogModelStatus } from "@opencode-ai/core/models-dev"
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
export type ModelStatus = typeof ModelStatus.Type
+1 -1
View File
@@ -8,7 +8,7 @@ import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "@opencode-ai/core/models"
import * as ModelsDev from "@opencode-ai/core/models-dev"
import { Auth } from "../auth"
import { Env } from "../env"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ModelMessage, ToolResultPart } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type * as Provider from "./provider"
import type * as ModelsDev from "@opencode-ai/core/models"
import type * as ModelsDev from "@opencode-ai/core/models-dev"
import { iife } from "@/util/iife"
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
+73 -75
View File
@@ -3,14 +3,12 @@ import { Effect, Context, Layer, Scope } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository"
import { RepositoryCache } from "./repository-cache"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
export type Resolved =
| {
name: string
@@ -21,23 +19,25 @@ export type Resolved =
name: string
kind: "git"
repository: string
reference: RepositoryReference
reference: RemoteReference
path: string
branch?: string
}
| {
name: string
kind: "invalid"
repository: string
repository?: string
message: string
}
type State = {
references: Resolved[]
materializeAll: Effect.Effect<void>
materializeByPath: { path: string; run: Effect.Effect<void> }[]
materializeByPath: Materializer[]
}
type Materializer = { path: string; run: Effect.Effect<void> }
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly list: () => Effect.Effect<Resolved[]>
@@ -90,31 +90,75 @@ function containsReferencePath(referencePath: string, target: string) {
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
}
function uniqueGitReferences(references: Resolved[]) {
const seenPath = new Set<string>()
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git") return false
if (seenPath.has(reference.path)) return false
seenPath.add(reference.path)
return true
})
}
function materializeReference(cache: RepositoryCache.Interface, reference: Extract<Resolved, { kind: "git" }>) {
return cache.ensure({ reference: reference.reference, branch: reference.branch, refresh: true }).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository").pipe(
Effect.annotateLogs({ name: reference.name, cause }),
),
),
)
}
const materializers = Effect.fn("Reference.materializers")(function* (
cache: RepositoryCache.Interface,
references: Resolved[],
) {
return yield* Effect.forEach(
uniqueGitReferences(references),
Effect.fnUntraced(function* (reference) {
return { path: reference.path, run: yield* Effect.cached(materializeReference(cache, reference)) }
}),
{ concurrency: "unbounded" },
)
})
function materializeAll(input: { flags: RuntimeFlags.Info; materializers: Materializer[] }) {
if (!input.flags.experimentalScout) return Effect.void
return Effect.forEach(
input.materializers,
Effect.fnUntraced(function* (item) {
yield* item.run
}),
{ concurrency: 4, discard: true },
)
}
function materializeByPath(materializers: Materializer[], target: string) {
return materializers.find((item) => containsReferencePath(item.path, target))?.run ?? Effect.void
}
function containsGitReferencePath(references: Resolved[], target: string) {
return references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, target))
}
export function resolve(input: {
name: string
reference: ReferenceEntry
reference: ConfigReference.NormalizedEntry
directory: string
worktree: string
}): Resolved {
if (typeof input.reference === "string") {
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
}
return resolveGit({ name: input.name, repository: input.reference })
if (input.reference.kind === "invalid") {
return { name: input.name, kind: "invalid", message: input.reference.message }
}
if ("path" in input.reference) {
if (input.reference.kind === "local") {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
}
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
}
export function resolveAll(input: {
references: NonNullable<Config.Info["reference"]>
directory: string
worktree: string
}) {
export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; worktree: string }) {
const seen = new Map<string, { name: string; branch?: string }>()
return Object.entries(input.references).map(([name, reference]) => {
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
@@ -140,8 +184,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const flags = yield* RuntimeFlags.Service
@@ -149,53 +192,14 @@ export const layer = Layer.effect(
Effect.fn("Reference.state")(function* (ctx) {
const cfg = yield* config.get()
const references = resolveAll({
references: cfg.reference ?? {},
references: ConfigReference.normalize(cfg.reference ?? {}),
directory: ctx.directory,
worktree: ctx.worktree,
})
const seenPath = new Set<string>()
const gitReferences = references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git") return false
if (seenPath.has(reference.path)) return false
seenPath.add(reference.path)
return true
})
const materializeByPath = yield* Effect.forEach(
gitReferences,
Effect.fnUntraced(function* (reference) {
const run = yield* Effect.cached(
RepositoryCache.ensure(
{ reference: reference.reference, branch: reference.branch, refresh: true },
{ fs, git },
).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository").pipe(
Effect.annotateLogs({ name: reference.name, cause }),
),
),
),
)
return { path: reference.path, run }
}),
{ concurrency: "unbounded" },
)
const materializeByPath = yield* materializers(cache, references)
const materializeAllCached = yield* Effect.cached(materializeAll({ flags, materializers: materializeByPath }))
const materializeAll = yield* Effect.cached(
flags.experimentalScout
? Effect.gen(function* () {
yield* Effect.forEach(
materializeByPath,
Effect.fnUntraced(function* (item) {
yield* item.run
}),
{ concurrency: 4, discard: true },
)
})
: Effect.void,
)
return { references, materializeAll, materializeByPath }
return { references, materializeAll: materializeAllCached, materializeByPath }
}),
)
@@ -214,18 +218,13 @@ export const layer = Layer.effect(
if (!flags.experimentalScout) return
const full = normalizedTarget(target)
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
return yield* InstanceState.useEffect(
state,
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
)
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
}),
contains: Effect.fn("Reference.contains")(function* (target?: string) {
if (!flags.experimentalScout) return false
const full = normalizedTarget(target)
if (!full) return false
return yield* InstanceState.use(state, (s) =>
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
)
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
}),
})
}),
@@ -233,8 +232,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
@@ -1,5 +1,5 @@
import path from "path"
import { Effect } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
import { Git } from "@/git"
@@ -8,7 +8,8 @@ import {
sameRepositoryReference,
parseRepositoryReference,
validateRepositoryBranch,
type Reference as RepositoryReference,
isRemoteRepositoryReference,
type RemoteReference,
} from "@/util/repository"
export type Result = {
@@ -21,6 +22,82 @@ export type Result = {
branch?: string
}
export type EnsureInput = {
reference: RemoteReference
refresh?: boolean
branch?: string
}
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
"RepositoryCacheInvalidRepositoryError",
{
repository: Schema.String,
message: Schema.String,
},
) {}
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
"RepositoryCacheInvalidBranchError",
{
branch: Schema.String,
message: Schema.String,
},
) {}
export class CloneFailedError extends Schema.TaggedErrorClass<CloneFailedError>()("RepositoryCacheCloneFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class FetchFailedError extends Schema.TaggedErrorClass<FetchFailedError>()("RepositoryCacheFetchFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class CheckoutFailedError extends Schema.TaggedErrorClass<CheckoutFailedError>()(
"RepositoryCacheCheckoutFailedError",
{
repository: Schema.String,
branch: Schema.String,
message: Schema.String,
},
) {}
export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("RepositoryCacheResetFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class LockFailedError extends Schema.TaggedErrorClass<LockFailedError>()("RepositoryCacheLockFailedError", {
localPath: Schema.String,
message: Schema.String,
}) {}
export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationError>()(
"RepositoryCacheOperationError",
{
operation: Schema.String,
path: Schema.String,
message: Schema.String,
},
) {}
export type Error =
| InvalidRepositoryError
| InvalidBranchError
| CloneFailedError
| FetchFailedError
| CheckoutFailedError
| ResetFailedError
| LockFailedError
| CacheOperationError
export interface Interface {
ensure: (input: EnsureInput) => Effect.Effect<Result, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/RepositoryCache") {}
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
if (!input.reuse) return "cloned" as const
if (input.branchMatches === false) return "refreshed" as const
@@ -43,18 +120,53 @@ function resetTarget(input: {
return "HEAD"
}
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
input: {
reference: RepositoryReference
refresh?: boolean
branch?: string
},
function errorMessage(error: unknown) {
return error instanceof globalThis.Error ? error.message : String(error)
}
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidRepositoryError ||
error instanceof InvalidBranchError ||
error instanceof CloneFailedError ||
error instanceof FetchFailedError ||
error instanceof CheckoutFailedError ||
error instanceof ResetFailedError ||
error instanceof LockFailedError ||
error instanceof CacheOperationError
)
}
export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) {
const reference = parseRepositoryReference(repository)
if (!reference) {
return yield* new InvalidRepositoryError({
repository,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
})
}
if (!isRemoteRepositoryReference(reference)) {
return yield* new InvalidRepositoryError({ repository, message: "Local file repositories are not supported" })
}
return reference
})
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
try {
validateRepositoryBranch(branch)
} catch (error) {
return yield* new InvalidBranchError({ branch, message: errorMessage(error) })
}
})
const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
input: EnsureInput,
services: {
fs: AppFileSystem.Interface
git: Git.Interface
},
) {
if (input.branch) validateRepositoryBranch(input.branch)
if (input.branch) yield* validateBranch(input.branch)
const repository = input.reference.label
const remote = input.reference.remote
@@ -62,10 +174,24 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
return yield* Effect.acquireUseRelease(
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })).pipe(
Effect.catch((error: unknown) =>
Effect.fail(new LockFailedError({ localPath, message: errorMessage(error) || `Failed to lock ${localPath}` })),
),
),
() =>
Effect.gen(function* () {
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(
Effect.catch((error: unknown) =>
Effect.fail(
new CacheOperationError({
operation: "ensure cache directory",
path: localPath,
message: errorMessage(error),
}),
),
),
)
const exists = yield* services.fs.existsSafe(localPath)
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
@@ -75,7 +201,17 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
if (exists && !reuse) {
yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
yield* services.fs.remove(localPath, { recursive: true }).pipe(
Effect.catch((error: unknown) =>
Effect.fail(
new CacheOperationError({
operation: "remove stale cache",
path: localPath,
message: errorMessage(error),
}),
),
),
)
}
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
@@ -91,14 +227,20 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
{ cwd: path.dirname(localPath) },
)
if (clone.exitCode !== 0) {
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
return yield* new CloneFailedError({
repository,
message: clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`,
})
}
}
if (status === "refreshed") {
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
if (fetch.exitCode !== 0) {
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
return yield* new FetchFailedError({
repository,
message: fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`,
})
}
if (input.branch) {
@@ -106,9 +248,12 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
cwd: localPath,
})
if (checkout.exitCode !== 0) {
throw new Error(
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
)
return yield* new CheckoutFailedError({
repository,
branch: input.branch,
message:
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
})
}
}
@@ -122,7 +267,10 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
if (reset.exitCode !== 0) {
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
return yield* new ResetFailedError({
repository,
message: reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`,
})
}
}
@@ -144,4 +292,23 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
)
})
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
return Service.of({
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
return yield* ensureWithServices(input, { fs, git })
}),
})
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
)
export * as RepositoryCache from "./repository-cache"
@@ -20,10 +20,11 @@ function eventData(data: unknown): Sse.Event {
function eventResponse(bus: Bus.Interface) {
return Effect.gen(function* () {
const context = yield* Effect.context()
const events = bus.subscribeAll().pipe(
Stream.provideContext(context),
// Subscribe eagerly: the bus subscription is acquired in the request scope
// at this yield, so any publish from now on is queued for the body-pump
// fiber to drain — closing the race where Stream.concat(server.connected,
// lazy-subscribe) used to drop publishes in the prefix-consume window.
const events = (yield* bus.subscribeAll()).pipe(
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
)
const heartbeat = Stream.tick("10 seconds").pipe(
@@ -1,6 +1,6 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
@@ -1,6 +1,6 @@
import { WorkspaceID } from "@/control-plane/schema"
import { SessionV2 } from "@/v2/session"
import { Effect, Schema } from "effect"
import { DateTime, Effect, Schema } from "effect"
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
@@ -55,7 +55,13 @@ const sessionCursor = {
filters: Pick<SessionCursor, "directory" | "path" | "workspaceID" | "roots" | "start" | "search">,
) {
return Buffer.from(
JSON.stringify({ id: session.id, time: session.time.created, order, direction, ...filters }),
JSON.stringify({
...filters,
id: session.id,
time: DateTime.toEpochMillis(session.time.updated),
order,
direction,
}),
).toString("base64url")
},
decode(input: string) {
@@ -30,7 +30,7 @@ import { InstanceLayer } from "@/project/instance-layer"
import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
+156 -83
View File
@@ -2,7 +2,10 @@ import { Provider } from "@/provider/provider"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Record } from "effect"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema } from "ai"
import type { LLMEvent } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import type { LLMClientService } from "@opencode-ai/llm/route"
import { mergeDeep } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform"
@@ -23,10 +26,11 @@ import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { LLMAISDK } from "./llm/ai-sdk"
import { LLMNativeRuntime } from "./llm/native-runtime"
const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>>
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
@@ -51,10 +55,8 @@ export type StreamRequest = StreamInput & {
abort: AbortSignal
}
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
@@ -62,7 +64,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/LL
const live: Layer.Layer<
Service,
never,
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service | RuntimeFlags.Service
| Auth.Service
| Config.Service
| Provider.Service
| Plugin.Service
| Permission.Service
| LLMClientService
| RuntimeFlags.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -71,6 +79,7 @@ const live: Layer.Layer<
const provider = yield* Provider.Service
const plugin = yield* Plugin.Service
const perm = yield* Permission.Service
const llmClient = yield* LLMClient.Service
const flags = yield* RuntimeFlags.Service
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
@@ -202,7 +211,7 @@ const live: Layer.Layer<
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
tools["_noop"] = tool({
tools["_noop"] = aiTool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
@@ -322,86 +331,141 @@ const live: Layer.Layer<
? (yield* InstanceState.context).project.id
: undefined
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
const requestHeaders = {
...(input.model.providerID.startsWith("opencode")
? {
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": flags.client,
"User-Agent": `opencode/${InstallationVersion}`,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
}),
...input.model.headers,
...headers,
}
if (flags.experimentalNativeLlm) {
const native = LLMNativeRuntime.stream({
model: input.model,
provider: item,
auth: info,
llmClient,
isOpenaiOauth,
system,
messages,
tools: sortedTools,
toolChoice: input.toolChoice,
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
maxOutputTokens: params.maxOutputTokens,
providerOptions: params.options,
headers: requestHeaders,
abort: input.abort,
})
if (native.type === "supported") {
yield* Effect.logInfo("llm runtime selected").pipe(
Effect.annotateLogs({
"llm.runtime": "native",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
}),
)
return {
type: "native" as const,
stream: native.stream,
}
}
yield* Effect.logInfo("llm runtime selected").pipe(
Effect.annotateLogs({
"llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
"llm.native_unsupported_reason": native.reason,
}),
)
l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason })
}
yield* Effect.logInfo("llm runtime selected").pipe(
Effect.annotateLogs({
"llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
}),
)
return {
type: "ai-sdk" as const,
result: streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
tools: sortedTools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": opencodeProjectID,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": flags.client,
"User-Agent": `opencode/${InstallationVersion}`,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
toolName: "invalid",
}
},
},
})
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
tools: sortedTools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: requestHeaders,
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
}),
}
})
const stream: Interface["stream"] = (input) =>
@@ -415,7 +479,15 @@ const live: Layer.Layer<
const result = yield* run({ ...input, abort: ctrl.signal })
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
if (result.type === "native") return result.stream
const state = LLMAISDK.adapterState()
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
e instanceof Error ? e : new Error(String(e)),
).pipe(
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
)
}),
),
)
@@ -432,6 +504,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))),
Layer.provide(RuntimeFlags.defaultLayer),
),
)
@@ -0,0 +1,67 @@
# Session LLM Runtime Boundaries
`../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection.
This folder contains adapters behind that service boundary:
- `ai-sdk.ts` converts AI SDK `fullStream` parts into `@opencode-ai/llm` `LLMEvent`s. This is the default runtime path.
- `native-request.ts` converts opencode's normalized session input into a native `@opencode-ai/llm` `LLMRequest`. It does not execute requests.
- `native-runtime.ts` is the opt-in native runtime adapter. It decides whether a selected model is supported, builds the native request, bridges opencode tools into native executable tools, and delegates transport to `LLMClient` / `RequestExecutor`.
## Runtime selection
Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others.
```txt
╭───────────────────╮
╭───────────────────────────▶│ session processor │
│ ╰─────────┬─────────╯
│ │
│ │
│ │
│ ▼
│ ╭─────────────────────────╮
│ │ LLM.Service (../llm.ts) │
│ ╰────────────┬────────────╯
│ │
│ │
│ │
│ ▼
│ ╭───────────╮
│ ╭─╯ ╰─╮
│ │ native gate │
│ ╰─╮ ╭─╯
│ ╰─────┬─────╯
│ │
│ ╭────── no ──────┴─────── yes ────────╮
│ │ │
│ ▼ ▼
│ ╭───────────────────────────╮ ╭───────────────────╮
│ │ AI SDK │ │ native-runtime.ts │
│ │ streamText / generateText │ ╰────────┬──────────╯
│ ╰─────────────┬─────────────╯ │
│ │ │
│ ╭───╯ │
│ │ │
│ ▼ ▼
│ ╭───────────────────────╮ ╭────────────────────────────╮
│ │ ai-sdk.ts │ │ native-request.ts │
│ │ fullStream → LLMEvent │ │ session input → LLMRequest │
│ ╰──────────┬────────────╯ ╰──────────────┬─────────────╯
│ │ │
│ │ ╭───╯
│ │ │
│ ▼ ▼
│ ╭─────────────────╮ ╭─────────────────────────────╮
╰───────┤ LLMEvent stream │◀────────────┤ LLMClient · RequestExecutor │
╰─────────────────╯ ╰─────────────────────────────╯
```
`native-runtime.ts` evaluates the gate and either bridges into `@opencode-ai/llm` or returns control so `llm.ts` can take the AI SDK path. Tool execution stays opencode-owned in both branches; only request lowering and transport differ.
Safety boundary:
- AI SDK remains the default.
- `OPENCODE_EXPERIMENTAL_NATIVE_LLM=true` or the umbrella `OPENCODE_EXPERIMENTAL=true` opts in. Native is not a global replacement.
- Native execution currently runs only for OpenAI-compatible Responses models exposed through `@ai-sdk/openai`: direct `openai` API-key auth and console-managed `opencode`/Zen API-key config.
- Unsupported providers, OpenAI OAuth, and missing API-key cases fall back to AI SDK.

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