Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d2e75ef49 | ||
|
|
ce09fc8356 | ||
|
|
44a35c5895 | ||
|
|
8a321c4536 | ||
|
|
ef9e567e5f | ||
|
|
b396b71c6f | ||
|
|
d8efc575fa | ||
|
|
e1fbed8fb6 | ||
|
|
12ae22378f | ||
|
|
a88b436eec | ||
|
|
dbe36851bc | ||
|
|
ff9d7cab5c | ||
|
|
88681d389b | ||
|
|
ae2ecd1ed3 | ||
|
|
159d271e1e | ||
|
|
762850dfe5 | ||
|
|
b039702e8c | ||
|
|
094886c848 | ||
|
|
dc0297f4ac | ||
|
|
3ab67f3280 | ||
|
|
a2e6bd503b | ||
|
|
05ac345696 | ||
|
|
e3feca09b3 | ||
|
|
896ad7b884 | ||
|
|
0a945219a9 | ||
|
|
94828eb44b | ||
|
|
96192495ae | ||
|
|
f7b5576bcc | ||
|
|
cb35493242 | ||
|
|
5bfd7fd16c | ||
|
|
2932b41e64 | ||
|
|
eb389c58eb | ||
|
|
54ff0a669b | ||
|
|
e56999fd36 | ||
|
|
1124315267 | ||
|
|
2bf3f3041f | ||
|
|
6e4db5666a | ||
|
|
564cde393e | ||
|
|
c813927bb6 | ||
|
|
5970c12d90 | ||
|
|
116a4e33ba | ||
|
|
611e48c4ac | ||
|
|
836a33198e | ||
|
|
7afd477d1a | ||
|
|
fe143df151 | ||
|
|
e94aecaa08 | ||
|
|
418c0ea04a | ||
|
|
4312e5df05 | ||
|
|
f80f3e33ec | ||
|
|
479206449e | ||
|
|
6849059b00 | ||
|
|
43df145cd2 | ||
|
|
28a0bf6c8f | ||
|
|
30e4edffd5 | ||
|
|
5452ab6db7 | ||
|
|
e85119aa64 | ||
|
|
71b27a1b0f | ||
|
|
32f37d86f0 | ||
|
|
b14ea406ba | ||
|
|
de846ca57d | ||
|
|
f06b78751e | ||
|
|
969d0f48b2 | ||
|
|
fc19dcc70c | ||
|
|
49c6b46afc | ||
|
|
f97e115ee2 | ||
|
|
e92b1fe7d7 | ||
|
|
f98449c9b5 |
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -307,7 +307,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
"@smithy/util-utf8": "4.2.2",
|
"@smithy/util-utf8": "4.2.2",
|
||||||
@@ -384,7 +384,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -421,6 +421,7 @@
|
|||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -489,6 +490,7 @@
|
|||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@octokit/webhooks-types": "7.6.1",
|
"@octokit/webhooks-types": "7.6.1",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
@@ -520,7 +522,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -536,9 +538,9 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.2.11",
|
"@opentui/core": ">=0.2.14",
|
||||||
"@opentui/keymap": ">=0.2.11",
|
"@opentui/keymap": ">=0.2.14",
|
||||||
"@opentui/solid": ">=0.2.11",
|
"@opentui/solid": ">=0.2.14",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -558,7 +560,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -573,7 +575,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -608,7 +610,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -657,7 +659,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -721,9 +723,9 @@
|
|||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.2.11",
|
"@opentui/core": "0.2.14",
|
||||||
"@opentui/keymap": "0.2.11",
|
"@opentui/keymap": "0.2.14",
|
||||||
"@opentui/solid": "0.2.11",
|
"@opentui/solid": "0.2.14",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
@@ -735,7 +737,7 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.12",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
"@types/node": "24.12.2",
|
"@types/node": "24.12.2",
|
||||||
@@ -764,7 +766,7 @@
|
|||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"virtua": "0.42.3",
|
"virtua": "0.49.1",
|
||||||
"vite": "7.1.4",
|
"vite": "7.1.4",
|
||||||
"vite-plugin-solid": "2.11.10",
|
"vite-plugin-solid": "2.11.10",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
@@ -1590,23 +1592,23 @@
|
|||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
"@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=="],
|
"@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/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=="],
|
"@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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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=="],
|
"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=="],
|
"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/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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -223,8 +223,6 @@ const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
|
|||||||
properties: { value: stripeWebhook.secret },
|
properties: { value: stripeWebhook.secret },
|
||||||
})
|
})
|
||||||
|
|
||||||
const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
|
||||||
|
|
||||||
////////////////
|
////////////////
|
||||||
// CONSOLE
|
// CONSOLE
|
||||||
////////////////
|
////////////////
|
||||||
@@ -274,7 +272,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
new sst.Secret("CLOUDFLARE_API_TOKEN", process.env.CLOUDFLARE_API_TOKEN!),
|
new sst.Secret("CLOUDFLARE_API_TOKEN", process.env.CLOUDFLARE_API_TOKEN!),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
gatewayKv,
|
|
||||||
],
|
],
|
||||||
environment: {
|
environment: {
|
||||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-Ucvyzyq+oYvWglkeowSvb0LgDzkAvaSdq0CdA6jgN6U=",
|
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
|
||||||
"aarch64-linux": "sha256-SERwZvvN6P8/OwNolHmC0KU9H5laVQm+FD/NNKauZA8=",
|
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
|
||||||
"aarch64-darwin": "sha256-I1ABwMHkTAntlYyg43w0cW8iPYfZa9MT0In2C7plB5g=",
|
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
|
||||||
"x86_64-darwin": "sha256-degJTL0RG7QQO8/USgIF//ya7oNmwChTmAoJcpXbIp0="
|
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@
|
|||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.13",
|
"packageManager": "bun@1.3.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||||
@@ -31,13 +31,13 @@
|
|||||||
"@effect/opentelemetry": "4.0.0-beta.65",
|
"@effect/opentelemetry": "4.0.0-beta.65",
|
||||||
"@effect/platform-node": "4.0.0-beta.65",
|
"@effect/platform-node": "4.0.0-beta.65",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@types/bun": "1.3.12",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.2.11",
|
"@opentui/core": "0.2.14",
|
||||||
"@opentui/keymap": "0.2.11",
|
"@opentui/keymap": "0.2.14",
|
||||||
"@opentui/solid": "0.2.11",
|
"@opentui/solid": "0.2.14",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"shiki": "3.20.0",
|
"shiki": "3.20.0",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"virtua": "0.42.3",
|
"virtua": "0.49.1",
|
||||||
"vite": "7.1.4",
|
"vite": "7.1.4",
|
||||||
"@solidjs/meta": "0.29.4",
|
"@solidjs/meta": "0.29.4",
|
||||||
"@solidjs/router": "0.15.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()
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { test } from "@playwright/test"
|
|
||||||
|
|
||||||
test(
|
|
||||||
"test something cool",
|
|
||||||
{
|
|
||||||
annotation: { type: "todo" },
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
test.fixme()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -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: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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" })
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta name="theme-color" content="#F8F7F7" />
|
<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="og:image" content="/social-share.png" />
|
||||||
<meta property="twitter: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>
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
document.documentElement.dataset.theme = themeId
|
document.documentElement.dataset.theme = themeId
|
||||||
document.documentElement.dataset.colorScheme = mode
|
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
|
if (themeId === "oc-2") return
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + mode)
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -99,8 +99,6 @@ const EXAMPLES = [
|
|||||||
"prompt.example.25",
|
"prompt.example.25",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const NON_EMPTY_TEXT = /[^\s\u200B]/
|
|
||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const queryOptions = useQueryOptions()
|
const queryOptions = useQueryOptions()
|
||||||
@@ -860,7 +858,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
? rawParts[0].content
|
? rawParts[0].content
|
||||||
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
||||||
const hasNonText = rawParts.some((part) => part.type !== "text")
|
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) {
|
if (shouldReset) {
|
||||||
closePopover()
|
closePopover()
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function trimSessions(
|
|||||||
.filter((s) => !s.time?.archived)
|
.filter((s) => !s.time?.archived)
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
const roots = all.filter((s) => !s.parentID)
|
const roots = all.filter((s) => !s.parentID)
|
||||||
|
roots.sort(compareSessionRecent)
|
||||||
const children = all.filter((s) => !!s.parentID)
|
const children = all.filter((s) => !!s.parentID)
|
||||||
const base = roots.slice(0, limit)
|
const base = roots.slice(0, limit)
|
||||||
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
|
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { checksum } from "@opencode-ai/core/util/encode"
|
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 { NewSessionView, SessionHeader } from "@/components/session"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
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 { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||||
|
|
||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
type FollowupItem = FollowupDraft & { id: string }
|
type FollowupItem = FollowupDraft & { id: string }
|
||||||
@@ -75,7 +76,6 @@ type VcsMode = "git" | "branch"
|
|||||||
|
|
||||||
type SessionHistoryWindowInput = {
|
type SessionHistoryWindowInput = {
|
||||||
sessionID: () => string | undefined
|
sessionID: () => string | undefined
|
||||||
messagesReady: () => boolean
|
|
||||||
loaded: () => number
|
loaded: () => number
|
||||||
visibleUserMessages: () => UserMessage[]
|
visibleUserMessages: () => UserMessage[]
|
||||||
historyMore: () => boolean
|
historyMore: () => boolean
|
||||||
@@ -85,205 +85,74 @@ type SessionHistoryWindowInput = {
|
|||||||
scroller: () => HTMLDivElement | undefined
|
scroller: () => HTMLDivElement | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
|
||||||
* Maintains the rendered history window for a session timeline.
|
const historyScrollThreshold = 200
|
||||||
*
|
let shiftFrame: number | undefined
|
||||||
* 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
|
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
turnID: undefined as string | undefined,
|
shift: false,
|
||||||
turnStart: 0,
|
|
||||||
prefetchUntil: 0,
|
|
||||||
prefetchNoGrowth: 0,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
|
||||||
|
equals: same,
|
||||||
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 setTurnStart = (start: number) => {
|
const cancelShiftReset = () => {
|
||||||
const id = input.sessionID()
|
if (shiftFrame === undefined) return
|
||||||
const next = start > 0 ? start : 0
|
cancelAnimationFrame(shiftFrame)
|
||||||
if (!id) {
|
shiftFrame = undefined
|
||||||
setState({ turnID: undefined, turnStart: next })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setState({ turnID: id, turnStart: next })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderedUserMessages = createMemo(
|
const scheduleShiftReset = () => {
|
||||||
() => {
|
cancelShiftReset()
|
||||||
const msgs = input.visibleUserMessages()
|
shiftFrame = requestAnimationFrame(() => {
|
||||||
const start = turnStart()
|
shiftFrame = undefined
|
||||||
if (start <= 0) return msgs
|
setState("shift", false)
|
||||||
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 backfillTurns = () => {
|
const fetchOlderMessages = async () => {
|
||||||
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 id = input.sessionID()
|
const id = input.sessionID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (!input.historyMore() || input.historyLoading()) return
|
if (!input.historyMore() || input.historyLoading()) return
|
||||||
|
|
||||||
if (opts?.prefetch) {
|
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
|
||||||
const now = Date.now()
|
|
||||||
if (state.prefetchUntil > now) return
|
|
||||||
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
|
||||||
setState("prefetchUntil", now + prefetchCooldownMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = turnStart()
|
|
||||||
const beforeVisible = input.visibleUserMessages().length
|
const beforeVisible = input.visibleUserMessages().length
|
||||||
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
|
||||||
let loaded = input.loaded()
|
let loaded = input.loaded()
|
||||||
let added = 0
|
|
||||||
let growth = 0
|
let growth = 0
|
||||||
|
|
||||||
|
cancelShiftReset()
|
||||||
|
setState("shift", true)
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
await input.loadMore(id)
|
await input.loadMore(id)
|
||||||
if (input.sessionID() !== id) return
|
if (input.sessionID() !== id) return
|
||||||
|
|
||||||
const nextLoaded = input.loaded()
|
const nextLoaded = input.loaded()
|
||||||
const raw = nextLoaded - loaded
|
const raw = nextLoaded - loaded
|
||||||
added += raw
|
|
||||||
loaded = nextLoaded
|
loaded = nextLoaded
|
||||||
growth = input.visibleUserMessages().length - beforeVisible
|
growth = input.visibleUserMessages().length - beforeVisible
|
||||||
|
|
||||||
if (growth > 0) break
|
if (growth > 0) break
|
||||||
if (raw <= 0) break
|
if (raw <= 0) break
|
||||||
if (opts?.prefetch) break
|
|
||||||
if (!input.historyMore()) break
|
if (!input.historyMore()) break
|
||||||
}
|
}
|
||||||
|
|
||||||
const afterVisible = input.visibleUserMessages().length
|
if (growth > 0) {
|
||||||
|
scheduleShiftReset()
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (turnStart() !== start) return
|
setState("shift", false)
|
||||||
|
|
||||||
const currentRendered = renderedUserMessages().length
|
|
||||||
const base = Math.max(beforeRendered, currentRendered)
|
|
||||||
const target = Math.min(afterVisible, base + turnBatch)
|
|
||||||
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadAndReveal = () => fetchOlderMessages()
|
||||||
|
|
||||||
const onScrollerScroll = () => {
|
const onScrollerScroll = () => {
|
||||||
if (!input.userScrolled()) return
|
if (!input.userScrolled()) return
|
||||||
const el = input.scroller()
|
const el = input.scroller()
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (el.scrollTop >= turnScrollThreshold) return
|
if (el.scrollTop >= historyScrollThreshold) return
|
||||||
|
|
||||||
const start = turnStart()
|
|
||||||
if (start > 0) {
|
|
||||||
if (start <= turnPrefetchBuffer) {
|
|
||||||
void fetchOlderMessages({ prefetch: true })
|
|
||||||
}
|
|
||||||
backfillTurns()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void fetchOlderMessages()
|
void fetchOlderMessages()
|
||||||
}
|
}
|
||||||
@@ -292,27 +161,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
|||||||
on(
|
on(
|
||||||
input.sessionID,
|
input.sessionID,
|
||||||
() => {
|
() => {
|
||||||
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
cancelShiftReset()
|
||||||
|
setState({ shift: false })
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(
|
onCleanup(cancelShiftReset)
|
||||||
on(
|
|
||||||
() => [input.sessionID(), input.messagesReady()] as const,
|
|
||||||
([id, ready]) => {
|
|
||||||
if (!id || !ready) return
|
|
||||||
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
turnStart,
|
userMessages,
|
||||||
setTurnStart,
|
shift: () => state.shift,
|
||||||
renderedUserMessages,
|
|
||||||
loadAndReveal,
|
loadAndReveal,
|
||||||
onScrollerScroll,
|
onScrollerScroll,
|
||||||
}
|
}
|
||||||
@@ -333,6 +193,7 @@ export default function Page() {
|
|||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||||
|
const location = useLocation()
|
||||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -737,6 +598,7 @@ export default function Page() {
|
|||||||
let dockHeight = 0
|
let dockHeight = 0
|
||||||
let scroller: HTMLDivElement | undefined
|
let scroller: HTMLDivElement | undefined
|
||||||
let content: HTMLDivElement | undefined
|
let content: HTMLDivElement | undefined
|
||||||
|
let revealMessage = (_id: string) => {}
|
||||||
let scrollMark = 0
|
let scrollMark = 0
|
||||||
let messageMark = 0
|
let messageMark = 0
|
||||||
|
|
||||||
@@ -1403,9 +1265,8 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const historyWindow = createSessionHistoryWindow({
|
const historyLoader = createSessionHistoryLoader({
|
||||||
sessionID: () => params.id,
|
sessionID: () => params.id,
|
||||||
messagesReady,
|
|
||||||
loaded: () => messages().length,
|
loaded: () => messages().length,
|
||||||
visibleUserMessages,
|
visibleUserMessages,
|
||||||
historyMore,
|
historyMore,
|
||||||
@@ -1427,9 +1288,9 @@ export default function Page() {
|
|||||||
const el = scroller
|
const el = scroller
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (el.scrollHeight > el.clientHeight + 1) 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,
|
params.id,
|
||||||
messagesReady(),
|
messagesReady(),
|
||||||
historyWindow.turnStart(),
|
|
||||||
historyMore(),
|
historyMore(),
|
||||||
historyLoading(),
|
historyLoading(),
|
||||||
autoScroll.userScrolled(),
|
autoScroll.userScrolled(),
|
||||||
visibleUserMessages().length,
|
visibleUserMessages().length,
|
||||||
] as const,
|
] as const,
|
||||||
([id, ready, start, more, loading, scrolled]) => {
|
([id, ready, more, loading, scrolled]) => {
|
||||||
if (!id || !ready || loading || scrolled) return
|
if (!id || !ready || loading || scrolled) return
|
||||||
if (start <= 0 && !more) return
|
if (!more) return
|
||||||
fill()
|
fill()
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
@@ -1749,15 +1609,14 @@ export default function Page() {
|
|||||||
historyMore,
|
historyMore,
|
||||||
historyLoading,
|
historyLoading,
|
||||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||||
turnStart: historyWindow.turnStart,
|
|
||||||
currentMessageId: () => store.messageId,
|
currentMessageId: () => store.messageId,
|
||||||
pendingMessage: () => ui.pendingMessage,
|
pendingMessage: () => ui.pendingMessage,
|
||||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||||
setActiveMessage,
|
setActiveMessage,
|
||||||
setTurnStart: historyWindow.setTurnStart,
|
|
||||||
autoScroll,
|
autoScroll,
|
||||||
scroller: () => scroller,
|
scroller: () => scroller,
|
||||||
anchor,
|
anchor,
|
||||||
|
revealMessage: (id) => revealMessage(id),
|
||||||
scheduleScrollState,
|
scheduleScrollState,
|
||||||
consumePendingMessage: layout.pendingMessage.consume,
|
consumePendingMessage: layout.pendingMessage.consume,
|
||||||
})
|
})
|
||||||
@@ -1787,6 +1646,8 @@ export default function Page() {
|
|||||||
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
|
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useUsageExceededDialogs()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||||
{sessionSync() ?? ""}
|
{sessionSync() ?? ""}
|
||||||
@@ -1830,20 +1691,23 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
<Switch>
|
<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}>
|
<Match when={params.id}>
|
||||||
<Show when={messagesReady()}>
|
<Show when={messagesReady()}>
|
||||||
<MessageTimeline
|
<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}
|
actions={actions}
|
||||||
scroll={ui.scroll}
|
scroll={ui.scroll}
|
||||||
onResumeScroll={resumeScroll}
|
onResumeScroll={resumeScroll}
|
||||||
@@ -1853,8 +1717,11 @@ export default function Page() {
|
|||||||
onMarkScrollGesture={markScrollGesture}
|
onMarkScrollGesture={markScrollGesture}
|
||||||
hasScrollGesture={hasScrollGesture}
|
hasScrollGesture={hasScrollGesture}
|
||||||
onUserScroll={markUserScroll}
|
onUserScroll={markUserScroll}
|
||||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
onHistoryScroll={historyLoader.onScrollerScroll}
|
||||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||||
|
shouldAnchorBottom={() =>
|
||||||
|
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||||
|
}
|
||||||
centered={centered()}
|
centered={centered()}
|
||||||
setContentRef={(el) => {
|
setContentRef={(el) => {
|
||||||
content = el
|
content = el
|
||||||
@@ -1863,14 +1730,12 @@ export default function Page() {
|
|||||||
const root = scroller
|
const root = scroller
|
||||||
if (root) scheduleScrollState(root)
|
if (root) scheduleScrollState(root)
|
||||||
}}
|
}}
|
||||||
turnStart={historyWindow.turnStart()}
|
historyShift={historyLoader.shift()}
|
||||||
historyMore={historyMore()}
|
userMessages={historyLoader.userMessages()}
|
||||||
historyLoading={historyLoading()}
|
|
||||||
onLoadEarlier={() => {
|
|
||||||
void historyWindow.loadAndReveal()
|
|
||||||
}}
|
|
||||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
|
setRevealMessage={(fn) => {
|
||||||
|
revealMessage = fn
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</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>}>
|
<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>
|
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||||
</Show>
|
</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
|
historyMore: () => boolean
|
||||||
historyLoading: () => boolean
|
historyLoading: () => boolean
|
||||||
loadMore: (sessionID: string) => Promise<void>
|
loadMore: (sessionID: string) => Promise<void>
|
||||||
turnStart: () => number
|
|
||||||
currentMessageId: () => string | undefined
|
currentMessageId: () => string | undefined
|
||||||
pendingMessage: () => string | undefined
|
pendingMessage: () => string | undefined
|
||||||
setPendingMessage: (value: string | undefined) => void
|
setPendingMessage: (value: string | undefined) => void
|
||||||
setActiveMessage: (message: UserMessage | undefined) => void
|
setActiveMessage: (message: UserMessage | undefined) => void
|
||||||
setTurnStart: (value: number) => void
|
|
||||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||||
scroller: () => HTMLDivElement | undefined
|
scroller: () => HTMLDivElement | undefined
|
||||||
anchor: (id: string) => string
|
anchor: (id: string) => string
|
||||||
|
revealMessage?: (id: string) => void
|
||||||
scheduleScrollState: (el: HTMLDivElement) => void
|
scheduleScrollState: (el: HTMLDivElement) => void
|
||||||
consumePendingMessage: (key: string) => string | undefined
|
consumePendingMessage: (key: string) => string | undefined
|
||||||
}) => {
|
}) => {
|
||||||
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
||||||
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
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 pendingKey = ""
|
||||||
let clearing = false
|
let clearing = false
|
||||||
|
|
||||||
@@ -77,6 +75,7 @@ export const useSessionHashScroll = (input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
|
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
|
||||||
|
input.revealMessage?.(id)
|
||||||
const el = document.getElementById(input.anchor(id))
|
const el = document.getElementById(input.anchor(id))
|
||||||
if (el) return scrollToElement(el, behavior)
|
if (el) return scrollToElement(el, behavior)
|
||||||
if (left <= 0) return false
|
if (left <= 0) return false
|
||||||
@@ -89,18 +88,7 @@ export const useSessionHashScroll = (input: {
|
|||||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||||
cancel()
|
cancel()
|
||||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||||
|
input.revealMessage?.(message.id)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seek(message.id, behavior)) {
|
if (seek(message.id, behavior)) {
|
||||||
updateHash(message.id)
|
updateHash(message.id)
|
||||||
@@ -154,7 +142,6 @@ export const useSessionHashScroll = (input: {
|
|||||||
if (!input.sessionID() || !input.messagesReady()) return
|
if (!input.sessionID() || !input.messagesReady()) return
|
||||||
|
|
||||||
visibleUserMessages()
|
visibleUserMessages()
|
||||||
input.turnStart()
|
|
||||||
|
|
||||||
let targetId = input.pendingMessage()
|
let targetId = input.pendingMessage()
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Resource, waitUntil } from "@opencode-ai/console-resource"
|
import { Resource, waitUntil } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
export function createDataDumper(sessionId: string, requestId: string, projectId: string) {
|
export function createDataDumper(sessionId: string, requestId: string, projectId: string) {
|
||||||
|
return
|
||||||
if (Resource.App.stage !== "production") return
|
if (Resource.App.stage !== "production") return
|
||||||
if (sessionId === "") return
|
if (sessionId === "") return
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Vendored
-1
@@ -296,7 +296,6 @@ declare module "sst" {
|
|||||||
"AuthStorage": cloudflare.KVNamespace
|
"AuthStorage": cloudflare.KVNamespace
|
||||||
"Bucket": cloudflare.R2Bucket
|
"Bucket": cloudflare.R2Bucket
|
||||||
"EnterpriseStorage": cloudflare.R2Bucket
|
"EnterpriseStorage": cloudflare.R2Bucket
|
||||||
"GatewayKv": cloudflare.KVNamespace
|
|
||||||
"LogProcessor": cloudflare.Service
|
"LogProcessor": cloudflare.Service
|
||||||
"Stat": cloudflare.Service
|
"Stat": cloudflare.Service
|
||||||
"ZenData": cloudflare.R2Bucket
|
"ZenData": cloudflare.R2Bucket
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
-1
@@ -296,7 +296,6 @@ declare module "sst" {
|
|||||||
"AuthStorage": cloudflare.KVNamespace
|
"AuthStorage": cloudflare.KVNamespace
|
||||||
"Bucket": cloudflare.R2Bucket
|
"Bucket": cloudflare.R2Bucket
|
||||||
"EnterpriseStorage": cloudflare.R2Bucket
|
"EnterpriseStorage": cloudflare.R2Bucket
|
||||||
"GatewayKv": cloudflare.KVNamespace
|
|
||||||
"LogProcessor": cloudflare.Service
|
"LogProcessor": cloudflare.Service
|
||||||
"Stat": cloudflare.Service
|
"Stat": cloudflare.Service
|
||||||
"ZenData": cloudflare.R2Bucket
|
"ZenData": cloudflare.R2Bucket
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
-1
@@ -296,7 +296,6 @@ declare module "sst" {
|
|||||||
"AuthStorage": cloudflare.KVNamespace
|
"AuthStorage": cloudflare.KVNamespace
|
||||||
"Bucket": cloudflare.R2Bucket
|
"Bucket": cloudflare.R2Bucket
|
||||||
"EnterpriseStorage": cloudflare.R2Bucket
|
"EnterpriseStorage": cloudflare.R2Bucket
|
||||||
"GatewayKv": cloudflare.KVNamespace
|
|
||||||
"LogProcessor": cloudflare.Service
|
"LogProcessor": cloudflare.Service
|
||||||
"Stat": cloudflare.Service
|
"Stat": cloudflare.Service
|
||||||
"ZenData": cloudflare.R2Bucket
|
"ZenData": cloudflare.R2Bucket
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
|||||||
SHELL ["/bin/bash", "-lc"]
|
SHELL ["/bin/bash", "-lc"]
|
||||||
|
|
||||||
ARG NODE_VERSION=24.4.0
|
ARG NODE_VERSION=24.4.0
|
||||||
ARG BUN_VERSION=1.3.13
|
ARG BUN_VERSION=1.3.14
|
||||||
|
|
||||||
ENV BUN_INSTALL=/opt/bun
|
ENV BUN_INSTALL=/opt/bun
|
||||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export const Provider = Schema.Struct({
|
|||||||
|
|
||||||
export type Provider = Schema.Schema.Type<typeof Provider>
|
export type Provider = Schema.Schema.Type<typeof Provider>
|
||||||
|
|
||||||
|
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: () => Effect.Effect<Record<string, Provider>>
|
readonly get: () => Effect.Effect<Record<string, Provider>>
|
||||||
readonly refresh: (force?: boolean) => Effect.Effect<void>
|
readonly refresh: (force?: boolean) => Effect.Effect<void>
|
||||||
@@ -112,9 +114,7 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
|
||||||
|
|
||||||
type Requirements = AppFileSystem.Service | HttpClient.HttpClient
|
export const layer = Layer.effect(
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
|
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
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),
|
Effect.map((v) => v as Record<string, Provider> | undefined),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bundled at build time; absent in dev — `tryPromise` covers both.
|
const loadSnapshot = Effect.sync(() =>
|
||||||
const loadSnapshot = Effect.tryPromise({
|
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
|
||||||
// @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 fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||||
const text = yield* fetchApi()
|
const text = yield* fetchApi()
|
||||||
@@ -223,4 +220,4 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
|||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export * as ModelsDev from "./models"
|
export * as ModelsDev from "./models-dev"
|
||||||
-2
@@ -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,7 +1,7 @@
|
|||||||
import { DateTime, Effect } from "effect"
|
import { DateTime, Effect } from "effect"
|
||||||
import { Catalog } from "../catalog"
|
import { Catalog } from "../catalog"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
import { ModelsDev } from "../models"
|
import { ModelsDev } from "../models-dev"
|
||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { ProviderV2 } from "../provider"
|
import { ProviderV2 } from "../provider"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
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 { it } from "./lib/effect"
|
||||||
import { rm, writeFile, utimes, mkdir } from "fs/promises"
|
import { rm, writeFile, utimes, mkdir } from "fs/promises"
|
||||||
import path from "path"
|
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* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* Ref.make(initialState)
|
const state = yield* Ref.make(initialState)
|
||||||
const result = yield* provided(
|
const result = yield* provided(
|
||||||
state,
|
state,
|
||||||
ModelsDev.Service.use((s) => s.get()),
|
ModelsDev.Service.use((s) => s.get()),
|
||||||
)
|
)
|
||||||
expect(Object.keys(result).length).toBeGreaterThan(0)
|
expect(result).toEqual({})
|
||||||
const final = yield* Ref.get(state)
|
const final = yield* Ref.get(state)
|
||||||
expect(final.calls).toEqual([])
|
expect(final.calls).toEqual([])
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { initLogging } from "./logging"
|
|||||||
const logger = initLogging()
|
const logger = initLogging()
|
||||||
const { autoUpdater } = pkg
|
const { autoUpdater } = pkg
|
||||||
|
|
||||||
let downloadedUpdateVersion: string | undefined
|
|
||||||
|
|
||||||
export function setupAutoUpdater() {
|
export function setupAutoUpdater() {
|
||||||
if (!UPDATER_ENABLED) return
|
if (!UPDATER_ENABLED) return
|
||||||
autoUpdater.logger = logger
|
autoUpdater.logger = logger
|
||||||
@@ -26,12 +24,6 @@ export function setupAutoUpdater() {
|
|||||||
|
|
||||||
export async function checkUpdate() {
|
export async function checkUpdate() {
|
||||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
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", {
|
logger.log("checking for updates", {
|
||||||
currentVersion: app.getVersion(),
|
currentVersion: app.getVersion(),
|
||||||
channel: autoUpdater.channel,
|
channel: autoUpdater.channel,
|
||||||
@@ -57,7 +49,6 @@ export async function checkUpdate() {
|
|||||||
logger.log("update available", { version })
|
logger.log("update available", { version })
|
||||||
await autoUpdater.downloadUpdate()
|
await autoUpdater.downloadUpdate()
|
||||||
logger.log("update download completed", { version })
|
logger.log("update download completed", { version })
|
||||||
downloadedUpdateVersion = version
|
|
||||||
return { updateAvailable: true, version }
|
return { updateAvailable: true, version }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("update check failed", error)
|
logger.error("update check failed", error)
|
||||||
@@ -66,14 +57,15 @@ export async function checkUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function installUpdate(killSidecar: () => Promise<void>) {
|
export async function installUpdate(killSidecar: () => Promise<void>) {
|
||||||
if (!downloadedUpdateVersion) {
|
const result = await checkUpdate()
|
||||||
|
if (!result.updateAvailable) {
|
||||||
logger.log("install update skipped", {
|
logger.log("install update skipped", {
|
||||||
reason: "no downloaded update ready",
|
reason: result.failed ? "update check failed" : "no update available",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.log("installing downloaded update", {
|
logger.log("installing downloaded update", {
|
||||||
version: downloadedUpdateVersion,
|
version: result.version ?? null,
|
||||||
})
|
})
|
||||||
await killSidecar()
|
await killSidecar()
|
||||||
autoUpdater.quitAndInstall()
|
autoUpdater.quitAndInstall()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const rendererRoot = join(root, "../renderer")
|
|||||||
const rendererProtocol = "oc"
|
const rendererProtocol = "oc"
|
||||||
const rendererHost = "renderer"
|
const rendererHost = "renderer"
|
||||||
const clipboardWritePermission = "clipboard-sanitized-write"
|
const clipboardWritePermission = "clipboard-sanitized-write"
|
||||||
|
const notificationPermission = "notifications"
|
||||||
|
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
@@ -109,7 +111,7 @@ export function createMainWindow() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
allowClipboardWrite(win)
|
allowRendererPermissions(win)
|
||||||
|
|
||||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||||
const { requestHeaders } = details
|
const { requestHeaders } = details
|
||||||
@@ -162,7 +164,7 @@ export function createLoadingWindow() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
allowClipboardWrite(win)
|
allowRendererPermissions(win)
|
||||||
|
|
||||||
loadWindow(win, "loading.html")
|
loadWindow(win, "loading.html")
|
||||||
|
|
||||||
@@ -199,16 +201,16 @@ function loadWindow(win: BrowserWindow, html: string) {
|
|||||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function allowClipboardWrite(win: BrowserWindow) {
|
function allowRendererPermissions(win: BrowserWindow) {
|
||||||
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||||
callback(
|
callback(
|
||||||
permission === clipboardWritePermission &&
|
rendererPermissions.has(permission) &&
|
||||||
isTrustedRendererUrl(details.requestingUrl) &&
|
isTrustedRendererUrl(details.requestingUrl) &&
|
||||||
webContents.id === win.webContents.id,
|
webContents.id === win.webContents.id,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
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
|
if (webContents && webContents.id !== win.webContents.id) return false
|
||||||
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
|
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
<link rel="shortcut icon" href="./favicon-v3.ico" />
|
<link rel="shortcut icon" href="./favicon-v3.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
|
||||||
<meta name="theme-color" content="#F8F7F7" />
|
<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="og:image" content="./social-share.png" />
|
||||||
<meta property="twitter: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>
|
<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="shortcut icon" href="./favicon-v3.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
|
||||||
<meta name="theme-color" content="#F8F7F7" />
|
<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="og:image" content="./social-share.png" />
|
||||||
<meta property="twitter: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>
|
<script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export default createHandler(() => (
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>OpenCode</title>
|
<title>OpenCode</title>
|
||||||
<meta name="theme-color" content="#F8F7F7" />
|
<meta name="theme-color" content="#F8F7F7" />
|
||||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
|
||||||
{assets}
|
{assets}
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular">
|
<body class="antialiased overscroll-none text-12-regular">
|
||||||
|
|||||||
Vendored
-1
@@ -296,7 +296,6 @@ declare module "sst" {
|
|||||||
"AuthStorage": cloudflare.KVNamespace
|
"AuthStorage": cloudflare.KVNamespace
|
||||||
"Bucket": cloudflare.R2Bucket
|
"Bucket": cloudflare.R2Bucket
|
||||||
"EnterpriseStorage": cloudflare.R2Bucket
|
"EnterpriseStorage": cloudflare.R2Bucket
|
||||||
"GatewayKv": cloudflare.KVNamespace
|
|
||||||
"LogProcessor": cloudflare.Service
|
"LogProcessor": cloudflare.Service
|
||||||
"Stat": cloudflare.Service
|
"Stat": cloudflare.Service
|
||||||
"ZenData": cloudflare.R2Bucket
|
"ZenData": cloudflare.R2Bucket
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.15.4"
|
version = "1.15.5"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Vendored
-1
@@ -296,7 +296,6 @@ declare module "sst" {
|
|||||||
"AuthStorage": cloudflare.KVNamespace
|
"AuthStorage": cloudflare.KVNamespace
|
||||||
"Bucket": cloudflare.R2Bucket
|
"Bucket": cloudflare.R2Bucket
|
||||||
"EnterpriseStorage": cloudflare.R2Bucket
|
"EnterpriseStorage": cloudflare.R2Bucket
|
||||||
"GatewayKv": cloudflare.KVNamespace
|
|
||||||
"LogProcessor": cloudflare.Service
|
"LogProcessor": cloudflare.Service
|
||||||
"Stat": cloudflare.Service
|
"Stat": cloudflare.Service
|
||||||
"ZenData": cloudflare.R2Bucket
|
"ZenData": cloudflare.R2Bucket
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const TextDelta = Schema.Struct({
|
|||||||
type: Schema.tag("text-delta"),
|
type: Schema.tag("text-delta"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ export const ReasoningDelta = Schema.Struct({
|
|||||||
type: Schema.tag("reasoning-delta"),
|
type: Schema.tag("reasoning-delta"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||||
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ dist
|
|||||||
dist-*
|
dist-*
|
||||||
gen
|
gen
|
||||||
app.log
|
app.log
|
||||||
src/provider/models-snapshot.js
|
|
||||||
src/provider/models-snapshot.d.ts
|
|
||||||
script/build-*.ts
|
script/build-*.ts
|
||||||
temporary-*.md
|
temporary-*.md
|
||||||
.artifacts
|
.artifacts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"test": "bun test --timeout 30000",
|
"test": "bun test --timeout 30000",
|
||||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
"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",
|
"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",
|
"build": "bun run script/build.ts",
|
||||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||||
@@ -37,8 +39,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@octokit/webhooks-types": "7.6.1",
|
"@octokit/webhooks-types": "7.6.1",
|
||||||
"@opencode-ai/script": "workspace:*",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
@@ -99,6 +102,7 @@
|
|||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|||||||
@@ -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)}`)
|
||||||
@@ -11,7 +11,7 @@ const dir = path.resolve(__dirname, "..")
|
|||||||
|
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
await import("./generate.ts")
|
const generated = await import("./generate.ts")
|
||||||
|
|
||||||
// Load migrations from migration directories
|
// Load migrations from migration directories
|
||||||
const migrationDirs = (
|
const migrationDirs = (
|
||||||
@@ -52,6 +52,7 @@ await Bun.build({
|
|||||||
external: ["jsonc-parser", "@lydell/node-pty"],
|
external: ["jsonc-parser", "@lydell/node-pty"],
|
||||||
define: {
|
define: {
|
||||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||||
|
OPENCODE_MODELS_DEV: generated.modelsData,
|
||||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const dir = path.resolve(__dirname, "..")
|
|||||||
|
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
await import("./generate.ts")
|
const generated = await import("./generate.ts")
|
||||||
|
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
@@ -218,6 +218,7 @@ for (const item of targets) {
|
|||||||
define: {
|
define: {
|
||||||
OPENCODE_VERSION: `'${Script.version}'`,
|
OPENCODE_VERSION: `'${Script.version}'`,
|
||||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||||
|
OPENCODE_MODELS_DEV: generated.modelsData,
|
||||||
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
|
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
|
||||||
OPENCODE_WORKER_PATH: workerPath,
|
OPENCODE_WORKER_PATH: workerPath,
|
||||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||||
|
|||||||
@@ -8,16 +8,7 @@ const dir = path.resolve(__dirname, "..")
|
|||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||||
// Fetch and generate models.dev snapshot
|
export const modelsData = process.env.MODELS_DEV_API_JSON
|
||||||
const modelsData = process.env.MODELS_DEV_API_JSON
|
|
||||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||||
await Bun.write(
|
console.log("Loaded models.dev snapshot")
|
||||||
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")
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -37,8 +37,16 @@ export interface Interface {
|
|||||||
properties: BusProperties<D>,
|
properties: BusProperties<D>,
|
||||||
options?: { id?: string },
|
options?: { id?: string },
|
||||||
) => Effect.Effect<void>
|
) => Effect.Effect<void>
|
||||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
// subscribe / subscribeAll are eager: the underlying PubSub subscription is
|
||||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
// 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>(
|
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||||
def: D,
|
def: D,
|
||||||
callback: (event: Payload<D>) => unknown,
|
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>> {
|
const subscribe = <D extends BusEvent.Definition>(
|
||||||
log.info("subscribing", { type: def.type })
|
def: D,
|
||||||
return Stream.unwrap(
|
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
log.info("subscribing", { type: def.type })
|
||||||
const ps = yield* getOrCreate(s, def)
|
const s = yield* InstanceState.get(state)
|
||||||
return Stream.fromPubSub(ps)
|
const ps = yield* getOrCreate(s, def)
|
||||||
}),
|
const subscription = yield* PubSub.subscribe(ps)
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type })))
|
||||||
}
|
return Stream.fromSubscription(subscription)
|
||||||
|
})
|
||||||
|
|
||||||
function subscribeAll(): Stream.Stream<Payload> {
|
const subscribeAll = (): Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope> =>
|
||||||
log.info("subscribing", { type: "*" })
|
Effect.gen(function* () {
|
||||||
return Stream.unwrap(
|
log.info("subscribing", { type: "*" })
|
||||||
Effect.gen(function* () {
|
const s = yield* InstanceState.get(state)
|
||||||
const s = yield* InstanceState.get(state)
|
const subscription = yield* PubSub.subscribe(s.wildcard)
|
||||||
return Stream.fromPubSub(s.wildcard)
|
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" })))
|
||||||
}),
|
return Stream.fromSubscription(subscription)
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
})
|
||||||
}
|
|
||||||
|
|
||||||
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { effectCmd } from "../effect-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 { InstanceRef } from "@/effect/instance-ref"
|
||||||
import { SessionShare } from "@/share/session"
|
import { SessionShare } from "@/share/session"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { EOL } from "os"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ProviderID } from "../../provider/schema"
|
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 { effectCmd, fail } from "../effect-cmd"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cmd } from "./cmd"
|
|||||||
import { CliError, effectCmd, fail } from "../effect-cmd"
|
import { CliError, effectCmd, fail } from "../effect-cmd"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import * as Prompt from "../effect/prompt"
|
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 { map, pipe, sortBy, values } from "remeda"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|||||||
@@ -218,6 +218,15 @@ export const RunCommand = effectCmd({
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
describe: "show thinking blocks",
|
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", {
|
.option("interactive", {
|
||||||
alias: ["i"],
|
alias: ["i"],
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
@@ -269,6 +278,21 @@ export const RunCommand = effectCmd({
|
|||||||
die("--interactive cannot be used with --format json")
|
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) {
|
if (args.interactive && !process.stdout.isTTY) {
|
||||||
die("--interactive requires a TTY stdout")
|
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 root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||||
const directory = (() => {
|
const directory = (() => {
|
||||||
if (!args.dir) return args.attach ? undefined : root
|
if (!args.dir) return args.attach ? undefined : root
|
||||||
@@ -786,6 +812,8 @@ export const RunCommand = effectCmd({
|
|||||||
sessionID,
|
sessionID,
|
||||||
sessionTitle: sess.title,
|
sessionTitle: sess.title,
|
||||||
resume: Boolean(args.session || args.continue) && !args.fork,
|
resume: Boolean(args.session || args.continue) && !args.fork,
|
||||||
|
replay,
|
||||||
|
replayLimit: args["replay-limit"],
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
variant: args.variant,
|
variant: args.variant,
|
||||||
@@ -821,6 +849,8 @@ export const RunCommand = effectCmd({
|
|||||||
agent: args.agent,
|
agent: args.agent,
|
||||||
model,
|
model,
|
||||||
variant: args.variant,
|
variant: args.variant,
|
||||||
|
replay,
|
||||||
|
replayLimit: args["replay-limit"],
|
||||||
files,
|
files,
|
||||||
initialInput,
|
initialInput,
|
||||||
thinking,
|
thinking,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { pathToFileURL } from "bun"
|
import { pathToFileURL } from "bun"
|
||||||
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
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 fuzzysort from "fuzzysort"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
@@ -197,13 +197,45 @@ export function RunPromptBody(props: {
|
|||||||
onContentChange: () => void
|
onContentChange: () => void
|
||||||
bind: (area?: TextareaRenderable) => void
|
bind: (area?: TextareaRenderable) => void
|
||||||
}) {
|
}) {
|
||||||
|
const renderer = useRenderer()
|
||||||
let area: TextareaRenderable | undefined
|
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(() => {
|
onMount(() => {
|
||||||
props.bind(area)
|
props.bind(area)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
if (pasteTick) {
|
||||||
|
clearTimeout(pasteTick)
|
||||||
|
}
|
||||||
props.bind(undefined)
|
props.bind(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,6 +258,9 @@ export function RunPromptBody(props: {
|
|||||||
keyBindings={props.bindings()}
|
keyBindings={props.bindings()}
|
||||||
onSubmit={props.onSubmit}
|
onSubmit={props.onSubmit}
|
||||||
onKeyDown={props.onKeyDown}
|
onKeyDown={props.onKeyDown}
|
||||||
|
onPaste={() => {
|
||||||
|
refreshPasteLayout()
|
||||||
|
}}
|
||||||
onContentChange={props.onContentChange}
|
onContentChange={props.onContentChange}
|
||||||
ref={(next) => {
|
ref={(next) => {
|
||||||
area = next
|
area = next
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ type RunRuntimeInput = {
|
|||||||
files: RunInput["files"]
|
files: RunInput["files"]
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
thinking: boolean
|
thinking: boolean
|
||||||
|
replay?: boolean
|
||||||
|
replayLimit?: number
|
||||||
demo?: RunInput["demo"]
|
demo?: RunInput["demo"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,8 @@ type RunLocalInput = {
|
|||||||
files: RunInput["files"]
|
files: RunInput["files"]
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
thinking: boolean
|
thinking: boolean
|
||||||
|
replay?: boolean
|
||||||
|
replayLimit?: number
|
||||||
demo?: RunInput["demo"]
|
demo?: RunInput["demo"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,6 +494,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
|
replay: input.replay,
|
||||||
|
replayLimit: input.replayLimit,
|
||||||
limits: () => state.limits,
|
limits: () => state.limits,
|
||||||
footer,
|
footer,
|
||||||
trace: log,
|
trace: log,
|
||||||
@@ -722,6 +728,8 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
|
|||||||
files: input.files,
|
files: input.files,
|
||||||
initialInput: input.initialInput,
|
initialInput: input.initialInput,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
|
replay: input.replay,
|
||||||
|
replayLimit: input.replayLimit,
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
resolveSession: () => {
|
resolveSession: () => {
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -774,6 +782,8 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
|
|||||||
files: input.files,
|
files: input.files,
|
||||||
initialInput: input.initialInput,
|
initialInput: input.initialInput,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
|
replay: input.replay,
|
||||||
|
replayLimit: input.replayLimit,
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
boot: async () => ({
|
boot: async () => ({
|
||||||
sdk: input.sdk,
|
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"] = []
|
const parts: RunPrompt["parts"] = []
|
||||||
let text = msg.parts
|
let text = msg.parts
|
||||||
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
||||||
@@ -135,7 +135,7 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prompt: prompt(msg),
|
prompt: messagePrompt(msg),
|
||||||
provider: msg.info.model.providerID,
|
provider: msg.info.model.providerID,
|
||||||
model: msg.info.model.modelID,
|
model: msg.info.model.modelID,
|
||||||
variant: msg.info.model.variant,
|
variant: msg.info.model.variant,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
reduceSessionData,
|
reduceSessionData,
|
||||||
type SessionData,
|
type SessionData,
|
||||||
} from "./session-data"
|
} from "./session-data"
|
||||||
|
import { replaySession } from "./session-replay"
|
||||||
import {
|
import {
|
||||||
bootstrapSubagentCalls,
|
bootstrapSubagentCalls,
|
||||||
bootstrapSubagentData,
|
bootstrapSubagentData,
|
||||||
@@ -66,6 +67,8 @@ type StreamInput = {
|
|||||||
directory?: string
|
directory?: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
thinking: boolean
|
thinking: boolean
|
||||||
|
replay?: boolean
|
||||||
|
replayLimit?: number
|
||||||
limits: () => Record<string, number>
|
limits: () => Record<string, number>
|
||||||
footer: FooterApi
|
footer: FooterApi
|
||||||
trace?: Trace
|
trace?: Trace
|
||||||
@@ -432,7 +435,12 @@ function createLayer(input: StreamInput) {
|
|||||||
blockerTick: 0,
|
blockerTick: 0,
|
||||||
blockers: new Map(),
|
blockers: new Map(),
|
||||||
}
|
}
|
||||||
|
let booting = true
|
||||||
|
const buffered: Event[] = []
|
||||||
|
const replayedParts = new Set<string>()
|
||||||
const recovering = new Set<string>()
|
const recovering = new Set<string>()
|
||||||
|
const tracked = (sessionID: string | undefined) =>
|
||||||
|
sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID))
|
||||||
const currentSubagentState = () => {
|
const currentSubagentState = () => {
|
||||||
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
|
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
|
||||||
state.selectedSubagent = undefined
|
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(() =>
|
Effect.promise(() =>
|
||||||
input.sdk.session.messages({
|
input.sdk.session.messages({
|
||||||
sessionID,
|
sessionID,
|
||||||
limit,
|
...(typeof limit === "number" ? { limit } : {}),
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.map((item) => item.data ?? []),
|
Effect.map((item) => item.data ?? []),
|
||||||
@@ -596,7 +604,14 @@ function createLayer(input: StreamInput) {
|
|||||||
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
|
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
|
||||||
const [messagesList, children, permissions, questions] = yield* Effect.all(
|
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(() =>
|
Effect.promise(() =>
|
||||||
input.sdk.session.children({
|
input.sdk.session.children({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -619,12 +634,52 @@ function createLayer(input: StreamInput) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
bootstrapSessionData({
|
const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
|
||||||
data: state.data,
|
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
|
||||||
messages: messagesList,
|
const history = input.replay
|
||||||
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
|
? replaySession({
|
||||||
questions: questions.filter((item) => item.sessionID === input.sessionID),
|
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({
|
bootstrapSubagentData({
|
||||||
data: state.subagent,
|
data: state.subagent,
|
||||||
messages: messagesList,
|
messages: messagesList,
|
||||||
@@ -632,6 +687,7 @@ function createLayer(input: StreamInput) {
|
|||||||
permissions,
|
permissions,
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
|
clearFinishedSubagents(state.subagent)
|
||||||
|
|
||||||
for (const request of [
|
for (const request of [
|
||||||
...state.data.permissions,
|
...state.data.permissions,
|
||||||
@@ -642,9 +698,29 @@ function createLayer(input: StreamInput) {
|
|||||||
seedBlocker(request.id)
|
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()
|
const snapshot = currentSubagentState()
|
||||||
traceTabs(input.trace, [], snapshot.tabs)
|
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()]
|
const sessions = [...state.subagent.tabs.keys()]
|
||||||
if (sessions.length === 0) {
|
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")(() =>
|
const watch = Effect.fn("RunStreamTransport.watch")(() =>
|
||||||
Stream.fromAsyncIterable(events.stream, (error) =>
|
Stream.fromAsyncIterable(events.stream, (error) =>
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
@@ -762,53 +918,25 @@ function createLayer(input: StreamInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionID = sid(event)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.trace?.write("recv.event", event)
|
input.trace?.write("recv.event", event)
|
||||||
trackBlocker(event)
|
yield* applyEvent(event)
|
||||||
|
yield* drainBuffered()
|
||||||
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)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
|
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* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
|
||||||
|
yield* bootstrap()
|
||||||
|
|
||||||
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
|
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
|
||||||
if (closed || next.signal?.aborted || input.footer.isClosed) {
|
if (closed || next.signal?.aborted || input.footer.isClosed) {
|
||||||
|
|||||||
@@ -420,9 +420,26 @@ function ensureBlockerTab(
|
|||||||
title: string | undefined,
|
title: string | undefined,
|
||||||
kind: "permission" | "question",
|
kind: "permission" | "question",
|
||||||
) {
|
) {
|
||||||
if (data.tabs.has(sessionID)) {
|
const current = data.tabs.get(sessionID)
|
||||||
|
if (current) {
|
||||||
ensureDetail(data, sessionID)
|
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, {
|
data.tabs.set(sessionID, {
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export type RunInput = {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
|
replay?: boolean
|
||||||
|
replayLimit?: number
|
||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
model: PromptModel | undefined
|
model: PromptModel | undefined
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createMemo, createResource } from "solid-js"
|
import { createMemo, createResource } from "solid-js"
|
||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
|
||||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const project = useProject()
|
||||||
|
|
||||||
const [store] = createStore({
|
const [store] = createStore({
|
||||||
filter: "",
|
filter: "",
|
||||||
@@ -17,6 +19,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
|||||||
async () => {
|
async () => {
|
||||||
const result = await sdk.client.find.files({
|
const result = await sdk.client.find.files({
|
||||||
query: store.filter,
|
query: store.filter,
|
||||||
|
workspace: project.workspace.current(),
|
||||||
})
|
})
|
||||||
if (result.error) return []
|
if (result.error) return []
|
||||||
const sliced = (result.data ?? []).slice(0, 5)
|
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 { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useEditorContext } from "@tui/context/editor"
|
import { useEditorContext } from "@tui/context/editor"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
@@ -19,7 +20,7 @@ import type { PromptInfo } from "./history"
|
|||||||
import { useFrecency } from "./frecency"
|
import { useFrecency } from "./frecency"
|
||||||
import { useBindings } from "../../keymap"
|
import { useBindings } from "../../keymap"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference/reference"
|
||||||
import type { Config } from "@/config/config"
|
import { ConfigReference } from "@/config/reference"
|
||||||
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
|
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
|
||||||
|
|
||||||
function removeLineRange(input: string) {
|
function removeLineRange(input: string) {
|
||||||
@@ -85,6 +86,7 @@ export function Autocomplete(props: {
|
|||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const project = useProject()
|
||||||
const command = useCommandPalette()
|
const command = useCommandPalette()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
@@ -310,7 +312,7 @@ export function Autocomplete(props: {
|
|||||||
`Referenced configured reference @${reference.name}.`,
|
`Referenced configured reference @${reference.name}.`,
|
||||||
...(reference.kind === "local" ? ["Kind: local directory"] : []),
|
...(reference.kind === "local" ? ["Kind: local directory"] : []),
|
||||||
...(reference.kind === "git" ? ["Kind: git repository"] : []),
|
...(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" ? [`Repository: ${reference.repository}`] : []),
|
||||||
...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
||||||
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
|
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
|
||||||
@@ -324,7 +326,7 @@ export function Autocomplete(props: {
|
|||||||
|
|
||||||
const references = createMemo(() =>
|
const references = createMemo(() =>
|
||||||
Reference.resolveAll({
|
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(),
|
directory: sync.path.directory || process.cwd(),
|
||||||
worktree: sync.path.worktree || 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
|
// Get files from SDK
|
||||||
const result = await sdk.client.find.files({
|
const result = await sdk.client.find.files({
|
||||||
query: baseQuery,
|
query: baseQuery,
|
||||||
|
workspace: project.workspace.current(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const options: AutocompleteOption[] = []
|
const options: AutocompleteOption[] = []
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { MessageID, PartID } from "@/session/schema"
|
|||||||
import { createStore, produce, unwrap } from "solid-js/store"
|
import { createStore, produce, unwrap } from "solid-js/store"
|
||||||
import { usePromptHistory, type PromptInfo } from "./history"
|
import { usePromptHistory, type PromptInfo } from "./history"
|
||||||
import { computePromptTraits } from "./traits"
|
import { computePromptTraits } from "./traits"
|
||||||
import { assign } from "./part"
|
import { assign, expandPastedTextPlaceholders } from "./part"
|
||||||
import { usePromptStash } from "./stash"
|
import { usePromptStash } from "./stash"
|
||||||
import { DialogStash } from "../dialog-stash"
|
import { DialogStash } from "../dialog-stash"
|
||||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||||
@@ -1544,6 +1544,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
}}
|
}}
|
||||||
ref={(r: TextareaRenderable) => {
|
ref={(r: TextareaRenderable) => {
|
||||||
input = r
|
input = r
|
||||||
|
Object.assign(r, {
|
||||||
|
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
|
||||||
|
})
|
||||||
setInputTarget(r)
|
setInputTarget(r)
|
||||||
if (promptPartTypeId === 0) {
|
if (promptPartTypeId === 0) {
|
||||||
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
||||||
|
|||||||
@@ -14,3 +14,10 @@ export function assign(part: Item): Item & { id: PartID } {
|
|||||||
id: PartID.ascending(),
|
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.home": keybind("home", "Move to first dialog item"),
|
||||||
"dialog.select.end": keybind("end", "Move to last dialog item"),
|
"dialog.select.end": keybind("end", "Move to last dialog item"),
|
||||||
"dialog.select.submit": keybind("return", "Submit selected 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"),
|
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
|
||||||
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
||||||
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
import { Option, Schema } from "effect"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import type { PromptInfo } from "../component/prompt/history"
|
import type { PromptInfo } from "../component/prompt/history"
|
||||||
|
import { tryJsonConfig } from "@/util/json"
|
||||||
|
|
||||||
export type HomeRoute = {
|
export type HomeRoute = {
|
||||||
type: "home"
|
type: "home"
|
||||||
@@ -21,17 +23,25 @@ export type PluginRoute = {
|
|||||||
|
|
||||||
export type Route = HomeRoute | SessionRoute | PluginRoute
|
export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||||
|
|
||||||
|
const HOME_ROUTE: Route = { type: "home" }
|
||||||
|
|
||||||
|
// Schema covers only the env-fed shape — `prompt` is set programmatically and
|
||||||
|
// not expected from OPENCODE_ROUTE, so we keep it out of validation.
|
||||||
|
export const RouteSchema = Schema.Union([
|
||||||
|
Schema.Struct({ type: Schema.Literal("home") }),
|
||||||
|
Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }),
|
||||||
|
Schema.Struct({
|
||||||
|
type: Schema.Literal("plugin"),
|
||||||
|
id: Schema.String,
|
||||||
|
data: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||||
name: "Route",
|
name: "Route",
|
||||||
init: (props: { initialRoute?: Route }) => {
|
init: (props: { initialRoute?: Route }) => {
|
||||||
const [store, setStore] = createStore<Route>(
|
const fromEnv = tryJsonConfig(process.env["OPENCODE_ROUTE"], RouteSchema, "OPENCODE_ROUTE")
|
||||||
props.initialRoute ??
|
const [store, setStore] = createStore<Route>(props.initialRoute ?? Option.getOrElse(fromEnv, () => HOME_ROUTE))
|
||||||
(process.env["OPENCODE_ROUTE"]
|
|
||||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
|
||||||
: {
|
|
||||||
type: "home",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get data() {
|
get data() {
|
||||||
|
|||||||
@@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) {
|
|||||||
{
|
{
|
||||||
scope: ["extmark.paste"],
|
scope: ["extmark.paste"],
|
||||||
style: {
|
style: {
|
||||||
foreground: theme.background,
|
foreground: selectedForeground(theme, theme.warning),
|
||||||
background: theme.warning,
|
background: theme.warning,
|
||||||
bold: true,
|
bold: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
ToolTextContent,
|
ToolTextContent,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
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 id = "internal:session-v2-debug"
|
||||||
const route = "session.v2.messages"
|
const route = "session.v2.messages"
|
||||||
@@ -198,26 +199,28 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
|
|||||||
|
|
||||||
function ShellMessage(props: { message: SessionMessageShell }) {
|
function ShellMessage(props: { message: SessionMessageShell }) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
const output = createMemo(() => stripAnsi(props.message.output.trim()))
|
const output = createMemo(() => stripAnsi(props.message.output.trim()))
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const lines = createMemo(() => output().split("\n"))
|
const maxLines = 10
|
||||||
const overflow = createMemo(() => lines().length > 10)
|
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
|
||||||
|
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
||||||
const limited = createMemo(() => {
|
const limited = createMemo(() => {
|
||||||
if (expanded() || !overflow()) return output()
|
if (expanded() || !collapsed().overflow) return output()
|
||||||
return [...lines().slice(0, 10), "…"].join("\n")
|
return collapsed().output
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<BlockTool
|
<BlockTool
|
||||||
title="# Shell"
|
title="# Shell"
|
||||||
spinner={!props.message.time.completed}
|
spinner={!props.message.time.completed}
|
||||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
||||||
>
|
>
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<text fg={theme.text}>$ {props.message.command}</text>
|
<text fg={theme.text}>$ {props.message.command}</text>
|
||||||
<Show when={output()}>
|
<Show when={output()}>
|
||||||
<text fg={theme.text}>{limited()}</text>
|
<text fg={theme.text}>{limited()}</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={overflow()}>
|
<Show when={collapsed().overflow}>
|
||||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -518,14 +521,15 @@ type ToolProps = {
|
|||||||
|
|
||||||
function GenericTool(props: ToolProps) {
|
function GenericTool(props: ToolProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
const output = createMemo(() => props.output?.trim() ?? "")
|
const output = createMemo(() => props.output?.trim() ?? "")
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const lines = createMemo(() => output().split("\n"))
|
|
||||||
const maxLines = 3
|
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(() => {
|
const limited = createMemo(() => {
|
||||||
if (expanded() || !overflow()) return output()
|
if (expanded() || !collapsed().overflow) return output()
|
||||||
return [...lines().slice(0, maxLines), "…"].join("\n")
|
return collapsed().output
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -539,11 +543,11 @@ function GenericTool(props: ToolProps) {
|
|||||||
<BlockTool
|
<BlockTool
|
||||||
title={`# ${props.part.name} ${input(props.input)}`}
|
title={`# ${props.part.name} ${input(props.input)}`}
|
||||||
part={props.part}
|
part={props.part}
|
||||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
||||||
>
|
>
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<text fg={theme.text}>{limited()}</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>
|
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -702,15 +706,17 @@ function BlockTool(props: {
|
|||||||
|
|
||||||
function Bash(props: ToolProps) {
|
function Bash(props: ToolProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
|
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
|
||||||
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
|
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
|
||||||
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
|
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const lines = createMemo(() => output().split("\n"))
|
const maxLines = 10
|
||||||
const overflow = createMemo(() => lines().length > 10)
|
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
|
||||||
|
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
||||||
const limited = createMemo(() => {
|
const limited = createMemo(() => {
|
||||||
if (expanded() || !overflow()) return output()
|
if (expanded() || !collapsed().overflow) return output()
|
||||||
return [...lines().slice(0, 10), "…"].join("\n")
|
return collapsed().output
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -719,12 +725,12 @@ function Bash(props: ToolProps) {
|
|||||||
title={title()}
|
title={title()}
|
||||||
part={props.part}
|
part={props.part}
|
||||||
spinner={props.part.state.status === "running"}
|
spinner={props.part.state.status === "running"}
|
||||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
||||||
>
|
>
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<text fg={theme.text}>$ {command()}</text>
|
<text fg={theme.text}>$ {command()}</text>
|
||||||
<text fg={theme.text}>{limited()}</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>
|
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ type RuntimeState = {
|
|||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
plugins_by_id: Map<string, PluginEntry>
|
plugins_by_id: Map<string, PluginEntry>
|
||||||
pending: Map<string, ConfigPlugin.Origin>
|
pending: Map<string, ConfigPlugin.Origin>
|
||||||
|
dispose_timeout_ms: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.plugin" })
|
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()
|
const ctrl = new AbortController()
|
||||||
let list: { key: symbol; fn: TuiDispose }[] = []
|
let list: { key: symbol; fn: TuiDispose }[] = []
|
||||||
let done = false
|
let done = false
|
||||||
@@ -436,14 +437,14 @@ function createPluginScope(load: PluginLoad, id: string) {
|
|||||||
ctrl.abort()
|
ctrl.abort()
|
||||||
const queue = [...list].reverse()
|
const queue = [...list].reverse()
|
||||||
list = []
|
list = []
|
||||||
const until = Date.now() + DISPOSE_TIMEOUT_MS
|
const until = Date.now() + disposeTimeoutMs
|
||||||
for (const item of queue) {
|
for (const item of queue) {
|
||||||
const left = until - Date.now()
|
const left = until - Date.now()
|
||||||
if (left <= 0) {
|
if (left <= 0) {
|
||||||
fail("timed out cleaning up tui plugin", {
|
fail("timed out cleaning up tui plugin", {
|
||||||
path: load.spec,
|
path: load.spec,
|
||||||
id,
|
id,
|
||||||
timeout: DISPOSE_TIMEOUT_MS,
|
timeout: disposeTimeoutMs,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -454,7 +455,7 @@ function createPluginScope(load: PluginLoad, id: string) {
|
|||||||
fail("timed out cleaning up tui plugin", {
|
fail("timed out cleaning up tui plugin", {
|
||||||
path: load.spec,
|
path: load.spec,
|
||||||
id,
|
id,
|
||||||
timeout: DISPOSE_TIMEOUT_MS,
|
timeout: disposeTimeoutMs,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -523,7 +524,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
|
|||||||
if (persist) writePluginEnabledState(state.api, plugin.id, true)
|
if (persist) writePluginEnabledState(state.api, plugin.id, true)
|
||||||
if (plugin.scope) return 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 api = pluginApi(state, plugin, scope, plugin.id)
|
||||||
const ok = await Promise.resolve()
|
const ok = await Promise.resolve()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -1002,7 +1003,12 @@ let loaded: Promise<void> | undefined
|
|||||||
let runtime: RuntimeState | undefined
|
let runtime: RuntimeState | undefined
|
||||||
export const Slot = View
|
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()
|
const cwd = process.cwd()
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
if (dir !== cwd) {
|
if (dir !== cwd) {
|
||||||
@@ -1052,7 +1058,7 @@ export async function dispose() {
|
|||||||
state.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 { api, config } = input
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
const slots = setupSlots(api)
|
const slots = setupSlots(api)
|
||||||
@@ -1064,6 +1070,7 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
|
|||||||
plugins: [],
|
plugins: [],
|
||||||
plugins_by_id: new Map(),
|
plugins_by_id: new Map(),
|
||||||
pending: new Map(),
|
pending: new Map(),
|
||||||
|
dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS,
|
||||||
}
|
}
|
||||||
runtime = next
|
runtime = next
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import { UI } from "@/cli/ui.ts"
|
|||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
|
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||||
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
||||||
import { SessionRetry } from "@/session/retry"
|
import { SessionRetry } from "@/session/retry"
|
||||||
@@ -1696,12 +1697,12 @@ function GenericTool(props: ToolProps<any>) {
|
|||||||
const ctx = use()
|
const ctx = use()
|
||||||
const output = createMemo(() => props.output?.trim() ?? "")
|
const output = createMemo(() => props.output?.trim() ?? "")
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const lines = createMemo(() => output().split("\n"))
|
|
||||||
const maxLines = 3
|
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(() => {
|
const limited = createMemo(() => {
|
||||||
if (expanded() || !overflow()) return output()
|
if (expanded() || !collapsed().overflow) return output()
|
||||||
return [...lines().slice(0, maxLines), "…"].join("\n")
|
return collapsed().output
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1716,11 +1717,11 @@ function GenericTool(props: ToolProps<any>) {
|
|||||||
<BlockTool
|
<BlockTool
|
||||||
title={`# ${props.tool} ${input(props.input)}`}
|
title={`# ${props.tool} ${input(props.input)}`}
|
||||||
part={props.part}
|
part={props.part}
|
||||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
||||||
>
|
>
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<text fg={theme.text}>{limited()}</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>
|
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -1871,14 +1872,16 @@ function BlockTool(props: {
|
|||||||
function Shell(props: ToolProps<typeof ShellTool>) {
|
function Shell(props: ToolProps<typeof ShellTool>) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const pathFormatter = usePathFormatter()
|
const pathFormatter = usePathFormatter()
|
||||||
|
const ctx = use()
|
||||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const lines = createMemo(() => output().split("\n"))
|
const maxLines = 10
|
||||||
const overflow = createMemo(() => lines().length > 10)
|
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
||||||
|
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
||||||
const limited = createMemo(() => {
|
const limited = createMemo(() => {
|
||||||
if (expanded() || !overflow()) return output()
|
if (expanded() || !collapsed().overflow) return output()
|
||||||
return [...lines().slice(0, 10), "…"].join("\n")
|
return collapsed().output
|
||||||
})
|
})
|
||||||
|
|
||||||
const workdirDisplay = createMemo(() => {
|
const workdirDisplay = createMemo(() => {
|
||||||
@@ -1902,14 +1905,14 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
|||||||
title={title()}
|
title={title()}
|
||||||
part={props.part}
|
part={props.part}
|
||||||
spinner={isRunning()}
|
spinner={isRunning()}
|
||||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
||||||
>
|
>
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<text fg={theme.text}>$ {props.input.command}</text>
|
<text fg={theme.text}>$ {props.input.command}</text>
|
||||||
<Show when={output()}>
|
<Show when={output()}>
|
||||||
<text fg={theme.text}>{limited()}</text>
|
<text fg={theme.text}>{limited()}</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={overflow()}>
|
<Show when={collapsed().overflow}>
|
||||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "./dialog"
|
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 { Spinner } from "../component/spinner"
|
||||||
|
import { useTuiConfig } from "../context/tui-config"
|
||||||
|
import { useBindings, useCommandShortcut } from "../keymap"
|
||||||
|
|
||||||
export type DialogPromptProps = {
|
export type DialogPromptProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -18,8 +20,32 @@ export type DialogPromptProps = {
|
|||||||
export function DialogPrompt(props: DialogPromptProps) {
|
export function DialogPrompt(props: DialogPromptProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const tuiConfig = useTuiConfig()
|
||||||
|
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
|
||||||
|
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
|
||||||
let textarea: 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(() => {
|
onMount(() => {
|
||||||
dialog.setSize("medium")
|
dialog.setSize("medium")
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -59,13 +85,10 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
{props.description}
|
{props.description}
|
||||||
<textarea
|
<textarea
|
||||||
onSubmit={() => {
|
|
||||||
if (props.busy) return
|
|
||||||
props.onConfirm?.(textarea.plainText)
|
|
||||||
}}
|
|
||||||
height={3}
|
height={3}
|
||||||
ref={(val: TextareaRenderable) => {
|
ref={(val: TextareaRenderable) => {
|
||||||
textarea = val
|
textarea = val
|
||||||
|
setTextareaTarget(val)
|
||||||
}}
|
}}
|
||||||
initialValue={props.value}
|
initialValue={props.value}
|
||||||
placeholder={props.placeholder ?? "Enter text"}
|
placeholder={props.placeholder ?? "Enter text"}
|
||||||
@@ -80,9 +103,11 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||||||
</box>
|
</box>
|
||||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||||
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
|
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
|
||||||
<text fg={theme.text}>
|
<Show when={submitShortcut()}>
|
||||||
enter <span style={{ fg: theme.textMuted }}>submit</span>
|
<text fg={theme.text}>
|
||||||
</text>
|
{submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</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 = {
|
type FocusableSelectionTarget = {
|
||||||
hasSelection: () => boolean
|
hasSelection: () => boolean
|
||||||
|
getClipboardText?: (text: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Renderer = {
|
type Renderer = {
|
||||||
@@ -23,10 +24,17 @@ type SelectionKeyEvent = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function copy(renderer: Renderer, toast: Toast): boolean {
|
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
|
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" }))
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,21 @@ export function FormatError(input: unknown): string | undefined {
|
|||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidConfigError: malformed env-var JSON at a safety boundary
|
||||||
|
if (isTaggedError(input, "InvalidConfigError")) {
|
||||||
|
const source = stringField(input, "source") ?? ""
|
||||||
|
const value = stringField(input, "value") ?? ""
|
||||||
|
const reason = stringField(input, "reason") ?? ""
|
||||||
|
return [
|
||||||
|
`Error: ${source} is invalid and cannot be applied.`,
|
||||||
|
` Value: '${value}'`,
|
||||||
|
` Reason: ${reason}`,
|
||||||
|
"",
|
||||||
|
`opencode will not start with a malformed ${source}. Set ${source}`,
|
||||||
|
`correctly or unset it to use defaults.`,
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// UICancelledError: user cancelled an interactive CLI prompt
|
// UICancelledError: user cancelled an interactive CLI prompt
|
||||||
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
|
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
|
|||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { Account } from "@/account/account"
|
import { Account } from "@/account/account"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
|
import { requireJsonConfig } from "@/util/json"
|
||||||
import type { ConsoleState } from "./console-state"
|
import type { ConsoleState } from "./console-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
@@ -703,7 +704,8 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Flag.OPENCODE_PERMISSION) {
|
if (Flag.OPENCODE_PERMISSION) {
|
||||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
const decoded = requireJsonConfig(Flag.OPENCODE_PERMISSION, ConfigPermission.Info, "OPENCODE_PERMISSION")
|
||||||
|
result.permission = mergeDeep(result.permission ?? {}, decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.tools) {
|
if (result.tools) {
|
||||||
|
|||||||
@@ -18,6 +18,52 @@ const Local = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
|
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 const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" })
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
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,
|
workspaceID: WorkspaceID,
|
||||||
state: Record<string, number>,
|
state: Record<string, number>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
timeout?: number,
|
||||||
) => Effect.Effect<void, WaitForSyncError>
|
) => Effect.Effect<void, WaitForSyncError>
|
||||||
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
|
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
@@ -946,12 +947,13 @@ export const layer = Layer.effect(
|
|||||||
workspaceID: WorkspaceID,
|
workspaceID: WorkspaceID,
|
||||||
state: Record<string, number>,
|
state: Record<string, number>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
timeout = TIMEOUT,
|
||||||
) {
|
) {
|
||||||
if (synced(state)) return
|
if (synced(state)) return
|
||||||
|
|
||||||
yield* Effect.catch(
|
yield* Effect.catch(
|
||||||
waitEvent({
|
waitEvent({
|
||||||
timeout: TIMEOUT,
|
timeout,
|
||||||
signal,
|
signal,
|
||||||
fn(event) {
|
fn(event) {
|
||||||
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
|
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { FileWatcher } from "@/file/watcher"
|
|||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Plugin } from "@/plugin"
|
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 { Provider } from "@/provider/provider"
|
||||||
import { ProviderAuth } from "@/provider/auth"
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
|||||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||||
|
experimentalNativeLlm: enabledByExperimental("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
||||||
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to bus events, fiber interrupted when scope closes
|
// Subscribe to bus events, fiber interrupted when scope closes
|
||||||
yield* bus.subscribeAll().pipe(
|
yield* (yield* bus.subscribeAll()).pipe(
|
||||||
Stream.runForEach((input) =>
|
Stream.runForEach((input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
for (const hook of hooks) {
|
for (const hook of hooks) {
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ export const layer: Layer.Layer<
|
|||||||
|
|
||||||
const initState = yield* InstanceState.make(
|
const initState = yield* InstanceState.make(
|
||||||
Effect.fn("Project.initState")(function* (ctx) {
|
Effect.fn("Project.initState")(function* (ctx) {
|
||||||
yield* bus.subscribe(Command.Event.Executed).pipe(
|
yield* (yield* bus.subscribe(Command.Event.Executed)).pipe(
|
||||||
Stream.runForEach((payload) =>
|
Stream.runForEach((payload) =>
|
||||||
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
|
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
|
|||||||
const value = { current, root }
|
const value = { current, root }
|
||||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
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.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||||
Stream.runForEach((_evt) =>
|
Stream.runForEach((_evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Schema } from "effect"
|
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 const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
|
||||||
export type ModelStatus = typeof ModelStatus.Type
|
export type ModelStatus = typeof ModelStatus.Type
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Npm } from "@opencode-ai/core/npm"
|
|||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { Hash } from "@opencode-ai/core/util/hash"
|
||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import { type LanguageModelV3 } from "@ai-sdk/provider"
|
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 { Auth } from "../auth"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ModelMessage, ToolResultPart } from "ai"
|
|||||||
import { mergeDeep, unique } from "remeda"
|
import { mergeDeep, unique } from "remeda"
|
||||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||||
import type * as Provider from "./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"
|
import { iife } from "@/util/iife"
|
||||||
|
|
||||||
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
|
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import { Effect, Context, Layer, Scope } from "effect"
|
|||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
import { ConfigReference } from "@/config/reference"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Git } from "@/git"
|
import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository"
|
||||||
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
|
|
||||||
import { RepositoryCache } from "./repository-cache"
|
import { RepositoryCache } from "./repository-cache"
|
||||||
|
|
||||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
|
||||||
|
|
||||||
export type Resolved =
|
export type Resolved =
|
||||||
| {
|
| {
|
||||||
name: string
|
name: string
|
||||||
@@ -21,23 +19,25 @@ export type Resolved =
|
|||||||
name: string
|
name: string
|
||||||
kind: "git"
|
kind: "git"
|
||||||
repository: string
|
repository: string
|
||||||
reference: RepositoryReference
|
reference: RemoteReference
|
||||||
path: string
|
path: string
|
||||||
branch?: string
|
branch?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: string
|
name: string
|
||||||
kind: "invalid"
|
kind: "invalid"
|
||||||
repository: string
|
repository?: string
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
references: Resolved[]
|
references: Resolved[]
|
||||||
materializeAll: Effect.Effect<void>
|
materializeAll: Effect.Effect<void>
|
||||||
materializeByPath: { path: string; run: Effect.Effect<void> }[]
|
materializeByPath: Materializer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Materializer = { path: string; run: Effect.Effect<void> }
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly list: () => Effect.Effect<Resolved[]>
|
readonly list: () => Effect.Effect<Resolved[]>
|
||||||
@@ -90,31 +90,75 @@ function containsReferencePath(referencePath: string, target: string) {
|
|||||||
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
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: {
|
export function resolve(input: {
|
||||||
name: string
|
name: string
|
||||||
reference: ReferenceEntry
|
reference: ConfigReference.NormalizedEntry
|
||||||
directory: string
|
directory: string
|
||||||
worktree: string
|
worktree: string
|
||||||
}): Resolved {
|
}): Resolved {
|
||||||
if (typeof input.reference === "string") {
|
if (input.reference.kind === "invalid") {
|
||||||
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
|
return { name: input.name, kind: "invalid", message: input.reference.message }
|
||||||
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
|
|
||||||
}
|
|
||||||
return resolveGit({ name: input.name, repository: input.reference })
|
|
||||||
}
|
}
|
||||||
|
if (input.reference.kind === "local") {
|
||||||
if ("path" in input.reference) {
|
|
||||||
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
|
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 })
|
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAll(input: {
|
export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; worktree: string }) {
|
||||||
references: NonNullable<Config.Info["reference"]>
|
|
||||||
directory: string
|
|
||||||
worktree: string
|
|
||||||
}) {
|
|
||||||
const seen = new Map<string, { name: string; branch?: string }>()
|
const seen = new Map<string, { name: string; branch?: string }>()
|
||||||
return Object.entries(input.references).map(([name, reference]) => {
|
return Object.entries(input.references).map(([name, reference]) => {
|
||||||
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
|
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
|
||||||
@@ -140,8 +184,7 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const cache = yield* RepositoryCache.Service
|
||||||
const git = yield* Git.Service
|
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
|
|
||||||
@@ -149,53 +192,14 @@ export const layer = Layer.effect(
|
|||||||
Effect.fn("Reference.state")(function* (ctx) {
|
Effect.fn("Reference.state")(function* (ctx) {
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const references = resolveAll({
|
const references = resolveAll({
|
||||||
references: cfg.reference ?? {},
|
references: ConfigReference.normalize(cfg.reference ?? {}),
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
worktree: ctx.worktree,
|
worktree: ctx.worktree,
|
||||||
})
|
})
|
||||||
const seenPath = new Set<string>()
|
const materializeByPath = yield* materializers(cache, references)
|
||||||
const gitReferences = references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
const materializeAllCached = yield* Effect.cached(materializeAll({ flags, materializers: materializeByPath }))
|
||||||
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 materializeAll = yield* Effect.cached(
|
return { references, materializeAll: materializeAllCached, materializeByPath }
|
||||||
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 }
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,18 +218,13 @@ export const layer = Layer.effect(
|
|||||||
if (!flags.experimentalScout) return
|
if (!flags.experimentalScout) return
|
||||||
const full = normalizedTarget(target)
|
const full = normalizedTarget(target)
|
||||||
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
||||||
return yield* InstanceState.useEffect(
|
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
|
||||||
state,
|
|
||||||
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
||||||
if (!flags.experimentalScout) return false
|
if (!flags.experimentalScout) return false
|
||||||
const full = normalizedTarget(target)
|
const full = normalizedTarget(target)
|
||||||
if (!full) return false
|
if (!full) return false
|
||||||
return yield* InstanceState.use(state, (s) =>
|
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
|
||||||
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -233,8 +232,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(RepositoryCache.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
Layer.provide(RuntimeFlags.defaultLayer),
|
Layer.provide(RuntimeFlags.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Flock } from "@opencode-ai/core/util/flock"
|
import { Flock } from "@opencode-ai/core/util/flock"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
sameRepositoryReference,
|
sameRepositoryReference,
|
||||||
parseRepositoryReference,
|
parseRepositoryReference,
|
||||||
validateRepositoryBranch,
|
validateRepositoryBranch,
|
||||||
type Reference as RepositoryReference,
|
isRemoteRepositoryReference,
|
||||||
|
type RemoteReference,
|
||||||
} from "@/util/repository"
|
} from "@/util/repository"
|
||||||
|
|
||||||
export type Result = {
|
export type Result = {
|
||||||
@@ -21,6 +22,82 @@ export type Result = {
|
|||||||
branch?: string
|
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 }) {
|
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||||
if (!input.reuse) return "cloned" as const
|
if (!input.reuse) return "cloned" as const
|
||||||
if (input.branchMatches === false) return "refreshed" as const
|
if (input.branchMatches === false) return "refreshed" as const
|
||||||
@@ -43,18 +120,53 @@ function resetTarget(input: {
|
|||||||
return "HEAD"
|
return "HEAD"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
function errorMessage(error: unknown) {
|
||||||
input: {
|
return error instanceof globalThis.Error ? error.message : String(error)
|
||||||
reference: RepositoryReference
|
}
|
||||||
refresh?: boolean
|
|
||||||
branch?: string
|
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: {
|
services: {
|
||||||
fs: AppFileSystem.Interface
|
fs: AppFileSystem.Interface
|
||||||
git: Git.Interface
|
git: Git.Interface
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (input.branch) validateRepositoryBranch(input.branch)
|
if (input.branch) yield* validateBranch(input.branch)
|
||||||
|
|
||||||
const repository = input.reference.label
|
const repository = input.reference.label
|
||||||
const remote = input.reference.remote
|
const remote = input.reference.remote
|
||||||
@@ -62,10 +174,24 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
|||||||
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
|
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
|
||||||
|
|
||||||
return yield* Effect.acquireUseRelease(
|
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* () {
|
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 exists = yield* services.fs.existsSafe(localPath)
|
||||||
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
|
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 originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
||||||
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
||||||
if (exists && !reuse) {
|
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
|
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) },
|
{ cwd: path.dirname(localPath) },
|
||||||
)
|
)
|
||||||
if (clone.exitCode !== 0) {
|
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") {
|
if (status === "refreshed") {
|
||||||
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
||||||
if (fetch.exitCode !== 0) {
|
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) {
|
if (input.branch) {
|
||||||
@@ -106,9 +248,12 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
|||||||
cwd: localPath,
|
cwd: localPath,
|
||||||
})
|
})
|
||||||
if (checkout.exitCode !== 0) {
|
if (checkout.exitCode !== 0) {
|
||||||
throw new Error(
|
return yield* new CheckoutFailedError({
|
||||||
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
|
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 })
|
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
|
||||||
if (reset.exitCode !== 0) {
|
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"
|
export * as RepositoryCache from "./repository-cache"
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ function eventData(data: unknown): Sse.Event {
|
|||||||
|
|
||||||
function eventResponse(bus: Bus.Interface) {
|
function eventResponse(bus: Bus.Interface) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const context = yield* Effect.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
|
||||||
const events = bus.subscribeAll().pipe(
|
// fiber to drain — closing the race where Stream.concat(server.connected,
|
||||||
Stream.provideContext(context),
|
// 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),
|
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
|
||||||
)
|
)
|
||||||
const heartbeat = Stream.tick("10 seconds").pipe(
|
const heartbeat = Stream.tick("10 seconds").pipe(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ProviderAuth } from "@/provider/auth"
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
import { Config } from "@/config/config"
|
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 { Provider } from "@/provider/provider"
|
||||||
import { ProviderID } from "@/provider/schema"
|
import { ProviderID } from "@/provider/schema"
|
||||||
import { mapValues } from "remeda"
|
import { mapValues } from "remeda"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { WorkspaceID } from "@/control-plane/schema"
|
import { WorkspaceID } from "@/control-plane/schema"
|
||||||
import { SessionV2 } from "@/v2/session"
|
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 { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../../api"
|
import { InstanceHttpApi } from "../../api"
|
||||||
|
|
||||||
@@ -55,7 +55,13 @@ const sessionCursor = {
|
|||||||
filters: Pick<SessionCursor, "directory" | "path" | "workspaceID" | "roots" | "start" | "search">,
|
filters: Pick<SessionCursor, "directory" | "path" | "workspaceID" | "roots" | "start" | "search">,
|
||||||
) {
|
) {
|
||||||
return Buffer.from(
|
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")
|
).toString("base64url")
|
||||||
},
|
},
|
||||||
decode(input: string) {
|
decode(input: string) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user