mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-03 15:21:31 +08:00
Compare commits
3 Commits
kit/httpap
...
oc-run-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df70d33edd | ||
|
|
eff0387bf4 | ||
|
|
b3ecd4df81 |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -32,7 +32,6 @@ rekram1-node
|
||||
-ricardo-m-l
|
||||
-robinmordasiewicz
|
||||
rubdos
|
||||
-saisharan0103 spamming ai prs
|
||||
shantur
|
||||
simonklee
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
|
||||
@@ -132,7 +132,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||
- Built-in opt-in LSP support
|
||||
- Out-of-the-box LSP support
|
||||
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
|
||||
92
bun.lock
92
bun.lock
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -170,7 +170,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -228,7 +228,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -263,7 +263,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -309,7 +309,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -354,7 +354,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -496,7 +496,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -511,8 +511,8 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.2",
|
||||
"@opentui/solid": ">=0.2.2",
|
||||
"@opentui/core": ">=0.2.0",
|
||||
"@opentui/solid": ">=0.2.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -531,7 +531,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -546,7 +546,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -581,7 +581,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -630,7 +630,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -690,8 +690,8 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.2",
|
||||
"@opentui/solid": "0.2.2",
|
||||
"@opentui/core": "0.2.0",
|
||||
"@opentui/solid": "0.2.0",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -715,7 +715,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.59",
|
||||
"effect": "4.0.0-beta.57",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -1618,21 +1618,21 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.2", "", { "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.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "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-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.0", "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-kZR9i0FPAcVtomrPsKuSb+D9smooplo9zggFfU2vnnguNuQjGNbEmuJtxhCacy7ig9g3GomdNtQAzD4LiAY+3w=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -2768,21 +2768,21 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
||||
|
||||
"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-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
|
||||
"bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="],
|
||||
|
||||
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
|
||||
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="],
|
||||
|
||||
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
|
||||
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="],
|
||||
|
||||
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
|
||||
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="],
|
||||
|
||||
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
|
||||
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
@@ -3078,7 +3078,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
|
||||
"effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -4204,7 +4204,7 @@
|
||||
|
||||
"pagefind": ["pagefind@1.5.2", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.5.2", "@pagefind/darwin-x64": "1.5.2", "@pagefind/freebsd-x64": "1.5.2", "@pagefind/linux-arm64": "1.5.2", "@pagefind/linux-x64": "1.5.2", "@pagefind/windows-arm64": "1.5.2", "@pagefind/windows-x64": "1.5.2" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q=="],
|
||||
|
||||
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
|
||||
|
||||
@@ -5640,8 +5640,6 @@
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
|
||||
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
@@ -6124,6 +6122,8 @@
|
||||
|
||||
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
|
||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -6132,8 +6132,6 @@
|
||||
|
||||
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
@@ -6800,7 +6798,7 @@
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
||||
"opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -7160,6 +7158,16 @@
|
||||
|
||||
"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/core/bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=",
|
||||
"aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=",
|
||||
"aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=",
|
||||
"x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U="
|
||||
"x86_64-linux": "sha256-OtyfKTBEHsJpjzAjN9vCR0PzGzdK6CDHdyU7eZ6Gl1s=",
|
||||
"aarch64-linux": "sha256-3eHJs3S/+uDUPAouWPsdBOlEvAOhOYx5bJzahL0tAJk=",
|
||||
"aarch64-darwin": "sha256-rFXzrkhPVb3yM20J8R8m7GqroNNk1vAEz+o/Ks+iAI4=",
|
||||
"x86_64-darwin": "sha256-lb1IGgbpxg723Qxj2WVPkxKUUmyOIsFOAhA5LoZ8GwY="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ stdenvNoCC.mkDerivation {
|
||||
--filter './packages/opencode' \
|
||||
--filter './packages/desktop' \
|
||||
--filter './packages/app' \
|
||||
--filter './packages/shared' \
|
||||
--frozen-lockfile \
|
||||
--ignore-scripts \
|
||||
--no-progress
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.2",
|
||||
"@opentui/solid": "0.2.2",
|
||||
"@opentui/core": "0.2.0",
|
||||
"@opentui/solid": "0.2.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -53,7 +53,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.59",
|
||||
"effect": "4.0.0-beta.57",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Config } from "effect"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
@@ -11,10 +10,6 @@ function falsy(key: string) {
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
// Channels that default to the new effect-httpapi server backend. The legacy
|
||||
// hono backend remains the default for stable (`prod`/`latest`) installs.
|
||||
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
@@ -86,16 +81,8 @@ export const Flag = {
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
// Defaults to true on dev/beta/local channels so internal users exercise the
|
||||
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
|
||||
// on the legacy hono backend until the rollout is complete. An explicit env
|
||||
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
|
||||
// stable users and an escape hatch for dev/beta users.
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI:
|
||||
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
|
||||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -120,17 +120,13 @@ export const layer = Layer.effect(
|
||||
}
|
||||
})()
|
||||
|
||||
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
|
||||
if (yield* afs.existsSafe(dir)) {
|
||||
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
|
||||
}
|
||||
|
||||
const tree = yield* reify({ dir, add: [pkg] })
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) {
|
||||
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
|
||||
if (Option.isSome(result.entrypoint)) return result
|
||||
return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
}
|
||||
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
}, Effect.scoped)
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export * as Log from "./log"
|
||||
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { createWriteStream } from "fs"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
describe("global paths", () => {
|
||||
test("tmp path is under the system temp directory", () => {
|
||||
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
|
||||
expect(Global.make().tmp).toBe(Global.Path.tmp)
|
||||
})
|
||||
|
||||
test("tmp path is created on module load", async () => {
|
||||
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const win = process.platform === "win32"
|
||||
@@ -20,14 +15,6 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
||||
}),
|
||||
)
|
||||
|
||||
const npmLayer = (cache: string) =>
|
||||
Npm.layer.pipe(
|
||||
Layer.provide(EffectFlock.layer),
|
||||
Layer.provide(AppFileSystem.layer),
|
||||
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
||||
describe("Npm.sanitize", () => {
|
||||
test("keeps normal scoped package specs unchanged", () => {
|
||||
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
|
||||
@@ -42,28 +29,6 @@ describe("Npm.sanitize", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.add", () => {
|
||||
test("reifies when package cache directory exists without the package installed", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "fixture-provider"))
|
||||
await writePackage(path.join(tmp.path, "fixture-provider"), {
|
||||
name: "fixture-provider",
|
||||
main: "index.js",
|
||||
})
|
||||
await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n")
|
||||
|
||||
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
|
||||
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
|
||||
|
||||
const entry = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(Option.isSome(entry.entrypoint)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.install", () => {
|
||||
test("respects omit from project .npmrc", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -26,20 +26,13 @@ const applyZoom = (next: number) => {
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
|
||||
|
||||
if (event.key === "-") {
|
||||
event.preventDefault()
|
||||
applyZoom(clamp(webviewZoom() - 0.2))
|
||||
return
|
||||
}
|
||||
if (event.key === "=" || event.key === "+") {
|
||||
event.preventDefault()
|
||||
applyZoom(clamp(webviewZoom() + 0.2))
|
||||
return
|
||||
}
|
||||
if (event.key === "0") {
|
||||
event.preventDefault()
|
||||
applyZoom(1)
|
||||
}
|
||||
let newZoom = webviewZoom()
|
||||
|
||||
if (event.key === "-") newZoom -= 0.2
|
||||
if (event.key === "=" || event.key === "+") newZoom += 0.2
|
||||
if (event.key === "0") newZoom = 1
|
||||
|
||||
applyZoom(clamp(newZoom))
|
||||
})
|
||||
|
||||
export { webviewZoom }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.33"
|
||||
version = "1.14.31"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE `session_message` (
|
||||
`id` text PRIMARY KEY,
|
||||
`session_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`time_created` integer NOT NULL,
|
||||
`time_updated` integer NOT NULL,
|
||||
`data` text NOT NULL,
|
||||
CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint
|
||||
DROP TABLE `session_entry`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "aaa2ebeb-caa4-478d-8365-4fc595d16856",
|
||||
"prevIds": ["61f807f9-6398-4067-be05-804acc2561bc"],
|
||||
"prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -37,7 +37,7 @@
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_message",
|
||||
"name": "session_entry",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
@@ -598,7 +598,7 @@
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -608,7 +608,7 @@
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -618,7 +618,7 @@
|
||||
"generated": null,
|
||||
"name": "type",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -628,7 +628,7 @@
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -638,7 +638,7 @@
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -648,7 +648,7 @@
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1112,9 +1112,9 @@
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_message_session_id_session_id_fk",
|
||||
"name": "fk_session_entry_session_id_session_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
@@ -1226,8 +1226,8 @@
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_message_pk",
|
||||
"table": "session_message",
|
||||
"name": "session_entry_pk",
|
||||
"table": "session_entry",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
@@ -1322,9 +1322,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_session_idx",
|
||||
"name": "session_entry_session_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1340,9 +1340,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_session_type_idx",
|
||||
"name": "session_entry_session_type_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1354,9 +1354,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_time_created_idx",
|
||||
"name": "session_entry_time_created_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint
|
||||
ALTER TABLE `session` ADD `model` text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.31",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -61,7 +61,6 @@ const createEmbeddedWebUIBundle = async () => {
|
||||
await $`bun run --cwd ${appDir} build`
|
||||
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
|
||||
.map((file) => file.replaceAll("\\", "/"))
|
||||
.filter((file) => !file.endsWith(".map"))
|
||||
.sort()
|
||||
const imports = files.map((file, i) => {
|
||||
const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,6 @@ import { LoadAPIKeyError } from "ai"
|
||||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
|
||||
type ModeOption = { id: string; name: string; description?: string }
|
||||
type ModelOption = { modelId: string; name: string }
|
||||
@@ -130,7 +129,7 @@ async function sendUsageUpdate(
|
||||
})
|
||||
}
|
||||
|
||||
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||
export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||
return {
|
||||
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
|
||||
return new Agent(connection, fullConfig)
|
||||
@@ -145,7 +144,7 @@ export class Agent implements ACPAgent {
|
||||
private sessionManager: ACPSessionManager
|
||||
private eventAbort = new AbortController()
|
||||
private eventStarted = false
|
||||
private shellSnapshots = new Map<string, string>()
|
||||
private bashSnapshots = new Map<string, string>()
|
||||
private toolStarts = new Set<string>()
|
||||
private permissionQueues = new Map<string, Promise<void>>()
|
||||
private permissionOptions: PermissionOption[] = [
|
||||
@@ -284,16 +283,16 @@ export class Agent implements ACPAgent {
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
return
|
||||
|
||||
case "running":
|
||||
const output = this.shellOutput(part)
|
||||
const output = this.bashOutput(part)
|
||||
const content: ToolCallContent[] = []
|
||||
if (output) {
|
||||
const hash = Hash.fast(output)
|
||||
if (part.tool === ShellID.ToolID) {
|
||||
if (this.shellSnapshots.get(part.callID) === hash) {
|
||||
if (part.tool === "bash") {
|
||||
if (this.bashSnapshots.get(part.callID) === hash) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -312,7 +311,7 @@ export class Agent implements ACPAgent {
|
||||
})
|
||||
return
|
||||
}
|
||||
this.shellSnapshots.set(part.callID, hash)
|
||||
this.bashSnapshots.set(part.callID, hash)
|
||||
}
|
||||
content.push({
|
||||
type: "content",
|
||||
@@ -343,7 +342,7 @@ export class Agent implements ACPAgent {
|
||||
|
||||
case "completed": {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
@@ -424,7 +423,7 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -838,10 +837,10 @@ export class Agent implements ACPAgent {
|
||||
await this.toolStart(sessionId, part)
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
break
|
||||
case "running":
|
||||
const output = this.shellOutput(part)
|
||||
const output = this.bashOutput(part)
|
||||
const runningContent: ToolCallContent[] = []
|
||||
if (output) {
|
||||
runningContent.push({
|
||||
@@ -872,7 +871,7 @@ export class Agent implements ACPAgent {
|
||||
break
|
||||
case "completed":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
@@ -952,7 +951,7 @@ export class Agent implements ACPAgent {
|
||||
break
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -1106,8 +1105,8 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
}
|
||||
|
||||
private shellOutput(part: ToolPart) {
|
||||
if (part.tool !== ShellID.ToolID) return
|
||||
private bashOutput(part: ToolPart) {
|
||||
if (part.tool !== "bash") return
|
||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||
const output = part.state.metadata["output"]
|
||||
if (typeof output !== "string") return
|
||||
@@ -1550,11 +1549,9 @@ export class Agent implements ACPAgent {
|
||||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case ShellID.ToolID:
|
||||
case "bash":
|
||||
return "execute"
|
||||
|
||||
case "webfetch":
|
||||
return "fetch"
|
||||
|
||||
@@ -1579,7 +1576,6 @@ function toToolKind(toolName: string): ToolKind {
|
||||
|
||||
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case "read":
|
||||
case "edit":
|
||||
@@ -1588,7 +1584,7 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
|
||||
case "glob":
|
||||
case "grep":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case ShellID.ToolID:
|
||||
case "bash":
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
|
||||
@@ -24,7 +24,6 @@ export function payloads() {
|
||||
.map(([type, def]) => {
|
||||
return z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.literal(type),
|
||||
properties: zodObject(def.properties),
|
||||
})
|
||||
@@ -40,7 +39,6 @@ export function effectPayloads() {
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { Identifier } from "@/id/id"
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory?: string
|
||||
@@ -8,15 +7,6 @@ export type GlobalEvent = {
|
||||
payload: any
|
||||
}
|
||||
|
||||
class GlobalBusEmitter extends EventEmitter<{
|
||||
export const GlobalBus = new EventEmitter<{
|
||||
event: [GlobalEvent]
|
||||
}> {
|
||||
override emit(eventName: "event", event: GlobalEvent): boolean {
|
||||
if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) {
|
||||
event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending")
|
||||
}
|
||||
return super.emit(eventName, event)
|
||||
}
|
||||
}
|
||||
|
||||
export const GlobalBus = new GlobalBusEmitter()
|
||||
}>()
|
||||
|
||||
@@ -5,7 +5,6 @@ import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Identifier } from "@/id/id"
|
||||
|
||||
const log = Log.create({ service: "bus" })
|
||||
|
||||
@@ -19,7 +18,6 @@ export const InstanceDisposed = BusEvent.define(
|
||||
)
|
||||
|
||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||
id: string
|
||||
type: D["type"]
|
||||
properties: BusProperties<D>
|
||||
}
|
||||
@@ -30,11 +28,7 @@ type State = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: BusProperties<D>,
|
||||
options?: { id?: string },
|
||||
) => Effect.Effect<void>
|
||||
readonly publish: <D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) => Effect.Effect<void>
|
||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||
@@ -59,7 +53,6 @@ export const layer = Layer.effect(
|
||||
// Publish InstanceDisposed before shutting down so subscribers see it
|
||||
yield* PubSub.publish(wildcard, {
|
||||
type: InstanceDisposed.type,
|
||||
id: createID(),
|
||||
properties: { directory: ctx.directory },
|
||||
})
|
||||
yield* PubSub.shutdown(wildcard)
|
||||
@@ -84,10 +77,10 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>, options?: { id?: string }) {
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
|
||||
return Effect.gen(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties }
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = s.typed.get(def.type)
|
||||
@@ -180,16 +173,8 @@ const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||
export function createID() {
|
||||
return Identifier.create("evt", "ascending")
|
||||
}
|
||||
|
||||
export async function publish<D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: BusProperties<D>,
|
||||
options?: { id?: string },
|
||||
) {
|
||||
return runPromise((svc) => svc.publish(def, properties, options))
|
||||
export async function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
|
||||
return runPromise((svc) => svc.publish(def, properties))
|
||||
}
|
||||
|
||||
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceRuntime } from "../project/instance-runtime"
|
||||
import { WithInstance } from "../project/with-instance"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
return WithInstance.provide({
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await cb()
|
||||
return result
|
||||
} finally {
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
await Instance.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
@@ -172,65 +172,60 @@ const openEffect = Effect.fn("open")(function* () {
|
||||
yield* Prompt.outro("Opened " + url)
|
||||
})
|
||||
|
||||
export const LoginCommand = effectCmd({
|
||||
export const LoginCommand = cmd({
|
||||
command: "login <url>",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "server URL",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.account.login")(function* (args) {
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(loginEffect(args.url))
|
||||
}),
|
||||
await AppRuntime.runPromise(loginEffect(args.url))
|
||||
},
|
||||
})
|
||||
|
||||
export const LogoutCommand = effectCmd({
|
||||
export const LogoutCommand = cmd({
|
||||
command: "logout [email]",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("email", {
|
||||
describe: "account email to log out from",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.account.logout")(function* (args) {
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(logoutEffect(args.email))
|
||||
}),
|
||||
await AppRuntime.runPromise(logoutEffect(args.email))
|
||||
},
|
||||
})
|
||||
|
||||
export const SwitchCommand = effectCmd({
|
||||
export const SwitchCommand = cmd({
|
||||
command: "switch",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.switch")(function* () {
|
||||
async handler() {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(switchEffect())
|
||||
}),
|
||||
await AppRuntime.runPromise(switchEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const OrgsCommand = effectCmd({
|
||||
export const OrgsCommand = cmd({
|
||||
command: "orgs",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.orgs")(function* () {
|
||||
async handler() {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(orgsEffect())
|
||||
}),
|
||||
await AppRuntime.runPromise(orgsEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const OpenCommand = effectCmd({
|
||||
export const OpenCommand = cmd({
|
||||
command: "open",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.open")(function* () {
|
||||
async handler() {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(openEffect())
|
||||
}),
|
||||
await AppRuntime.runPromise(openEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const ConsoleCommand = cmd({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { cmd } from "./cmd"
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ACP } from "@/acp/agent"
|
||||
import { Server } from "@/server/server"
|
||||
@@ -9,7 +9,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
|
||||
const log = Log.create({ service: "acp-command" })
|
||||
|
||||
export const AcpCommand = effectCmd({
|
||||
export const AcpCommand = cmd({
|
||||
command: "acp",
|
||||
describe: "start ACP (Agent Client Protocol) server",
|
||||
builder: (yargs) => {
|
||||
@@ -19,53 +19,52 @@ export const AcpCommand = effectCmd({
|
||||
default: process.cwd(),
|
||||
})
|
||||
},
|
||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||
handler: async (args) => {
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = await Server.listen(opts)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
})
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
})
|
||||
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = await ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
return agent.create(conn, { sdk })
|
||||
}, stream)
|
||||
|
||||
log.info("setup connection")
|
||||
process.stdin.resume()
|
||||
await new Promise((resolve, reject) => {
|
||||
process.stdin.on("end", resolve)
|
||||
process.stdin.on("error", reject)
|
||||
})
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
return agent.create(conn, { sdk })
|
||||
}, stream)
|
||||
|
||||
log.info("setup connection")
|
||||
process.stdin.resume()
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
process.stdin.on("end", () => resolve())
|
||||
process.stdin.on("error", reject)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,11 +10,8 @@ import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import matter from "gray-matter"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { WithInstance } from "../../project/with-instance"
|
||||
import { EOL } from "os"
|
||||
import type { Argv } from "yargs"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
|
||||
type AgentMode = "all" | "primary" | "subagent"
|
||||
|
||||
@@ -64,7 +61,7 @@ const AgentCreateCommand = cmd({
|
||||
describe: "model to use in the format of provider/model",
|
||||
}),
|
||||
async handler(args) {
|
||||
await WithInstance.provide({
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
const cliPath = args.path
|
||||
@@ -235,23 +232,28 @@ const AgentCreateCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
const AgentListCommand = effectCmd({
|
||||
const AgentListCommand = cmd({
|
||||
command: "list",
|
||||
describe: "list all available agents",
|
||||
handler: Effect.fn("Cli.agent.list")(function* () {
|
||||
const agents = yield* Agent.Service.use((svc) => svc.list())
|
||||
const sortedAgents = agents.sort((a, b) => {
|
||||
if (a.native !== b.native) {
|
||||
return a.native ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||
const sortedAgents = agents.sort((a, b) => {
|
||||
if (a.native !== b.native) {
|
||||
return a.native ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
for (const agent of sortedAgents) {
|
||||
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||
}
|
||||
}),
|
||||
for (const agent of sortedAgents) {
|
||||
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
export type WithDoubleDash<T> = T & { "--"?: string[] }
|
||||
type WithDoubleDash<T> = T & { "--"?: string[] }
|
||||
|
||||
export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
|
||||
return input
|
||||
|
||||
@@ -7,13 +7,14 @@ import { Session } from "@/session/session"
|
||||
import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { effectCmd, fail } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
export const AgentCommand = effectCmd({
|
||||
export const AgentCommand = cmd({
|
||||
command: "agent <name>",
|
||||
describe: "show agent configuration details",
|
||||
builder: (yargs) =>
|
||||
@@ -31,60 +32,60 @@ export const AgentCommand = effectCmd({
|
||||
type: "string",
|
||||
description: "Tool params as JSON or a JS object literal",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.agent")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
return yield* run(args, ctx)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const agentName = args.name as string
|
||||
const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName)))
|
||||
if (!agent) {
|
||||
process.stderr.write(
|
||||
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const availableTools = await getAvailableTools(agent)
|
||||
const resolvedTools = await resolveTools(agent, availableTools)
|
||||
const toolID = args.tool as string | undefined
|
||||
if (toolID) {
|
||||
const tool = availableTools.find((item) => item.id === toolID)
|
||||
if (!tool) {
|
||||
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
|
||||
process.exit(1)
|
||||
}
|
||||
if (resolvedTools[toolID] === false) {
|
||||
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
|
||||
process.exit(1)
|
||||
}
|
||||
const params = parseToolParams(args.params as string | undefined)
|
||||
const ctx = await createToolContext(agent)
|
||||
const result = await tool.execute(params, ctx)
|
||||
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const output = {
|
||||
...agent,
|
||||
tools: resolvedTools,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const run = Effect.fn("Cli.debug.agent.body")(function* (
|
||||
args: { name: string; tool?: string; params?: string },
|
||||
ctx: InstanceContext,
|
||||
) {
|
||||
const agentName = args.name
|
||||
const agent = yield* Agent.Service.use((svc) => svc.get(agentName))
|
||||
if (!agent) {
|
||||
process.stderr.write(
|
||||
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||
)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
const availableTools = yield* getAvailableTools(agent)
|
||||
const resolvedTools = resolveTools(agent, availableTools)
|
||||
const toolID = args.tool
|
||||
if (toolID) {
|
||||
const tool = availableTools.find((item) => item.id === toolID)
|
||||
if (!tool) {
|
||||
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
if (resolvedTools[toolID] === false) {
|
||||
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
const params = parseToolParams(args.params)
|
||||
const toolCtx = yield* createToolContext(agent, ctx)
|
||||
const result = yield* tool.execute(params, toolCtx)
|
||||
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
|
||||
return
|
||||
}
|
||||
async function getAvailableTools(agent: Agent.Info) {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const model = agent.model ?? (yield* provider.defaultModel())
|
||||
return yield* registry.tools({
|
||||
...model,
|
||||
agent,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const output = {
|
||||
...agent,
|
||||
tools: resolvedTools,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
|
||||
})
|
||||
|
||||
const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) {
|
||||
const provider = yield* Provider.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const model = agent.model ?? (yield* provider.defaultModel())
|
||||
return yield* registry.tools({ ...model, agent })
|
||||
})
|
||||
|
||||
function resolveTools(agent: Agent.Info, availableTools: { id: string }[]) {
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
const disabled = Permission.disabled(
|
||||
availableTools.map((tool) => tool.id),
|
||||
agent.permission,
|
||||
@@ -122,38 +123,50 @@ function parseToolParams(input?: string) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(function* (
|
||||
agent: Agent.Info,
|
||||
ctx: InstanceContext,
|
||||
) {
|
||||
const sessionSvc = yield* Session.Service
|
||||
const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model
|
||||
? agent.model
|
||||
: yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel()
|
||||
})
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "assistant",
|
||||
time: { created: now },
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: ctx.directory,
|
||||
root: ctx.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
yield* sessionSvc.updateMessage(message)
|
||||
async function createToolContext(agent: Agent.Info) {
|
||||
const { session, messageID } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const result = yield* session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model
|
||||
? agent.model
|
||||
: yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel()
|
||||
})
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: result.id,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* session.updateMessage(message)
|
||||
return { session: result, messageID }
|
||||
}),
|
||||
)
|
||||
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
@@ -176,4 +189,4 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const ConfigCommand = effectCmd({
|
||||
export const ConfigCommand = cmd({
|
||||
command: "config",
|
||||
describe: "show resolved configuration",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.config")(function* () {
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
}),
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { File } from "../../../file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
const FileSearchCommand = effectCmd({
|
||||
const FileSearchCommand = cmd({
|
||||
command: "search <query>",
|
||||
describe: "search files by query",
|
||||
builder: (yargs) =>
|
||||
@@ -14,13 +14,15 @@ const FileSearchCommand = effectCmd({
|
||||
demandOption: true,
|
||||
description: "Search query",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
||||
const results = yield* File.Service.use((svc) => svc.search({ query: args.query }))
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query })))
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FileReadCommand = effectCmd({
|
||||
const FileReadCommand = cmd({
|
||||
command: "read <path>",
|
||||
describe: "read file contents as JSON",
|
||||
builder: (yargs) =>
|
||||
@@ -29,23 +31,27 @@ const FileReadCommand = effectCmd({
|
||||
demandOption: true,
|
||||
description: "File path to read",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
||||
const content = yield* File.Service.use((svc) => svc.read(args.path))
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path)))
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FileStatusCommand = effectCmd({
|
||||
const FileStatusCommand = cmd({
|
||||
command: "status",
|
||||
describe: "show file status information",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.file.status")(function* () {
|
||||
const status = yield* File.Service.use((svc) => svc.status())
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
}),
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status()))
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FileListCommand = effectCmd({
|
||||
const FileListCommand = cmd({
|
||||
command: "list <path>",
|
||||
describe: "list files in a directory",
|
||||
builder: (yargs) =>
|
||||
@@ -54,13 +60,15 @@ const FileListCommand = effectCmd({
|
||||
demandOption: true,
|
||||
description: "File path to list",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
||||
const files = yield* File.Service.use((svc) => svc.list(args.path))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path)))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FileTreeCommand = effectCmd({
|
||||
const FileTreeCommand = cmd({
|
||||
command: "tree [dir]",
|
||||
describe: "show directory tree",
|
||||
builder: (yargs) =>
|
||||
@@ -69,10 +77,12 @@ const FileTreeCommand = effectCmd({
|
||||
description: "Directory to tree",
|
||||
default: process.cwd(),
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
|
||||
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
|
||||
console.log(JSON.stringify(tree, null, 2))
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
|
||||
console.log(JSON.stringify(tree, null, 2))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const FileCommand = cmd({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { ConfigCommand } from "./config"
|
||||
import { FileCommand } from "./file"
|
||||
@@ -27,19 +26,19 @@ export const DebugCommand = cmd({
|
||||
.command(StartupCommand)
|
||||
.command(AgentCommand)
|
||||
.command(PathsCommand)
|
||||
.command(WaitCommand)
|
||||
.command({
|
||||
command: "wait",
|
||||
describe: "wait indefinitely (for debugging)",
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
|
||||
})
|
||||
},
|
||||
})
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const WaitCommand = effectCmd({
|
||||
command: "wait",
|
||||
describe: "wait indefinitely (for debugging)",
|
||||
handler: Effect.fn("Cli.debug.wait")(function* () {
|
||||
yield* Effect.sleep(Duration.days(1))
|
||||
}),
|
||||
})
|
||||
|
||||
const PathsCommand = cmd({
|
||||
command: "paths",
|
||||
describe: "show global paths (data, config, cache, state)",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { EOL } from "os"
|
||||
@@ -13,39 +14,47 @@ export const LSPCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const DiagnosticsCommand = effectCmd({
|
||||
const DiagnosticsCommand = cmd({
|
||||
command: "diagnostics <file>",
|
||||
describe: "get diagnostics for a file",
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) {
|
||||
const out = yield* LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, "full")
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const out = await AppRuntime.runPromise(
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, "full")
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const SymbolsCommand = effectCmd({
|
||||
export const SymbolsCommand = cmd({
|
||||
command: "symbols <query>",
|
||||
describe: "search workspace symbols",
|
||||
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) {
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const DocumentSymbolsCommand = effectCmd({
|
||||
export const DocumentSymbolsCommand = cmd({
|
||||
command: "document-symbols <uri>",
|
||||
describe: "get symbols from a document",
|
||||
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
@@ -12,22 +13,24 @@ export const RipgrepCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const TreeCommand = effectCmd({
|
||||
const TreeCommand = cmd({
|
||||
command: "tree",
|
||||
describe: "show file tree using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs.option("limit", {
|
||||
type: "number",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
|
||||
process.stdout.write(tree + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const tree = await AppRuntime.runPromise(
|
||||
Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })),
|
||||
)
|
||||
process.stdout.write(tree + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FilesCommand = effectCmd({
|
||||
const FilesCommand = cmd({
|
||||
command: "files",
|
||||
describe: "list files using ripgrep",
|
||||
builder: (yargs) =>
|
||||
@@ -44,26 +47,29 @@ const FilesCommand = effectCmd({
|
||||
type: "number",
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const rg = yield* Ripgrep.Service
|
||||
const files = yield* rg
|
||||
.files({
|
||||
cwd: ctx.directory,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Stream.take(args.limit ?? Infinity),
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
Effect.orDie,
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
return yield* rg
|
||||
.files({
|
||||
cwd: Instance.directory,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Stream.take(args.limit ?? Infinity),
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
process.stdout.write(files.join(EOL) + EOL)
|
||||
}),
|
||||
process.stdout.write(files.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const SearchCommand = effectCmd({
|
||||
const SearchCommand = cmd({
|
||||
command: "search <pattern>",
|
||||
describe: "search file contents using ripgrep",
|
||||
builder: (yargs) =>
|
||||
@@ -81,19 +87,19 @@ const SearchCommand = effectCmd({
|
||||
type: "number",
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const results = yield* Effect.orDie(
|
||||
Ripgrep.Service.use((svc) =>
|
||||
svc.search({
|
||||
cwd: ctx.directory,
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await AppRuntime.runPromise(
|
||||
Ripgrep.Service.use((svc) =>
|
||||
svc.search({
|
||||
cwd: Instance.directory,
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Skill } from "../../../skill"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const SkillCommand = effectCmd({
|
||||
export const SkillCommand = cmd({
|
||||
command: "skill",
|
||||
describe: "list all available skills",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.skill")(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const skills = yield* skill.all()
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
}),
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const skills = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Snapshot } from "../../../snapshot"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
@@ -10,16 +10,17 @@ export const SnapshotCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const TrackCommand = effectCmd({
|
||||
const TrackCommand = cmd({
|
||||
command: "track",
|
||||
describe: "track current snapshot state",
|
||||
handler: Effect.fn("Cli.debug.snapshot.track")(function* () {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.track())
|
||||
console.log(out)
|
||||
}),
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track())))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const PatchCommand = effectCmd({
|
||||
const PatchCommand = cmd({
|
||||
command: "patch <hash>",
|
||||
describe: "show patch for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
@@ -28,13 +29,14 @@ const PatchCommand = effectCmd({
|
||||
description: "hash",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash))))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const DiffCommand = effectCmd({
|
||||
const DiffCommand = cmd({
|
||||
command: "diff <hash>",
|
||||
describe: "show diff for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
@@ -43,8 +45,9 @@ const DiffCommand = effectCmd({
|
||||
description: "hash",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash))))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionID } from "../../session/schema"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
function redact(kind: string, id: string, value: string) {
|
||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
||||
@@ -218,11 +220,11 @@ function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] })
|
||||
}
|
||||
}
|
||||
|
||||
export const ExportCommand = effectCmd({
|
||||
export const ExportCommand = cmd({
|
||||
command: "export [sessionID]",
|
||||
describe: "export session data as JSON",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("sessionID", {
|
||||
describe: "session id to export",
|
||||
type: "string",
|
||||
@@ -230,62 +232,72 @@ export const ExportCommand = effectCmd({
|
||||
.option("sanitize", {
|
||||
describe: "redact sensitive transcript and file data",
|
||||
type: "boolean",
|
||||
}),
|
||||
handler: Effect.fn("Cli.export")(function* (args) {
|
||||
return yield* run(args)
|
||||
}),
|
||||
})
|
||||
|
||||
const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) {
|
||||
const svc = yield* Session.Service
|
||||
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
|
||||
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
|
||||
|
||||
if (!sessionID) {
|
||||
UI.empty()
|
||||
prompts.intro("Export session", { output: process.stderr })
|
||||
|
||||
const sessions = yield* svc.list()
|
||||
|
||||
if (sessions.length === 0) {
|
||||
prompts.log.error("No sessions found", { output: process.stderr })
|
||||
prompts.outro("Done", { output: process.stderr })
|
||||
return
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
|
||||
const selectedSession = yield* Effect.promise(() =>
|
||||
prompts.autocomplete({
|
||||
message: "Select session to export",
|
||||
maxItems: 10,
|
||||
options: sessions.map((session) => ({
|
||||
label: session.title,
|
||||
value: session.id,
|
||||
hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`,
|
||||
})),
|
||||
output: process.stderr,
|
||||
}),
|
||||
)
|
||||
|
||||
if (prompts.isCancel(selectedSession)) {
|
||||
return yield* Effect.die(new UI.CancelledError())
|
||||
}
|
||||
|
||||
sessionID = selectedSession
|
||||
|
||||
prompts.outro("Exporting session...", { output: process.stderr })
|
||||
}
|
||||
|
||||
// Match legacy try/catch — catches both typed failures and defects
|
||||
// (Session.Service.get throws NotFoundError as a defect, not a typed E).
|
||||
return yield* Effect.gen(function* () {
|
||||
const sessionInfo = yield* svc.get(sessionID!)
|
||||
const messages = yield* svc.messages({ sessionID: sessionInfo.id })
|
||||
|
||||
const exportData = { info: sessionInfo, messages }
|
||||
|
||||
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`)))
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
|
||||
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
|
||||
|
||||
if (!sessionID) {
|
||||
UI.empty()
|
||||
prompts.intro("Export session", {
|
||||
output: process.stderr,
|
||||
})
|
||||
|
||||
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
|
||||
|
||||
if (sessions.length === 0) {
|
||||
prompts.log.error("No sessions found", {
|
||||
output: process.stderr,
|
||||
})
|
||||
prompts.outro("Done", {
|
||||
output: process.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
|
||||
const selectedSession = await prompts.autocomplete({
|
||||
message: "Select session to export",
|
||||
maxItems: 10,
|
||||
options: sessions.map((session) => ({
|
||||
label: session.title,
|
||||
value: session.id,
|
||||
hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`,
|
||||
})),
|
||||
output: process.stderr,
|
||||
})
|
||||
|
||||
if (prompts.isCancel(selectedSession)) {
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
sessionID = selectedSession
|
||||
|
||||
prompts.outro("Exporting session...", {
|
||||
output: process.stderr,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!)))
|
||||
const messages = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })),
|
||||
)
|
||||
|
||||
const exportData = {
|
||||
info: sessionInfo,
|
||||
messages,
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
} catch {
|
||||
UI.error(`Session not found: ${sessionID!}`)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,9 +18,9 @@ import type {
|
||||
} from "@octokit/webhooks-types"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { Session } from "@/session/session"
|
||||
import type { SessionID } from "../../session/schema"
|
||||
@@ -199,192 +199,191 @@ export const GithubCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const GithubInstallCommand = effectCmd({
|
||||
export const GithubInstallCommand = cmd({
|
||||
command: "install",
|
||||
describe: "install the GitHub agent",
|
||||
handler: Effect.fn("Cli.github.install")(function* () {
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
yield* Effect.promise(async () => {
|
||||
{
|
||||
UI.empty()
|
||||
prompts.intro("Install GitHub agent")
|
||||
const app = await getAppInfo()
|
||||
await installGitHubApp()
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
{
|
||||
UI.empty()
|
||||
prompts.intro("Install GitHub agent")
|
||||
const app = await getAppInfo()
|
||||
await installGitHubApp()
|
||||
|
||||
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
|
||||
// TODO: add guide for copilot, for now just hide it
|
||||
delete p["github-copilot"]
|
||||
return p
|
||||
})
|
||||
|
||||
const provider = await promptProvider()
|
||||
const model = await promptModel()
|
||||
//const key = await promptKey()
|
||||
|
||||
await addWorkflowFiles()
|
||||
printNextSteps()
|
||||
|
||||
function printNextSteps() {
|
||||
let step2
|
||||
if (provider === "amazon-bedrock") {
|
||||
step2 =
|
||||
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
|
||||
} else {
|
||||
step2 = [
|
||||
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
|
||||
"",
|
||||
...providers[provider].env.map((e) => ` - ${e}`),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
prompts.outro(
|
||||
[
|
||||
"Next steps:",
|
||||
"",
|
||||
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
|
||||
step2,
|
||||
"",
|
||||
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
|
||||
"",
|
||||
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
|
||||
].join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
async function getAppInfo() {
|
||||
const project = ctx.project
|
||||
if (project.vcs !== "git") {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
// Get repo info
|
||||
const info = await AppRuntime.runPromise(
|
||||
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })),
|
||||
).then((x) => x.text().trim())
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree }
|
||||
}
|
||||
|
||||
async function promptProvider() {
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
anthropic: 1,
|
||||
openai: 2,
|
||||
google: 3,
|
||||
}
|
||||
let provider = await prompts.select({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
||||
})),
|
||||
),
|
||||
const providers = await ModelsDev.get().then((p) => {
|
||||
// TODO: add guide for copilot, for now just hide it
|
||||
delete p["github-copilot"]
|
||||
return p
|
||||
})
|
||||
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
const provider = await promptProvider()
|
||||
const model = await promptModel()
|
||||
//const key = await promptKey()
|
||||
|
||||
return provider
|
||||
}
|
||||
await addWorkflowFiles()
|
||||
printNextSteps()
|
||||
|
||||
async function promptModel() {
|
||||
const providerData = providers[provider]!
|
||||
|
||||
const model = await prompts.select({
|
||||
message: "Select model",
|
||||
maxItems: 8,
|
||||
options: pipe(
|
||||
providerData.models,
|
||||
values(),
|
||||
sortBy((x) => x.name ?? x.id),
|
||||
map((x) => ({
|
||||
label: x.name ?? x.id,
|
||||
value: x.id,
|
||||
})),
|
||||
),
|
||||
})
|
||||
|
||||
if (prompts.isCancel(model)) throw new UI.CancelledError()
|
||||
return model
|
||||
}
|
||||
|
||||
async function installGitHubApp() {
|
||||
const s = prompts.spinner()
|
||||
s.start("Installing GitHub app")
|
||||
|
||||
// Get installation
|
||||
const installation = await getInstallation()
|
||||
if (installation) return s.stop("GitHub app already installed")
|
||||
|
||||
// Open browser
|
||||
const url = "https://github.com/apps/opencode-agent"
|
||||
const command =
|
||||
process.platform === "darwin"
|
||||
? `open "${url}"`
|
||||
: process.platform === "win32"
|
||||
? `start "" "${url}"`
|
||||
: `xdg-open "${url}"`
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
|
||||
function printNextSteps() {
|
||||
let step2
|
||||
if (provider === "amazon-bedrock") {
|
||||
step2 =
|
||||
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
|
||||
} else {
|
||||
step2 = [
|
||||
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
|
||||
"",
|
||||
...providers[provider].env.map((e) => ` - ${e}`),
|
||||
].join("\n")
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for installation
|
||||
s.message("Waiting for GitHub app to be installed")
|
||||
const MAX_RETRIES = 120
|
||||
let retries = 0
|
||||
do {
|
||||
const installation = await getInstallation()
|
||||
if (installation) break
|
||||
prompts.outro(
|
||||
[
|
||||
"Next steps:",
|
||||
"",
|
||||
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
|
||||
step2,
|
||||
"",
|
||||
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
|
||||
"",
|
||||
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
|
||||
].join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
if (retries > MAX_RETRIES) {
|
||||
s.stop(
|
||||
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
|
||||
)
|
||||
async function getAppInfo() {
|
||||
const project = Instance.project
|
||||
if (project.vcs !== "git") {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
retries++
|
||||
await sleep(1000)
|
||||
} while (true) // oxlint-disable-line no-constant-condition
|
||||
|
||||
s.stop("Installed GitHub app")
|
||||
|
||||
async function getInstallation() {
|
||||
return await fetch(
|
||||
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.installation)
|
||||
// Get repo info
|
||||
const info = await AppRuntime.runPromise(
|
||||
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
|
||||
).then((x) => x.text().trim())
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree }
|
||||
}
|
||||
}
|
||||
|
||||
async function addWorkflowFiles() {
|
||||
const envStr =
|
||||
provider === "amazon-bedrock"
|
||||
? ""
|
||||
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
|
||||
async function promptProvider() {
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
anthropic: 1,
|
||||
openai: 2,
|
||||
google: 3,
|
||||
}
|
||||
let provider = await prompts.select({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
||||
})),
|
||||
),
|
||||
})
|
||||
|
||||
await Filesystem.write(
|
||||
path.join(app.root, WORKFLOW_FILE),
|
||||
`name: opencode
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
async function promptModel() {
|
||||
const providerData = providers[provider]!
|
||||
|
||||
const model = await prompts.select({
|
||||
message: "Select model",
|
||||
maxItems: 8,
|
||||
options: pipe(
|
||||
providerData.models,
|
||||
values(),
|
||||
sortBy((x) => x.name ?? x.id),
|
||||
map((x) => ({
|
||||
label: x.name ?? x.id,
|
||||
value: x.id,
|
||||
})),
|
||||
),
|
||||
})
|
||||
|
||||
if (prompts.isCancel(model)) throw new UI.CancelledError()
|
||||
return model
|
||||
}
|
||||
|
||||
async function installGitHubApp() {
|
||||
const s = prompts.spinner()
|
||||
s.start("Installing GitHub app")
|
||||
|
||||
// Get installation
|
||||
const installation = await getInstallation()
|
||||
if (installation) return s.stop("GitHub app already installed")
|
||||
|
||||
// Open browser
|
||||
const url = "https://github.com/apps/opencode-agent"
|
||||
const command =
|
||||
process.platform === "darwin"
|
||||
? `open "${url}"`
|
||||
: process.platform === "win32"
|
||||
? `start "" "${url}"`
|
||||
: `xdg-open "${url}"`
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for installation
|
||||
s.message("Waiting for GitHub app to be installed")
|
||||
const MAX_RETRIES = 120
|
||||
let retries = 0
|
||||
do {
|
||||
const installation = await getInstallation()
|
||||
if (installation) break
|
||||
|
||||
if (retries > MAX_RETRIES) {
|
||||
s.stop(
|
||||
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
|
||||
)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
retries++
|
||||
await sleep(1000)
|
||||
} while (true) // oxlint-disable-line no-constant-condition
|
||||
|
||||
s.stop("Installed GitHub app")
|
||||
|
||||
async function getInstallation() {
|
||||
return await fetch(
|
||||
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.installation)
|
||||
}
|
||||
}
|
||||
|
||||
async function addWorkflowFiles() {
|
||||
const envStr =
|
||||
provider === "amazon-bedrock"
|
||||
? ""
|
||||
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
|
||||
|
||||
await Filesystem.write(
|
||||
path.join(app.root, WORKFLOW_FILE),
|
||||
`name: opencode
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -415,16 +414,17 @@ jobs:
|
||||
uses: anomalyco/opencode/github@latest${envStr}
|
||||
with:
|
||||
model: ${provider}/${model}`,
|
||||
)
|
||||
)
|
||||
|
||||
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
|
||||
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const GithubRunCommand = effectCmd({
|
||||
export const GithubRunCommand = cmd({
|
||||
command: "run",
|
||||
describe: "run the GitHub agent",
|
||||
builder: (yargs) =>
|
||||
@@ -437,10 +437,8 @@ export const GithubRunCommand = effectCmd({
|
||||
type: "string",
|
||||
describe: "GitHub personal access token (github_pat_********)",
|
||||
}),
|
||||
handler: Effect.fn("Cli.github.run")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided")
|
||||
yield* Effect.promise(async () => {
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const isMock = args.token || args.event
|
||||
|
||||
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
|
||||
@@ -503,21 +501,21 @@ export const GithubRunCommand = effectCmd({
|
||||
: "issue"
|
||||
: undefined
|
||||
const gitText = async (args: string[]) => {
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result.text().trim()
|
||||
}
|
||||
const gitRun = async (args: string[]) => {
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const gitStatus = (args: string[]) =>
|
||||
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
|
||||
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
|
||||
const commitChanges = async (summary: string, actor?: string) => {
|
||||
const args = ["commit", "-m", summary]
|
||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||
@@ -881,7 +879,7 @@ export const GithubRunCommand = effectCmd({
|
||||
function subscribeSessionEvents() {
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
bash: ["Shell", UI.Style.TEXT_DANGER_BOLD],
|
||||
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
||||
@@ -1647,5 +1645,5 @@ query($owner: String!, $repo: String!, $number: Int!) {
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { Argv } from "yargs"
|
||||
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { CliError, effectCmd } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
|
||||
const decodePart = Schema.decodeUnknownSync(MessageV2.Part)
|
||||
@@ -75,143 +78,135 @@ export function transformShareData(shareData: ShareData[]): {
|
||||
}
|
||||
}
|
||||
|
||||
type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> }
|
||||
|
||||
export const ImportCommand = effectCmd({
|
||||
export const ImportCommand = cmd({
|
||||
command: "import <file>",
|
||||
describe: "import session data from JSON file or URL",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", {
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.positional("file", {
|
||||
describe: "path to JSON file or share URL",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.import")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided")
|
||||
return yield* runImport(args.file, ctx.project.id)
|
||||
}),
|
||||
})
|
||||
|
||||
const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) {
|
||||
const share = yield* ShareNext.Service
|
||||
|
||||
let exportData: ExportData | undefined
|
||||
|
||||
const isUrl = file.startsWith("http://") || file.startsWith("https://")
|
||||
|
||||
if (isUrl) {
|
||||
const slug = parseShareUrl(file)
|
||||
if (!slug) {
|
||||
const baseUrl = yield* Effect.orDie(share.url())
|
||||
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = new URL(file).origin
|
||||
const req = yield* Effect.orDie(share.request())
|
||||
const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const tryFetch = (url: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fetch(url, { headers }),
|
||||
catch: (e) =>
|
||||
new CliError({
|
||||
message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`,
|
||||
}),
|
||||
})
|
||||
|
||||
const dataPath = req.api.data(slug)
|
||||
let response = yield* tryFetch(`${baseUrl}${dataPath}`)
|
||||
|
||||
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
|
||||
response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const shareData = yield* Effect.tryPromise({
|
||||
try: () => response.json() as Promise<ShareData[]>,
|
||||
catch: () => new CliError({ message: "Share data was not valid JSON" }),
|
||||
})
|
||||
const transformed = transformShareData(shareData)
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
let exportData:
|
||||
| {
|
||||
info: SDKSession
|
||||
messages: Array<{
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}>
|
||||
}
|
||||
| undefined
|
||||
|
||||
if (!transformed) {
|
||||
process.stdout.write(`Share not found or empty: ${slug}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
|
||||
|
||||
exportData = transformed
|
||||
} else {
|
||||
exportData = yield* Effect.promise(() =>
|
||||
Filesystem.readJson<NonNullable<typeof exportData>>(file).catch(() => undefined),
|
||||
)
|
||||
if (!exportData) {
|
||||
process.stdout.write(`File not found: ${file}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (isUrl) {
|
||||
const slug = parseShareUrl(args.file)
|
||||
if (!slug) {
|
||||
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
|
||||
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
if (!exportData) {
|
||||
process.stdout.write(`Failed to read session data`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
const parsed = new URL(args.file)
|
||||
const baseUrl = parsed.origin
|
||||
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
|
||||
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const info = Schema.decodeUnknownSync(Session.Info)({
|
||||
...exportData.info,
|
||||
projectID,
|
||||
}) as Session.Info
|
||||
const row = Session.toRow(info)
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionTable)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
|
||||
.run(),
|
||||
)
|
||||
|
||||
for (const msg of exportData.messages) {
|
||||
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
|
||||
const { id, sessionID: _, ...msgData } = msgInfo
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: row.id,
|
||||
time_created: msgInfo.time?.created ?? Date.now(),
|
||||
data: msgData,
|
||||
const dataPath = req.api.data(slug)
|
||||
let response = await fetch(`${baseUrl}${dataPath}`, {
|
||||
headers,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run(),
|
||||
)
|
||||
|
||||
for (const part of msg.parts) {
|
||||
const partInfo = decodePart(part) as MessageV2.Part
|
||||
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
|
||||
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
|
||||
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const shareData: ShareData[] = await response.json()
|
||||
const transformed = transformShareData(shareData)
|
||||
|
||||
if (!transformed) {
|
||||
process.stdout.write(`Share not found or empty: ${slug}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
exportData = transformed
|
||||
} else {
|
||||
exportData = await Filesystem.readJson<NonNullable<typeof exportData>>(args.file).catch(() => undefined)
|
||||
if (!exportData) {
|
||||
process.stdout.write(`File not found: ${args.file}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!exportData) {
|
||||
process.stdout.write(`Failed to read session data`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const info = Schema.decodeUnknownSync(Session.Info)({
|
||||
...exportData.info,
|
||||
projectID: Instance.project.id,
|
||||
}) as Session.Info
|
||||
const row = Session.toRow(info)
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(PartTable)
|
||||
.values({
|
||||
id: partId,
|
||||
message_id: messageID,
|
||||
session_id: row.id,
|
||||
data: partData,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.insert(SessionTable)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`Imported session: ${exportData.info.id}`)
|
||||
process.stdout.write(EOL)
|
||||
for (const msg of exportData.messages) {
|
||||
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
|
||||
const { id, sessionID: _, ...msgData } = msgInfo
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: row.id,
|
||||
time_created: msgInfo.time?.created ?? Date.now(),
|
||||
data: msgData,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run(),
|
||||
)
|
||||
|
||||
for (const part of msg.parts) {
|
||||
const partInfo = decodePart(part) as MessageV2.Part
|
||||
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(PartTable)
|
||||
.values({
|
||||
id: partId,
|
||||
message_id: messageID,
|
||||
session_id: row.id,
|
||||
data: partData,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`Imported session: ${exportData.info.id}`)
|
||||
process.stdout.write(EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { Cause } from "effect"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
@@ -12,7 +10,6 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMCP } from "../../config/mcp"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { WithInstance } from "../../project/with-instance"
|
||||
import { Installation } from "../../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "path"
|
||||
@@ -67,31 +64,35 @@ function oauthServers(config: Config.Info) {
|
||||
)
|
||||
}
|
||||
|
||||
function listState() {
|
||||
return Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const statuses = yield* mcp.status()
|
||||
const stored = yield* Effect.all(
|
||||
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, statuses, stored }
|
||||
})
|
||||
async function listState() {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const statuses = yield* mcp.status()
|
||||
const stored = yield* Effect.all(
|
||||
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, statuses, stored }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function authState() {
|
||||
return Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const auth = yield* Effect.all(
|
||||
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, auth }
|
||||
})
|
||||
async function authState() {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const auth = yield* Effect.all(
|
||||
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, auth }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const McpCommand = cmd({
|
||||
@@ -108,68 +109,73 @@ export const McpCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const McpListCommand = effectCmd({
|
||||
export const McpListCommand = cmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list MCP servers and their status",
|
||||
handler: Effect.fn("Cli.mcp.list")(function* () {
|
||||
UI.empty()
|
||||
prompts.intro("MCP Servers")
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
prompts.intro("MCP Servers")
|
||||
|
||||
const { config, statuses, stored } = yield* listState()
|
||||
const servers = configuredServers(config)
|
||||
const { config, statuses, stored } = await listState()
|
||||
const servers = configuredServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No MCP servers configured")
|
||||
prompts.outro("Add servers with: opencode mcp add")
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const status = statuses[name]
|
||||
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
|
||||
const hasStoredTokens = stored[name]
|
||||
|
||||
let statusIcon: string
|
||||
let statusText: string
|
||||
let hint = ""
|
||||
|
||||
if (!status) {
|
||||
statusIcon = "○"
|
||||
statusText = "not initialized"
|
||||
} else if (status.status === "connected") {
|
||||
statusIcon = "✓"
|
||||
statusText = "connected"
|
||||
if (hasOAuth && hasStoredTokens) {
|
||||
hint = " (OAuth)"
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No MCP servers configured")
|
||||
prompts.outro("Add servers with: opencode mcp add")
|
||||
return
|
||||
}
|
||||
} else if (status.status === "disabled") {
|
||||
statusIcon = "○"
|
||||
statusText = "disabled"
|
||||
} else if (status.status === "needs_auth") {
|
||||
statusIcon = "⚠"
|
||||
statusText = "needs authentication"
|
||||
} else if (status.status === "needs_client_registration") {
|
||||
statusIcon = "✗"
|
||||
statusText = "needs client registration"
|
||||
hint = "\n " + status.error
|
||||
} else {
|
||||
statusIcon = "✗"
|
||||
statusText = "failed"
|
||||
hint = "\n " + status.error
|
||||
}
|
||||
|
||||
const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ")
|
||||
prompts.log.info(
|
||||
`${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`,
|
||||
)
|
||||
}
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const status = statuses[name]
|
||||
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
|
||||
const hasStoredTokens = stored[name]
|
||||
|
||||
prompts.outro(`${servers.length} server(s)`)
|
||||
}),
|
||||
let statusIcon: string
|
||||
let statusText: string
|
||||
let hint = ""
|
||||
|
||||
if (!status) {
|
||||
statusIcon = "○"
|
||||
statusText = "not initialized"
|
||||
} else if (status.status === "connected") {
|
||||
statusIcon = "✓"
|
||||
statusText = "connected"
|
||||
if (hasOAuth && hasStoredTokens) {
|
||||
hint = " (OAuth)"
|
||||
}
|
||||
} else if (status.status === "disabled") {
|
||||
statusIcon = "○"
|
||||
statusText = "disabled"
|
||||
} else if (status.status === "needs_auth") {
|
||||
statusIcon = "⚠"
|
||||
statusText = "needs authentication"
|
||||
} else if (status.status === "needs_client_registration") {
|
||||
statusIcon = "✗"
|
||||
statusText = "needs client registration"
|
||||
hint = "\n " + status.error
|
||||
} else {
|
||||
statusIcon = "✗"
|
||||
statusText = "failed"
|
||||
hint = "\n " + status.error
|
||||
}
|
||||
|
||||
const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ")
|
||||
prompts.log.info(
|
||||
`${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`,
|
||||
)
|
||||
}
|
||||
|
||||
prompts.outro(`${servers.length} server(s)`)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const McpAuthCommand = effectCmd({
|
||||
export const McpAuthCommand = cmd({
|
||||
command: "auth [name]",
|
||||
describe: "authenticate with an OAuth-enabled MCP server",
|
||||
builder: (yargs) =>
|
||||
@@ -179,98 +185,98 @@ export const McpAuthCommand = effectCmd({
|
||||
type: "string",
|
||||
})
|
||||
.command(McpAuthListCommand),
|
||||
handler: Effect.fn("Cli.mcp.auth")(function* (args) {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Authentication")
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Authentication")
|
||||
|
||||
const { config, auth } = yield* authState()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const servers = oauthServers(config)
|
||||
const { config, auth } = await authState()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const servers = oauthServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
|
||||
prompts.log.info(`
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
|
||||
prompts.log.info(`
|
||||
"mcp": {
|
||||
"my-server": {
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp"
|
||||
}
|
||||
}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
// Build options with auth status
|
||||
const options = servers.map(([name, cfg]) => {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = cfg.url
|
||||
return {
|
||||
label: `${icon} ${name} (${statusText})`,
|
||||
value: name,
|
||||
hint: url,
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const selected = yield* Effect.promise(() =>
|
||||
prompts.select({
|
||||
message: "Select MCP server to authenticate",
|
||||
options,
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
serverName = selected
|
||||
}
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
// Build options with auth status
|
||||
const options = servers.map(([name, cfg]) => {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = cfg.url
|
||||
return {
|
||||
label: `${icon} ${name} (${statusText})`,
|
||||
value: name,
|
||||
hint: url,
|
||||
}
|
||||
})
|
||||
|
||||
const serverConfig = mcpServers[serverName]
|
||||
if (!serverConfig) {
|
||||
prompts.log.error(`MCP server not found: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const selected = await prompts.select({
|
||||
message: "Select MCP server to authenticate",
|
||||
options,
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
serverName = selected
|
||||
}
|
||||
|
||||
if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) {
|
||||
prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const serverConfig = mcpServers[serverName]
|
||||
if (!serverConfig) {
|
||||
prompts.log.error(`MCP server not found: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already authenticated
|
||||
const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))
|
||||
if (authStatus === "authenticated") {
|
||||
const confirm = yield* Effect.promise(() =>
|
||||
prompts.confirm({
|
||||
message: `${serverName} already has valid credentials. Re-authenticate?`,
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(confirm) || !confirm) {
|
||||
prompts.outro("Cancelled")
|
||||
return
|
||||
}
|
||||
} else if (authStatus === "expired") {
|
||||
prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`)
|
||||
}
|
||||
if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) {
|
||||
prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Starting OAuth flow...")
|
||||
// Check if already authenticated
|
||||
const authStatus =
|
||||
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
|
||||
if (authStatus === "authenticated") {
|
||||
const confirm = await prompts.confirm({
|
||||
message: `${serverName} already has valid credentials. Re-authenticate?`,
|
||||
})
|
||||
if (prompts.isCancel(confirm) || !confirm) {
|
||||
prompts.outro("Cancelled")
|
||||
return
|
||||
}
|
||||
} else if (authStatus === "expired") {
|
||||
prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`)
|
||||
}
|
||||
|
||||
// Subscribe to browser open failure events to show URL for manual opening
|
||||
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
|
||||
if (evt.properties.mcpName === serverName) {
|
||||
spinner.stop("Could not open browser automatically")
|
||||
prompts.log.warn("Please open this URL in your browser to authenticate:")
|
||||
prompts.log.info(evt.properties.url)
|
||||
spinner.start("Waiting for authorization...")
|
||||
}
|
||||
})
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Starting OAuth flow...")
|
||||
|
||||
// Subscribe to browser open failure events to show URL for manual opening
|
||||
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
|
||||
if (evt.properties.mcpName === serverName) {
|
||||
spinner.stop("Could not open browser automatically")
|
||||
prompts.log.warn("Please open this URL in your browser to authenticate:")
|
||||
prompts.log.info(evt.properties.url)
|
||||
spinner.start("Waiting for authorization...")
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
|
||||
|
||||
yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe(
|
||||
Effect.tap((status) =>
|
||||
Effect.sync(() => {
|
||||
if (status.status === "connected") {
|
||||
spinner.stop("Authentication successful!")
|
||||
} else if (status.status === "needs_client_registration") {
|
||||
@@ -294,53 +300,55 @@ export const McpAuthCommand = effectCmd({
|
||||
} else {
|
||||
spinner.stop("Unexpected status: " + status.status, 1)
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
} catch (error) {
|
||||
spinner.stop("Authentication failed", 1)
|
||||
const error = Cause.squash(cause)
|
||||
prompts.log.error(error instanceof Error ? error.message : String(error))
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(Effect.sync(() => unsubscribe())),
|
||||
)
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
prompts.outro("Done")
|
||||
}),
|
||||
prompts.outro("Done")
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const McpAuthListCommand = effectCmd({
|
||||
export const McpAuthListCommand = cmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list OAuth-capable MCP servers and their auth status",
|
||||
handler: Effect.fn("Cli.mcp.auth.list")(function* () {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Status")
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Status")
|
||||
|
||||
const { config, auth } = yield* authState()
|
||||
const servers = oauthServers(config)
|
||||
const { config, auth } = await authState()
|
||||
const servers = oauthServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = serverConfig.url
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = serverConfig.url
|
||||
|
||||
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
|
||||
}
|
||||
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${servers.length} OAuth-capable server(s)`)
|
||||
}),
|
||||
prompts.outro(`${servers.length} OAuth-capable server(s)`)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const McpLogoutCommand = effectCmd({
|
||||
export const McpLogoutCommand = cmd({
|
||||
command: "logout [name]",
|
||||
describe: "remove OAuth credentials for an MCP server",
|
||||
builder: (yargs) =>
|
||||
@@ -348,54 +356,57 @@ export const McpLogoutCommand = effectCmd({
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.mcp.logout")(function* (args) {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Logout")
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Logout")
|
||||
|
||||
const credentials = yield* McpAuth.Service.use((auth) => auth.all())
|
||||
const serverNames = Object.keys(credentials)
|
||||
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
|
||||
const serverNames = Object.keys(credentials)
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
prompts.log.warn("No MCP OAuth credentials stored")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
if (serverNames.length === 0) {
|
||||
prompts.log.warn("No MCP OAuth credentials stored")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
const selected = yield* Effect.promise(() =>
|
||||
prompts.select({
|
||||
message: "Select MCP server to logout",
|
||||
options: serverNames.map((name) => {
|
||||
const entry = credentials[name]
|
||||
const hasTokens = !!entry.tokens
|
||||
const hasClient = !!entry.clientInfo
|
||||
let hint = ""
|
||||
if (hasTokens && hasClient) hint = "tokens + client"
|
||||
else if (hasTokens) hint = "tokens"
|
||||
else if (hasClient) hint = "client registration"
|
||||
return {
|
||||
label: name,
|
||||
value: name,
|
||||
hint,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
serverName = selected
|
||||
}
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
const selected = await prompts.select({
|
||||
message: "Select MCP server to logout",
|
||||
options: serverNames.map((name) => {
|
||||
const entry = credentials[name]
|
||||
const hasTokens = !!entry.tokens
|
||||
const hasClient = !!entry.clientInfo
|
||||
let hint = ""
|
||||
if (hasTokens && hasClient) hint = "tokens + client"
|
||||
else if (hasTokens) hint = "tokens"
|
||||
else if (hasClient) hint = "client registration"
|
||||
return {
|
||||
label: name,
|
||||
value: name,
|
||||
hint,
|
||||
}
|
||||
}),
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
serverName = selected
|
||||
}
|
||||
|
||||
if (!credentials[serverName]) {
|
||||
prompts.log.error(`No credentials found for: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
if (!credentials[serverName]) {
|
||||
prompts.log.error(`No credentials found for: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName))
|
||||
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
}),
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
|
||||
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function resolveConfigPath(baseDir: string, global = false) {
|
||||
@@ -437,7 +448,7 @@ export const McpAddCommand = cmd({
|
||||
command: "add",
|
||||
describe: "add an MCP server",
|
||||
async handler() {
|
||||
await WithInstance.provide({
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
@@ -607,7 +618,7 @@ export const McpDebugCommand = cmd({
|
||||
demandOption: true,
|
||||
}),
|
||||
async handler(args) {
|
||||
await WithInstance.provide({
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import type { Argv } from "yargs"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID } from "../../provider/schema"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "../ui"
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const ModelsCommand = effectCmd({
|
||||
export const ModelsCommand = cmd({
|
||||
command: "models [provider]",
|
||||
describe: "list all available models",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("provider", {
|
||||
describe: "provider ID to filter models by",
|
||||
type: "string",
|
||||
@@ -23,44 +26,63 @@ export const ModelsCommand = effectCmd({
|
||||
.option("refresh", {
|
||||
describe: "refresh the models cache from models.dev",
|
||||
type: "boolean",
|
||||
}),
|
||||
handler: Effect.fn("Cli.models")(function* (args) {
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
if (args.refresh) {
|
||||
yield* ModelsDev.Service.use((s) => s.refresh(true))
|
||||
await ModelsDev.refresh(true)
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
|
||||
}
|
||||
|
||||
const provider = yield* Provider.Service
|
||||
const providers = yield* provider.list()
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const providers = yield* svc.list()
|
||||
|
||||
const print = (providerID: ProviderID, verbose?: boolean) => {
|
||||
const p = providers[providerID]
|
||||
const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sorted) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
const print = (providerID: ProviderID, verbose?: boolean) => {
|
||||
const provider = providers[providerID]
|
||||
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sorted) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.provider) {
|
||||
const providerID = ProviderID.make(args.provider)
|
||||
if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`)
|
||||
print(providerID, args.verbose)
|
||||
return
|
||||
}
|
||||
if (args.provider) {
|
||||
const providerID = ProviderID.make(args.provider)
|
||||
const provider = providers[providerID]
|
||||
if (!provider) {
|
||||
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
|
||||
return
|
||||
}
|
||||
|
||||
const ids = Object.keys(providers).sort((a, b) => {
|
||||
const aIsOpencode = a.startsWith("opencode")
|
||||
const bIsOpencode = b.startsWith("opencode")
|
||||
if (aIsOpencode && !bIsOpencode) return -1
|
||||
if (!aIsOpencode && bIsOpencode) return 1
|
||||
return a.localeCompare(b)
|
||||
yield* Effect.sync(() => print(providerID, args.verbose))
|
||||
return
|
||||
}
|
||||
|
||||
const ids = Object.keys(providers).sort((a, b) => {
|
||||
const aIsOpencode = a.startsWith("opencode")
|
||||
const bIsOpencode = b.startsWith("opencode")
|
||||
if (aIsOpencode && !bIsOpencode) return -1
|
||||
if (!aIsOpencode && bIsOpencode) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
for (const providerID of ids) {
|
||||
print(ProviderID.make(providerID), args.verbose)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
for (const providerID of ids) print(ProviderID.make(providerID), args.verbose)
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { intro, log, outro, spinner } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import type { Argv } from "yargs"
|
||||
|
||||
import { ConfigPaths } from "@/config/paths"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
||||
import { resolvePluginTarget } from "../../plugin/shared"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
type Spin = {
|
||||
start: (msg: string) => void
|
||||
@@ -175,12 +175,12 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
|
||||
}
|
||||
}
|
||||
|
||||
export const PluginCommand = effectCmd({
|
||||
export const PluginCommand = cmd({
|
||||
command: "plugin <module>",
|
||||
aliases: ["plug"],
|
||||
describe: "install plugin and update config",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("module", {
|
||||
type: "string",
|
||||
describe: "npm module name",
|
||||
@@ -196,8 +196,9 @@ export const PluginCommand = effectCmd({
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "replace existing plugin version",
|
||||
}),
|
||||
handler: Effect.fn("Cli.plug")(function* (args) {
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
const mod = String(args.module ?? "").trim()
|
||||
if (!mod) {
|
||||
UI.error("module is required")
|
||||
@@ -213,18 +214,20 @@ export const PluginCommand = effectCmd({
|
||||
global: Boolean(args.global),
|
||||
force: Boolean(args.force),
|
||||
})
|
||||
let ok = true
|
||||
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const ok = yield* Effect.promise(() =>
|
||||
run({
|
||||
vcs: ctx.project.vcs,
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
}),
|
||||
)
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
fn: async () => {
|
||||
ok = await run({
|
||||
vcs: Instance.project.vcs,
|
||||
worktree: Instance.worktree,
|
||||
directory: Instance.directory,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
outro("Done")
|
||||
if (!ok) process.exitCode = 1
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Git } from "@/git"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
export const PrCommand = effectCmd({
|
||||
export const PrCommand = cmd({
|
||||
command: "pr <number>",
|
||||
describe: "fetch and checkout a GitHub PR branch, then run opencode",
|
||||
builder: (yargs) =>
|
||||
@@ -14,102 +14,125 @@ export const PrCommand = effectCmd({
|
||||
describe: "PR number to checkout",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.pr")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* fail("Could not load instance context")
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return yield* fail("Could not find git repository. Please run this command from a git repository.")
|
||||
}
|
||||
|
||||
const git = yield* Git.Service
|
||||
const worktree = ctx.worktree
|
||||
|
||||
const prNumber = args.number
|
||||
const localBranchName = `pr/${prNumber}`
|
||||
UI.println(`Fetching and checking out PR #${prNumber}...`)
|
||||
|
||||
const checkout = yield* Effect.promise(() =>
|
||||
Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }),
|
||||
)
|
||||
if (checkout.code !== 0) {
|
||||
return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
|
||||
}
|
||||
|
||||
const prInfoResult = yield* Effect.promise(() =>
|
||||
Process.text(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"view",
|
||||
`${prNumber}`,
|
||||
"--json",
|
||||
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
|
||||
],
|
||||
{ nothrow: true },
|
||||
),
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
|
||||
if (prInfoResult.code === 0 && prInfoResult.text.trim()) {
|
||||
const prInfo = JSON.parse(prInfoResult.text)
|
||||
|
||||
if (prInfo?.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) {
|
||||
const forkOwner = prInfo.headRepositoryOwner.login
|
||||
const forkName = prInfo.headRepository.name
|
||||
const remoteName = forkOwner
|
||||
|
||||
const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim()
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: worktree,
|
||||
})
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
const project = Instance.project
|
||||
if (project.vcs !== "git") {
|
||||
UI.error("Could not find git repository. Please run this command from a git repository.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
yield* git.run(["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], {
|
||||
cwd: worktree,
|
||||
})
|
||||
}
|
||||
const prNumber = args.number
|
||||
const localBranchName = `pr/${prNumber}`
|
||||
UI.println(`Fetching and checking out PR #${prNumber}...`)
|
||||
|
||||
if (prInfo?.body) {
|
||||
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
||||
if (sessionMatch) {
|
||||
const sessionUrl = sessionMatch[0]
|
||||
UI.println(`Found opencode session: ${sessionUrl}`)
|
||||
UI.println(`Importing session...`)
|
||||
// Use gh pr checkout with custom branch name
|
||||
const result = await Process.run(
|
||||
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
|
||||
{
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
const importResult = yield* Effect.promise(() =>
|
||||
Process.text(["opencode", "import", sessionUrl], { nothrow: true }),
|
||||
)
|
||||
if (importResult.code === 0) {
|
||||
const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/)
|
||||
if (sessionIdMatch) {
|
||||
sessionId = sessionIdMatch[1]
|
||||
UI.println(`Session imported: ${sessionId}`)
|
||||
if (result.code !== 0) {
|
||||
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Fetch PR info for fork handling and session link detection
|
||||
const prInfoResult = await Process.text(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"view",
|
||||
`${prNumber}`,
|
||||
"--json",
|
||||
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
|
||||
],
|
||||
{ nothrow: true },
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
|
||||
if (prInfoResult.code === 0) {
|
||||
const prInfoText = prInfoResult.text
|
||||
if (prInfoText.trim()) {
|
||||
const prInfo = JSON.parse(prInfoText)
|
||||
|
||||
// Handle fork PRs
|
||||
if (prInfo && prInfo.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) {
|
||||
const forkOwner = prInfo.headRepositoryOwner.login
|
||||
const forkName = prInfo.headRepository.name
|
||||
const remoteName = forkOwner
|
||||
|
||||
// Check if remote already exists
|
||||
const remotes = await AppRuntime.runPromise(
|
||||
Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
|
||||
).then((x) => x.text().trim())
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
await AppRuntime.runPromise(
|
||||
Git.Service.use((git) =>
|
||||
git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: Instance.worktree,
|
||||
}),
|
||||
),
|
||||
)
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
}
|
||||
|
||||
// Set upstream to the fork so pushes go there
|
||||
const headRefName = prInfo.headRefName
|
||||
await AppRuntime.runPromise(
|
||||
Git.Service.use((git) =>
|
||||
git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
cwd: Instance.worktree,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Check for opencode session link in PR body
|
||||
if (prInfo && prInfo.body) {
|
||||
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
||||
if (sessionMatch) {
|
||||
const sessionUrl = sessionMatch[0]
|
||||
UI.println(`Found opencode session: ${sessionUrl}`)
|
||||
UI.println(`Importing session...`)
|
||||
|
||||
const importResult = await Process.text(["opencode", "import", sessionUrl], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (importResult.code === 0) {
|
||||
const importOutput = importResult.text.trim()
|
||||
// Extract session ID from the output (format: "Imported session: <session-id>")
|
||||
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
|
||||
if (sessionIdMatch) {
|
||||
sessionId = sessionIdMatch[1]
|
||||
UI.println(`Session imported: ${sessionId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`)
|
||||
UI.println()
|
||||
UI.println("Starting opencode...")
|
||||
UI.println()
|
||||
UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`)
|
||||
UI.println()
|
||||
UI.println("Starting opencode...")
|
||||
UI.println()
|
||||
|
||||
const opencodeArgs = sessionId ? ["-s", sessionId] : []
|
||||
const code = yield* Effect.promise(
|
||||
() =>
|
||||
Process.spawn(["opencode", ...opencodeArgs], {
|
||||
const opencodeArgs = sessionId ? ["-s", sessionId] : []
|
||||
const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
cwd: process.cwd(),
|
||||
}).exited,
|
||||
)
|
||||
// Match legacy throw semantics — propagate as a defect so the top-level
|
||||
// index.ts catch handles it identically (exit 1, "Unexpected error" banner).
|
||||
if (code !== 0) return yield* Effect.die(new Error(`opencode exited with code ${code}`))
|
||||
}),
|
||||
})
|
||||
const code = await opencodeProcess.exited
|
||||
if (code !== 0) throw new Error(`opencode exited with code ${code}`)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,16 +4,13 @@ import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
|
||||
const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get()))
|
||||
const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true)))
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Config } from "@/config/config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Plugin } from "../../plugin"
|
||||
import { WithInstance } from "../../project/with-instance"
|
||||
import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "@/util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
@@ -248,7 +245,7 @@ export const ProvidersListCommand = cmd({
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
const database = await getModels()
|
||||
const database = await ModelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
@@ -303,7 +300,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
await WithInstance.provide({
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
@@ -337,14 +334,14 @@ export const ProvidersLoginCommand = cmd({
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await refreshModels().catch(() => {})
|
||||
await ModelsDev.refresh(true).catch(() => {})
|
||||
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const providers = await getModels().then((x) => {
|
||||
const providers = await ModelsDev.get().then((x) => {
|
||||
const filtered: Record<string, (typeof x)[string]> = {}
|
||||
for (const [key, value] of Object.entries(x)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
@@ -508,7 +505,7 @@ export const ProvidersLogoutCommand = cmd({
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await getModels()
|
||||
const database = await ModelsDev.get()
|
||||
const selected = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1302
packages/opencode/src/cli/cmd/run/demo.ts
Normal file
1302
packages/opencode/src/cli/cmd/run/demo.ts
Normal file
File diff suppressed because it is too large
Load Diff
183
packages/opencode/src/cli/cmd/run/entry.body.ts
Normal file
183
packages/opencode/src/cli/cmd/run/entry.body.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { toolEntryBody } from "./tool"
|
||||
import type { RunEntryBody, StreamCommit } from "./types"
|
||||
|
||||
export type EntryFlags = {
|
||||
startOnNewLine: boolean
|
||||
trailingNewline: boolean
|
||||
}
|
||||
|
||||
export const RUN_ENTRY_NONE: RunEntryBody = {
|
||||
type: "none",
|
||||
}
|
||||
|
||||
export function cleanRunText(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
function textBody(content: string): RunEntryBody {
|
||||
if (!content) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function codeBody(content: string, filetype?: string): RunEntryBody {
|
||||
if (!content) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return {
|
||||
type: "code",
|
||||
content,
|
||||
filetype,
|
||||
}
|
||||
}
|
||||
|
||||
function markdownBody(content: string): RunEntryBody {
|
||||
if (!content) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return {
|
||||
type: "markdown",
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function userBody(raw: string): RunEntryBody {
|
||||
if (!raw.trim()) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
const lead = raw.match(/^\n+/)?.[0] ?? ""
|
||||
const body = lead ? raw.slice(lead.length) : raw
|
||||
return textBody(`${lead}› ${body}`)
|
||||
}
|
||||
|
||||
function reasoningBody(raw: string): RunEntryBody {
|
||||
const clean = raw.replace(/\[REDACTED\]/g, "")
|
||||
if (!clean) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
const lead = clean.match(/^\n+/)?.[0] ?? ""
|
||||
const body = lead ? clean.slice(lead.length) : clean
|
||||
const mark = "Thinking:"
|
||||
if (body.startsWith(mark)) {
|
||||
return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown")
|
||||
}
|
||||
|
||||
return codeBody(clean, "markdown")
|
||||
}
|
||||
|
||||
function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
|
||||
return textBody(phase === "progress" ? raw : raw.trim())
|
||||
}
|
||||
|
||||
export function entryFlags(commit: StreamCommit): EntryFlags {
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant" || commit.kind === "reasoning") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function entryDone(commit: StreamCommit): boolean {
|
||||
if (commit.kind === "assistant" || commit.kind === "reasoning") {
|
||||
return commit.phase === "final"
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean {
|
||||
if (commit.phase !== "progress") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (body.type === "none") {
|
||||
return false
|
||||
}
|
||||
|
||||
return commit.kind === "assistant" || commit.kind === "reasoning" || commit.kind === "tool"
|
||||
}
|
||||
|
||||
export function entryBody(commit: StreamCommit): RunEntryBody {
|
||||
const raw = cleanRunText(commit.text)
|
||||
|
||||
if (commit.kind === "user") {
|
||||
return userBody(raw)
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
if (commit.phase === "start") {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return markdownBody(raw)
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
if (commit.phase === "start") {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return reasoningBody(raw)
|
||||
}
|
||||
|
||||
return systemBody(raw, commit.phase)
|
||||
}
|
||||
487
packages/opencode/src/cli/cmd/run/footer.permission.tsx
Normal file
487
packages/opencode/src/cli/cmd/run/footer.permission.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
// Permission UI body for the direct-mode footer.
|
||||
//
|
||||
// Renders inside the footer when the reducer pushes a FooterView of type
|
||||
// "permission". Uses a three-stage state machine (permission.shared.ts):
|
||||
//
|
||||
// permission → shows the request with Allow once / Always / Reject buttons
|
||||
// always → confirmation step before granting permanent access
|
||||
// reject → text field for the rejection message
|
||||
//
|
||||
// Keyboard: left/right to select, enter to confirm, esc to reject.
|
||||
// The diff view (when available) uses the same diff component as scrollback
|
||||
// tool snapshots.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
createPermissionBodyState,
|
||||
permissionAlwaysLines,
|
||||
permissionCancel,
|
||||
permissionEscape,
|
||||
permissionHover,
|
||||
permissionInfo,
|
||||
permissionLabel,
|
||||
permissionOptions,
|
||||
permissionReject,
|
||||
permissionRun,
|
||||
permissionShift,
|
||||
type PermissionOption,
|
||||
} from "./permission.shared"
|
||||
import { toolDiffView, toolFiletype } from "./tool"
|
||||
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||
import type { PermissionReply, RunDiffStyle } from "./types"
|
||||
|
||||
type RejectArea = {
|
||||
isDestroyed: boolean
|
||||
plainText: string
|
||||
cursorOffset: number
|
||||
setText(text: string): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
function buttons(
|
||||
list: PermissionOption[],
|
||||
selected: PermissionOption,
|
||||
theme: RunFooterTheme,
|
||||
disabled: boolean,
|
||||
onHover: (option: PermissionOption) => void,
|
||||
onSelect: (option: PermissionOption) => void,
|
||||
) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} flexShrink={0} paddingBottom={1}>
|
||||
<For each={list}>
|
||||
{(option) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={option === selected ? theme.highlight : transparent}
|
||||
onMouseOver={() => {
|
||||
if (!disabled) onHover(option)
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled) onSelect(option)
|
||||
}}
|
||||
>
|
||||
<text fg={option === selected ? theme.surface : theme.muted}>{permissionLabel(option)}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function RejectField(props: {
|
||||
theme: RunFooterTheme
|
||||
text: string
|
||||
disabled: boolean
|
||||
onChange: (text: string) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
let area: RejectArea | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (area.plainText !== props.text) {
|
||||
area.setText(props.text)
|
||||
area.cursorOffset = props.text.length
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed || props.disabled) {
|
||||
return
|
||||
}
|
||||
area.focus()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id="run-direct-footer-permission-reject"
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={3}
|
||||
paddingBottom={1}
|
||||
wrapMode="word"
|
||||
placeholder="Tell OpenCode what to do differently"
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused={!props.disabled}
|
||||
onContentChange={() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
props.onChange(area.plainText)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
props.onCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return" && !event.meta && !event.ctrl && !event.shift) {
|
||||
event.preventDefault()
|
||||
props.onConfirm()
|
||||
}
|
||||
}}
|
||||
ref={(item) => {
|
||||
area = item as RejectArea
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunPermissionBody(props: {
|
||||
request: PermissionRequest
|
||||
theme: RunFooterTheme
|
||||
block: RunBlockTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
onReply: (input: PermissionReply) => void | Promise<void>
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
|
||||
const info = createMemo(() => permissionInfo(props.request))
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const view = createMemo(() => toolDiffView(dims().width, props.diffStyle))
|
||||
const narrow = createMemo(() => dims().width < 80)
|
||||
const opts = createMemo(() => permissionOptions(state().stage))
|
||||
const busy = createMemo(() => state().submitting)
|
||||
const title = createMemo(() => {
|
||||
if (state().stage === "always") {
|
||||
return "Always allow"
|
||||
}
|
||||
|
||||
if (state().stage === "reject") {
|
||||
return "Reject permission"
|
||||
}
|
||||
|
||||
return "Permission required"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = props.request.id
|
||||
if (state().requestID === id) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(createPermissionBodyState(id))
|
||||
})
|
||||
|
||||
const shift = (dir: -1 | 1) => {
|
||||
setState((prev) => permissionShift(prev, dir))
|
||||
}
|
||||
|
||||
const submit = async (next: PermissionReply) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
submitting: true,
|
||||
}))
|
||||
|
||||
try {
|
||||
await props.onReply(next)
|
||||
} catch {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
submitting: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const run = (option: PermissionOption) => {
|
||||
const cur = state()
|
||||
const next = permissionRun(cur, props.request.id, option)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void submit(next.reply)
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
const next = permissionReject(state(), props.request.id)
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
void submit(next)
|
||||
}
|
||||
|
||||
const cancelReject = () => {
|
||||
setState((prev) => permissionCancel(prev))
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
const cur = state()
|
||||
if (cur.stage === "reject") {
|
||||
return
|
||||
}
|
||||
|
||||
if (cur.submitting) {
|
||||
if (["left", "right", "h", "l", "tab", "return", "escape"].includes(event.name)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "tab") {
|
||||
shift(event.shift ? -1 : 1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "left" || event.name === "h") {
|
||||
shift(-1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "right" || event.name === "l") {
|
||||
shift(1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return") {
|
||||
run(state().selected)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name !== "escape") {
|
||||
return
|
||||
}
|
||||
|
||||
setState((prev) => permissionEscape(prev))
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
return (
|
||||
<box id="run-direct-footer-permission-body" width="100%" height="100%" flexDirection="column">
|
||||
<box
|
||||
id="run-direct-footer-permission-head"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>△</text>
|
||||
<text fg={props.theme.text}>{title()}</text>
|
||||
</box>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2}>
|
||||
<text fg={props.theme.muted} flexShrink={0}>
|
||||
{info().icon}
|
||||
</text>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{info().title}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={state().stage === "reject"}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={state().stage !== "reject"}
|
||||
fallback={
|
||||
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
|
||||
<box
|
||||
id="run-direct-footer-permission-reject-bar"
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.line}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
>
|
||||
<box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
|
||||
<RejectField
|
||||
theme={props.theme}
|
||||
text={state().message}
|
||||
disabled={busy()}
|
||||
onChange={(text) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
message: text,
|
||||
}))
|
||||
}}
|
||||
onConfirm={reject}
|
||||
onCancel={cancelReject}
|
||||
/>
|
||||
</box>
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0} paddingBottom={1}>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<Show
|
||||
when={info().diff}
|
||||
fallback={
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<For each={info().lines}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<diff
|
||||
diff={info().diff!}
|
||||
view={view()}
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={props.theme.text}
|
||||
addedBg={props.block.diffAddedBg}
|
||||
removedBg={props.block.diffRemovedBg}
|
||||
contextBg={props.block.diffContextBg}
|
||||
addedSignColor={props.block.diffHighlightAdded}
|
||||
removedSignColor={props.block.diffHighlightRemoved}
|
||||
lineNumberFg={props.block.diffLineNumber}
|
||||
lineNumberBg={props.block.diffContextBg}
|
||||
addedLineNumberBg={props.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!info().diff && info().lines.length === 0}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>No diff provided</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-permission-actions"
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.pane}
|
||||
gap={1}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
{buttons(
|
||||
opts(),
|
||||
state().selected,
|
||||
props.theme,
|
||||
busy(),
|
||||
(option) => {
|
||||
setState((prev) => permissionHover(prev, option))
|
||||
},
|
||||
run,
|
||||
)}
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0} paddingBottom={1}>
|
||||
<text fg={props.theme.text}>
|
||||
{"⇆"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>{state().stage === "always" ? "cancel" : "reject"}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
977
packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Normal file
977
packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Normal file
@@ -0,0 +1,977 @@
|
||||
// Prompt textarea component and its state machine for direct interactive mode.
|
||||
//
|
||||
// createPromptState() wires keybinds, history navigation, leader-key sequences,
|
||||
// and direct-mode `@` autocomplete for files, subagents, and MCP resources.
|
||||
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
|
||||
// while RunPromptAutocomplete renders a fixed-height suggestion list below it.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { pathToFileURL } from "bun"
|
||||
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import path from "path"
|
||||
import {
|
||||
Index,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import * as Locale from "@/util/locale"
|
||||
import {
|
||||
createPromptHistory,
|
||||
isExitCommand,
|
||||
movePromptHistory,
|
||||
promptCycle,
|
||||
promptHit,
|
||||
promptInfo,
|
||||
promptKeys,
|
||||
pushPromptHistory,
|
||||
} from "./prompt.shared"
|
||||
import type { FooterKeybinds, FooterState, RunAgent, RunPrompt, RunPromptPart, RunResource } from "./types"
|
||||
import { transparent, type RunFooterTheme } from "./theme"
|
||||
|
||||
const LEADER_TIMEOUT_MS = 2000
|
||||
const AUTOCOMPLETE_ROWS = 6
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
export const TEXTAREA_MIN_ROWS = 1
|
||||
export const TEXTAREA_MAX_ROWS = 6
|
||||
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1
|
||||
|
||||
export const HINT_BREAKPOINTS = {
|
||||
send: 50,
|
||||
newline: 66,
|
||||
history: 80,
|
||||
variant: 95,
|
||||
}
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||
|
||||
type Auto = {
|
||||
display: string
|
||||
value: string
|
||||
part: Mention
|
||||
description?: string
|
||||
directory?: boolean
|
||||
}
|
||||
|
||||
type PromptInput = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: Accessor<RunAgent[]>
|
||||
resources: Accessor<RunResource[]>
|
||||
keybinds: FooterKeybinds
|
||||
state: Accessor<FooterState>
|
||||
view: Accessor<string>
|
||||
prompt: Accessor<boolean>
|
||||
width: Accessor<number>
|
||||
theme: Accessor<RunFooterTheme>
|
||||
history?: RunPrompt[]
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onRows: (rows: number) => void
|
||||
onStatus: (text: string) => void
|
||||
}
|
||||
|
||||
export type PromptState = {
|
||||
placeholder: Accessor<StyledText | string>
|
||||
bindings: Accessor<KeyBinding[]>
|
||||
visible: Accessor<boolean>
|
||||
options: Accessor<Auto[]>
|
||||
selected: Accessor<number>
|
||||
onSubmit: () => void
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onContentChange: () => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}
|
||||
|
||||
function clamp(rows: number): number {
|
||||
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: RunPrompt): RunPrompt {
|
||||
return {
|
||||
text: prompt.text,
|
||||
parts: structuredClone(prompt.parts),
|
||||
}
|
||||
}
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
return hash === -1 ? input : input.slice(0, hash)
|
||||
}
|
||||
|
||||
function extractLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
if (hash === -1) {
|
||||
return { base: input }
|
||||
}
|
||||
|
||||
const base = input.slice(0, hash)
|
||||
const line = input.slice(hash + 1)
|
||||
const match = line.match(/^(\d+)(?:-(\d*))?$/)
|
||||
if (!match) {
|
||||
return { base }
|
||||
}
|
||||
|
||||
const start = Number(match[1])
|
||||
const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined
|
||||
return { base, line: { start, end } }
|
||||
}
|
||||
|
||||
export function hintFlags(width: number) {
|
||||
return {
|
||||
send: width >= HINT_BREAKPOINTS.send,
|
||||
newline: width >= HINT_BREAKPOINTS.newline,
|
||||
history: width >= HINT_BREAKPOINTS.history,
|
||||
variant: width >= HINT_BREAKPOINTS.variant,
|
||||
}
|
||||
}
|
||||
|
||||
export function RunPromptBody(props: {
|
||||
theme: () => RunFooterTheme
|
||||
placeholder: () => StyledText | string
|
||||
bindings: () => KeyBinding[]
|
||||
onSubmit: () => void
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onContentChange: () => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}) {
|
||||
let area: TextareaRenderable | undefined
|
||||
|
||||
onMount(() => {
|
||||
props.bind(area)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
props.bind(undefined)
|
||||
})
|
||||
|
||||
return (
|
||||
<box id="run-direct-footer-prompt" width="100%">
|
||||
<box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
|
||||
<textarea
|
||||
id="run-direct-footer-composer"
|
||||
width="100%"
|
||||
minHeight={TEXTAREA_MIN_ROWS}
|
||||
maxHeight={TEXTAREA_MAX_ROWS}
|
||||
wrapMode="word"
|
||||
placeholder={props.placeholder()}
|
||||
placeholderColor={props.theme().muted}
|
||||
textColor={props.theme().text}
|
||||
focusedTextColor={props.theme().text}
|
||||
backgroundColor={props.theme().surface}
|
||||
focusedBackgroundColor={props.theme().surface}
|
||||
cursorColor={props.theme().text}
|
||||
keyBindings={props.bindings()}
|
||||
onSubmit={props.onSubmit}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onContentChange={props.onContentChange}
|
||||
ref={(next) => {
|
||||
area = next
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunPromptAutocomplete(props: {
|
||||
theme: () => RunFooterTheme
|
||||
options: () => Auto[]
|
||||
selected: () => number
|
||||
}) {
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-complete"
|
||||
width="100%"
|
||||
height={AUTOCOMPLETE_ROWS}
|
||||
border={["left"]}
|
||||
borderColor={props.theme().border}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-complete-fill"
|
||||
width="100%"
|
||||
height={AUTOCOMPLETE_ROWS}
|
||||
flexDirection="column"
|
||||
backgroundColor={transparent}
|
||||
>
|
||||
<Index
|
||||
each={props.options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={props.theme().muted}>No matching items</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={index === props.selected() ? props.theme().highlight : undefined}
|
||||
>
|
||||
<text
|
||||
fg={index === props.selected() ? props.theme().surface : props.theme().text}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{item().display}
|
||||
</text>
|
||||
<Show when={item().description}>
|
||||
<text
|
||||
fg={index === props.selected() ? props.theme().surface : props.theme().muted}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{item().description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Index>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function createPromptState(input: PromptInput): PromptState {
|
||||
const keys = createMemo(() => promptKeys(input.keybinds))
|
||||
const bindings = createMemo(() => keys().bindings)
|
||||
const placeholder = createMemo(() => {
|
||||
if (!input.state().first) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return new StyledText([
|
||||
bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
|
||||
])
|
||||
})
|
||||
|
||||
let history = createPromptHistory(input.history)
|
||||
let draft: RunPrompt = { text: "", parts: [] }
|
||||
let stash: RunPrompt = { text: "", parts: [] }
|
||||
let area: TextareaRenderable | undefined
|
||||
let leader = false
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
let tick = false
|
||||
let prev = input.view()
|
||||
let type = 0
|
||||
let parts: Mention[] = []
|
||||
let marks = new Map<number, number>()
|
||||
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
const [at, setAt] = createSignal(0)
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
const width = createMemo(() => Math.max(20, input.width() - 8))
|
||||
const agents = createMemo<Auto[]>(() => {
|
||||
return input
|
||||
.agents()
|
||||
.filter((item) => !item.hidden && item.mode !== "primary")
|
||||
.map((item) => ({
|
||||
display: "@" + item.name,
|
||||
value: item.name,
|
||||
part: {
|
||||
type: "agent",
|
||||
name: item.name,
|
||||
source: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
const resources = createMemo<Auto[]>(() => {
|
||||
return input.resources().map((item) => ({
|
||||
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
|
||||
value: item.name,
|
||||
description: item.description,
|
||||
part: {
|
||||
type: "file",
|
||||
mime: item.mimeType ?? "text/plain",
|
||||
filename: item.name,
|
||||
url: item.uri,
|
||||
source: {
|
||||
type: "resource",
|
||||
clientName: item.client,
|
||||
uri: item.uri,
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
const [files] = createResource(
|
||||
query,
|
||||
async (value) => {
|
||||
if (!visible()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const next = extractLineRange(value)
|
||||
const list = await input.findFiles(next.base)
|
||||
return list
|
||||
.sort((a, b) => {
|
||||
const dir = Number(b.endsWith("/")) - Number(a.endsWith("/"))
|
||||
if (dir !== 0) {
|
||||
return dir
|
||||
}
|
||||
|
||||
const depth = a.split("/").length - b.split("/").length
|
||||
if (depth !== 0) {
|
||||
return depth
|
||||
}
|
||||
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
.map((item): Auto => {
|
||||
const url = pathToFileURL(path.resolve(input.directory, item))
|
||||
let filename = item
|
||||
if (next.line && !item.endsWith("/")) {
|
||||
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
|
||||
url.searchParams.set("start", String(next.line.start))
|
||||
if (next.line.end !== undefined) {
|
||||
url.searchParams.set("end", String(next.line.end))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
display: Locale.truncateMiddle("@" + filename, width()),
|
||||
value: filename,
|
||||
directory: item.endsWith("/"),
|
||||
part: {
|
||||
type: "file",
|
||||
mime: item.endsWith("/") ? "application/x-directory" : "text/plain",
|
||||
filename,
|
||||
url: url.href,
|
||||
source: {
|
||||
type: "file",
|
||||
path: item,
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
{ initialValue: [] as Auto[] },
|
||||
)
|
||||
const options = createMemo(() => {
|
||||
const mixed = [...agents(), ...files(), ...resources()]
|
||||
if (!query()) {
|
||||
return mixed.slice(0, AUTOCOMPLETE_ROWS)
|
||||
}
|
||||
|
||||
return fuzzysort
|
||||
.go(removeLineRange(query()), mixed, {
|
||||
keys: [(item) => (item.value || item.display).trimEnd(), "description"],
|
||||
limit: AUTOCOMPLETE_ROWS,
|
||||
})
|
||||
.map((item) => item.obj)
|
||||
})
|
||||
const popup = createMemo(() => {
|
||||
return visible() ? AUTOCOMPLETE_ROWS - 1 : 0
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
leader = false
|
||||
if (!timeout) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
}
|
||||
|
||||
const arm = () => {
|
||||
clear()
|
||||
leader = true
|
||||
timeout = setTimeout(() => {
|
||||
clear()
|
||||
}, LEADER_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
setVisible(false)
|
||||
setQuery("")
|
||||
setSelected(0)
|
||||
}
|
||||
|
||||
const syncRows = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
input.onRows(clamp(area.virtualLineCount || 1) + popup())
|
||||
}
|
||||
|
||||
const scheduleRows = () => {
|
||||
if (tick) {
|
||||
return
|
||||
}
|
||||
|
||||
tick = true
|
||||
queueMicrotask(() => {
|
||||
tick = false
|
||||
syncRows()
|
||||
})
|
||||
}
|
||||
|
||||
const syncParts = () => {
|
||||
if (!area || area.isDestroyed || type === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const next: Mention[] = []
|
||||
const map = new Map<number, number>()
|
||||
for (const item of area.extmarks.getAllForTypeId(type)) {
|
||||
const idx = marks.get(item.id)
|
||||
if (idx === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const part = parts[idx]
|
||||
if (!part) {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = area.plainText.slice(item.start, item.end)
|
||||
const prev =
|
||||
part.type === "agent"
|
||||
? (part.source?.value ?? "@" + part.name)
|
||||
: (part.source?.text.value ?? "@" + (part.filename ?? ""))
|
||||
if (text !== prev) {
|
||||
continue
|
||||
}
|
||||
|
||||
const copy = structuredClone(part)
|
||||
if (copy.type === "agent") {
|
||||
copy.source = {
|
||||
start: item.start,
|
||||
end: item.end,
|
||||
value: text,
|
||||
}
|
||||
}
|
||||
if (copy.type === "file" && copy.source?.text) {
|
||||
copy.source.text.start = item.start
|
||||
copy.source.text.end = item.end
|
||||
copy.source.text.value = text
|
||||
}
|
||||
|
||||
map.set(item.id, next.length)
|
||||
next.push(copy)
|
||||
}
|
||||
|
||||
const stale = map.size !== marks.size
|
||||
parts = next
|
||||
marks = map
|
||||
if (stale) {
|
||||
restoreParts(next)
|
||||
}
|
||||
}
|
||||
|
||||
const clearParts = () => {
|
||||
if (area && !area.isDestroyed) {
|
||||
area.extmarks.clear()
|
||||
}
|
||||
parts = []
|
||||
marks = new Map()
|
||||
}
|
||||
|
||||
const restoreParts = (value: RunPromptPart[]) => {
|
||||
clearParts()
|
||||
parts = value
|
||||
.filter((item): item is Mention => item.type === "file" || item.type === "agent")
|
||||
.map((item) => structuredClone(item))
|
||||
if (!area || area.isDestroyed || type === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const box = area
|
||||
parts.forEach((item, idx) => {
|
||||
const start = item.type === "agent" ? item.source?.start : item.source?.text.start
|
||||
const end = item.type === "agent" ? item.source?.end : item.source?.text.end
|
||||
if (start === undefined || end === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = box.extmarks.create({
|
||||
start,
|
||||
end,
|
||||
virtual: true,
|
||||
typeId: type,
|
||||
})
|
||||
marks.set(id, idx)
|
||||
})
|
||||
}
|
||||
|
||||
const restore = (value: RunPrompt, cursor = value.text.length) => {
|
||||
draft = clonePrompt(value)
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
hide()
|
||||
area.setText(value.text)
|
||||
restoreParts(value.parts)
|
||||
area.cursorOffset = Math.min(cursor, area.plainText.length)
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
const text = area.plainText
|
||||
if (visible()) {
|
||||
if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
setQuery(text.slice(at() + 1, cursor))
|
||||
return
|
||||
}
|
||||
|
||||
if (cursor === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const head = text.slice(0, cursor)
|
||||
const idx = head.lastIndexOf("@")
|
||||
if (idx === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const before = idx === 0 ? undefined : head[idx - 1]
|
||||
const tail = head.slice(idx)
|
||||
if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) {
|
||||
setAt(idx)
|
||||
setSelected(0)
|
||||
setVisible(true)
|
||||
setQuery(head.slice(idx + 1))
|
||||
}
|
||||
}
|
||||
|
||||
const bind = (next?: TextareaRenderable) => {
|
||||
if (area === next) {
|
||||
return
|
||||
}
|
||||
|
||||
if (area && !area.isDestroyed) {
|
||||
area.off("line-info-change", scheduleRows)
|
||||
}
|
||||
|
||||
area = next
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 0) {
|
||||
type = area.extmarks.registerType("run-direct-prompt-part")
|
||||
}
|
||||
area.on("line-info-change", scheduleRows)
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed || !input.prompt()) {
|
||||
return
|
||||
}
|
||||
|
||||
restore(draft)
|
||||
refresh()
|
||||
})
|
||||
}
|
||||
|
||||
const syncDraft = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
syncParts()
|
||||
draft = {
|
||||
text: area.plainText,
|
||||
parts: structuredClone(parts),
|
||||
}
|
||||
}
|
||||
|
||||
const push = (value: RunPrompt) => {
|
||||
history = pushPromptHistory(history, value)
|
||||
}
|
||||
|
||||
const move = (dir: -1 | 1, event: KeyEvent) => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (history.index === null && dir === -1) {
|
||||
stash = clonePrompt(draft)
|
||||
}
|
||||
|
||||
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
||||
if (!next.apply || next.text === undefined || next.cursor === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
history = next.state
|
||||
const value =
|
||||
next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
|
||||
restore(value, next.cursor)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const cycle = (event: KeyEvent): boolean => {
|
||||
const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
|
||||
if (!next.consume) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (next.clear) {
|
||||
clear()
|
||||
}
|
||||
|
||||
if (next.arm) {
|
||||
arm()
|
||||
}
|
||||
|
||||
if (next.cycle) {
|
||||
input.onCycle()
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
const select = (item?: Auto) => {
|
||||
const next = item ?? options()[selected()]
|
||||
if (!next || !area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
const tail = area.plainText.at(cursor)
|
||||
const append = "@" + next.value + (tail === " " ? "" : " ")
|
||||
area.cursorOffset = at()
|
||||
const start = area.logicalCursor
|
||||
area.cursorOffset = cursor
|
||||
const end = area.logicalCursor
|
||||
area.deleteRange(start.row, start.col, end.row, end.col)
|
||||
area.insertText(append)
|
||||
|
||||
const text = "@" + next.value
|
||||
const startOffset = at()
|
||||
const endOffset = startOffset + Bun.stringWidth(text)
|
||||
const part = structuredClone(next.part)
|
||||
if (part.type === "agent") {
|
||||
part.source = {
|
||||
start: startOffset,
|
||||
end: endOffset,
|
||||
value: text,
|
||||
}
|
||||
}
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = startOffset
|
||||
part.source.text.end = endOffset
|
||||
part.source.text.value = text
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
const prev = parts.findIndex((item) => item.type === "file" && item.url === part.url)
|
||||
if (prev !== -1) {
|
||||
const mark = [...marks.entries()].find((item) => item[1] === prev)?.[0]
|
||||
if (mark !== undefined) {
|
||||
area.extmarks.delete(mark)
|
||||
}
|
||||
parts = parts.filter((_, idx) => idx !== prev)
|
||||
marks = new Map(
|
||||
[...marks.entries()]
|
||||
.filter((item) => item[0] !== mark)
|
||||
.map((item) => [item[0], item[1] > prev ? item[1] - 1 : item[1]]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const id = area.extmarks.create({
|
||||
start: startOffset,
|
||||
end: endOffset,
|
||||
virtual: true,
|
||||
typeId: type,
|
||||
})
|
||||
marks.set(id, parts.length)
|
||||
parts.push(part)
|
||||
hide()
|
||||
syncDraft()
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
}
|
||||
|
||||
const expand = () => {
|
||||
const next = options()[selected()]
|
||||
if (!next?.directory || !area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
area.cursorOffset = at()
|
||||
const start = area.logicalCursor
|
||||
area.cursorOffset = cursor
|
||||
const end = area.logicalCursor
|
||||
area.deleteRange(start.row, start.col, end.row, end.col)
|
||||
area.insertText("@" + next.value)
|
||||
syncDraft()
|
||||
refresh()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyEvent) => {
|
||||
if (visible()) {
|
||||
const name = event.name.toLowerCase()
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift
|
||||
if (name === "up" || (ctrl && name === "p")) {
|
||||
event.preventDefault()
|
||||
if (options().length > 0) {
|
||||
setSelected((selected() - 1 + options().length) % options().length)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "down" || (ctrl && name === "n")) {
|
||||
event.preventDefault()
|
||||
if (options().length > 0) {
|
||||
setSelected((selected() + 1) % options().length)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "escape") {
|
||||
event.preventDefault()
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "return") {
|
||||
event.preventDefault()
|
||||
select()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "tab") {
|
||||
event.preventDefault()
|
||||
if (options()[selected()]?.directory) {
|
||||
expand()
|
||||
return
|
||||
}
|
||||
|
||||
select()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.ctrl && event.name === "c") {
|
||||
const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const key = promptInfo(event)
|
||||
if (promptHit(keys().interrupts, key)) {
|
||||
if (input.onInterrupt()) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (cycle(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const up = promptHit(keys().previous, key)
|
||||
const down = promptHit(keys().next, key)
|
||||
if (!up && !down) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const dir = up ? -1 : 1
|
||||
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
|
||||
move(dir, event)
|
||||
return
|
||||
}
|
||||
|
||||
if (dir === -1 && area.visualCursor.visualRow === 0) {
|
||||
area.cursorOffset = 0
|
||||
}
|
||||
|
||||
const end =
|
||||
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
|
||||
? area.height - 1
|
||||
: Math.max(0, area.virtualLineCount - 1)
|
||||
if (dir === 1 && area.visualCursor.visualRow === end) {
|
||||
area.cursorOffset = area.plainText.length
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (input.prompt()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.ctrl && event.name === "c") {
|
||||
const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (visible()) {
|
||||
select()
|
||||
return
|
||||
}
|
||||
|
||||
syncDraft()
|
||||
const next = clonePrompt(draft)
|
||||
if (!next.text.trim()) {
|
||||
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
||||
return
|
||||
}
|
||||
|
||||
if (isExitCommand(next.text)) {
|
||||
input.onExit()
|
||||
return
|
||||
}
|
||||
|
||||
area.setText("")
|
||||
clearParts()
|
||||
hide()
|
||||
draft = { text: "", parts: [] }
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
queueMicrotask(async () => {
|
||||
if (await input.onSubmit(next)) {
|
||||
push(next)
|
||||
return
|
||||
}
|
||||
|
||||
restore(next)
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clear()
|
||||
if (area && !area.isDestroyed) {
|
||||
area.off("line-info-change", scheduleRows)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.width()
|
||||
popup()
|
||||
if (input.prompt()) {
|
||||
scheduleRows()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
setSelected(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.state().phase
|
||||
if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
|
||||
return
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
area.focus()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const kind = input.view()
|
||||
if (kind === prev) {
|
||||
return
|
||||
}
|
||||
|
||||
if (prev === "prompt") {
|
||||
syncDraft()
|
||||
}
|
||||
|
||||
clear()
|
||||
hide()
|
||||
prev = kind
|
||||
if (kind !== "prompt") {
|
||||
return
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
restore(draft)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
placeholder,
|
||||
bindings,
|
||||
visible,
|
||||
options,
|
||||
selected,
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
onContentChange: () => {
|
||||
syncDraft()
|
||||
refresh()
|
||||
scheduleRows()
|
||||
},
|
||||
bind,
|
||||
}
|
||||
}
|
||||
591
packages/opencode/src/cli/cmd/run/footer.question.tsx
Normal file
591
packages/opencode/src/cli/cmd/run/footer.question.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
// Question UI body for the direct-mode footer.
|
||||
//
|
||||
// Renders inside the footer when the reducer pushes a FooterView of type
|
||||
// "question". Supports single-question and multi-question flows:
|
||||
//
|
||||
// Single question: options list with up/down selection, digit shortcuts,
|
||||
// and optional custom text input.
|
||||
//
|
||||
// Multi-question: tabbed interface where each question is a tab, plus a
|
||||
// final "Confirm" tab that shows all answers for review. Tab/shift-tab
|
||||
// or left/right to navigate between questions.
|
||||
//
|
||||
// All state logic lives in question.shared.ts as a pure state machine.
|
||||
// This component just renders it and dispatches keyboard events.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
createQuestionBodyState,
|
||||
questionConfirm,
|
||||
questionCustom,
|
||||
questionInfo,
|
||||
questionInput,
|
||||
questionMove,
|
||||
questionOther,
|
||||
questionPicked,
|
||||
questionReject,
|
||||
questionSave,
|
||||
questionSelect,
|
||||
questionSetEditing,
|
||||
questionSetSelected,
|
||||
questionSetSubmitting,
|
||||
questionSetTab,
|
||||
questionSingle,
|
||||
questionStoreCustom,
|
||||
questionSubmit,
|
||||
questionSync,
|
||||
questionTabs,
|
||||
questionTotal,
|
||||
} from "./question.shared"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
type Area = {
|
||||
isDestroyed: boolean
|
||||
plainText: string
|
||||
cursorOffset: number
|
||||
setText(text: string): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
export function RunQuestionBody(props: {
|
||||
request: QuestionRequest
|
||||
theme: RunFooterTheme
|
||||
onReply: (input: QuestionReply) => void | Promise<void>
|
||||
onReject: (input: QuestionReject) => void | Promise<void>
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [state, setState] = createSignal(createQuestionBodyState(props.request.id))
|
||||
const single = createMemo(() => questionSingle(props.request))
|
||||
const confirm = createMemo(() => questionConfirm(props.request, state()))
|
||||
const info = createMemo(() => questionInfo(props.request, state()))
|
||||
const input = createMemo(() => questionInput(state()))
|
||||
const other = createMemo(() => questionOther(props.request, state()))
|
||||
const picked = createMemo(() => questionPicked(state()))
|
||||
const disabled = createMemo(() => state().submitting)
|
||||
const narrow = createMemo(() => dims().width < 80)
|
||||
const verb = createMemo(() => {
|
||||
if (confirm()) {
|
||||
return "submit"
|
||||
}
|
||||
|
||||
if (info()?.multiple) {
|
||||
return "toggle"
|
||||
}
|
||||
|
||||
if (single()) {
|
||||
return "submit"
|
||||
}
|
||||
|
||||
return "confirm"
|
||||
})
|
||||
let area: Area | undefined
|
||||
|
||||
createEffect(() => {
|
||||
setState((prev) => questionSync(prev, props.request.id))
|
||||
})
|
||||
|
||||
const setTab = (tab: number) => {
|
||||
setState((prev) => questionSetTab(prev, tab))
|
||||
}
|
||||
|
||||
const move = (dir: -1 | 1) => {
|
||||
setState((prev) => questionMove(prev, props.request, dir))
|
||||
}
|
||||
|
||||
const beginReply = async (input: QuestionReply) => {
|
||||
setState((prev) => questionSetSubmitting(prev, true))
|
||||
|
||||
try {
|
||||
await props.onReply(input)
|
||||
} catch {
|
||||
setState((prev) => questionSetSubmitting(prev, false))
|
||||
}
|
||||
}
|
||||
|
||||
const beginReject = async (input: QuestionReject) => {
|
||||
setState((prev) => questionSetSubmitting(prev, true))
|
||||
|
||||
try {
|
||||
await props.onReject(input)
|
||||
} catch {
|
||||
setState((prev) => questionSetSubmitting(prev, false))
|
||||
}
|
||||
}
|
||||
|
||||
const saveCustom = () => {
|
||||
const cur = state()
|
||||
const next = questionSave(cur, props.request)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const choose = (selected: number) => {
|
||||
const base = state()
|
||||
const cur = questionSetSelected(base, selected)
|
||||
const next = questionSelect(cur, props.request)
|
||||
if (next.state !== base) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const mark = (selected: number) => {
|
||||
setState((prev) => questionSetSelected(prev, selected))
|
||||
}
|
||||
|
||||
const select = () => {
|
||||
const cur = state()
|
||||
const next = questionSelect(cur, props.request)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
void beginReply(questionSubmit(props.request, state()))
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
void beginReject(questionReject(props.request))
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
const cur = state()
|
||||
if (cur.submitting) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (cur.editing) {
|
||||
if (event.name === "escape") {
|
||||
setState((prev) => questionSetEditing(prev, false))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return" && !event.shift && !event.ctrl && !event.meta) {
|
||||
saveCustom()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && (event.name === "left" || event.name === "h")) {
|
||||
setTab((cur.tab - 1 + questionTabs(props.request)) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && (event.name === "right" || event.name === "l")) {
|
||||
setTab((cur.tab + 1) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && event.name === "tab") {
|
||||
const dir = event.shift ? -1 : 1
|
||||
setTab((cur.tab + dir + questionTabs(props.request)) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (questionConfirm(props.request, cur)) {
|
||||
if (event.name === "return") {
|
||||
submit()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
reject()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const total = questionTotal(props.request, cur)
|
||||
const max = Math.min(total, 9)
|
||||
const digit = Number(event.name)
|
||||
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
|
||||
choose(digit - 1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "up" || event.name === "k") {
|
||||
move(-1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
move(1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return") {
|
||||
select()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
reject()
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!state().editing || !area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (area.plainText !== input()) {
|
||||
area.setText(input())
|
||||
area.cursorOffset = input().length
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed || !state().editing) {
|
||||
return
|
||||
}
|
||||
|
||||
area.focus()
|
||||
area.cursorOffset = area.plainText.length
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box id="run-direct-footer-question-body" width="100%" height="100%" flexDirection="column">
|
||||
<box
|
||||
id="run-direct-footer-question-panel"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={3}
|
||||
paddingTop={1}
|
||||
marginBottom={1}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
backgroundColor={props.theme.surface}
|
||||
>
|
||||
<Show when={!single()}>
|
||||
<box id="run-direct-footer-question-tabs" flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<For each={props.request.questions}>
|
||||
{(item, index) => {
|
||||
const active = () => state().tab === index()
|
||||
const answered = () => (state().answers[index()]?.length ?? 0) > 0
|
||||
return (
|
||||
<box
|
||||
id={`run-direct-footer-question-tab-${index()}`}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={active() ? props.theme.highlight : props.theme.surface}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) setTab(index())
|
||||
}}
|
||||
>
|
||||
<text fg={active() ? props.theme.surface : answered() ? props.theme.text : props.theme.muted}>
|
||||
{item.header}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box
|
||||
id="run-direct-footer-question-tab-confirm"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) setTab(props.request.questions.length)
|
||||
}}
|
||||
>
|
||||
<text fg={confirm() ? props.theme.surface : props.theme.muted}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!confirm()}
|
||||
fallback={
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.text}>Review</text>
|
||||
</box>
|
||||
<For each={props.request.questions}>
|
||||
{(item, index) => {
|
||||
const value = () => state().answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text wrapMode="word">
|
||||
<span style={{ fg: props.theme.muted }}>{item.header}:</span>{" "}
|
||||
<span style={{ fg: answered() ? props.theme.text : props.theme.error }}>
|
||||
{answered() ? value() : "(not answered)"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{info()?.question}
|
||||
{info()?.multiple ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexGrow={1} flexShrink={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column">
|
||||
<For each={info()?.options ?? []}>
|
||||
{(item, index) => {
|
||||
const active = () => state().selected === index()
|
||||
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
|
||||
return (
|
||||
<box
|
||||
id={`run-direct-footer-question-option-${index()}`}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
onMouseOver={() => {
|
||||
if (!disabled()) {
|
||||
mark(index())
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (!disabled()) {
|
||||
mark(index())
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) {
|
||||
choose(index())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? props.theme.line : undefined} paddingRight={1}>
|
||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>{`${index() + 1}.`}</text>
|
||||
</box>
|
||||
<box backgroundColor={active() ? props.theme.line : undefined}>
|
||||
<text
|
||||
fg={active() ? props.theme.highlight : hit() ? props.theme.success : props.theme.text}
|
||||
>
|
||||
{info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{hit() ? "✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
{item.description}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={questionCustom(props.request, state())}>
|
||||
<box
|
||||
id="run-direct-footer-question-option-custom"
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
onMouseOver={() => {
|
||||
if (!disabled()) {
|
||||
mark(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (!disabled()) {
|
||||
mark(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) {
|
||||
choose(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={other() ? props.theme.line : undefined} paddingRight={1}>
|
||||
<text
|
||||
fg={other() ? props.theme.highlight : props.theme.muted}
|
||||
>{`${(info()?.options.length ?? 0) + 1}.`}</text>
|
||||
</box>
|
||||
<box backgroundColor={other() ? props.theme.line : undefined}>
|
||||
<text
|
||||
fg={other() ? props.theme.highlight : picked() ? props.theme.success : props.theme.text}
|
||||
>
|
||||
{info()?.multiple
|
||||
? `[${picked() ? "✓" : " "}] Type your own answer`
|
||||
: "Type your own answer"}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{picked() ? "✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
when={state().editing}
|
||||
fallback={
|
||||
<Show when={input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
{input()}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
id="run-direct-footer-question-custom"
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={4}
|
||||
wrapMode="word"
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused={!disabled()}
|
||||
onContentChange={() => {
|
||||
if (!area || area.isDestroyed || disabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = area.plainText
|
||||
setState((prev) => questionStoreCustom(prev, prev.tab, text))
|
||||
}}
|
||||
ref={(item) => {
|
||||
area = item as Area
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-question-actions"
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
<Show
|
||||
when={!disabled()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
Waiting for question event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
gap={narrow() ? 1 : 2}
|
||||
flexShrink={0}
|
||||
paddingBottom={1}
|
||||
width={narrow() ? "100%" : undefined}
|
||||
>
|
||||
<Show
|
||||
when={!state().editing}
|
||||
fallback={
|
||||
<>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>save</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
||||
</text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={!single()}>
|
||||
<text fg={props.theme.text}>
|
||||
{"⇆"} <span style={{ fg: props.theme.muted }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm()}>
|
||||
<text fg={props.theme.text}>
|
||||
{"↑↓"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>{verb()}</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>dismiss</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
192
packages/opencode/src/cli/cmd/run/footer.subagent.tsx
Normal file
192
packages/opencode/src/cli/cmd/run/footer.subagent.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import "opentui-spinner/solid"
|
||||
import { createMemo, indexArray, mapArray } from "solid-js"
|
||||
import { SPINNER_FRAMES } from "../tui/component/spinner"
|
||||
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||
|
||||
export const SUBAGENT_TAB_ROWS = 2
|
||||
export const SUBAGENT_INSPECTOR_ROWS = 8
|
||||
|
||||
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return theme.highlight
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return theme.error
|
||||
}
|
||||
|
||||
return theme.highlight
|
||||
}
|
||||
|
||||
function statusIcon(status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return "●"
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return "◍"
|
||||
}
|
||||
|
||||
return "◔"
|
||||
}
|
||||
|
||||
function tabText(tab: FooterSubagentTab, slot: string, count: number, width: number) {
|
||||
const perTab = Math.max(
|
||||
1,
|
||||
Math.floor((width - 4 - Math.max(0, count - 1) * 3) / Math.max(1, count)),
|
||||
)
|
||||
if (count >= 8 || perTab < 12) {
|
||||
return `[${slot}]`
|
||||
}
|
||||
|
||||
const prefix = `[${slot}]`
|
||||
if (count >= 5 || perTab < 24) {
|
||||
return prefix
|
||||
}
|
||||
|
||||
const label = tab.description || tab.title || tab.label
|
||||
return `${prefix} ${label}`
|
||||
}
|
||||
|
||||
export function RunFooterSubagentTabs(props: {
|
||||
tabs: FooterSubagentTab[]
|
||||
selected?: string
|
||||
theme: RunFooterTheme
|
||||
width: number
|
||||
}) {
|
||||
const items = mapArray(
|
||||
() => props.tabs,
|
||||
(tab, index) => {
|
||||
const active = () => props.selected === tab.sessionID
|
||||
const slot = () => String(index() + 1)
|
||||
return (
|
||||
<box paddingRight={1}>
|
||||
<box flexDirection="row" gap={1} width="100%">
|
||||
{tab.status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(props.theme, tab.status)} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(props.theme, tab.status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(tab.status)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={active() ? props.theme.text : props.theme.muted} wrapMode="none" truncate>
|
||||
{tabText(tab, slot(), props.tabs.length, props.width)}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-subagent-tabs"
|
||||
width="100%"
|
||||
height={SUBAGENT_TAB_ROWS}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingBottom={1}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>{items()}</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunFooterSubagentBody(props: {
|
||||
active: () => boolean
|
||||
theme: () => RunTheme
|
||||
detail: () => FooterSubagentDetail | undefined
|
||||
width: () => number
|
||||
diffStyle?: RunDiffStyle
|
||||
onCycle: (dir: -1 | 1) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme())
|
||||
const footer = createMemo(() => theme().footer)
|
||||
const commits = createMemo(() => props.detail()?.commits ?? [])
|
||||
const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
|
||||
const scrollbar = createMemo(() => ({
|
||||
trackOptions: {
|
||||
backgroundColor: footer().surface,
|
||||
foregroundColor: footer().line,
|
||||
},
|
||||
}))
|
||||
const rows = indexArray(commits, (commit, index) => (
|
||||
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
|
||||
<RunEntryContent commit={commit()} theme={theme()} opts={opts()} width={props.width()} />
|
||||
</box>
|
||||
))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (!props.active()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "tab" && !event.shift) {
|
||||
event.preventDefault()
|
||||
props.onCycle(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "up" || event.name === "k") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(1)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-subagent"
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
backgroundColor={footer().surface}
|
||||
>
|
||||
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
verticalScrollbarOptions={scrollbar()}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
{commits().length > 0 ? (
|
||||
rows()
|
||||
) : (
|
||||
<text fg={footer().muted} wrapMode="word">
|
||||
No subagent activity yet
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
705
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
705
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
@@ -0,0 +1,705 @@
|
||||
// RunFooter -- the mutable control surface for direct interactive mode.
|
||||
//
|
||||
// In the split-footer architecture, scrollback is immutable (append-only)
|
||||
// and the footer is the only region that can repaint. RunFooter owns both
|
||||
// sides of that boundary:
|
||||
//
|
||||
// Scrollback: append() queues StreamCommit entries and flush() drains them
|
||||
// through retained scrollback surfaces. Commits coalesce in a microtask
|
||||
// queue so direct-mode transcript updates still preserve ordering without
|
||||
// rebuilding the session model.
|
||||
//
|
||||
// Footer: event() updates the SolidJS signal-backed FooterState, which
|
||||
// drives the reactive footer view (prompt, status, permission, question).
|
||||
// present() swaps the active footer view and resizes the footer region.
|
||||
//
|
||||
// Lifecycle:
|
||||
// - close() flushes pending commits and notifies listeners (the prompt
|
||||
// queue uses this to know when to stop).
|
||||
// - destroy() does the same plus tears down event listeners and clears
|
||||
// internal state.
|
||||
// - The renderer's DESTROY event triggers destroy() so the footer
|
||||
// doesn't outlive the renderer.
|
||||
//
|
||||
// Interrupt and exit use a two-press pattern: first press shows a hint,
|
||||
// second press within 5 seconds actually fires the action.
|
||||
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { withRunSpan } from "./otel"
|
||||
import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
|
||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
import { RunFooterView } from "./footer.view"
|
||||
import { RunScrollbackStream } from "./scrollback.surface"
|
||||
import type { RunTheme } from "./theme"
|
||||
import type {
|
||||
RunAgent,
|
||||
FooterApi,
|
||||
FooterEvent,
|
||||
FooterKeybinds,
|
||||
FooterPatch,
|
||||
FooterPromptRoute,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterView,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunDiffStyle,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type RunFooterOptions = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
wrote?: boolean
|
||||
sessionID: () => string | undefined
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
first: boolean
|
||||
history?: RunPrompt[]
|
||||
theme: RunTheme
|
||||
keybinds: FooterKeybinds
|
||||
diffStyle: RunDiffStyle
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onInterrupt?: () => void
|
||||
onExit?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
treeSitterClient?: TreeSitterClient
|
||||
}
|
||||
|
||||
const PERMISSION_ROWS = 12
|
||||
const QUESTION_ROWS = 14
|
||||
|
||||
function createEmptySubagentState(): FooterSubagentState {
|
||||
return {
|
||||
tabs: [],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
}
|
||||
}
|
||||
|
||||
function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
||||
if (next.type === "queue") {
|
||||
return { queue: next.queue }
|
||||
}
|
||||
|
||||
if (next.type === "first") {
|
||||
return { first: next.first }
|
||||
}
|
||||
|
||||
if (next.type === "model") {
|
||||
return { model: next.model }
|
||||
}
|
||||
|
||||
if (next.type === "turn.send") {
|
||||
return {
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: next.queue,
|
||||
}
|
||||
}
|
||||
|
||||
if (next.type === "turn.wait") {
|
||||
return {
|
||||
phase: "running",
|
||||
status: "waiting for assistant",
|
||||
}
|
||||
}
|
||||
|
||||
if (next.type === "turn.idle") {
|
||||
return {
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: next.queue,
|
||||
}
|
||||
}
|
||||
|
||||
if (next.type === "turn.duration") {
|
||||
return { duration: next.duration }
|
||||
}
|
||||
|
||||
if (next.type === "stream.patch") {
|
||||
return next.patch
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export class RunFooter implements FooterApi {
|
||||
private closed = false
|
||||
private destroyed = false
|
||||
private prompts = new Set<(input: RunPrompt) => void>()
|
||||
private closes = new Set<() => void>()
|
||||
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
|
||||
private queue: StreamCommit[] = []
|
||||
private pending = false
|
||||
private flushing: Promise<void> = Promise.resolve()
|
||||
// Fixed portion of footer height above the textarea.
|
||||
private base: number
|
||||
private rows = TEXTAREA_MIN_ROWS
|
||||
private agents: Accessor<RunAgent[]>
|
||||
private setAgents: Setter<RunAgent[]>
|
||||
private resources: Accessor<RunResource[]>
|
||||
private setResources: Setter<RunResource[]>
|
||||
private state: Accessor<FooterState>
|
||||
private setState: Setter<FooterState>
|
||||
private view: Accessor<FooterView>
|
||||
private setView: Setter<FooterView>
|
||||
private subagent: Accessor<FooterSubagentState>
|
||||
private setSubagent: (next: FooterSubagentState) => void
|
||||
private promptRoute: FooterPromptRoute = { type: "composer" }
|
||||
private tabsVisible = false
|
||||
private interruptTimeout: NodeJS.Timeout | undefined
|
||||
private exitTimeout: NodeJS.Timeout | undefined
|
||||
private interruptHint: string
|
||||
private scrollback: RunScrollbackStream
|
||||
|
||||
constructor(
|
||||
private renderer: CliRenderer,
|
||||
private options: RunFooterOptions,
|
||||
) {
|
||||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: options.modelLabel,
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: options.first,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
this.state = state
|
||||
this.setState = setState
|
||||
const [view, setView] = createSignal<FooterView>({ type: "prompt" })
|
||||
this.view = view
|
||||
this.setView = setView
|
||||
const [agents, setAgents] = createSignal(options.agents)
|
||||
this.agents = agents
|
||||
this.setAgents = setAgents
|
||||
const [resources, setResources] = createSignal(options.resources)
|
||||
this.resources = resources
|
||||
this.setResources = setResources
|
||||
const [subagent, setSubagent] = createStore<FooterSubagentState>(createEmptySubagentState())
|
||||
this.subagent = () => subagent
|
||||
this.setSubagent = (next) => {
|
||||
setSubagent("tabs", reconcile(next.tabs, { key: "sessionID" }))
|
||||
setSubagent("details", reconcile(next.details))
|
||||
setSubagent("permissions", reconcile(next.permissions, { key: "id" }))
|
||||
setSubagent("questions", reconcile(next.questions, { key: "id" }))
|
||||
}
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
|
||||
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
|
||||
diffStyle: options.diffStyle,
|
||||
wrote: options.wrote,
|
||||
sessionID: options.sessionID,
|
||||
treeSitterClient: options.treeSitterClient,
|
||||
})
|
||||
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
|
||||
void render(
|
||||
() =>
|
||||
createComponent(RunFooterView, {
|
||||
directory: options.directory,
|
||||
state: this.state,
|
||||
view: this.view,
|
||||
subagent: this.subagent,
|
||||
findFiles: options.findFiles,
|
||||
agents: this.agents,
|
||||
resources: this.resources,
|
||||
theme: options.theme,
|
||||
diffStyle: options.diffStyle,
|
||||
keybinds: options.keybinds,
|
||||
history: options.history,
|
||||
agent: options.agentLabel,
|
||||
onSubmit: this.handlePrompt,
|
||||
onPermissionReply: this.handlePermissionReply,
|
||||
onQuestionReply: this.handleQuestionReply,
|
||||
onQuestionReject: this.handleQuestionReject,
|
||||
onCycle: this.handleCycle,
|
||||
onInterrupt: this.handleInterrupt,
|
||||
onExitRequest: this.handleExit,
|
||||
onExit: () => this.close(),
|
||||
onRows: this.syncRows,
|
||||
onLayout: this.syncLayout,
|
||||
onStatus: this.setStatus,
|
||||
onSubagentSelect: options.onSubagentSelect,
|
||||
}),
|
||||
this.renderer,
|
||||
).catch(() => {
|
||||
if (!this.isGone) {
|
||||
this.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public get isClosed(): boolean {
|
||||
return this.closed || this.isGone
|
||||
}
|
||||
|
||||
private get isGone(): boolean {
|
||||
return this.destroyed || this.renderer.isDestroyed
|
||||
}
|
||||
|
||||
public onPrompt(fn: (input: RunPrompt) => void): () => void {
|
||||
this.prompts.add(fn)
|
||||
return () => {
|
||||
this.prompts.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
public onClose(fn: () => void): () => void {
|
||||
if (this.isClosed) {
|
||||
fn()
|
||||
return () => {}
|
||||
}
|
||||
|
||||
this.closes.add(fn)
|
||||
return () => {
|
||||
this.closes.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
public event(next: FooterEvent): void {
|
||||
if (next.type === "catalog") {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setAgents(next.agents)
|
||||
this.setResources(next.resources)
|
||||
return
|
||||
}
|
||||
|
||||
const patch = eventPatch(next)
|
||||
if (patch) {
|
||||
this.patch(patch)
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "stream.subagent") {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setSubagent(next.state)
|
||||
this.applyHeight()
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "stream.view") {
|
||||
this.present(next.view)
|
||||
}
|
||||
}
|
||||
|
||||
private patch(next: FooterPatch): void {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
const prev = this.state()
|
||||
const state = {
|
||||
phase: next.phase ?? prev.phase,
|
||||
status: typeof next.status === "string" ? next.status : prev.status,
|
||||
queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
|
||||
model: typeof next.model === "string" ? next.model : prev.model,
|
||||
duration: typeof next.duration === "string" ? next.duration : prev.duration,
|
||||
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
||||
first: typeof next.first === "boolean" ? next.first : prev.first,
|
||||
interrupt:
|
||||
typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
|
||||
? Math.max(0, Math.floor(next.interrupt))
|
||||
: prev.interrupt,
|
||||
exit:
|
||||
typeof next.exit === "number" && Number.isFinite(next.exit) ? Math.max(0, Math.floor(next.exit)) : prev.exit,
|
||||
}
|
||||
|
||||
if (state.phase === "idle") {
|
||||
state.interrupt = 0
|
||||
}
|
||||
|
||||
this.setState(state)
|
||||
|
||||
if (prev.phase === "running" && state.phase === "idle") {
|
||||
this.flush()
|
||||
this.completeScrollback()
|
||||
}
|
||||
}
|
||||
|
||||
private completeScrollback(): void {
|
||||
const phase = this.state().phase
|
||||
this.flushing = this.flushing
|
||||
.then(() =>
|
||||
withRunSpan(
|
||||
"RunFooter.completeScrollback",
|
||||
{
|
||||
"opencode.footer.phase": phase,
|
||||
"session.id": this.options.sessionID() || undefined,
|
||||
},
|
||||
async () => {
|
||||
await this.scrollback.complete()
|
||||
},
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
private present(view: FooterView): void {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setView(view)
|
||||
this.applyHeight()
|
||||
}
|
||||
|
||||
// Queues a scrollback commit. Consecutive progress chunks for the same
|
||||
// part coalesce by appending text, reducing the number of retained-surface
|
||||
// updates. Actual flush happens on the next microtask, so a burst of events
|
||||
// from one reducer pass becomes a single ordered drain.
|
||||
public append(commit: StreamCommit): void {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
const last = this.queue.at(-1)
|
||||
if (
|
||||
last &&
|
||||
last.phase === "progress" &&
|
||||
commit.phase === "progress" &&
|
||||
last.kind === commit.kind &&
|
||||
last.source === commit.source &&
|
||||
last.partID === commit.partID &&
|
||||
last.tool === commit.tool
|
||||
) {
|
||||
last.text += commit.text
|
||||
} else {
|
||||
this.queue.push(commit)
|
||||
}
|
||||
|
||||
if (this.pending) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pending = true
|
||||
queueMicrotask(() => {
|
||||
this.pending = false
|
||||
this.flush()
|
||||
})
|
||||
}
|
||||
|
||||
public idle(): Promise<void> {
|
||||
if (this.isGone) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.flush()
|
||||
if (this.state().phase === "idle") {
|
||||
this.completeScrollback()
|
||||
}
|
||||
|
||||
return this.flushing.then(async () => {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.queue.length > 0) {
|
||||
return this.idle()
|
||||
}
|
||||
|
||||
await this.renderer.idle().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flush()
|
||||
this.notifyClose()
|
||||
}
|
||||
|
||||
public requestExit(): boolean {
|
||||
return this.handleExit()
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.handleDestroy()
|
||||
}
|
||||
|
||||
private notifyClose(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.closed = true
|
||||
for (const fn of [...this.closes]) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus = (status: string): void => {
|
||||
this.patch({ status })
|
||||
}
|
||||
|
||||
// Resizes the footer to fit the current view. Permission and question views
|
||||
// get fixed extra rows; the prompt view scales with textarea line count.
|
||||
private applyHeight(): void {
|
||||
const type = this.view().type
|
||||
const tabs = this.tabsVisible ? SUBAGENT_TAB_ROWS : 0
|
||||
const height =
|
||||
type === "permission"
|
||||
? this.base + PERMISSION_ROWS
|
||||
: type === "question"
|
||||
? this.base + QUESTION_ROWS
|
||||
: this.promptRoute.type === "subagent"
|
||||
? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
|
||||
: Math.max(
|
||||
this.base + TEXTAREA_MIN_ROWS,
|
||||
Math.min(this.base + tabs + PROMPT_MAX_ROWS, this.base + tabs + this.rows),
|
||||
)
|
||||
|
||||
if (height !== this.renderer.footerHeight) {
|
||||
this.renderer.footerHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
private syncRows = (value: number): void => {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
|
||||
if (rows === this.rows) {
|
||||
return
|
||||
}
|
||||
|
||||
this.rows = rows
|
||||
if (this.view().type === "prompt") {
|
||||
this.applyHeight()
|
||||
}
|
||||
}
|
||||
|
||||
private syncLayout = (next: { route: FooterPromptRoute; tabs: boolean }): void => {
|
||||
this.promptRoute = next.route
|
||||
this.tabsVisible = next.tabs
|
||||
if (this.view().type === "prompt") {
|
||||
this.applyHeight()
|
||||
}
|
||||
}
|
||||
|
||||
private handlePrompt = (input: RunPrompt): boolean => {
|
||||
if (this.isClosed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.state().first) {
|
||||
this.patch({ first: false })
|
||||
}
|
||||
|
||||
if (this.prompts.size === 0) {
|
||||
this.patch({ status: "input queue unavailable" })
|
||||
return false
|
||||
}
|
||||
|
||||
for (const fn of [...this.prompts]) {
|
||||
fn(input)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private handlePermissionReply = async (input: PermissionReply): Promise<void> => {
|
||||
if (this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.options.onPermissionReply(input)
|
||||
}
|
||||
|
||||
private handleQuestionReply = async (input: QuestionReply): Promise<void> => {
|
||||
if (this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.options.onQuestionReply(input)
|
||||
}
|
||||
|
||||
private handleQuestionReject = async (input: QuestionReject): Promise<void> => {
|
||||
if (this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.options.onQuestionReject(input)
|
||||
}
|
||||
|
||||
private handleCycle = (): void => {
|
||||
const result = this.options.onCycleVariant?.()
|
||||
if (!result) {
|
||||
this.patch({ status: "no variants available" })
|
||||
return
|
||||
}
|
||||
|
||||
const patch: FooterPatch = {
|
||||
status: result.status ?? "variant updated",
|
||||
}
|
||||
|
||||
if (result.modelLabel) {
|
||||
patch.model = result.modelLabel
|
||||
}
|
||||
|
||||
this.patch(patch)
|
||||
}
|
||||
|
||||
private clearInterruptTimer(): void {
|
||||
if (!this.interruptTimeout) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.interruptTimeout)
|
||||
this.interruptTimeout = undefined
|
||||
}
|
||||
|
||||
private armInterruptTimer(): void {
|
||||
this.clearInterruptTimer()
|
||||
this.interruptTimeout = setTimeout(() => {
|
||||
this.interruptTimeout = undefined
|
||||
if (this.isGone || this.state().phase !== "running") {
|
||||
return
|
||||
}
|
||||
|
||||
this.patch({ interrupt: 0 })
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
private clearExitTimer(): void {
|
||||
if (!this.exitTimeout) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.exitTimeout)
|
||||
this.exitTimeout = undefined
|
||||
}
|
||||
|
||||
private armExitTimer(): void {
|
||||
this.clearExitTimer()
|
||||
this.exitTimeout = setTimeout(() => {
|
||||
this.exitTimeout = undefined
|
||||
if (this.isGone || this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.patch({ exit: 0 })
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Two-press interrupt: first press shows a hint ("esc again to interrupt"),
|
||||
// second press within 5 seconds fires onInterrupt. The timer resets the
|
||||
// counter if the user doesn't follow through.
|
||||
private handleInterrupt = (): boolean => {
|
||||
if (this.isClosed || this.state().phase !== "running") {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = this.state().interrupt + 1
|
||||
this.patch({ interrupt: next })
|
||||
|
||||
if (next < 2) {
|
||||
this.armInterruptTimer()
|
||||
this.patch({ status: `${this.interruptHint} again to interrupt` })
|
||||
return true
|
||||
}
|
||||
|
||||
this.clearInterruptTimer()
|
||||
this.patch({ interrupt: 0, status: "interrupting" })
|
||||
this.options.onInterrupt?.()
|
||||
return true
|
||||
}
|
||||
|
||||
private handleExit = (): boolean => {
|
||||
if (this.isClosed) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.clearInterruptTimer()
|
||||
const next = this.state().exit + 1
|
||||
this.patch({ exit: next, interrupt: 0 })
|
||||
|
||||
if (next < 2) {
|
||||
this.armExitTimer()
|
||||
this.patch({ status: "Press Ctrl-c again to exit" })
|
||||
return true
|
||||
}
|
||||
|
||||
this.clearExitTimer()
|
||||
this.patch({ exit: 0, status: "exiting" })
|
||||
this.close()
|
||||
this.options.onExit?.()
|
||||
return true
|
||||
}
|
||||
|
||||
private handleDestroy = (): void => {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flush()
|
||||
this.destroyed = true
|
||||
this.notifyClose()
|
||||
this.clearInterruptTimer()
|
||||
this.clearExitTimer()
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.prompts.clear()
|
||||
this.closes.clear()
|
||||
this.scrollback.destroy()
|
||||
}
|
||||
|
||||
// Drains the commit queue to scrollback. The surface manager owns grouping,
|
||||
// spacing, and progressive markdown/code settling so direct mode can append
|
||||
// immutable transcript rows without rewriting history.
|
||||
private flush(): void {
|
||||
if (this.isGone || this.queue.length === 0) {
|
||||
this.queue.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
const batch = this.queue.splice(0)
|
||||
const phase = this.state().phase
|
||||
this.flushing = this.flushing
|
||||
.then(() =>
|
||||
withRunSpan(
|
||||
"RunFooter.flush",
|
||||
{
|
||||
"opencode.batch.commits": batch.length,
|
||||
"opencode.footer.phase": phase,
|
||||
"session.id": this.options.sessionID() || undefined,
|
||||
},
|
||||
async () => {
|
||||
for (const item of batch) {
|
||||
await this.scrollback.append(item)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
516
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
516
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
// Top-level footer layout for direct interactive mode.
|
||||
//
|
||||
// Renders the footer region as a vertical stack:
|
||||
// 1. Spacer row (visual separation from scrollback)
|
||||
// 2. Composer frame with left-border accent -- swaps between prompt,
|
||||
// permission, and question bodies via Switch/Match
|
||||
// 3. Meta row showing agent name and model label
|
||||
// 4. Bottom border + status row (spinner, interrupt hint, duration, usage)
|
||||
//
|
||||
// All state comes from the parent RunFooter through SolidJS signals.
|
||||
// The view itself is stateless except for derived memos.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||
import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
|
||||
import { RunPromptAutocomplete, RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
||||
import { RunPermissionBody } from "./footer.permission"
|
||||
import { RunQuestionBody } from "./footer.question"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
import type {
|
||||
FooterKeybinds,
|
||||
FooterPromptRoute,
|
||||
RunAgent,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterView,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunDiffStyle,
|
||||
} from "./types"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
type RunFooterViewProps = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: () => RunAgent[]
|
||||
resources: () => RunResource[]
|
||||
state: () => FooterState
|
||||
view?: () => FooterView
|
||||
subagent?: () => FooterSubagentState
|
||||
theme?: RunTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
keybinds: FooterKeybinds
|
||||
history?: RunPrompt[]
|
||||
agent: string
|
||||
onSubmit: (input: RunPrompt) => boolean
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onRows: (rows: number) => void
|
||||
onLayout: (input: { route: FooterPromptRoute; tabs: boolean }) => void
|
||||
onStatus: (text: string) => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
}
|
||||
|
||||
function subagentShortcut(event: {
|
||||
name: string
|
||||
ctrl?: boolean
|
||||
meta?: boolean
|
||||
shift?: boolean
|
||||
super?: boolean
|
||||
}): number | undefined {
|
||||
if (!event.ctrl || event.meta || event.super) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!/^[0-9]$/.test(event.name)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const slot = Number(event.name)
|
||||
return slot === 0 ? 9 : slot - 1
|
||||
}
|
||||
|
||||
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
|
||||
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
const term = useTerminalDimensions()
|
||||
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
|
||||
const subagent = createMemo<FooterSubagentState>(() => {
|
||||
return (
|
||||
props.subagent?.() ?? {
|
||||
tabs: [],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
|
||||
const selected = createMemo(() => {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? current.sessionID : undefined
|
||||
})
|
||||
const tabs = createMemo(() => subagent().tabs)
|
||||
const showTabs = createMemo(() => active().type === "prompt" && tabs().length > 0)
|
||||
const detail = createMemo(() => {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||
})
|
||||
const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
|
||||
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
|
||||
const hints = createMemo(() => hintFlags(term().width))
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const queue = createMemo(() => props.state().queue)
|
||||
const duration = createMemo(() => props.state().duration)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const interruptKey = createMemo(() => interrupt() || "/exit")
|
||||
const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const theme = createMemo(() => runTheme().footer)
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const spin = createMemo(() => {
|
||||
return {
|
||||
frames: createFrames({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
color: createColors({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
}
|
||||
})
|
||||
const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
|
||||
const view = active()
|
||||
return view.type === "permission" ? view : undefined
|
||||
})
|
||||
const question = createMemo<Extract<FooterView, { type: "question" }> | undefined>(() => {
|
||||
const view = active()
|
||||
return view.type === "question" ? view : undefined
|
||||
})
|
||||
const promptView = createMemo(() => {
|
||||
if (active().type !== "prompt") {
|
||||
return active().type
|
||||
}
|
||||
|
||||
return route().type === "composer" ? "prompt" : "subagent"
|
||||
})
|
||||
|
||||
const openTab = (sessionID: string) => {
|
||||
setRoute({ type: "subagent", sessionID })
|
||||
props.onSubagentSelect?.(sessionID)
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
setRoute({ type: "composer" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const toggleTab = (sessionID: string) => {
|
||||
const current = route()
|
||||
if (current.type === "subagent" && current.sessionID === sessionID) {
|
||||
closeTab()
|
||||
return
|
||||
}
|
||||
|
||||
openTab(sessionID)
|
||||
}
|
||||
|
||||
const cycleTab = (dir: -1 | 1) => {
|
||||
if (tabs().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const routeState = route()
|
||||
const current =
|
||||
routeState.type === "subagent" ? tabs().findIndex((item) => item.sessionID === routeState.sessionID) : -1
|
||||
const index = current === -1 ? 0 : (current + dir + tabs().length) % tabs().length
|
||||
const next = tabs()[index]
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
openTab(next.sessionID)
|
||||
}
|
||||
const composer = createPromptState({
|
||||
directory: props.directory,
|
||||
findFiles: props.findFiles,
|
||||
agents: props.agents,
|
||||
resources: props.resources,
|
||||
keybinds: props.keybinds,
|
||||
state: props.state,
|
||||
view: promptView,
|
||||
prompt,
|
||||
width: () => term().width,
|
||||
theme,
|
||||
history: props.history,
|
||||
onSubmit: props.onSubmit,
|
||||
onCycle: props.onCycle,
|
||||
onInterrupt: props.onInterrupt,
|
||||
onExitRequest: props.onExitRequest,
|
||||
onExit: props.onExit,
|
||||
onRows: props.onRows,
|
||||
onStatus: props.onStatus,
|
||||
})
|
||||
const menu = createMemo(() => prompt() && composer.visible())
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (active().type !== "prompt") {
|
||||
return
|
||||
}
|
||||
|
||||
const slot = subagentShortcut(event)
|
||||
if (slot !== undefined) {
|
||||
const next = tabs()[slot]
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
toggleTab(next.sessionID)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = route()
|
||||
if (current.type === "composer") {
|
||||
return
|
||||
}
|
||||
|
||||
if (tabs().some((item) => item.sessionID === current.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeTab()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onLayout({
|
||||
route: route(),
|
||||
tabs: tabs().length > 0,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-shell"
|
||||
width="100%"
|
||||
height="100%"
|
||||
border={false}
|
||||
backgroundColor="transparent"
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
padding={0}
|
||||
>
|
||||
<box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
|
||||
|
||||
<Show when={showTabs()}>
|
||||
<RunFooterSubagentTabs tabs={tabs()} selected={selected()} theme={theme()} width={term().width} />
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={inspecting()}
|
||||
fallback={
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
<box
|
||||
id="run-direct-footer-composer-frame"
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
bottomLeft: "╹",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-composer-area"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
paddingLeft={0}
|
||||
paddingRight={0}
|
||||
paddingTop={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme().surface}
|
||||
gap={0}
|
||||
>
|
||||
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
|
||||
<Switch>
|
||||
<Match when={active().type === "prompt" && route().type === "composer"}>
|
||||
<RunPromptBody
|
||||
theme={theme}
|
||||
placeholder={composer.placeholder}
|
||||
bindings={composer.bindings}
|
||||
onSubmit={composer.onSubmit}
|
||||
onKeyDown={composer.onKeyDown}
|
||||
onContentChange={composer.onContentChange}
|
||||
bind={composer.bind}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
<RunPermissionBody
|
||||
request={permission()!.request}
|
||||
theme={theme()}
|
||||
block={block()}
|
||||
diffStyle={props.diffStyle}
|
||||
onReply={props.onPermissionReply}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "question"}>
|
||||
<RunQuestionBody
|
||||
request={question()!.request}
|
||||
theme={theme()}
|
||||
onReply={props.onQuestionReply}
|
||||
onReject={props.onQuestionReject}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-meta-row"
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
>
|
||||
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.agent}
|
||||
</text>
|
||||
<text
|
||||
id="run-direct-footer-model"
|
||||
fg={theme().text}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
{props.state().model}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-line-6"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
backgroundColor="transparent"
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "╹",
|
||||
}}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-line-6-fill"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={theme().surface}
|
||||
backgroundColor={menu() ? theme().shade : "transparent"}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
horizontal: "▀",
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={menu()}
|
||||
fallback={
|
||||
<box
|
||||
id="run-direct-footer-row"
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show when={busy() || exiting()}>
|
||||
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={exiting()}>
|
||||
<text
|
||||
id="run-direct-footer-hint-exit"
|
||||
fg={theme().highlight}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
marginLeft={1}
|
||||
>
|
||||
Press Ctrl-c again to exit
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<Show when={busy() && !exiting()}>
|
||||
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
|
||||
<text
|
||||
id="run-direct-footer-hint-interrupt"
|
||||
fg={armed() ? theme().highlight : theme().text}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{interruptKey()}{" "}
|
||||
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
|
||||
{armed() ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!busy() && !exiting() && duration().length > 0}>
|
||||
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
|
||||
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
|
||||
▣
|
||||
</text>
|
||||
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
|
||||
·
|
||||
</text>
|
||||
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
|
||||
{duration()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
|
||||
<box
|
||||
id="run-direct-footer-hint-group"
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
flexShrink={0}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Show when={queue() > 0}>
|
||||
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
|
||||
{queue()} queued
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={usage().length > 0}>
|
||||
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
|
||||
{usage()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={variant().length > 0 && hints().variant}>
|
||||
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
|
||||
{variant()} variant
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-subagent-frame"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
}}
|
||||
>
|
||||
<RunFooterSubagentBody
|
||||
active={inspecting}
|
||||
theme={runTheme}
|
||||
detail={detail}
|
||||
width={() => term().width}
|
||||
diffStyle={props.diffStyle}
|
||||
onCycle={cycleTab}
|
||||
onClose={closeTab}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
119
packages/opencode/src/cli/cmd/run/otel.ts
Normal file
119
packages/opencode/src/cli/cmd/run/otel.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { INVALID_SPAN_CONTEXT, context, trace, SpanStatusCode, type Span } from "@opentelemetry/api"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { Observability } from "@opencode-ai/core/effect/observability"
|
||||
|
||||
type AttributeValue = string | number | boolean | undefined
|
||||
|
||||
export type RunSpanAttributes = Record<string, AttributeValue>
|
||||
|
||||
const noop = trace.wrapSpanContext(INVALID_SPAN_CONTEXT)
|
||||
const tracer = trace.getTracer("opencode.run")
|
||||
const runtime = ManagedRuntime.make(Observability.layer, { memoMap })
|
||||
let ready: Promise<void> | undefined
|
||||
|
||||
function attributes(input?: RunSpanAttributes): Record<string, string | number | boolean> | undefined {
|
||||
if (!input) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const out = Object.entries(input).flatMap(([key, value]) => (value === undefined ? [] : [[key, value] as const]))
|
||||
if (out.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Object.fromEntries(out)
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
if (typeof error === "string") {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function ensure() {
|
||||
if (!Observability.enabled) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (ready) {
|
||||
return ready
|
||||
}
|
||||
|
||||
ready = runtime.runPromise(Effect.void).then(
|
||||
() => undefined,
|
||||
(error) => {
|
||||
ready = undefined
|
||||
throw error
|
||||
},
|
||||
)
|
||||
return ready
|
||||
}
|
||||
|
||||
function finish<A>(span: Span, out: Promise<A>) {
|
||||
return out.then(
|
||||
(value) => {
|
||||
span.end()
|
||||
return value
|
||||
},
|
||||
(error) => {
|
||||
recordRunSpanError(span, error)
|
||||
span.end()
|
||||
throw error
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function setRunSpanAttributes(span: Span, input?: RunSpanAttributes): void {
|
||||
const next = attributes(input)
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
span.setAttributes(next)
|
||||
}
|
||||
|
||||
export function recordRunSpanError(span: Span, error: unknown): void {
|
||||
const next = message(error)
|
||||
span.recordException(error instanceof Error ? error : next)
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: next,
|
||||
})
|
||||
}
|
||||
|
||||
export function withRunSpan<A>(
|
||||
name: string,
|
||||
input: RunSpanAttributes | undefined,
|
||||
fn: (span: Span) => Promise<A> | A,
|
||||
): A | Promise<A> {
|
||||
if (!Observability.enabled) {
|
||||
return fn(noop)
|
||||
}
|
||||
|
||||
return ensure().then(
|
||||
() => {
|
||||
const span = tracer.startSpan(name, {
|
||||
attributes: attributes(input),
|
||||
})
|
||||
|
||||
return context.with(
|
||||
trace.setSpan(context.active(), span),
|
||||
() =>
|
||||
finish(
|
||||
span,
|
||||
new Promise<A>((resolve) => {
|
||||
resolve(fn(span))
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
() => fn(noop),
|
||||
)
|
||||
}
|
||||
256
packages/opencode/src/cli/cmd/run/permission.shared.ts
Normal file
256
packages/opencode/src/cli/cmd/run/permission.shared.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// Pure state machine for the permission UI.
|
||||
//
|
||||
// Lives outside the JSX component so it can be tested independently. The
|
||||
// machine has three stages:
|
||||
//
|
||||
// permission → initial view with Allow once / Always / Reject options
|
||||
// always → confirmation step (Confirm / Cancel)
|
||||
// reject → text input for rejection message
|
||||
//
|
||||
// permissionRun() is the main transition: given the current state and the
|
||||
// selected option, it returns a new state and optionally a PermissionReply
|
||||
// to send to the SDK. The component calls this on enter/click.
|
||||
//
|
||||
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
||||
// the request, delegating to tool.ts for tool-specific formatting.
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionReply } from "./types"
|
||||
import { toolPath, toolPermissionInfo } from "./tool"
|
||||
|
||||
type Dict = Record<string, unknown>
|
||||
|
||||
export type PermissionStage = "permission" | "always" | "reject"
|
||||
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
|
||||
|
||||
export type PermissionBodyState = {
|
||||
requestID: string
|
||||
stage: PermissionStage
|
||||
selected: PermissionOption
|
||||
message: string
|
||||
submitting: boolean
|
||||
}
|
||||
|
||||
export type PermissionInfo = {
|
||||
icon: string
|
||||
title: string
|
||||
lines: string[]
|
||||
diff?: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type PermissionStep = {
|
||||
state: PermissionBodyState
|
||||
reply?: PermissionReply
|
||||
}
|
||||
|
||||
function dict(v: unknown): Dict {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { ...v }
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
||||
function data(request: PermissionRequest): Dict {
|
||||
const meta = dict(request.metadata)
|
||||
return {
|
||||
...meta,
|
||||
...dict(meta.input),
|
||||
}
|
||||
}
|
||||
|
||||
function patterns(request: PermissionRequest): string[] {
|
||||
return request.patterns.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function createPermissionBodyState(requestID: string): PermissionBodyState {
|
||||
return {
|
||||
requestID,
|
||||
stage: "permission",
|
||||
selected: "once",
|
||||
message: "",
|
||||
submitting: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
||||
if (stage === "permission") {
|
||||
return ["once", "always", "reject"]
|
||||
}
|
||||
|
||||
if (stage === "always") {
|
||||
return ["confirm", "cancel"]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
const pats = patterns(request)
|
||||
const input = data(request)
|
||||
const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats)
|
||||
if (info) {
|
||||
return info
|
||||
}
|
||||
|
||||
if (request.permission === "external_directory") {
|
||||
const meta = dict(request.metadata)
|
||||
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
|
||||
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${toolPath(dir, { home: true })}`,
|
||||
lines: pats.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
if (request.permission === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
lines: ["This keeps the session running despite repeated failures."],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${request.permission}`,
|
||||
lines: [`Tool: ${request.permission}`],
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionAlwaysLines(request: PermissionRequest): string[] {
|
||||
if (request.always.length === 1 && request.always[0] === "*") {
|
||||
return [`This will allow ${request.permission} until OpenCode is restarted.`]
|
||||
}
|
||||
|
||||
return [
|
||||
"This will allow the following patterns until OpenCode is restarted.",
|
||||
...request.always.map((item) => `- ${item}`),
|
||||
]
|
||||
}
|
||||
|
||||
export function permissionLabel(option: PermissionOption): string {
|
||||
if (option === "once") return "Allow once"
|
||||
if (option === "always") return "Allow always"
|
||||
if (option === "reject") return "Reject"
|
||||
if (option === "confirm") return "Confirm"
|
||||
return "Cancel"
|
||||
}
|
||||
|
||||
export function permissionReply(requestID: string, reply: PermissionReply["reply"], message?: string): PermissionReply {
|
||||
return {
|
||||
requestID,
|
||||
reply,
|
||||
...(message && message.trim() ? { message: message.trim() } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
|
||||
const list = permissionOptions(state.stage)
|
||||
if (list.length === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
const idx = Math.max(0, list.indexOf(state.selected))
|
||||
const selected = list[(idx + dir + list.length) % list.length]
|
||||
return {
|
||||
...state,
|
||||
selected,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionHover(state: PermissionBodyState, option: PermissionOption): PermissionBodyState {
|
||||
return {
|
||||
...state,
|
||||
selected: option,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionRun(state: PermissionBodyState, requestID: string, option: PermissionOption): PermissionStep {
|
||||
if (state.submitting) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (state.stage === "permission") {
|
||||
if (option === "always") {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
stage: "always",
|
||||
selected: "confirm",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (option === "reject") {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
stage: "reject",
|
||||
selected: "reject",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
reply: permissionReply(requestID, "once"),
|
||||
}
|
||||
}
|
||||
|
||||
if (state.stage !== "always") {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (option === "cancel") {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
stage: "permission",
|
||||
selected: "always",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
reply: permissionReply(requestID, "always"),
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionReject(state: PermissionBodyState, requestID: string): PermissionReply | undefined {
|
||||
if (state.submitting) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return permissionReply(requestID, "reject", state.message)
|
||||
}
|
||||
|
||||
export function permissionCancel(state: PermissionBodyState): PermissionBodyState {
|
||||
return {
|
||||
...state,
|
||||
stage: "permission",
|
||||
selected: "reject",
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionEscape(state: PermissionBodyState): PermissionBodyState {
|
||||
if (state.stage === "always") {
|
||||
return {
|
||||
...state,
|
||||
stage: "permission",
|
||||
selected: "always",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
stage: "reject",
|
||||
selected: "reject",
|
||||
}
|
||||
}
|
||||
271
packages/opencode/src/cli/cmd/run/prompt.shared.ts
Normal file
271
packages/opencode/src/cli/cmd/run/prompt.shared.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// Pure state machine for the prompt input.
|
||||
//
|
||||
// Handles keybind parsing, history ring navigation, and the leader-key
|
||||
// sequence for variant cycling. All functions are pure -- they take state
|
||||
// in and return new state out, with no side effects.
|
||||
//
|
||||
// The history ring (PromptHistoryState) stores past prompts and tracks
|
||||
// the current browse position. When the user arrows up at cursor offset 0,
|
||||
// the current draft is saved and history begins. Arrowing past the end
|
||||
// restores the draft.
|
||||
//
|
||||
// The leader-key cycle (promptCycle) uses a two-step pattern: first press
|
||||
// arms the leader, second press within the timeout fires the action.
|
||||
import type { KeyBinding } from "@opentui/core"
|
||||
import * as Keybind from "@/util/keybind"
|
||||
import type { FooterKeybinds, RunPrompt } from "./types"
|
||||
|
||||
const HISTORY_LIMIT = 200
|
||||
|
||||
export type PromptHistoryState = {
|
||||
items: RunPrompt[]
|
||||
index: number | null
|
||||
draft: string
|
||||
}
|
||||
|
||||
export type PromptKeys = {
|
||||
leaders: Keybind.Info[]
|
||||
cycles: Keybind.Info[]
|
||||
interrupts: Keybind.Info[]
|
||||
previous: Keybind.Info[]
|
||||
next: Keybind.Info[]
|
||||
bindings: KeyBinding[]
|
||||
}
|
||||
|
||||
export type PromptCycle = {
|
||||
arm: boolean
|
||||
clear: boolean
|
||||
cycle: boolean
|
||||
consume: boolean
|
||||
}
|
||||
|
||||
export type PromptMove = {
|
||||
state: PromptHistoryState
|
||||
text?: string
|
||||
cursor?: number
|
||||
apply: boolean
|
||||
}
|
||||
|
||||
export function promptCopy(prompt: RunPrompt): RunPrompt {
|
||||
return {
|
||||
text: prompt.text,
|
||||
parts: structuredClone(prompt.parts),
|
||||
}
|
||||
}
|
||||
|
||||
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
|
||||
return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
|
||||
}
|
||||
|
||||
function mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
|
||||
return Keybind.parse(binding).map((item) => ({
|
||||
name: item.name,
|
||||
ctrl: item.ctrl || undefined,
|
||||
meta: item.meta || undefined,
|
||||
shift: item.shift || undefined,
|
||||
super: item.super || undefined,
|
||||
action,
|
||||
}))
|
||||
}
|
||||
|
||||
function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
|
||||
return [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
...mapInputBindings(keybinds.inputSubmit, "submit"),
|
||||
...mapInputBindings(keybinds.inputNewline, "newline"),
|
||||
]
|
||||
}
|
||||
|
||||
export function promptKeys(keybinds: FooterKeybinds): PromptKeys {
|
||||
return {
|
||||
leaders: Keybind.parse(keybinds.leader),
|
||||
cycles: Keybind.parse(keybinds.variantCycle),
|
||||
interrupts: Keybind.parse(keybinds.interrupt),
|
||||
previous: Keybind.parse(keybinds.historyPrevious),
|
||||
next: Keybind.parse(keybinds.historyNext),
|
||||
bindings: textareaBindings(keybinds),
|
||||
}
|
||||
}
|
||||
|
||||
export function printableBinding(binding: string, leader: string): string {
|
||||
const first = Keybind.parse(binding).at(0)
|
||||
if (!first) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let text = Keybind.toString(first)
|
||||
const lead = Keybind.parse(leader).at(0)
|
||||
if (lead) {
|
||||
text = text.replace("<leader>", Keybind.toString(lead))
|
||||
}
|
||||
|
||||
return text.replace(/escape/g, "esc")
|
||||
}
|
||||
|
||||
export function isExitCommand(input: string): boolean {
|
||||
const text = input.trim().toLowerCase()
|
||||
return text === "/exit" || text === "/quit" || text === ":q"
|
||||
}
|
||||
|
||||
export function promptInfo(event: {
|
||||
name: string
|
||||
ctrl?: boolean
|
||||
meta?: boolean
|
||||
shift?: boolean
|
||||
super?: boolean
|
||||
}): Keybind.Info {
|
||||
return {
|
||||
name: event.name === " " ? "space" : event.name,
|
||||
ctrl: !!event.ctrl,
|
||||
meta: !!event.meta,
|
||||
shift: !!event.shift,
|
||||
super: !!event.super,
|
||||
leader: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function promptHit(bindings: Keybind.Info[], event: Keybind.Info): boolean {
|
||||
return bindings.some((item) => Keybind.match(item, event))
|
||||
}
|
||||
|
||||
export function promptCycle(
|
||||
armed: boolean,
|
||||
event: Keybind.Info,
|
||||
leaders: Keybind.Info[],
|
||||
cycles: Keybind.Info[],
|
||||
): PromptCycle {
|
||||
if (!armed && promptHit(leaders, event)) {
|
||||
return {
|
||||
arm: true,
|
||||
clear: false,
|
||||
cycle: false,
|
||||
consume: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (armed) {
|
||||
return {
|
||||
arm: false,
|
||||
clear: true,
|
||||
cycle: promptHit(cycles, { ...event, leader: true }),
|
||||
consume: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (!promptHit(cycles, event)) {
|
||||
return {
|
||||
arm: false,
|
||||
clear: false,
|
||||
cycle: false,
|
||||
consume: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
arm: false,
|
||||
clear: false,
|
||||
cycle: true,
|
||||
consume: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
||||
const next: RunPrompt[] = []
|
||||
for (const item of list) {
|
||||
if (next.length > 0 && promptSame(next[next.length - 1], item)) {
|
||||
continue
|
||||
}
|
||||
|
||||
next.push(item)
|
||||
}
|
||||
|
||||
return {
|
||||
items: next.slice(-HISTORY_LIMIT),
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
}
|
||||
|
||||
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
|
||||
if (!prompt.text.trim()) {
|
||||
return state
|
||||
}
|
||||
|
||||
const next = promptCopy(prompt)
|
||||
if (state.items[state.items.length - 1] && promptSame(state.items[state.items.length - 1], next)) {
|
||||
return {
|
||||
...state,
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
}
|
||||
|
||||
const items = [...state.items, next].slice(-HISTORY_LIMIT)
|
||||
return {
|
||||
...state,
|
||||
items,
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
}
|
||||
|
||||
export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptMove {
|
||||
if (state.items.length === 0) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (dir === -1 && cursor !== 0) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (dir === 1 && cursor !== text.length) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (state.index === null) {
|
||||
if (dir === 1) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
const idx = state.items.length - 1
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
index: idx,
|
||||
draft: text,
|
||||
},
|
||||
text: state.items[idx].text,
|
||||
cursor: 0,
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
|
||||
const idx = state.index + dir
|
||||
if (idx < 0) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (idx >= state.items.length) {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
index: null,
|
||||
},
|
||||
text: state.draft,
|
||||
cursor: state.draft.length,
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
index: idx,
|
||||
},
|
||||
text: state.items[idx].text,
|
||||
cursor: dir === -1 ? 0 : state.items[idx].text.length,
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
340
packages/opencode/src/cli/cmd/run/question.shared.ts
Normal file
340
packages/opencode/src/cli/cmd/run/question.shared.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
// Pure state machine for the question UI.
|
||||
//
|
||||
// Supports both single-question and multi-question flows. Single questions
|
||||
// submit immediately on selection. Multi-question flows use tabs and a
|
||||
// final confirmation step.
|
||||
//
|
||||
// State transitions:
|
||||
// questionSelect → picks an option (single: submits, multi: toggles/advances)
|
||||
// questionSave → saves custom text input
|
||||
// questionMove → arrow key navigation through options
|
||||
// questionSetTab → tab navigation between questions
|
||||
// questionSubmit → builds the final QuestionReply with all answers
|
||||
//
|
||||
// Custom answers: if a question has custom=true, an extra "Type your own
|
||||
// answer" option appears. Selecting it enters editing mode with a text field.
|
||||
import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
export type QuestionBodyState = {
|
||||
requestID: string
|
||||
tab: number
|
||||
answers: string[][]
|
||||
custom: string[]
|
||||
selected: number
|
||||
editing: boolean
|
||||
submitting: boolean
|
||||
}
|
||||
|
||||
export type QuestionStep = {
|
||||
state: QuestionBodyState
|
||||
reply?: QuestionReply
|
||||
}
|
||||
|
||||
export function createQuestionBodyState(requestID: string): QuestionBodyState {
|
||||
return {
|
||||
requestID,
|
||||
tab: 0,
|
||||
answers: [],
|
||||
custom: [],
|
||||
selected: 0,
|
||||
editing: false,
|
||||
submitting: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSync(state: QuestionBodyState, requestID: string): QuestionBodyState {
|
||||
if (state.requestID === requestID) {
|
||||
return state
|
||||
}
|
||||
|
||||
return createQuestionBodyState(requestID)
|
||||
}
|
||||
|
||||
export function questionSingle(request: QuestionRequest): boolean {
|
||||
return request.questions.length === 1 && request.questions[0]?.multiple !== true
|
||||
}
|
||||
|
||||
export function questionTabs(request: QuestionRequest): number {
|
||||
return questionSingle(request) ? 1 : request.questions.length + 1
|
||||
}
|
||||
|
||||
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
return !questionSingle(request) && state.tab === request.questions.length
|
||||
}
|
||||
|
||||
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
|
||||
return request.questions[state.tab]
|
||||
}
|
||||
|
||||
export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
return questionInfo(request, state)?.custom !== false
|
||||
}
|
||||
|
||||
export function questionInput(state: QuestionBodyState): string {
|
||||
return state.custom[state.tab] ?? ""
|
||||
}
|
||||
|
||||
export function questionPicked(state: QuestionBodyState): boolean {
|
||||
const value = questionInput(state)
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return state.answers[state.tab]?.includes(value) ?? false
|
||||
}
|
||||
|
||||
export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info || info.custom === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return state.selected === info.options.length
|
||||
}
|
||||
|
||||
export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return info.options.length + (questionCustom(request, state) ? 1 : 0)
|
||||
}
|
||||
|
||||
export function questionAnswers(state: QuestionBodyState, count: number): string[][] {
|
||||
return Array.from({ length: count }, (_, idx) => state.answers[idx] ?? [])
|
||||
}
|
||||
|
||||
export function questionSetTab(state: QuestionBodyState, tab: number): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
tab,
|
||||
selected: 0,
|
||||
editing: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSetSelected(state: QuestionBodyState, selected: number): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
selected,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSetEditing(state: QuestionBodyState, editing: boolean): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
editing,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSetSubmitting(state: QuestionBodyState, submitting: boolean): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
submitting,
|
||||
}
|
||||
}
|
||||
|
||||
function storeAnswers(state: QuestionBodyState, tab: number, list: string[]): QuestionBodyState {
|
||||
const answers = [...state.answers]
|
||||
answers[tab] = list
|
||||
return {
|
||||
...state,
|
||||
answers,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionStoreCustom(state: QuestionBodyState, tab: number, text: string): QuestionBodyState {
|
||||
const custom = [...state.custom]
|
||||
custom[tab] = text
|
||||
return {
|
||||
...state,
|
||||
custom,
|
||||
}
|
||||
}
|
||||
|
||||
function questionPick(
|
||||
state: QuestionBodyState,
|
||||
request: QuestionRequest,
|
||||
answer: string,
|
||||
custom = false,
|
||||
): QuestionStep {
|
||||
const answers = [...state.answers]
|
||||
answers[state.tab] = [answer]
|
||||
let next: QuestionBodyState = {
|
||||
...state,
|
||||
answers,
|
||||
editing: false,
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
const list = [...state.custom]
|
||||
list[state.tab] = answer
|
||||
next = {
|
||||
...next,
|
||||
custom: list,
|
||||
}
|
||||
}
|
||||
|
||||
if (questionSingle(request)) {
|
||||
return {
|
||||
state: next,
|
||||
reply: {
|
||||
requestID: request.id,
|
||||
answers: [[answer]],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: questionSetTab(next, state.tab + 1),
|
||||
}
|
||||
}
|
||||
|
||||
function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyState {
|
||||
const list = [...(state.answers[state.tab] ?? [])]
|
||||
const idx = list.indexOf(answer)
|
||||
if (idx === -1) {
|
||||
list.push(answer)
|
||||
} else {
|
||||
list.splice(idx, 1)
|
||||
}
|
||||
|
||||
return storeAnswers(state, state.tab, list)
|
||||
}
|
||||
|
||||
export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState {
|
||||
const total = questionTotal(request, state)
|
||||
if (total === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
selected: (state.selected + dir + total) % total,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (questionOther(request, state)) {
|
||||
if (!info.multiple) {
|
||||
return {
|
||||
state: questionSetEditing(state, true),
|
||||
}
|
||||
}
|
||||
|
||||
const value = questionInput(state)
|
||||
if (value && questionPicked(state)) {
|
||||
return {
|
||||
state: questionToggle(state, value),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: questionSetEditing(state, true),
|
||||
}
|
||||
}
|
||||
|
||||
const option = info.options[state.selected]
|
||||
if (!option) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (info.multiple) {
|
||||
return {
|
||||
state: questionToggle(state, option.label),
|
||||
}
|
||||
}
|
||||
|
||||
return questionPick(state, request, option.label)
|
||||
}
|
||||
|
||||
export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
const value = questionInput(state).trim()
|
||||
const prev = state.custom[state.tab]
|
||||
if (!value) {
|
||||
if (!prev) {
|
||||
return {
|
||||
state: questionSetEditing(state, false),
|
||||
}
|
||||
}
|
||||
|
||||
const next = questionStoreCustom(state, state.tab, "")
|
||||
return {
|
||||
state: questionSetEditing(
|
||||
storeAnswers(
|
||||
next,
|
||||
state.tab,
|
||||
(state.answers[state.tab] ?? []).filter((item) => item !== prev),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (info.multiple) {
|
||||
const answers = [...(state.answers[state.tab] ?? [])]
|
||||
if (prev) {
|
||||
const idx = answers.indexOf(prev)
|
||||
if (idx !== -1) {
|
||||
answers.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (!answers.includes(value)) {
|
||||
answers.push(value)
|
||||
}
|
||||
|
||||
const next = questionStoreCustom(state, state.tab, value)
|
||||
return {
|
||||
state: questionSetEditing(storeAnswers(next, state.tab, answers), false),
|
||||
}
|
||||
}
|
||||
|
||||
return questionPick(state, request, value, true)
|
||||
}
|
||||
|
||||
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply {
|
||||
return {
|
||||
requestID: request.id,
|
||||
answers: questionAnswers(state, request.questions.length),
|
||||
}
|
||||
}
|
||||
|
||||
export function questionReject(request: QuestionRequest): QuestionReject {
|
||||
return {
|
||||
requestID: request.id,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string {
|
||||
if (state.submitting) {
|
||||
return "Waiting for question event..."
|
||||
}
|
||||
|
||||
if (questionConfirm(request, state)) {
|
||||
return "enter submit esc dismiss"
|
||||
}
|
||||
|
||||
if (state.editing) {
|
||||
return "enter save esc cancel"
|
||||
}
|
||||
|
||||
const info = questionInfo(request, state)
|
||||
if (questionSingle(request)) {
|
||||
return `↑↓ select enter ${info?.multiple ? "toggle" : "submit"} esc dismiss`
|
||||
}
|
||||
|
||||
return `⇆ tab ↑↓ select enter ${info?.multiple ? "toggle" : "confirm"} esc dismiss`
|
||||
}
|
||||
202
packages/opencode/src/cli/cmd/run/runtime.boot.ts
Normal file
202
packages/opencode/src/cli/cmd/run/runtime.boot.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Boot-time resolution for direct interactive mode.
|
||||
//
|
||||
// These functions run concurrently at startup to gather everything the runtime
|
||||
// needs before the first frame: keybinds from TUI config, diff display style,
|
||||
// model variant list with context limits, and session history for the prompt
|
||||
// history ring. All are async because they read config or hit the SDK, but
|
||||
// none block each other.
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { reusePendingTask } from "./runtime.shared"
|
||||
import { resolveSession, sessionHistory } from "./session.shared"
|
||||
import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt } from "./types"
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
const DEFAULT_KEYBINDS: FooterKeybinds = {
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
variants: string[]
|
||||
limits: Record<string, number>
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
first: boolean
|
||||
history: RunPrompt[]
|
||||
variant: string | undefined
|
||||
}
|
||||
|
||||
type Config = Awaited<ReturnType<typeof TuiConfig.get>>
|
||||
type BootService = {
|
||||
readonly resolveModelInfo: (sdk: RunInput["sdk"], model: RunInput["model"]) => Effect.Effect<ModelInfo>
|
||||
readonly resolveSessionInfo: (
|
||||
sdk: RunInput["sdk"],
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
) => Effect.Effect<SessionInfo>
|
||||
readonly resolveFooterKeybinds: () => Effect.Effect<FooterKeybinds>
|
||||
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
|
||||
}
|
||||
|
||||
const configTask: { current?: Promise<Config> } = {}
|
||||
|
||||
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
|
||||
|
||||
function loadConfig() {
|
||||
return reusePendingTask(configTask, () => TuiConfig.get())
|
||||
}
|
||||
|
||||
function emptyModelInfo(): ModelInfo {
|
||||
return {
|
||||
variants: [],
|
||||
limits: {},
|
||||
}
|
||||
}
|
||||
|
||||
function emptySessionInfo(): SessionInfo {
|
||||
return {
|
||||
first: true,
|
||||
history: [],
|
||||
variant: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function footerKeybinds(config: Config | undefined): FooterKeybinds {
|
||||
const leader = config?.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader
|
||||
const cycle = config?.keybinds?.variant_cycle?.trim() || "ctrl+t"
|
||||
const interrupt = config?.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt
|
||||
const previous = config?.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious
|
||||
const next = config?.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext
|
||||
const submit = config?.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit
|
||||
const newline = config?.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline
|
||||
|
||||
const bindings = cycle
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
|
||||
if (!bindings.some((binding) => binding.toLowerCase() === "<leader>t")) {
|
||||
bindings.push("<leader>t")
|
||||
}
|
||||
|
||||
return {
|
||||
leader,
|
||||
variantCycle: bindings.join(","),
|
||||
interrupt,
|
||||
historyPrevious: previous,
|
||||
historyNext: next,
|
||||
inputSubmit: submit,
|
||||
inputNewline: newline,
|
||||
}
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = Effect.fn("RunBoot.config")(() =>
|
||||
Effect.promise(loadConfig).pipe(
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
|
||||
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (sdk: RunInput["sdk"], model: RunInput["model"]) {
|
||||
const providers = yield* Effect.promise(() => sdk.provider.list()).pipe(
|
||||
Effect.map((item) => item.data?.all ?? []),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
const limits = Object.fromEntries(
|
||||
providers.flatMap((provider) =>
|
||||
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
|
||||
const limit = info?.limit?.context
|
||||
if (typeof limit !== "number" || limit <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [[`${provider.id}/${modelID}`, limit] as const]
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
variants: [],
|
||||
limits,
|
||||
}
|
||||
}
|
||||
|
||||
const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]
|
||||
return {
|
||||
variants: Object.keys(info?.variants ?? {}),
|
||||
limits,
|
||||
}
|
||||
})
|
||||
|
||||
const resolveSessionInfo = Effect.fn("RunBoot.resolveSessionInfo")(function* (
|
||||
sdk: RunInput["sdk"],
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
) {
|
||||
const session = yield* Effect.promise(() => resolveSession(sdk, sessionID)).pipe(
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!session) {
|
||||
return emptySessionInfo()
|
||||
}
|
||||
|
||||
return {
|
||||
first: session.first,
|
||||
history: sessionHistory(session),
|
||||
variant: pickVariant(model, session),
|
||||
}
|
||||
})
|
||||
|
||||
const resolveFooterKeybinds = Effect.fn("RunBoot.resolveFooterKeybinds")(function* () {
|
||||
return footerKeybinds(yield* config())
|
||||
})
|
||||
|
||||
const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
|
||||
return (yield* config())?.diff_style ?? "auto"
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
resolveModelInfo,
|
||||
resolveSessionInfo,
|
||||
resolveFooterKeybinds,
|
||||
resolveDiffStyle,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const runtime = makeRuntime(Service, layer)
|
||||
|
||||
// Fetches available variants and context limits for every provider/model pair.
|
||||
export async function resolveModelInfo(sdk: RunInput["sdk"], model: RunInput["model"]): Promise<ModelInfo> {
|
||||
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, model)).catch(() => emptyModelInfo())
|
||||
}
|
||||
|
||||
// Fetches session messages to determine if this is the first turn and build prompt history.
|
||||
export async function resolveSessionInfo(
|
||||
sdk: RunInput["sdk"],
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
): Promise<SessionInfo> {
|
||||
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
|
||||
}
|
||||
|
||||
// Reads keybind overrides from TUI config and merges them with defaults.
|
||||
// Always ensures <leader>t is present in the variant cycle binding.
|
||||
export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
|
||||
return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS)
|
||||
}
|
||||
|
||||
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
|
||||
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
|
||||
}
|
||||
290
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
Normal file
290
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
// Lifecycle management for the split-footer renderer.
|
||||
//
|
||||
// Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme
|
||||
// from the terminal palette, writes the entry splash to scrollback, and
|
||||
// constructs the RunFooter. Returns a Lifecycle handle whose close() writes
|
||||
// the exit splash and tears everything down in the right order:
|
||||
// footer.close → footer.destroy → renderer shutdown.
|
||||
//
|
||||
// Also wires SIGINT so Ctrl-c during a turn triggers the two-press exit
|
||||
// sequence through RunFooter.requestExit().
|
||||
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { withRunSpan } from "./otel"
|
||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||
import { resolveRunTheme } from "./theme"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterKeybinds,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunAgent,
|
||||
RunDiffStyle,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
} from "./types"
|
||||
import { formatModelLabel } from "./variant.shared"
|
||||
|
||||
const FOOTER_HEIGHT = 7
|
||||
|
||||
type SplashState = {
|
||||
entry: boolean
|
||||
exit: boolean
|
||||
}
|
||||
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type FooterLabels = {
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
}
|
||||
|
||||
export type LifecycleInput = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
getSessionID?: () => string | undefined
|
||||
first: boolean
|
||||
history: RunPrompt[]
|
||||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
keybinds: FooterKeybinds
|
||||
diffStyle: RunDiffStyle
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onInterrupt?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
}
|
||||
|
||||
export type Lifecycle = {
|
||||
footer: FooterApi
|
||||
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
|
||||
}
|
||||
|
||||
// Gracefully tears down the renderer. Order matters: switch external output
|
||||
// back to passthrough before leaving split-footer mode, so pending stdout
|
||||
// doesn't get captured into the now-dead scrollback pipeline.
|
||||
function shutdown(renderer: CliRenderer): void {
|
||||
if (renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (renderer.externalOutputMode === "capture-stdout") {
|
||||
renderer.externalOutputMode = "passthrough"
|
||||
}
|
||||
|
||||
if (renderer.screenMode === "split-footer") {
|
||||
renderer.screenMode = "main-screen"
|
||||
}
|
||||
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
||||
if (title && !SessionApi.isDefaultTitle(title)) {
|
||||
return {
|
||||
title,
|
||||
showSession: true,
|
||||
}
|
||||
}
|
||||
|
||||
const next = history.find((item) => item.text.trim().length > 0)
|
||||
return {
|
||||
title: next?.text ?? title,
|
||||
showSession: !!next,
|
||||
}
|
||||
}
|
||||
|
||||
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
||||
const agentLabel = Locale.titlecase(input.agent ?? "build")
|
||||
|
||||
if (!input.model) {
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: "Model default",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: formatModelLabel(input.model, input.variant),
|
||||
}
|
||||
}
|
||||
|
||||
function queueSplash(
|
||||
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
|
||||
state: SplashState,
|
||||
phase: keyof SplashState,
|
||||
write: ScrollbackWriter | undefined,
|
||||
): boolean {
|
||||
if (state[phase]) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!write) {
|
||||
return false
|
||||
}
|
||||
|
||||
state[phase] = true
|
||||
renderer.writeToScrollback(write)
|
||||
renderer.requestRender()
|
||||
return true
|
||||
}
|
||||
|
||||
// Boots the split-footer renderer and constructs the RunFooter.
|
||||
//
|
||||
// The renderer starts in split-footer mode with captured stdout so that
|
||||
// scrollback commits and footer repaints happen in the same frame. After
|
||||
// the entry splash, RunFooter takes over the footer region.
|
||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
||||
return withRunSpan(
|
||||
"RunLifecycle.boot",
|
||||
{
|
||||
"opencode.agent.name": input.agent,
|
||||
"opencode.directory": input.directory,
|
||||
"opencode.first": input.first,
|
||||
"opencode.model.provider": input.model?.providerID,
|
||||
"opencode.model.id": input.model?.modelID,
|
||||
"opencode.model.variant": input.variant,
|
||||
"session.id": input.getSessionID?.() || input.sessionID || undefined,
|
||||
},
|
||||
async () => {
|
||||
const renderer = await createCliRenderer({
|
||||
targetFps: 30,
|
||||
maxFps: 60,
|
||||
useMouse: false,
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
screenMode: "split-footer",
|
||||
footerHeight: FOOTER_HEIGHT,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const theme = await resolveRunTheme(renderer)
|
||||
renderer.setBackgroundColor(theme.background)
|
||||
const state: SplashState = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
}
|
||||
const splash = splashInfo(input.sessionTitle, input.history)
|
||||
const meta = splashMeta({
|
||||
title: splash.title,
|
||||
session_id: input.sessionID,
|
||||
})
|
||||
const footerTask = import("./footer")
|
||||
const wrote = queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"entry",
|
||||
entrySplash({
|
||||
...meta,
|
||||
theme: theme.splash,
|
||||
showSession: splash.showSession,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
|
||||
const { RunFooter } = await footerTask
|
||||
|
||||
const labels = footerLabels({
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
})
|
||||
const footer = new RunFooter(renderer, {
|
||||
directory: input.directory,
|
||||
findFiles: input.findFiles,
|
||||
agents: input.agents,
|
||||
resources: input.resources,
|
||||
sessionID: input.getSessionID ?? (() => input.sessionID),
|
||||
...labels,
|
||||
first: input.first,
|
||||
history: input.history,
|
||||
theme,
|
||||
wrote,
|
||||
keybinds: input.keybinds,
|
||||
diffStyle: input.diffStyle,
|
||||
onPermissionReply: input.onPermissionReply,
|
||||
onQuestionReply: input.onQuestionReply,
|
||||
onQuestionReject: input.onQuestionReject,
|
||||
onCycleVariant: input.onCycleVariant,
|
||||
onInterrupt: input.onInterrupt,
|
||||
onSubagentSelect: input.onSubagentSelect,
|
||||
})
|
||||
|
||||
const sigint = () => {
|
||||
footer.requestExit()
|
||||
}
|
||||
process.on("SIGINT", sigint)
|
||||
|
||||
let closed = false
|
||||
const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
return withRunSpan(
|
||||
"RunLifecycle.close",
|
||||
{
|
||||
"opencode.show_exit": next.showExit,
|
||||
"session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined,
|
||||
},
|
||||
async () => {
|
||||
process.off("SIGINT", sigint)
|
||||
|
||||
try {
|
||||
await footer.idle().catch(() => {})
|
||||
|
||||
const show = renderer.isDestroyed ? false : next.showExit
|
||||
if (!renderer.isDestroyed && show) {
|
||||
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
|
||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
|
||||
queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"exit",
|
||||
exitSplash({
|
||||
...splashMeta({
|
||||
title: splash.title,
|
||||
session_id: sessionID,
|
||||
}),
|
||||
theme: theme.splash,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
footer.close()
|
||||
await footer.idle().catch(() => {})
|
||||
footer.destroy()
|
||||
shutdown(renderer)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
footer,
|
||||
close,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
235
packages/opencode/src/cli/cmd/run/runtime.queue.ts
Normal file
235
packages/opencode/src/cli/cmd/run/runtime.queue.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
// Serial prompt queue for direct interactive mode.
|
||||
//
|
||||
// Prompts arrive from the footer (user types and hits enter) and queue up
|
||||
// here. The queue drains one turn at a time: it appends the user row to
|
||||
// scrollback, calls input.run() to execute the turn through the stream
|
||||
// transport, and waits for completion before starting the next prompt.
|
||||
//
|
||||
// The queue also handles /exit and /quit commands, empty-prompt rejection,
|
||||
// and tracks per-turn wall-clock duration for the footer status line.
|
||||
//
|
||||
// Resolves when the footer closes and all in-flight work finishes.
|
||||
import * as Locale from "@/util/locale"
|
||||
import { isExitCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
type Deferred<T = void> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T | PromiseLike<T>) => void
|
||||
reject: (error?: unknown) => void
|
||||
}
|
||||
|
||||
export type QueueInput = {
|
||||
footer: FooterApi
|
||||
initialInput?: string
|
||||
trace?: Trace
|
||||
onSend?: (prompt: RunPrompt) => void
|
||||
run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
type State = {
|
||||
queue: RunPrompt[]
|
||||
ctrl?: AbortController
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
function defer<T = void>(): Deferred<T> {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (error?: unknown) => void
|
||||
const promise = new Promise<T>((next, fail) => {
|
||||
resolve = next
|
||||
reject = fail
|
||||
})
|
||||
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
// Runs the prompt queue until the footer closes.
|
||||
//
|
||||
// Subscribes to footer prompt events, queues them, and drains one at a
|
||||
// time through input.run(). If the user submits multiple prompts while
|
||||
// a turn is running, they queue up and execute in order. The footer shows
|
||||
// the queue depth so the user knows how many are pending.
|
||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const stop = defer<{ type: "closed" }>()
|
||||
const done = defer()
|
||||
const state: State = {
|
||||
queue: [],
|
||||
closed: input.footer.isClosed,
|
||||
}
|
||||
let draining: Promise<void> | undefined
|
||||
|
||||
const emit = (next: FooterEvent, row: Record<string, unknown>) => {
|
||||
input.trace?.write("ui.patch", row)
|
||||
input.footer.event(next)
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (!state.closed || draining) {
|
||||
return
|
||||
}
|
||||
|
||||
done.resolve()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (state.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
state.closed = true
|
||||
state.queue.length = 0
|
||||
state.ctrl?.abort()
|
||||
stop.resolve({ type: "closed" })
|
||||
finish()
|
||||
}
|
||||
|
||||
const drain = () => {
|
||||
if (draining || state.closed || state.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
draining = (async () => {
|
||||
try {
|
||||
while (!state.closed && state.queue.length > 0) {
|
||||
const prompt = state.queue.shift()
|
||||
if (!prompt) {
|
||||
continue
|
||||
}
|
||||
|
||||
emit(
|
||||
{
|
||||
type: "turn.send",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
const start = Date.now()
|
||||
const ctrl = new AbortController()
|
||||
state.ctrl = ctrl
|
||||
|
||||
try {
|
||||
const task = input.run(prompt, ctrl.signal).then(
|
||||
() => ({ type: "done" as const }),
|
||||
(error) => ({ type: "error" as const, error }),
|
||||
)
|
||||
|
||||
await input.footer.idle()
|
||||
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
input.onSend?.(prompt)
|
||||
|
||||
const next = await Promise.race([task, stop.promise])
|
||||
if (next.type === "closed") {
|
||||
ctrl.abort()
|
||||
break
|
||||
}
|
||||
|
||||
if (next.type === "error") {
|
||||
throw next.error
|
||||
}
|
||||
} finally {
|
||||
if (state.ctrl === ctrl) {
|
||||
state.ctrl = undefined
|
||||
}
|
||||
|
||||
const duration = Locale.duration(Math.max(0, Date.now() - start))
|
||||
emit(
|
||||
{
|
||||
type: "turn.duration",
|
||||
duration,
|
||||
},
|
||||
{
|
||||
duration,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
done.reject(error)
|
||||
return
|
||||
} finally {
|
||||
draining = undefined
|
||||
emit(
|
||||
{
|
||||
type: "turn.idle",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
finish()
|
||||
})()
|
||||
}
|
||||
|
||||
const submit = (prompt: RunPrompt) => {
|
||||
if (!prompt.text.trim() || state.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isExitCommand(prompt.text)) {
|
||||
input.footer.close()
|
||||
return
|
||||
}
|
||||
|
||||
state.queue.push(prompt)
|
||||
emit(
|
||||
{
|
||||
type: "queue",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
emit(
|
||||
{
|
||||
type: "first",
|
||||
first: false,
|
||||
},
|
||||
{
|
||||
first: false,
|
||||
},
|
||||
)
|
||||
drain()
|
||||
}
|
||||
|
||||
const offPrompt = input.footer.onPrompt((prompt) => {
|
||||
submit(prompt)
|
||||
})
|
||||
const offClose = input.footer.onClose(() => {
|
||||
close()
|
||||
})
|
||||
|
||||
try {
|
||||
if (state.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
submit({
|
||||
text: input.initialInput ?? "",
|
||||
parts: [],
|
||||
})
|
||||
finish()
|
||||
await done.promise
|
||||
} finally {
|
||||
offPrompt()
|
||||
offClose()
|
||||
close()
|
||||
await draining?.catch(() => {})
|
||||
}
|
||||
}
|
||||
17
packages/opencode/src/cli/cmd/run/runtime.shared.ts
Normal file
17
packages/opencode/src/cli/cmd/run/runtime.shared.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
type PendingTask<T> = {
|
||||
current?: Promise<T>
|
||||
}
|
||||
|
||||
export function reusePendingTask<T>(slot: PendingTask<T>, run: () => Promise<T>) {
|
||||
if (slot.current) {
|
||||
return slot.current
|
||||
}
|
||||
|
||||
const task = run().finally(() => {
|
||||
if (slot.current === task) {
|
||||
slot.current = undefined
|
||||
}
|
||||
})
|
||||
slot.current = task
|
||||
return task
|
||||
}
|
||||
590
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
590
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
// Top-level orchestrator for `run --interactive`.
|
||||
//
|
||||
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
|
||||
// and prompt queue together into a single session loop. Two entry points:
|
||||
//
|
||||
// runInteractiveMode -- used when an SDK client already exists (attach mode)
|
||||
// runInteractiveLocalMode -- used for local in-process mode (no server)
|
||||
//
|
||||
// Both delegate to runInteractiveRuntime, which:
|
||||
// 1. resolves keybinds, diff style, model info, and session history,
|
||||
// 2. creates the split-footer lifecycle (renderer + RunFooter),
|
||||
// 3. starts the stream transport (SDK event subscription), lazily for fresh
|
||||
// local sessions,
|
||||
// 4. runs the prompt queue until the footer closes.
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createRunDemo } from "./demo"
|
||||
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
||||
import { trace } from "./trace"
|
||||
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
|
||||
import type { RunInput, RunPrompt } from "./types"
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export { pickVariant, resolveVariant } from "./variant.shared"
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export { runPromptQueue } from "./runtime.queue"
|
||||
|
||||
type BootContext = Pick<
|
||||
RunInput,
|
||||
"sdk" | "directory" | "sessionID" | "sessionTitle" | "resume" | "agent" | "model" | "variant"
|
||||
>
|
||||
|
||||
type RunRuntimeInput = {
|
||||
boot: () => Promise<BootContext>
|
||||
afterPaint?: (ctx: BootContext) => Promise<void> | void
|
||||
resolveSession?: (
|
||||
ctx: BootContext,
|
||||
) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
demo?: RunInput["demo"]
|
||||
demoText?: RunInput["demoText"]
|
||||
}
|
||||
|
||||
type RunLocalInput = {
|
||||
directory: string
|
||||
fetch: typeof globalThis.fetch
|
||||
resolveAgent: () => Promise<string | undefined>
|
||||
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
|
||||
share: (sdk: RunInput["sdk"], sessionID: string) => Promise<void>
|
||||
agent: RunInput["agent"]
|
||||
model: RunInput["model"]
|
||||
variant: RunInput["variant"]
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
demo?: RunInput["demo"]
|
||||
demoText?: RunInput["demoText"]
|
||||
}
|
||||
|
||||
type StreamState = {
|
||||
mod: Awaited<typeof import("./stream.transport")>
|
||||
handle: Awaited<ReturnType<Awaited<typeof import("./stream.transport")>["createSessionTransport"]>>
|
||||
}
|
||||
|
||||
type ResolvedSession = {
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
agent?: string | undefined
|
||||
}
|
||||
|
||||
type RuntimeState = {
|
||||
shown: boolean
|
||||
aborting: boolean
|
||||
variants: string[]
|
||||
limits: Record<string, number>
|
||||
activeVariant: string | undefined
|
||||
sessionID: string
|
||||
history: RunPrompt[]
|
||||
sessionTitle?: string
|
||||
agent: string | undefined
|
||||
demo?: ReturnType<typeof createRunDemo>
|
||||
selectSubagent?: (sessionID: string | undefined) => void
|
||||
session?: Promise<void>
|
||||
stream?: Promise<StreamState>
|
||||
}
|
||||
|
||||
function hasSession(input: RunRuntimeInput, state: RuntimeState) {
|
||||
return !input.resolveSession || !!state.sessionID
|
||||
}
|
||||
|
||||
function eagerStream(input: RunRuntimeInput, ctx: BootContext) {
|
||||
return ctx.resume === true || !input.resolveSession || !!input.demo
|
||||
}
|
||||
|
||||
async function resolveExitTitle(
|
||||
ctx: BootContext,
|
||||
input: RunRuntimeInput,
|
||||
state: RuntimeState,
|
||||
): Promise<string | undefined> {
|
||||
if (!state.shown || !hasSession(input, state)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return ctx.sdk.session
|
||||
.get({
|
||||
sessionID: state.sessionID,
|
||||
})
|
||||
.then((x) => x.data?.title)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
// Core runtime loop. Boot resolves the SDK context, then we set up the
|
||||
// lifecycle (renderer + footer), wire the stream transport for SDK events,
|
||||
// and feed prompts through the queue until the user exits.
|
||||
//
|
||||
// Files only attach on the first prompt turn -- after that, includeFiles
|
||||
// flips to false so subsequent turns don't re-send attachments.
|
||||
async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
return withRunSpan(
|
||||
"RunInteractive.session",
|
||||
{
|
||||
"opencode.mode": input.resolveSession ? "local" : "attach",
|
||||
"opencode.initial_input": !!input.initialInput,
|
||||
"opencode.demo": input.demo,
|
||||
},
|
||||
async (span) => {
|
||||
const start = performance.now()
|
||||
const log = trace()
|
||||
const keybindTask = resolveFooterKeybinds()
|
||||
const diffTask = resolveDiffStyle()
|
||||
const ctx = await input.boot()
|
||||
const modelTask = resolveModelInfo(ctx.sdk, ctx.model)
|
||||
const sessionTask =
|
||||
ctx.resume === true
|
||||
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
||||
: Promise.resolve({
|
||||
first: true,
|
||||
history: [],
|
||||
variant: undefined,
|
||||
})
|
||||
const savedTask = resolveSavedVariant(ctx.model)
|
||||
const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
|
||||
keybindTask,
|
||||
diffTask,
|
||||
sessionTask,
|
||||
savedTask,
|
||||
])
|
||||
const state: RuntimeState = {
|
||||
shown: !session.first,
|
||||
aborting: false,
|
||||
variants: [],
|
||||
limits: {},
|
||||
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
|
||||
sessionID: ctx.sessionID,
|
||||
history: [...session.history],
|
||||
sessionTitle: ctx.sessionTitle,
|
||||
agent: ctx.agent,
|
||||
}
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.directory": ctx.directory,
|
||||
"opencode.resume": ctx.resume === true,
|
||||
"opencode.agent.name": state.agent,
|
||||
"opencode.model.provider": ctx.model?.providerID,
|
||||
"opencode.model.id": ctx.model?.modelID,
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
"session.id": state.sessionID || undefined,
|
||||
})
|
||||
const ensureSession = () => {
|
||||
if (!input.resolveSession || state.sessionID) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (state.session) {
|
||||
return state.session
|
||||
}
|
||||
|
||||
state.session = input.resolveSession(ctx).then((next) => {
|
||||
state.sessionID = next.sessionID
|
||||
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
|
||||
state.agent = next.agent
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.agent.name": state.agent,
|
||||
"session.id": state.sessionID,
|
||||
})
|
||||
})
|
||||
return state.session
|
||||
}
|
||||
|
||||
const shell = await createRuntimeLifecycle({
|
||||
directory: ctx.directory,
|
||||
findFiles: (query) =>
|
||||
ctx.sdk.find
|
||||
.files({ query, directory: ctx.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
agents: [],
|
||||
resources: [],
|
||||
sessionID: state.sessionID,
|
||||
sessionTitle: state.sessionTitle,
|
||||
getSessionID: () => state.sessionID,
|
||||
first: session.first,
|
||||
history: session.history,
|
||||
agent: state.agent,
|
||||
model: ctx.model,
|
||||
variant: state.activeVariant,
|
||||
keybinds,
|
||||
diffStyle,
|
||||
onPermissionReply: async (next) => {
|
||||
if (state.demo?.permission(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
log?.write("send.permission.reply", next)
|
||||
await ctx.sdk.permission.reply(next)
|
||||
},
|
||||
onQuestionReply: async (next) => {
|
||||
if (state.demo?.questionReply(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.sdk.question.reply(next)
|
||||
},
|
||||
onQuestionReject: async (next) => {
|
||||
if (state.demo?.questionReject(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.sdk.question.reject(next)
|
||||
},
|
||||
onCycleVariant: () => {
|
||||
if (!ctx.model || state.variants.length === 0) {
|
||||
return {
|
||||
status: "no variants available",
|
||||
}
|
||||
}
|
||||
|
||||
state.activeVariant = cycleVariant(state.activeVariant, state.variants)
|
||||
saveVariant(ctx.model, state.activeVariant)
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
})
|
||||
return {
|
||||
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
|
||||
modelLabel: formatModelLabel(ctx.model, state.activeVariant),
|
||||
}
|
||||
},
|
||||
onInterrupt: () => {
|
||||
if (!hasSession(input, state) || state.aborting) {
|
||||
return
|
||||
}
|
||||
|
||||
state.aborting = true
|
||||
void ctx.sdk.session
|
||||
.abort({
|
||||
sessionID: state.sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
state.aborting = false
|
||||
})
|
||||
},
|
||||
onSubagentSelect: (sessionID) => {
|
||||
state.selectSubagent?.(sessionID)
|
||||
log?.write("subagent.select", {
|
||||
sessionID,
|
||||
})
|
||||
},
|
||||
})
|
||||
const footer = shell.footer
|
||||
|
||||
const loadCatalog = async (): Promise<void> => {
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
const [agents, resources] = await Promise.all([
|
||||
ctx.sdk.app
|
||||
.agents({ directory: ctx.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
ctx.sdk.experimental.resource
|
||||
.list({ directory: ctx.directory })
|
||||
.then((x) => Object.values(x.data ?? {}))
|
||||
.catch(() => []),
|
||||
])
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({
|
||||
type: "catalog",
|
||||
agents,
|
||||
resources,
|
||||
})
|
||||
}
|
||||
|
||||
void footer
|
||||
.idle()
|
||||
.then(loadCatalog)
|
||||
.catch(() => {})
|
||||
|
||||
if (Flag.OPENCODE_SHOW_TTFD) {
|
||||
footer.append({
|
||||
kind: "system",
|
||||
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
|
||||
phase: "final",
|
||||
source: "system",
|
||||
})
|
||||
}
|
||||
|
||||
if (input.demo) {
|
||||
await ensureSession()
|
||||
state.demo = createRunDemo({
|
||||
mode: input.demo,
|
||||
text: input.demoText,
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.afterPaint) {
|
||||
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
|
||||
}
|
||||
|
||||
void modelTask.then((info) => {
|
||||
state.variants = info.variants
|
||||
state.limits = info.limits
|
||||
|
||||
const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
|
||||
if (next === state.activeVariant) {
|
||||
return
|
||||
}
|
||||
|
||||
state.activeVariant = next
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
})
|
||||
if (!ctx.model || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({
|
||||
type: "model",
|
||||
model: formatModelLabel(ctx.model, state.activeVariant),
|
||||
})
|
||||
})
|
||||
|
||||
const streamTask = import("./stream.transport")
|
||||
const ensureStream = () => {
|
||||
if (state.stream) {
|
||||
return state.stream
|
||||
}
|
||||
|
||||
// Share eager prewarm and first-turn boot through one in-flight promise,
|
||||
// but clear it if transport creation fails so a later prompt can retry.
|
||||
const next = (async () => {
|
||||
await ensureSession()
|
||||
if (footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
const mod = await streamTask
|
||||
if (footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
const handle = await mod.createSessionTransport({
|
||||
sdk: ctx.sdk,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
footer,
|
||||
trace: log,
|
||||
})
|
||||
if (footer.isClosed) {
|
||||
await handle.close()
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
state.selectSubagent = (sessionID) => handle.selectSubagent(sessionID)
|
||||
return { mod, handle }
|
||||
})()
|
||||
state.stream = next
|
||||
void next.catch(() => {
|
||||
if (state.stream === next) {
|
||||
state.stream = undefined
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
const runQueue = async () => {
|
||||
let includeFiles = true
|
||||
if (state.demo) {
|
||||
await state.demo.start()
|
||||
}
|
||||
|
||||
const mod = await import("./runtime.queue")
|
||||
await mod.runPromptQueue({
|
||||
footer,
|
||||
initialInput: input.initialInput,
|
||||
trace: log,
|
||||
onSend: (prompt) => {
|
||||
state.shown = true
|
||||
state.history.push(prompt)
|
||||
},
|
||||
run: async (prompt, signal) => {
|
||||
if (state.demo && (await state.demo.prompt(prompt, signal))) {
|
||||
return
|
||||
}
|
||||
|
||||
return withRunSpan(
|
||||
"RunInteractive.turn",
|
||||
{
|
||||
"opencode.agent.name": state.agent,
|
||||
"opencode.model.provider": ctx.model?.providerID,
|
||||
"opencode.model.id": ctx.model?.modelID,
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
"opencode.prompt.chars": prompt.text.length,
|
||||
"opencode.prompt.parts": prompt.parts.length,
|
||||
"opencode.prompt.include_files": includeFiles,
|
||||
"opencode.prompt.file_parts": includeFiles ? input.files.length : 0,
|
||||
"session.id": state.sessionID || undefined,
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const next = await ensureStream()
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.agent.name": state.agent,
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
"session.id": state.sessionID || undefined,
|
||||
})
|
||||
await next.handle.runPromptTurn({
|
||||
agent: state.agent,
|
||||
model: ctx.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
signal,
|
||||
})
|
||||
includeFiles = false
|
||||
} catch (error) {
|
||||
if (signal.aborted || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
recordRunSpanError(span, error)
|
||||
const text =
|
||||
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
|
||||
(error instanceof Error ? error.message : String(error))
|
||||
footer.append({ kind: "error", text, phase: "start", source: "system" })
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const eager = eagerStream(input, ctx)
|
||||
if (eager) {
|
||||
await ensureStream()
|
||||
}
|
||||
|
||||
if (!eager && input.resolveSession) {
|
||||
queueMicrotask(() => {
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
void ensureStream().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await runQueue()
|
||||
} finally {
|
||||
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
const title = await resolveExitTitle(ctx, input, state)
|
||||
|
||||
await shell.close({
|
||||
showExit: state.shown && hasSession(input, state),
|
||||
sessionTitle: title,
|
||||
sessionID: state.sessionID,
|
||||
history: state.history,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Local in-process mode. Creates an SDK client backed by a direct fetch to
|
||||
// the in-process server, so no external HTTP server is needed.
|
||||
export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
|
||||
return withRunSpan(
|
||||
"RunInteractive.localMode",
|
||||
{
|
||||
"opencode.directory": input.directory,
|
||||
"opencode.initial_input": !!input.initialInput,
|
||||
"opencode.demo": input.demo,
|
||||
},
|
||||
async () => {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://opencode.internal",
|
||||
fetch: input.fetch,
|
||||
directory: input.directory,
|
||||
})
|
||||
let session: Promise<ResolvedSession> | undefined
|
||||
|
||||
return runInteractiveRuntime({
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
demo: input.demo,
|
||||
demoText: input.demoText,
|
||||
resolveSession: () => {
|
||||
if (session) {
|
||||
return session
|
||||
}
|
||||
|
||||
session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
|
||||
if (!next?.id) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
void input.share(sdk, next.id).catch(() => {})
|
||||
return {
|
||||
sessionID: next.id,
|
||||
sessionTitle: next.title,
|
||||
agent,
|
||||
}
|
||||
})
|
||||
return session
|
||||
},
|
||||
boot: async () => {
|
||||
return {
|
||||
sdk,
|
||||
directory: input.directory,
|
||||
sessionID: "",
|
||||
sessionTitle: undefined,
|
||||
resume: false,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Attach mode. Uses the caller-provided SDK client directly.
|
||||
export async function runInteractiveMode(input: RunInput): Promise<void> {
|
||||
return withRunSpan(
|
||||
"RunInteractive.attachMode",
|
||||
{
|
||||
"opencode.directory": input.directory,
|
||||
"opencode.initial_input": !!input.initialInput,
|
||||
"session.id": input.sessionID,
|
||||
},
|
||||
async () =>
|
||||
runInteractiveRuntime({
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
demo: input.demo,
|
||||
demoText: input.demoText,
|
||||
boot: async () => ({
|
||||
sdk: input.sdk,
|
||||
directory: input.directory,
|
||||
sessionID: input.sessionID,
|
||||
sessionTitle: input.sessionTitle,
|
||||
resume: input.resume,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
92
packages/opencode/src/cli/cmd/run/scrollback.shared.ts
Normal file
92
packages/opencode/src/cli/cmd/run/scrollback.shared.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { SyntaxStyle, TextAttributes, type ColorInput } from "@opentui/core"
|
||||
import { type RunEntryTheme, type RunTheme } from "./theme"
|
||||
import type { StreamCommit } from "./types"
|
||||
|
||||
function syntax(style?: SyntaxStyle): SyntaxStyle {
|
||||
return style ?? SyntaxStyle.fromTheme([])
|
||||
}
|
||||
|
||||
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
|
||||
if (commit.kind === "reasoning") {
|
||||
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
|
||||
}
|
||||
|
||||
return syntax(theme.block.syntax)
|
||||
}
|
||||
|
||||
export function entryFailed(commit: StreamCommit): boolean {
|
||||
return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
|
||||
}
|
||||
|
||||
export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } {
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
fg: theme.user.body,
|
||||
attrs: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (entryFailed(commit)) {
|
||||
return {
|
||||
fg: theme.error.body,
|
||||
attrs: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return {
|
||||
fg: theme.system.body,
|
||||
attrs: TextAttributes.DIM,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool" && commit.phase === "start") {
|
||||
return {
|
||||
fg: theme.tool.start ?? theme.tool.body,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
return { fg: theme.assistant.body }
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
return {
|
||||
fg: theme.reasoning.body,
|
||||
attrs: TextAttributes.DIM,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "error") {
|
||||
return {
|
||||
fg: theme.error.body,
|
||||
attrs: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return { fg: theme.tool.body }
|
||||
}
|
||||
|
||||
return { fg: theme.system.body }
|
||||
}
|
||||
|
||||
export function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput {
|
||||
if (commit.kind === "assistant") {
|
||||
return theme.entry.assistant.body
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
return theme.entry.reasoning.body
|
||||
}
|
||||
|
||||
if (entryFailed(commit)) {
|
||||
return theme.entry.error.body
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return theme.block.text
|
||||
}
|
||||
|
||||
return entryLook(commit, theme.entry).fg
|
||||
}
|
||||
370
packages/opencode/src/cli/cmd/run/scrollback.surface.ts
Normal file
370
packages/opencode/src/cli/cmd/run/scrollback.surface.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Retained streaming append logic for direct-mode scrollback.
|
||||
//
|
||||
// Static entries are rendered through `scrollback.writer.tsx`. This file only
|
||||
// keeps the retained-surface machinery needed for streaming assistant,
|
||||
// reasoning, and tool progress entries that need stable markdown/code layout
|
||||
// while content is still arriving.
|
||||
import {
|
||||
CodeRenderable,
|
||||
MarkdownRenderable,
|
||||
TextRenderable,
|
||||
getTreeSitterClient,
|
||||
type TreeSitterClient,
|
||||
type CliRenderer,
|
||||
type ScrollbackSurface,
|
||||
} from "@opentui/core"
|
||||
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
|
||||
import { withRunSpan } from "./otel"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter } from "./scrollback.writer"
|
||||
import { type RunTheme } from "./theme"
|
||||
import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
|
||||
|
||||
type ActiveBody = Exclude<RunEntryBody, { type: "none" | "structured" }>
|
||||
|
||||
type ActiveEntry = {
|
||||
body: ActiveBody
|
||||
commit: StreamCommit
|
||||
surface: ScrollbackSurface
|
||||
renderable: TextRenderable | CodeRenderable | MarkdownRenderable
|
||||
content: string
|
||||
committedRows: number
|
||||
committedBlocks: number
|
||||
pendingSpacerRows: number
|
||||
rendered: boolean
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function commitMarkdownBlocks(input: {
|
||||
surface: ScrollbackSurface
|
||||
renderable: MarkdownRenderable
|
||||
startBlock: number
|
||||
endBlockExclusive: number
|
||||
trailingNewline: boolean
|
||||
beforeCommit?: () => void
|
||||
}) {
|
||||
if (input.endBlockExclusive <= input.startBlock) {
|
||||
return false
|
||||
}
|
||||
|
||||
const first = input.renderable._blockStates[input.startBlock]
|
||||
const last = input.renderable._blockStates[input.endBlockExclusive - 1]
|
||||
if (!first || !last) {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = input.renderable._blockStates[input.endBlockExclusive]
|
||||
const start = first.renderable.y
|
||||
const end = next ? next.renderable.y : last.renderable.y + last.renderable.height
|
||||
|
||||
input.beforeCommit?.()
|
||||
input.surface.commitRows(start, end, {
|
||||
trailingNewline: input.trailingNewline,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
export class RunScrollbackStream {
|
||||
private tail: StreamCommit | undefined
|
||||
private rendered: StreamCommit | undefined
|
||||
private active: ActiveEntry | undefined
|
||||
private diffStyle: RunDiffStyle | undefined
|
||||
private sessionID?: () => string | undefined
|
||||
private treeSitterClient: TreeSitterClient | undefined
|
||||
private wrote: boolean
|
||||
|
||||
constructor(
|
||||
private renderer: CliRenderer,
|
||||
private theme: RunTheme,
|
||||
options: {
|
||||
wrote?: boolean
|
||||
diffStyle?: RunDiffStyle
|
||||
sessionID?: () => string | undefined
|
||||
treeSitterClient?: TreeSitterClient
|
||||
} = {},
|
||||
) {
|
||||
this.diffStyle = options.diffStyle
|
||||
this.sessionID = options.sessionID
|
||||
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
|
||||
this.wrote = options.wrote ?? false
|
||||
}
|
||||
|
||||
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
|
||||
const surface = this.renderer.createScrollbackSurface({
|
||||
startOnNewLine: entryFlags(commit).startOnNewLine,
|
||||
})
|
||||
const id = `run-scrollback-entry-${nextId++}`
|
||||
const style = entryLook(commit, this.theme.entry)
|
||||
const renderable =
|
||||
body.type === "text"
|
||||
? new TextRenderable(surface.renderContext, {
|
||||
id,
|
||||
content: "",
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
fg: style.fg,
|
||||
attributes: style.attrs,
|
||||
})
|
||||
: body.type === "code"
|
||||
? new CodeRenderable(surface.renderContext, {
|
||||
id,
|
||||
content: "",
|
||||
filetype: body.filetype,
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
drawUnstyledText: false,
|
||||
streaming: true,
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
})
|
||||
: new MarkdownRenderable(surface.renderContext, {
|
||||
id,
|
||||
content: "",
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
width: "100%",
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
tableOptions: { widthMode: "content" },
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
})
|
||||
|
||||
surface.root.add(renderable)
|
||||
|
||||
const rows = separatorRows(this.rendered, commit, body)
|
||||
|
||||
return {
|
||||
body,
|
||||
commit,
|
||||
surface,
|
||||
renderable,
|
||||
content: "",
|
||||
committedRows: 0,
|
||||
committedBlocks: 0,
|
||||
pendingSpacerRows: rows || (!this.rendered && this.wrote ? 1 : 0),
|
||||
rendered: false,
|
||||
}
|
||||
}
|
||||
|
||||
private markRendered(commit: StreamCommit | undefined): void {
|
||||
if (!commit) {
|
||||
return
|
||||
}
|
||||
|
||||
this.rendered = commit
|
||||
}
|
||||
|
||||
private writeSpacer(rows: number): void {
|
||||
if (rows === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.renderer.writeToScrollback(spacerWriter())
|
||||
this.wrote = false
|
||||
}
|
||||
|
||||
private flushPendingSpacer(active: ActiveEntry): void {
|
||||
this.writeSpacer(active.pendingSpacerRows)
|
||||
active.pendingSpacerRows = 0
|
||||
}
|
||||
|
||||
private async flushActive(done: boolean, trailingNewline: boolean): Promise<boolean> {
|
||||
const active = this.active
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (active.body.type === "text") {
|
||||
if (!(active.renderable instanceof TextRenderable)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const renderable = active.renderable
|
||||
renderable.content = active.content
|
||||
active.surface.render()
|
||||
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
|
||||
if (targetRows <= active.committedRows) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.flushPendingSpacer(active)
|
||||
active.surface.commitRows(active.committedRows, targetRows, {
|
||||
trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
|
||||
})
|
||||
active.committedRows = targetRows
|
||||
active.rendered = true
|
||||
return true
|
||||
}
|
||||
|
||||
if (active.body.type === "code") {
|
||||
if (!(active.renderable instanceof CodeRenderable)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const renderable = active.renderable
|
||||
renderable.content = active.content
|
||||
renderable.streaming = !done
|
||||
await active.surface.settle()
|
||||
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
|
||||
if (targetRows <= active.committedRows) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.flushPendingSpacer(active)
|
||||
active.surface.commitRows(active.committedRows, targetRows, {
|
||||
trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
|
||||
})
|
||||
active.committedRows = targetRows
|
||||
active.rendered = true
|
||||
return true
|
||||
}
|
||||
|
||||
if (!(active.renderable instanceof MarkdownRenderable)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const renderable = active.renderable
|
||||
renderable.content = active.content
|
||||
renderable.streaming = !done
|
||||
await active.surface.settle()
|
||||
const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
|
||||
if (targetBlockCount <= active.committedBlocks) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
commitMarkdownBlocks({
|
||||
surface: active.surface,
|
||||
renderable,
|
||||
startBlock: active.committedBlocks,
|
||||
endBlockExclusive: targetBlockCount,
|
||||
trailingNewline: done && targetBlockCount === renderable._blockStates.length ? trailingNewline : false,
|
||||
beforeCommit: () => this.flushPendingSpacer(active),
|
||||
})
|
||||
) {
|
||||
active.committedBlocks = targetBlockCount
|
||||
active.rendered = true
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private async finishActive(trailingNewline: boolean): Promise<StreamCommit | undefined> {
|
||||
if (!this.active) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const active = this.active
|
||||
|
||||
try {
|
||||
await this.flushActive(true, trailingNewline)
|
||||
} finally {
|
||||
if (this.active === active) {
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
if (!active.surface.isDestroyed) {
|
||||
active.surface.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
return active.rendered ? active.commit : undefined
|
||||
}
|
||||
|
||||
private async writeStreaming(commit: StreamCommit, body: ActiveBody): Promise<void> {
|
||||
if (!this.active || !sameEntryGroup(this.active.commit, commit) || this.active.body.type !== body.type) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
this.active = this.createEntry(commit, body)
|
||||
}
|
||||
|
||||
this.active.body = body
|
||||
this.active.commit = commit
|
||||
this.active.content += body.content
|
||||
await this.flushActive(false, false)
|
||||
if (this.active.rendered) {
|
||||
this.markRendered(this.active.commit)
|
||||
}
|
||||
}
|
||||
|
||||
public async append(commit: StreamCommit): Promise<void> {
|
||||
const same = sameEntryGroup(this.tail, commit)
|
||||
if (!same) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
|
||||
const body = entryBody(commit)
|
||||
if (body.type === "none") {
|
||||
if (entryDone(commit)) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
body.type !== "structured" &&
|
||||
(entryCanStream(commit, body) ||
|
||||
(commit.kind === "tool" && commit.phase === "final" && body.type === "markdown"))
|
||||
) {
|
||||
await this.writeStreaming(commit, body)
|
||||
if (entryDone(commit)) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
if (same) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
|
||||
const rows = separatorRows(this.rendered, commit, body)
|
||||
this.writeSpacer(rows || (!this.rendered && this.wrote ? 1 : 0))
|
||||
|
||||
this.renderer.writeToScrollback(
|
||||
entryWriter({
|
||||
commit,
|
||||
theme: this.theme,
|
||||
opts: {
|
||||
diffStyle: this.diffStyle,
|
||||
},
|
||||
}),
|
||||
)
|
||||
this.markRendered(commit)
|
||||
this.tail = commit
|
||||
}
|
||||
|
||||
private resetActive(): void {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.active.surface.isDestroyed) {
|
||||
this.active.surface.destroy()
|
||||
}
|
||||
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
public async complete(trailingNewline = false): Promise<void> {
|
||||
return withRunSpan(
|
||||
"RunScrollbackStream.complete",
|
||||
{
|
||||
"opencode.entry.active": !!this.active,
|
||||
"opencode.trailing_newline": trailingNewline,
|
||||
"session.id": this.sessionID?.() || undefined,
|
||||
},
|
||||
async () => {
|
||||
this.markRendered(await this.finishActive(trailingNewline))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.resetActive()
|
||||
}
|
||||
}
|
||||
330
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
Normal file
330
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
|
||||
import { createScrollbackWriter } from "@opentui/solid"
|
||||
import { TextRenderable, type ScrollbackWriter } from "@opentui/core"
|
||||
import { Match, Switch, createMemo } from "solid-js"
|
||||
import { entryBody, entryFlags } from "./entry.body"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
||||
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
|
||||
|
||||
function todoText(item: { status: string; content: string }): string {
|
||||
if (item.status === "completed") {
|
||||
return `[✓] ${item.content}`
|
||||
}
|
||||
|
||||
if (item.status === "cancelled") {
|
||||
return `~[ ] ${item.content}~`
|
||||
}
|
||||
|
||||
if (item.status === "in_progress") {
|
||||
return `[•] ${item.content}`
|
||||
}
|
||||
|
||||
return `[ ] ${item.content}`
|
||||
}
|
||||
|
||||
function todoColor(theme: RunTheme, status: string) {
|
||||
return status === "in_progress" ? theme.footer.warning : theme.block.muted
|
||||
}
|
||||
|
||||
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||
if (!commit.partID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (toolStructuredFinal(commit)) {
|
||||
return `tool:${commit.partID}:final`
|
||||
}
|
||||
|
||||
return `${commit.kind}:${commit.partID}`
|
||||
}
|
||||
|
||||
export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean {
|
||||
if (!left) {
|
||||
return false
|
||||
}
|
||||
|
||||
const current = entryGroupKey(left)
|
||||
const next = entryGroupKey(right)
|
||||
return Boolean(current && next && current === next)
|
||||
}
|
||||
|
||||
export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
|
||||
if (commit.kind === "tool") {
|
||||
if (body.type === "structured" || body.type === "markdown") {
|
||||
return "block"
|
||||
}
|
||||
|
||||
return "inline"
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
return "block"
|
||||
}
|
||||
|
||||
return "block"
|
||||
}
|
||||
|
||||
export function separatorRows(
|
||||
prev: StreamCommit | undefined,
|
||||
next: StreamCommit,
|
||||
body: RunEntryBody = entryBody(next),
|
||||
): number {
|
||||
if (!prev || sameEntryGroup(prev, next)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (entryLayout(prev) === "inline" && entryLayout(next, body) === "inline") {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
export function RunEntryContent(props: {
|
||||
commit: StreamCommit
|
||||
theme?: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
width?: number
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const body = createMemo(() => entryBody(props.commit))
|
||||
const style = createMemo(() => entryLook(props.commit, theme().entry))
|
||||
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
|
||||
const color = createMemo(() => entryColor(props.commit, theme()))
|
||||
const streaming = createMemo(() => props.commit.phase === "progress")
|
||||
const width = createMemo(() => Math.max(1, Math.trunc(props.width ?? 80)))
|
||||
const view = createMemo(() => toolDiffView(width(), props.opts?.diffStyle))
|
||||
const text = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "text" ? next : undefined
|
||||
})
|
||||
const code = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "code" ? next : undefined
|
||||
})
|
||||
const structured = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "structured" ? next.snapshot : undefined
|
||||
})
|
||||
const markdown = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "markdown" ? next : undefined
|
||||
})
|
||||
const code_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "code" ? next : undefined
|
||||
})
|
||||
const diff_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "diff" ? next : undefined
|
||||
})
|
||||
const task_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "task" ? next : undefined
|
||||
})
|
||||
const todo_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "todo" ? next : undefined
|
||||
})
|
||||
const question_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "question" ? next : undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch fallback={null}>
|
||||
<Match when={text()}>
|
||||
{(body) => (
|
||||
<text width="100%" wrapMode="word" fg={style().fg} attributes={style().attrs}>
|
||||
{body().content}
|
||||
</text>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={code()}>
|
||||
{(body) => (
|
||||
<code
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
filetype={body().filetype}
|
||||
drawUnstyledText={false}
|
||||
streaming={streaming()}
|
||||
syntaxStyle={syntax()}
|
||||
content={body().content}
|
||||
fg={color()}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={code_snapshot()}>
|
||||
{(snap) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{snap().title}
|
||||
</text>
|
||||
<box width="100%" paddingLeft={1}>
|
||||
<line_number width="100%" fg={theme().block.muted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
filetype={toolFiletype(snap().file)}
|
||||
streaming={false}
|
||||
syntaxStyle={syntax()}
|
||||
content={snap().content}
|
||||
fg={theme().block.text}
|
||||
/>
|
||||
</line_number>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={diff_snapshot()}>
|
||||
{(snap) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
{snap().items.map((item) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{item.title}
|
||||
</text>
|
||||
{item.diff.trim() ? (
|
||||
<box width="100%" paddingLeft={1}>
|
||||
<diff
|
||||
diff={item.diff}
|
||||
view={view()}
|
||||
filetype={toolFiletype(item.file)}
|
||||
syntaxStyle={syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme().block.text}
|
||||
addedBg={theme().block.diffAddedBg}
|
||||
removedBg={theme().block.diffRemovedBg}
|
||||
contextBg={theme().block.diffContextBg}
|
||||
addedSignColor={theme().block.diffHighlightAdded}
|
||||
removedSignColor={theme().block.diffHighlightRemoved}
|
||||
lineNumberFg={theme().block.diffLineNumber}
|
||||
lineNumberBg={theme().block.diffContextBg}
|
||||
addedLineNumberBg={theme().block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme().block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.diffRemoved}>
|
||||
-{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={task_snapshot()}>
|
||||
{(snap) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{snap().title}
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
|
||||
{snap().rows.map((row) => (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.text}>
|
||||
{row}
|
||||
</text>
|
||||
))}
|
||||
{snap().tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{snap().tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={todo_snapshot()}>
|
||||
{(snap) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
# Todos
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
{snap().items.map((item) => (
|
||||
<text width="100%" wrapMode="word" fg={todoColor(theme(), item.status)}>
|
||||
{todoText(item)}
|
||||
</text>
|
||||
))}
|
||||
{snap().tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{snap().tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={question_snapshot()}>
|
||||
{(snap) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
# Questions
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
{snap().items.map((item) => (
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{item.question}
|
||||
</text>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.text}>
|
||||
{item.answer}
|
||||
</text>
|
||||
</box>
|
||||
))}
|
||||
{snap().tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{snap().tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={markdown()}>
|
||||
{(body) => (
|
||||
<markdown
|
||||
width="100%"
|
||||
syntaxStyle={syntax()}
|
||||
streaming={streaming()}
|
||||
content={body().content}
|
||||
fg={color()}
|
||||
tableOptions={{ widthMode: "content" }}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function entryWriter(input: {
|
||||
commit: StreamCommit
|
||||
theme?: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
}): ScrollbackWriter {
|
||||
return createScrollbackWriter(
|
||||
(ctx) => <RunEntryContent commit={input.commit} theme={input.theme} opts={input.opts} width={ctx.width} />,
|
||||
entryFlags(input.commit),
|
||||
)
|
||||
}
|
||||
|
||||
export function spacerWriter(): ScrollbackWriter {
|
||||
return (ctx) => ({
|
||||
root: new TextRenderable(ctx.renderContext, {
|
||||
id: "run-scrollback-spacer",
|
||||
width: Math.max(1, Math.trunc(ctx.width)),
|
||||
height: 1,
|
||||
content: "",
|
||||
}),
|
||||
width: Math.max(1, Math.trunc(ctx.width)),
|
||||
height: 1,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
})
|
||||
}
|
||||
942
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
942
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
@@ -0,0 +1,942 @@
|
||||
// Core reducer for direct interactive mode.
|
||||
//
|
||||
// Takes raw SDK events and produces two outputs:
|
||||
// - StreamCommit[]: append-only scrollback entries (text, tool, error, etc.)
|
||||
// - FooterOutput: status bar patches and view transitions (permission, question)
|
||||
//
|
||||
// The reducer mutates SessionData in place for performance but has no
|
||||
// external side effects -- no IO, no footer calls. The caller
|
||||
// (stream.transport.ts) feeds events in and forwards output to the footer
|
||||
// through stream.ts.
|
||||
//
|
||||
// Key design decisions:
|
||||
//
|
||||
// - Text parts buffer in `data.text` until their message role is confirmed as
|
||||
// "assistant". This prevents echoing user-role text parts. The `ready()`
|
||||
// check gates output: if we see a text delta before the message.updated
|
||||
// event that tells us the role, we stash it and flush later via `replay()`.
|
||||
//
|
||||
// - Tool echo stripping: bash tools may echo their own output in the next
|
||||
// assistant text part. `stashEcho()` records completed bash output, and
|
||||
// `stripEcho()` removes it from the start of the next assistant chunk.
|
||||
//
|
||||
// - Permission and question requests queue in `data.permissions` and
|
||||
// `data.questions`. The footer shows whichever is first. When a reply
|
||||
// event arrives, the queue entry is removed and the footer falls back
|
||||
// to the next pending request or to the prompt view.
|
||||
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { toolView } from "./tool"
|
||||
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
type Tokens = {
|
||||
input?: number
|
||||
output?: number
|
||||
reasoning?: number
|
||||
cache?: {
|
||||
read?: number
|
||||
write?: number
|
||||
}
|
||||
}
|
||||
|
||||
type PartKind = "assistant" | "reasoning" | "user"
|
||||
type MessageRole = "assistant" | "user"
|
||||
type Dict = Record<string, unknown>
|
||||
type SessionCommit = StreamCommit
|
||||
|
||||
// Mutable accumulator for the reducer. Each field tracks a different aspect
|
||||
// of the stream so we can produce correct incremental output:
|
||||
//
|
||||
// - ids: parts and error keys we've already committed (dedup guard)
|
||||
// - tools: tool parts we've emitted a "start" for but not yet completed
|
||||
// - call: tool call inputs, keyed by msg:call, for enriching permission views
|
||||
// - role: message ID → "assistant" | "user", learned from message.updated
|
||||
// - msg: part ID → message ID
|
||||
// - part: part ID → "assistant" | "reasoning" (text parts only)
|
||||
// - text: part ID → full accumulated text so far
|
||||
// - sent: part ID → byte offset of last flushed text (for incremental output)
|
||||
// - end: part IDs whose time.end has arrived (part is finished)
|
||||
// - echo: message ID → bash outputs to strip from the next assistant chunk
|
||||
export type SessionData = {
|
||||
includeUserText: boolean
|
||||
announced: boolean
|
||||
ids: Set<string>
|
||||
tools: Set<string>
|
||||
call: Map<string, Dict>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
role: Map<string, MessageRole>
|
||||
msg: Map<string, string>
|
||||
part: Map<string, PartKind>
|
||||
text: Map<string, string>
|
||||
sent: Map<string, number>
|
||||
end: Set<string>
|
||||
echo: Map<string, Set<string>>
|
||||
}
|
||||
|
||||
export type SessionDataInput = {
|
||||
data: SessionData
|
||||
event: Event
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}
|
||||
|
||||
export type SessionDataOutput = {
|
||||
data: SessionData
|
||||
commits: SessionCommit[]
|
||||
footer?: FooterOutput
|
||||
}
|
||||
|
||||
export function createSessionData(
|
||||
input: {
|
||||
includeUserText?: boolean
|
||||
} = {},
|
||||
): SessionData {
|
||||
return {
|
||||
includeUserText: input.includeUserText ?? false,
|
||||
announced: false,
|
||||
ids: new Set(),
|
||||
tools: new Set(),
|
||||
call: new Map(),
|
||||
permissions: [],
|
||||
questions: [],
|
||||
role: new Map(),
|
||||
msg: new Map(),
|
||||
part: new Map(),
|
||||
text: new Map(),
|
||||
sent: new Map(),
|
||||
end: new Set(),
|
||||
echo: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function modelKey(provider: string, model: string): string {
|
||||
return `${provider}/${model}`
|
||||
}
|
||||
|
||||
function formatUsage(
|
||||
tokens: Tokens | undefined,
|
||||
limit: number | undefined,
|
||||
cost: number | undefined,
|
||||
): string | undefined {
|
||||
const total =
|
||||
(tokens?.input ?? 0) +
|
||||
(tokens?.output ?? 0) +
|
||||
(tokens?.reasoning ?? 0) +
|
||||
(tokens?.cache?.read ?? 0) +
|
||||
(tokens?.cache?.write ?? 0)
|
||||
|
||||
if (total <= 0) {
|
||||
if (typeof cost === "number" && cost > 0) {
|
||||
return money.format(cost)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const text =
|
||||
limit && limit > 0 ? `${Locale.number(total)} (${Math.round((total / limit) * 100)}%)` : Locale.number(total)
|
||||
|
||||
if (typeof cost === "number" && cost > 0) {
|
||||
return `${text} · ${money.format(cost)}`
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
export function formatError(error: {
|
||||
name?: string
|
||||
message?: string
|
||||
data?: {
|
||||
message?: string
|
||||
}
|
||||
}): string {
|
||||
if (error.data?.message) {
|
||||
return error.data.message
|
||||
}
|
||||
|
||||
if (error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error.name) {
|
||||
return error.name
|
||||
}
|
||||
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
function isAbort(error: { name?: string } | undefined): boolean {
|
||||
return error?.name === "MessageAbortedError"
|
||||
}
|
||||
|
||||
function msgErr(id: string): string {
|
||||
return `msg:${id}:error`
|
||||
}
|
||||
|
||||
function patch(patch?: FooterPatch, view?: FooterView): FooterOutput | undefined {
|
||||
if (!patch && !view) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
patch,
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
function out(data: SessionData, commits: SessionCommit[], footer?: FooterOutput): SessionDataOutput {
|
||||
if (!footer) {
|
||||
return {
|
||||
data,
|
||||
commits,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
commits,
|
||||
footer,
|
||||
}
|
||||
}
|
||||
|
||||
export function pickBlockerView(input: {
|
||||
permission?: PermissionRequest
|
||||
question?: QuestionRequest
|
||||
}): FooterView {
|
||||
if (input.permission) {
|
||||
return { type: "permission", request: input.permission }
|
||||
}
|
||||
|
||||
if (input.question) {
|
||||
return { type: "question", request: input.question }
|
||||
}
|
||||
|
||||
return { type: "prompt" }
|
||||
}
|
||||
|
||||
export function blockerStatus(view: FooterView) {
|
||||
if (view.type === "permission") {
|
||||
return "awaiting permission"
|
||||
}
|
||||
|
||||
if (view.type === "question") {
|
||||
return "awaiting answer"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function pickSessionView(data: SessionData): FooterView {
|
||||
return pickBlockerView({
|
||||
permission: data.permissions[0],
|
||||
question: data.questions[0],
|
||||
})
|
||||
}
|
||||
|
||||
function queueFooter(data: SessionData): FooterOutput {
|
||||
const view = pickSessionView(data)
|
||||
|
||||
return {
|
||||
view,
|
||||
patch: { status: blockerStatus(view) },
|
||||
}
|
||||
}
|
||||
|
||||
function queueOut(data: SessionData, commits: SessionCommit[]): SessionDataOutput {
|
||||
return out(data, commits, queueFooter(data))
|
||||
}
|
||||
|
||||
function upsert<T extends { id: string }>(list: T[], item: T) {
|
||||
const idx = list.findIndex((entry) => entry.id === item.id)
|
||||
if (idx === -1) {
|
||||
list.push(item)
|
||||
return
|
||||
}
|
||||
|
||||
list[idx] = item
|
||||
}
|
||||
|
||||
function remove(list: Array<{ id: string }>, id: string): boolean {
|
||||
const idx = list.findIndex((entry) => entry.id === id)
|
||||
if (idx === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
list.splice(idx, 1)
|
||||
return true
|
||||
}
|
||||
|
||||
export function bootstrapSessionData(input: {
|
||||
data: SessionData
|
||||
messages: Array<{
|
||||
parts: Part[]
|
||||
}>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
}) {
|
||||
for (const message of input.messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool") {
|
||||
continue
|
||||
}
|
||||
|
||||
input.data.call.set(key(part.messageID, part.callID), part.state.input)
|
||||
}
|
||||
}
|
||||
|
||||
for (const request of input.permissions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
upsert(input.data.permissions, enrichPermission(input.data, request))
|
||||
}
|
||||
|
||||
for (const request of input.questions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
upsert(input.data.questions, request)
|
||||
}
|
||||
}
|
||||
|
||||
function key(msg: string, call: string): string {
|
||||
return `${msg}:${call}`
|
||||
}
|
||||
|
||||
function enrichPermission(data: SessionData, request: PermissionRequest): PermissionRequest {
|
||||
if (!request.tool) {
|
||||
return request
|
||||
}
|
||||
|
||||
const input = data.call.get(key(request.tool.messageID, request.tool.callID))
|
||||
if (!input) {
|
||||
return request
|
||||
}
|
||||
|
||||
const meta = request.metadata ?? {}
|
||||
if (meta.input === input) {
|
||||
return request
|
||||
}
|
||||
|
||||
return {
|
||||
...request,
|
||||
metadata: {
|
||||
...meta,
|
||||
input,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the active permission request when the matching tool part gets
|
||||
// new input (e.g., a diff). This keeps the permission UI in sync with the
|
||||
// tool's evolving state. Only triggers a footer update if the currently
|
||||
// displayed permission was the one that changed.
|
||||
function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undefined {
|
||||
data.call.set(key(part.messageID, part.callID), part.state.input)
|
||||
if (data.permissions.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let changed = false
|
||||
let active = false
|
||||
data.permissions = data.permissions.map((request, index) => {
|
||||
if (!request.tool || request.tool.messageID !== part.messageID || request.tool.callID !== part.callID) {
|
||||
return request
|
||||
}
|
||||
|
||||
const next = enrichPermission(data, request)
|
||||
if (next === request) {
|
||||
return request
|
||||
}
|
||||
|
||||
changed = true
|
||||
active ||= index === 0
|
||||
return next
|
||||
})
|
||||
|
||||
if (!changed || !active) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
view: pickSessionView(data),
|
||||
}
|
||||
}
|
||||
|
||||
function toolStatus(part: ToolPart): string {
|
||||
if (part.tool !== "task") {
|
||||
return `running ${part.tool}`
|
||||
}
|
||||
|
||||
const state = part.state as {
|
||||
input?: {
|
||||
description?: unknown
|
||||
subagent_type?: unknown
|
||||
}
|
||||
}
|
||||
const desc = state.input?.description
|
||||
if (typeof desc === "string" && desc.trim()) {
|
||||
return `running ${desc.trim()}`
|
||||
}
|
||||
|
||||
const type = state.input?.subagent_type
|
||||
if (typeof type === "string" && type.trim()) {
|
||||
return `running ${type.trim()}`
|
||||
}
|
||||
|
||||
return "running task"
|
||||
}
|
||||
|
||||
// Returns true if we can flush this part's text to scrollback.
|
||||
//
|
||||
// We gate on the message role being "assistant" because user-role messages
|
||||
// also contain text parts (the user's own input) which we don't want to
|
||||
// echo. If we haven't received the message.updated event yet, we return
|
||||
// false and the text stays buffered until replay() flushes it.
|
||||
function ready(data: SessionData, partID: string): boolean {
|
||||
const msg = data.msg.get(partID)
|
||||
if (!msg) {
|
||||
return true
|
||||
}
|
||||
|
||||
const role = data.role.get(msg)
|
||||
if (!role) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
return true
|
||||
}
|
||||
|
||||
return data.includeUserText && role === "user"
|
||||
}
|
||||
|
||||
function syncText(data: SessionData, partID: string, next: string) {
|
||||
const prev = data.text.get(partID) ?? ""
|
||||
if (!next) {
|
||||
return prev
|
||||
}
|
||||
|
||||
if (!prev || next.length >= prev.length) {
|
||||
data.text.set(partID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
return prev
|
||||
}
|
||||
|
||||
// Records bash tool output for echo stripping. Some models echo bash output
|
||||
// verbatim at the start of their next text part. We save both the raw and
|
||||
// trimmed forms so stripEcho() can match either.
|
||||
function stashEcho(data: SessionData, part: ToolPart) {
|
||||
if (part.tool !== "bash") {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof part.messageID !== "string" || !part.messageID) {
|
||||
return
|
||||
}
|
||||
|
||||
const output = "output" in part.state ? part.state.output : undefined
|
||||
if (typeof output !== "string") {
|
||||
return
|
||||
}
|
||||
|
||||
const text = output.replace(/^\n+/, "")
|
||||
if (!text.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const set = data.echo.get(part.messageID) ?? new Set<string>()
|
||||
set.add(text)
|
||||
const trim = text.replace(/\n+$/, "")
|
||||
if (trim && trim !== text) {
|
||||
set.add(trim)
|
||||
}
|
||||
data.echo.set(part.messageID, set)
|
||||
}
|
||||
|
||||
function stripEcho(data: SessionData, msg: string | undefined, chunk: string): string {
|
||||
if (!msg) {
|
||||
return chunk
|
||||
}
|
||||
|
||||
const set = data.echo.get(msg)
|
||||
if (!set || set.size === 0) {
|
||||
return chunk
|
||||
}
|
||||
|
||||
data.echo.delete(msg)
|
||||
const list = [...set].sort((a, b) => b.length - a.length)
|
||||
for (const item of list) {
|
||||
if (!item || !chunk.startsWith(item)) {
|
||||
continue
|
||||
}
|
||||
|
||||
return chunk.slice(item.length).replace(/^\n+/, "")
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
function flushPart(data: SessionData, commits: SessionCommit[], partID: string, interrupted = false) {
|
||||
const kind = data.part.get(partID)
|
||||
if (!kind) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = data.text.get(partID) ?? ""
|
||||
const sent = data.sent.get(partID) ?? 0
|
||||
let chunk = text.slice(sent)
|
||||
const msg = data.msg.get(partID)
|
||||
|
||||
if (sent === 0) {
|
||||
chunk = chunk.replace(/^\n+/, "")
|
||||
// Some models emit a standalone whitespace token before real content.
|
||||
// Keep buffering until we have visible text so scrollback doesn't get a blank row.
|
||||
if (!chunk.trim()) {
|
||||
return
|
||||
}
|
||||
if (kind === "reasoning" && chunk) {
|
||||
chunk = `Thinking: ${chunk.replace(/\[REDACTED\]/g, "")}`
|
||||
}
|
||||
if (kind === "assistant" && chunk) {
|
||||
chunk = stripEcho(data, msg, chunk)
|
||||
if (!chunk.trim()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk) {
|
||||
data.sent.set(partID, text.length)
|
||||
commits.push({
|
||||
kind,
|
||||
text: chunk,
|
||||
phase: "progress",
|
||||
source: kind === "user" ? "system" : kind,
|
||||
messageID: msg,
|
||||
partID,
|
||||
})
|
||||
}
|
||||
|
||||
if (!interrupted) {
|
||||
return
|
||||
}
|
||||
|
||||
commits.push({
|
||||
kind,
|
||||
text: "",
|
||||
phase: "final",
|
||||
source: kind === "user" ? "system" : kind,
|
||||
messageID: msg,
|
||||
partID,
|
||||
interrupted: true,
|
||||
})
|
||||
}
|
||||
|
||||
function drop(data: SessionData, partID: string) {
|
||||
data.part.delete(partID)
|
||||
data.text.delete(partID)
|
||||
data.sent.delete(partID)
|
||||
data.msg.delete(partID)
|
||||
data.end.delete(partID)
|
||||
}
|
||||
|
||||
// Called when we learn a message's role (from message.updated). Flushes any
|
||||
// buffered text parts that were waiting on role confirmation. User-role
|
||||
// parts are silently dropped.
|
||||
function replay(data: SessionData, commits: SessionCommit[], messageID: string, role: MessageRole, thinking: boolean) {
|
||||
for (const [partID, msg] of data.msg.entries()) {
|
||||
if (msg !== messageID || data.ids.has(partID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === "user" && !data.includeUserText) {
|
||||
data.ids.add(partID)
|
||||
drop(data, partID)
|
||||
continue
|
||||
}
|
||||
|
||||
const kind = data.part.get(partID)
|
||||
if (!kind) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === "user" && kind === "assistant") {
|
||||
data.part.set(partID, "user")
|
||||
}
|
||||
|
||||
if (kind === "reasoning" && !thinking) {
|
||||
if (data.end.has(partID)) {
|
||||
data.ids.add(partID)
|
||||
}
|
||||
drop(data, partID)
|
||||
continue
|
||||
}
|
||||
|
||||
flushPart(data, commits, partID)
|
||||
|
||||
if (!data.end.has(partID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
data.ids.add(partID)
|
||||
drop(data, partID)
|
||||
}
|
||||
}
|
||||
|
||||
function toolCommit(
|
||||
part: ToolPart,
|
||||
next: Pick<SessionCommit, "text" | "phase" | "toolState"> & { toolError?: string },
|
||||
): SessionCommit {
|
||||
return {
|
||||
kind: "tool",
|
||||
source: "tool",
|
||||
messageID: part.messageID,
|
||||
partID: part.id,
|
||||
tool: part.tool,
|
||||
part,
|
||||
...next,
|
||||
}
|
||||
}
|
||||
|
||||
function startTool(part: ToolPart): SessionCommit {
|
||||
return toolCommit(part, {
|
||||
text: toolStatus(part),
|
||||
phase: "start",
|
||||
toolState: "running",
|
||||
})
|
||||
}
|
||||
|
||||
function doneTool(part: ToolPart): SessionCommit {
|
||||
return toolCommit(part, {
|
||||
text: "",
|
||||
phase: "final",
|
||||
toolState: "completed",
|
||||
})
|
||||
}
|
||||
|
||||
function failTool(part: ToolPart, text: string): SessionCommit {
|
||||
return toolCommit(part, {
|
||||
text,
|
||||
phase: "final",
|
||||
toolState: "error",
|
||||
toolError: text,
|
||||
})
|
||||
}
|
||||
|
||||
// Emits "interrupted" final entries for all in-flight parts. Called when a turn is aborted.
|
||||
export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
|
||||
for (const partID of data.part.keys()) {
|
||||
if (data.ids.has(partID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const msg = data.msg.get(partID)
|
||||
if (msg && data.role.get(msg) === "user" && !data.includeUserText) {
|
||||
data.ids.add(partID)
|
||||
drop(data, partID)
|
||||
continue
|
||||
}
|
||||
|
||||
flushPart(data, commits, partID, true)
|
||||
data.ids.add(partID)
|
||||
drop(data, partID)
|
||||
}
|
||||
}
|
||||
|
||||
// The main reducer. Takes one SDK event and returns scrollback commits and
|
||||
// footer updates. Called once per event from the stream transport's watch loop.
|
||||
//
|
||||
// Event handling follows the SDK event types:
|
||||
// message.updated → learn role, flush buffered parts, track usage
|
||||
// message.part.delta → accumulate text, flush if ready
|
||||
// message.part.updated → handle text/reasoning/tool state transitions
|
||||
// permission.* → manage the permission queue, drive footer view
|
||||
// question.* → manage the question queue, drive footer view
|
||||
// session.error → emit error scrollback entry
|
||||
export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
|
||||
const commits: SessionCommit[] = []
|
||||
const data = input.data
|
||||
const event = input.event
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const info = event.properties.info
|
||||
if (typeof info.id === "string") {
|
||||
data.role.set(info.id, info.role)
|
||||
replay(data, commits, info.id, info.role, input.thinking)
|
||||
}
|
||||
|
||||
if (info.role !== "assistant") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
let next: FooterPatch | undefined
|
||||
if (!data.announced) {
|
||||
data.announced = true
|
||||
next = { status: "assistant responding" }
|
||||
}
|
||||
|
||||
const usage = formatUsage(
|
||||
info.tokens,
|
||||
input.limits[modelKey(info.providerID, info.modelID)],
|
||||
typeof info.cost === "number" ? info.cost : undefined,
|
||||
)
|
||||
if (usage) {
|
||||
next = {
|
||||
...next,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof info.id === "string" && info.error && !isAbort(info.error) && !data.ids.has(msgErr(info.id))) {
|
||||
data.ids.add(msgErr(info.id))
|
||||
commits.push({
|
||||
kind: "error",
|
||||
text: formatError(info.error),
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: info.id,
|
||||
})
|
||||
}
|
||||
|
||||
return out(data, commits, patch(next))
|
||||
}
|
||||
|
||||
if (event.type === "message.part.delta") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (
|
||||
typeof event.properties.partID !== "string" ||
|
||||
typeof event.properties.field !== "string" ||
|
||||
typeof event.properties.delta !== "string"
|
||||
) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (event.properties.field !== "text") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const partID = event.properties.partID
|
||||
if (data.ids.has(partID)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (typeof event.properties.messageID === "string") {
|
||||
data.msg.set(partID, event.properties.messageID)
|
||||
}
|
||||
|
||||
const text = data.text.get(partID) ?? ""
|
||||
data.text.set(partID, text + event.properties.delta)
|
||||
|
||||
const kind = data.part.get(partID)
|
||||
if (!kind) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (kind === "reasoning" && !input.thinking) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (!ready(data, partID)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
flushPart(data, commits, partID)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (part.type === "tool") {
|
||||
const view = syncPermission(data, part)
|
||||
|
||||
if (part.state.status === "running") {
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits, view)
|
||||
}
|
||||
|
||||
if (!data.tools.has(part.id)) {
|
||||
data.tools.add(part.id)
|
||||
commits.push(startTool(part))
|
||||
}
|
||||
|
||||
return out(data, commits, view ?? patch({ status: toolStatus(part) }))
|
||||
}
|
||||
|
||||
if (part.state.status === "completed") {
|
||||
const seen = data.tools.has(part.id)
|
||||
const mode = toolView(part.tool)
|
||||
data.tools.delete(part.id)
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits, view)
|
||||
}
|
||||
|
||||
if (!seen) {
|
||||
commits.push(startTool(part))
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
stashEcho(data, part)
|
||||
|
||||
const output = part.state.output
|
||||
if (mode.output && typeof output === "string" && output.trim()) {
|
||||
commits.push({
|
||||
kind: "tool",
|
||||
text: output,
|
||||
phase: "progress",
|
||||
source: "tool",
|
||||
messageID: part.messageID,
|
||||
partID: part.id,
|
||||
tool: part.tool,
|
||||
part,
|
||||
toolState: "completed",
|
||||
})
|
||||
}
|
||||
|
||||
if (mode.final) {
|
||||
commits.push(doneTool(part))
|
||||
}
|
||||
|
||||
return out(data, commits, view)
|
||||
}
|
||||
|
||||
if (part.state.status === "error") {
|
||||
data.tools.delete(part.id)
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits, view)
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
const text =
|
||||
typeof part.state.error === "string" && part.state.error.trim() ? part.state.error : "unknown error"
|
||||
commits.push(failTool(part, text))
|
||||
return out(data, commits, view)
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type !== "text" && part.type !== "reasoning") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const kind = part.type === "text" ? "assistant" : "reasoning"
|
||||
if (typeof part.messageID === "string") {
|
||||
data.msg.set(part.id, part.messageID)
|
||||
}
|
||||
|
||||
const msg = part.messageID
|
||||
const role = msg ? data.role.get(msg) : undefined
|
||||
if (role === "user" && part.type === "text" && !data.includeUserText) {
|
||||
data.ids.add(part.id)
|
||||
drop(data, part.id)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (kind === "reasoning" && !input.thinking) {
|
||||
if (part.time?.end) {
|
||||
data.ids.add(part.id)
|
||||
}
|
||||
drop(data, part.id)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.part.set(part.id, role === "user" && kind === "assistant" ? "user" : kind)
|
||||
syncText(data, part.id, part.text)
|
||||
|
||||
if (part.time?.end) {
|
||||
data.end.add(part.id)
|
||||
}
|
||||
|
||||
if (msg && !role) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (!ready(data, part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
flushPart(data, commits, part.id)
|
||||
|
||||
if (!part.time?.end) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
drop(data, part.id)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
upsert(data.permissions, enrichPermission(data, event.properties))
|
||||
return queueOut(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "permission.replied") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (!remove(data.permissions, event.properties.requestID)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
return queueOut(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "question.asked") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
upsert(data.questions, event.properties)
|
||||
return queueOut(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "question.replied" || event.type === "question.rejected") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (!remove(data.questions, event.properties.requestID)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
return queueOut(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
if (event.properties.sessionID !== input.sessionID || !event.properties.error) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
commits.push({
|
||||
kind: "error",
|
||||
text: formatError(event.properties.error),
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
return out(data, commits)
|
||||
}
|
||||
196
packages/opencode/src/cli/cmd/run/session.shared.ts
Normal file
196
packages/opencode/src/cli/cmd/run/session.shared.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// Session message extraction and prompt history.
|
||||
//
|
||||
// Fetches session messages from the SDK and extracts user turn text for
|
||||
// the prompt history ring. Also finds the most recently used variant for
|
||||
// the current model so the footer can pre-select it.
|
||||
import { promptCopy, promptSame } from "./prompt.shared"
|
||||
import type { RunInput, RunPrompt } from "./types"
|
||||
|
||||
const LIMIT = 200
|
||||
|
||||
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
|
||||
|
||||
type Turn = {
|
||||
prompt: RunPrompt
|
||||
provider: string | undefined
|
||||
model: string | undefined
|
||||
variant: string | undefined
|
||||
}
|
||||
|
||||
export type RunSession = {
|
||||
first: boolean
|
||||
turns: Turn[]
|
||||
}
|
||||
|
||||
function fileName(url: string, filename?: string) {
|
||||
if (filename) {
|
||||
return filename
|
||||
}
|
||||
|
||||
try {
|
||||
const next = new URL(url)
|
||||
if (next.protocol !== "file:") {
|
||||
return url
|
||||
}
|
||||
|
||||
const name = next.pathname.split("/").at(-1)
|
||||
if (name) {
|
||||
return decodeURIComponent(name)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function fileSource(
|
||||
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
|
||||
text: { start: number; end: number; value: string },
|
||||
) {
|
||||
if (part.source) {
|
||||
return {
|
||||
...structuredClone(part.source),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "file" as const,
|
||||
path: part.filename ?? part.url,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
function prompt(msg: SessionMessages[number]): RunPrompt {
|
||||
const parts: RunPrompt["parts"] = []
|
||||
let text = msg.parts
|
||||
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
||||
return part.type === "text" && !part.synthetic
|
||||
})
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
let cursor = Bun.stringWidth(text)
|
||||
const used: Array<{ start: number; end: number }> = []
|
||||
|
||||
const take = (value: string): { start: number; end: number; value: string } | undefined => {
|
||||
let from = 0
|
||||
while (true) {
|
||||
const idx = text.indexOf(value, from)
|
||||
if (idx === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const start = Bun.stringWidth(text.slice(0, idx))
|
||||
const end = start + Bun.stringWidth(value)
|
||||
if (!used.some((item) => item.start < end && start < item.end)) {
|
||||
return { start, end, value }
|
||||
}
|
||||
|
||||
from = idx + value.length
|
||||
}
|
||||
}
|
||||
|
||||
const add = (value: string) => {
|
||||
const gap = text ? " " : ""
|
||||
const start = cursor + Bun.stringWidth(gap)
|
||||
text += gap + value
|
||||
const end = start + Bun.stringWidth(value)
|
||||
cursor = end
|
||||
return { start, end, value }
|
||||
}
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "file") {
|
||||
const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename))
|
||||
const span = next ?? add("@" + fileName(part.url, part.filename))
|
||||
used.push({ start: span.start, end: span.end })
|
||||
parts.push({
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: fileSource(part, span),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "agent") {
|
||||
continue
|
||||
}
|
||||
|
||||
const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name))
|
||||
used.push({ start: span.start, end: span.end })
|
||||
parts.push({
|
||||
type: "agent",
|
||||
name: part.name,
|
||||
source: span,
|
||||
})
|
||||
}
|
||||
|
||||
return { text, parts }
|
||||
}
|
||||
|
||||
function turn(msg: SessionMessages[number]): Turn | undefined {
|
||||
if (msg.info.role !== "user") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: prompt(msg),
|
||||
provider: msg.info.model.providerID,
|
||||
model: msg.info.model.modelID,
|
||||
variant: msg.info.model.variant,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSession(messages: SessionMessages): RunSession {
|
||||
return {
|
||||
first: messages.length === 0,
|
||||
turns: messages.flatMap((msg) => {
|
||||
const item = turn(msg)
|
||||
return item ? [item] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise<RunSession> {
|
||||
const response = await sdk.session.messages({
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
return createSession(response.data ?? [])
|
||||
}
|
||||
|
||||
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
||||
const out: RunPrompt[] = []
|
||||
|
||||
for (const turn of session.turns) {
|
||||
if (!turn.prompt.text.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(promptCopy(turn.prompt))
|
||||
}
|
||||
|
||||
return out.slice(-limit)
|
||||
}
|
||||
|
||||
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
|
||||
const turn = session.turns[idx]
|
||||
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
|
||||
continue
|
||||
}
|
||||
|
||||
return turn.variant
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
302
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
302
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
// Entry and exit splash banners for direct interactive mode scrollback.
|
||||
//
|
||||
// Renders the full opencode entry logo and a compact [O] exit badge, plus
|
||||
// session metadata and the resume command. These are scrollback snapshots, so
|
||||
// they become immutable terminal history once committed.
|
||||
//
|
||||
// Both variants use a cell-based renderer. cells() classifies each character
|
||||
// in the source template as text, full-block, half-block-mix, or
|
||||
// half-block-top, and draw() renders it with foreground/background shadow
|
||||
// colors from the theme.
|
||||
import {
|
||||
BoxRenderable,
|
||||
type ColorInput,
|
||||
RGBA,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackSnapshot,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { go, logo } from "@/cli/logo"
|
||||
import type { RunSplashTheme } from "./theme"
|
||||
|
||||
export const SPLASH_TITLE_LIMIT = 50
|
||||
export const SPLASH_TITLE_FALLBACK = "Untitled session"
|
||||
|
||||
type SplashInput = {
|
||||
title: string | undefined
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type SplashWriterInput = SplashInput & {
|
||||
theme: RunSplashTheme
|
||||
showSession?: boolean
|
||||
}
|
||||
|
||||
export type SplashMeta = {
|
||||
title: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type Cell = {
|
||||
char: string
|
||||
mark: "text" | "full" | "mix" | "top"
|
||||
}
|
||||
|
||||
let id = 0
|
||||
|
||||
function cells(line: string): Cell[] {
|
||||
const list: Cell[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
list.push({ char: " ", mark: "full" })
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "^") {
|
||||
list.push({ char: "▀", mark: "mix" })
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "~") {
|
||||
list.push({ char: "▀", mark: "top" })
|
||||
continue
|
||||
}
|
||||
|
||||
list.push({ char, mark: "text" })
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
function title(text: string | undefined): string {
|
||||
if (!text) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
let value = ""
|
||||
let gap = false
|
||||
for (const char of text.trim()) {
|
||||
if (char === " " || char === "\n" || char === "\r" || char === "\t") {
|
||||
gap = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (gap && value.length > 0) {
|
||||
value += " "
|
||||
}
|
||||
|
||||
value += char
|
||||
gap = false
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
return Locale.truncate(value, SPLASH_TITLE_LIMIT)
|
||||
}
|
||||
|
||||
function write(
|
||||
root: BoxRenderable,
|
||||
ctx: ScrollbackRenderContext,
|
||||
line: {
|
||||
left: number
|
||||
top: number
|
||||
text: string
|
||||
fg: ColorInput
|
||||
bg?: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
): void {
|
||||
if (line.left >= ctx.width) {
|
||||
return
|
||||
}
|
||||
|
||||
root.add(
|
||||
new TextRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-line-${id++}`,
|
||||
position: "absolute",
|
||||
left: line.left,
|
||||
top: line.top,
|
||||
width: Math.max(1, ctx.width - line.left),
|
||||
height: 1,
|
||||
wrapMode: "none",
|
||||
content: line.text,
|
||||
fg: line.fg,
|
||||
bg: line.bg,
|
||||
attributes: line.attrs,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function push(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
left: number,
|
||||
top: number,
|
||||
text: string,
|
||||
fg: ColorInput,
|
||||
bg?: ColorInput,
|
||||
attrs?: number,
|
||||
): void {
|
||||
lines.push({ left, top, text, fg, bg, attrs })
|
||||
}
|
||||
|
||||
function color(input: ColorInput, fallback: RGBA): RGBA {
|
||||
if (input instanceof RGBA) {
|
||||
return input
|
||||
}
|
||||
|
||||
if (typeof input === "string") {
|
||||
if (input === "transparent" || input === "none") {
|
||||
return RGBA.fromValues(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (input.startsWith("#")) {
|
||||
return RGBA.fromHex(input)
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function fallback(index: number, hex: string): RGBA {
|
||||
return RGBA.fromIndex(index, RGBA.fromHex(hex))
|
||||
}
|
||||
|
||||
function draw(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
row: string,
|
||||
input: {
|
||||
left: number
|
||||
top: number
|
||||
fg: ColorInput
|
||||
shadow: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
) {
|
||||
let x = input.left
|
||||
for (const cell of cells(row)) {
|
||||
if (cell.mark === "full" || cell.mark === "mix") {
|
||||
push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.mark === "top") {
|
||||
push(lines, x, input.top, cell.char, input.shadow, undefined, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
push(lines, x, input.top, cell.char, input.fg, undefined, input.attrs)
|
||||
x += 1
|
||||
}
|
||||
}
|
||||
|
||||
function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: ScrollbackRenderContext): ScrollbackSnapshot {
|
||||
const width = Math.max(1, ctx.width)
|
||||
const meta = splashMeta(input)
|
||||
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
|
||||
const left = color(input.theme.left, fallback(81, "#38bdf8"))
|
||||
const right = color(input.theme.right, RGBA.defaultForeground(RGBA.fromHex("#f8fafc")))
|
||||
const leftShadow = color(input.theme.leftShadow, fallback(238, "#334155"))
|
||||
let height = 1
|
||||
|
||||
if (kind === "entry") {
|
||||
const rightShadow = color(input.theme.rightShadow, fallback(240, "#475569"))
|
||||
|
||||
for (let i = 0; i < logo.left.length; i += 1) {
|
||||
const leftText = logo.left[i] ?? ""
|
||||
const rightText = logo.right[i] ?? ""
|
||||
|
||||
draw(lines, leftText, {
|
||||
left: 0,
|
||||
top: i,
|
||||
fg: left,
|
||||
shadow: leftShadow,
|
||||
})
|
||||
draw(lines, rightText, {
|
||||
left: leftText.length + 1,
|
||||
top: i,
|
||||
fg: right,
|
||||
shadow: rightShadow,
|
||||
})
|
||||
}
|
||||
|
||||
height = logo.left.length + 1
|
||||
|
||||
if (input.showSession !== false) {
|
||||
const top = logo.left.length + 1
|
||||
const label = "Session".padEnd(10, " ")
|
||||
push(lines, 0, top, label, left, undefined, TextAttributes.DIM)
|
||||
push(lines, label.length, top, meta.title, right, undefined, TextAttributes.BOLD)
|
||||
height = top + 1
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "exit") {
|
||||
const mark = go.right.slice(1)
|
||||
const top = 1
|
||||
const body_left = (mark[0]?.length ?? 0) + 2
|
||||
const session = "Session "
|
||||
const label = "Continue "
|
||||
|
||||
for (let i = 0; i < mark.length; i += 1) {
|
||||
draw(lines, mark[i] ?? "", {
|
||||
left: 0,
|
||||
top: top + i,
|
||||
fg: left,
|
||||
shadow: leftShadow,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.showSession !== false) {
|
||||
push(lines, body_left, top, session, left, undefined, TextAttributes.DIM)
|
||||
push(lines, body_left + session.length, top, meta.title, right, undefined, TextAttributes.BOLD)
|
||||
}
|
||||
|
||||
push(lines, body_left, top + 1, label, left, undefined, TextAttributes.DIM)
|
||||
push(lines, body_left + label.length, top + 1, `opencode run -i -s ${meta.session_id}`, right, undefined, TextAttributes.BOLD)
|
||||
height = top + mark.length
|
||||
}
|
||||
|
||||
const root = new BoxRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-${kind}-${id++}`,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
for (const line of lines) {
|
||||
write(root, ctx, line)
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
width,
|
||||
height,
|
||||
rowColumns: width,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function splashMeta(input: SplashInput): SplashMeta {
|
||||
return {
|
||||
title: title(input.title),
|
||||
session_id: input.session_id,
|
||||
}
|
||||
}
|
||||
|
||||
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "entry", ctx)
|
||||
}
|
||||
|
||||
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "exit", ctx)
|
||||
}
|
||||
876
packages/opencode/src/cli/cmd/run/stream.transport.ts
Normal file
876
packages/opencode/src/cli/cmd/run/stream.transport.ts
Normal file
@@ -0,0 +1,876 @@
|
||||
// SDK event subscription and prompt turn coordination.
|
||||
//
|
||||
// Creates a long-lived event stream subscription and feeds every event
|
||||
// through the session-data reducer. The reducer produces scrollback commits
|
||||
// and footer patches, which get forwarded to the footer through stream.ts.
|
||||
//
|
||||
// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the
|
||||
// SDK, arms a deferred Wait, and resolves when a session.status idle event
|
||||
// arrives for this session. If the turn is aborted (user interrupt), it
|
||||
// flushes any in-progress parts as interrupted entries.
|
||||
//
|
||||
// The tick counter prevents stale idle events from resolving the wrong turn.
|
||||
// We also re-check live session status before resolving an idle event so a
|
||||
// delayed idle from an older turn cannot complete a newer busy turn.
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import {
|
||||
blockerStatus,
|
||||
bootstrapSessionData,
|
||||
createSessionData,
|
||||
flushInterrupted,
|
||||
pickBlockerView,
|
||||
reduceSessionData,
|
||||
type SessionData,
|
||||
} from "./session-data"
|
||||
import {
|
||||
bootstrapSubagentCalls,
|
||||
bootstrapSubagentData,
|
||||
clearFinishedSubagents,
|
||||
createSubagentData,
|
||||
listSubagentPermissions,
|
||||
listSubagentQuestions,
|
||||
listSubagentTabs,
|
||||
reduceSubagentData,
|
||||
sameSubagentTab,
|
||||
snapshotSelectedSubagentData,
|
||||
SUBAGENT_BOOTSTRAP_LIMIT,
|
||||
SUBAGENT_CALL_BOOTSTRAP_LIMIT,
|
||||
type SubagentData,
|
||||
} from "./subagent-data"
|
||||
import { traceFooterOutput, writeSessionOutput } from "./stream"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterOutput,
|
||||
FooterPatch,
|
||||
FooterSubagentState,
|
||||
FooterSubagentTab,
|
||||
FooterView,
|
||||
RunFilePart,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
type StreamInput = {
|
||||
sdk: OpencodeClient
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: () => Record<string, number>
|
||||
footer: FooterApi
|
||||
trace?: Trace
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
type Wait = {
|
||||
tick: number
|
||||
armed: boolean
|
||||
live: boolean
|
||||
done: Deferred.Deferred<void, unknown>
|
||||
}
|
||||
|
||||
export type SessionTurnInput = {
|
||||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
prompt: RunPrompt
|
||||
files: RunFilePart[]
|
||||
includeFiles: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
type State = {
|
||||
data: SessionData
|
||||
subagent: SubagentData
|
||||
wait?: Wait
|
||||
tick: number
|
||||
fault?: unknown
|
||||
footerView: FooterView
|
||||
blockerTick: number
|
||||
selectedSubagent?: string
|
||||
blockers: Map<string, number>
|
||||
}
|
||||
|
||||
type TransportService = {
|
||||
readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect<void, unknown>
|
||||
readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect<void>
|
||||
readonly close: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
class Service extends Context.Service<Service, TransportService>()("@opencode/RunStreamTransport") {}
|
||||
|
||||
function sid(event: Event): string | undefined {
|
||||
if (event.type === "message.updated") {
|
||||
return event.properties.sessionID
|
||||
}
|
||||
|
||||
if (event.type === "message.part.delta") {
|
||||
return event.properties.sessionID
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
return event.properties.part.sessionID
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "permission.asked" ||
|
||||
event.type === "permission.replied" ||
|
||||
event.type === "question.asked" ||
|
||||
event.type === "question.replied" ||
|
||||
event.type === "question.rejected" ||
|
||||
event.type === "session.error" ||
|
||||
event.type === "session.status"
|
||||
) {
|
||||
return event.properties.sessionID
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isEvent(value: unknown): value is Event {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const type = Reflect.get(value, "type")
|
||||
const properties = Reflect.get(value, "properties")
|
||||
return typeof type === "string" && !!properties && typeof properties === "object"
|
||||
}
|
||||
|
||||
function active(event: Event, sessionID: string): boolean {
|
||||
if (sid(event) !== sessionID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.type !== "session.status") {
|
||||
return true
|
||||
}
|
||||
|
||||
return event.properties.status.type !== "idle"
|
||||
}
|
||||
|
||||
// Races the turn's deferred completion against an abort signal.
|
||||
function waitTurn(done: Wait["done"], signal: AbortSignal) {
|
||||
return Effect.raceAll([
|
||||
Deferred.await(done).pipe(Effect.as("idle" as const), Effect.exit),
|
||||
Effect.callback<"abort">((resume) => {
|
||||
if (signal.aborted) {
|
||||
resume(Effect.succeed("abort"))
|
||||
return Effect.void
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resume(Effect.succeed("abort"))
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
|
||||
}).pipe(Effect.exit),
|
||||
]).pipe(
|
||||
Effect.flatMap((exit) => (Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.succeed(exit.value))),
|
||||
)
|
||||
}
|
||||
|
||||
export function formatUnknownError(error: unknown): string {
|
||||
if (typeof error === "string") {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name
|
||||
}
|
||||
|
||||
if (error && typeof error === "object") {
|
||||
const value = error as { message?: unknown; name?: unknown }
|
||||
if (typeof value.message === "string" && value.message.trim()) {
|
||||
return value.message
|
||||
}
|
||||
|
||||
if (typeof value.name === "string" && value.name.trim()) {
|
||||
return value.name
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
function sameView(a: FooterView, b: FooterView) {
|
||||
if (a.type !== b.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (a.type === "prompt" && b.type === "prompt") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (a.type === "prompt" || b.type === "prompt") {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.request === b.request
|
||||
}
|
||||
|
||||
function blockerOrder(order: Map<string, number>, id: string) {
|
||||
return order.get(id) ?? Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
function firstByOrder<T extends { id: string }>(left: T[], right: T[], order: Map<string, number>) {
|
||||
return [...left, ...right].sort((a, b) => {
|
||||
const next = blockerOrder(order, a.id) - blockerOrder(order, b.id)
|
||||
if (next !== 0) {
|
||||
return next
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id)
|
||||
})[0]
|
||||
}
|
||||
|
||||
function pickView(data: SessionData, subagent: SubagentData, order: Map<string, number>): FooterView {
|
||||
return pickBlockerView({
|
||||
permission: firstByOrder(data.permissions, listSubagentPermissions(subagent), order),
|
||||
question: firstByOrder(data.questions, listSubagentQuestions(subagent), order),
|
||||
})
|
||||
}
|
||||
|
||||
function composeFooter(input: {
|
||||
patch?: FooterPatch
|
||||
subagent?: FooterSubagentState
|
||||
current: FooterView
|
||||
previous: FooterView
|
||||
}) {
|
||||
let footer: FooterOutput | undefined
|
||||
|
||||
if (input.subagent) {
|
||||
footer = {
|
||||
...footer,
|
||||
subagent: input.subagent,
|
||||
}
|
||||
}
|
||||
|
||||
if (!sameView(input.previous, input.current)) {
|
||||
footer = {
|
||||
...footer,
|
||||
view: input.current,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.current.type !== "prompt") {
|
||||
footer = {
|
||||
...footer,
|
||||
patch: {
|
||||
...input.patch,
|
||||
status: blockerStatus(input.current),
|
||||
},
|
||||
}
|
||||
return footer
|
||||
}
|
||||
|
||||
if (input.patch) {
|
||||
footer = {
|
||||
...footer,
|
||||
patch: input.patch,
|
||||
}
|
||||
return footer
|
||||
}
|
||||
|
||||
if (input.previous.type !== "prompt") {
|
||||
footer = {
|
||||
...footer,
|
||||
patch: {
|
||||
status: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return footer
|
||||
}
|
||||
|
||||
function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: FooterSubagentTab[]) {
|
||||
const before = new Map(prev.map((item) => [item.sessionID, item]))
|
||||
const after = new Map(next.map((item) => [item.sessionID, item]))
|
||||
|
||||
for (const [sessionID, tab] of after) {
|
||||
if (sameSubagentTab(before.get(sessionID), tab)) {
|
||||
continue
|
||||
}
|
||||
|
||||
trace?.write("subagent.tab", {
|
||||
sessionID,
|
||||
tab,
|
||||
})
|
||||
}
|
||||
|
||||
for (const sessionID of before.keys()) {
|
||||
if (after.has(sessionID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
trace?.write("subagent.tab", {
|
||||
sessionID,
|
||||
cleared: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createLayer(input: StreamInput) {
|
||||
return Layer.fresh(
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
const abort = yield* Scope.provide(scope)(
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => new AbortController()),
|
||||
(abort) => Effect.sync(() => abort.abort()),
|
||||
),
|
||||
)
|
||||
let closed = false
|
||||
let closeStream = () => {}
|
||||
const halt = () => {
|
||||
abort.abort()
|
||||
}
|
||||
const stop = () => {
|
||||
input.signal?.removeEventListener("abort", halt)
|
||||
abort.abort()
|
||||
closeStream()
|
||||
}
|
||||
const closeScope = () => {
|
||||
if (closed) {
|
||||
return Effect.void
|
||||
}
|
||||
|
||||
closed = true
|
||||
stop()
|
||||
return Scope.close(scope, Exit.void)
|
||||
}
|
||||
|
||||
input.signal?.addEventListener("abort", halt, { once: true })
|
||||
yield* Effect.addFinalizer(() => closeScope())
|
||||
|
||||
const events = yield* Scope.provide(scope)(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() =>
|
||||
input.sdk.event.subscribe(undefined, {
|
||||
signal: abort.signal,
|
||||
}),
|
||||
),
|
||||
(events) =>
|
||||
Effect.sync(() => {
|
||||
void events.stream.return(undefined).catch(() => {})
|
||||
}),
|
||||
),
|
||||
)
|
||||
closeStream = () => {
|
||||
void events.stream.return(undefined).catch(() => {})
|
||||
}
|
||||
input.trace?.write("recv.subscribe", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
const state: State = {
|
||||
data: createSessionData(),
|
||||
subagent: createSubagentData(),
|
||||
tick: 0,
|
||||
footerView: { type: "prompt" },
|
||||
blockerTick: 0,
|
||||
blockers: new Map(),
|
||||
}
|
||||
|
||||
const currentSubagentState = () => {
|
||||
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
|
||||
state.selectedSubagent = undefined
|
||||
}
|
||||
|
||||
return snapshotSelectedSubagentData(state.subagent, state.selectedSubagent)
|
||||
}
|
||||
|
||||
const seedBlocker = (id: string) => {
|
||||
if (state.blockers.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.blockerTick += 1
|
||||
state.blockers.set(id, state.blockerTick)
|
||||
}
|
||||
|
||||
const trackBlocker = (event: Event) => {
|
||||
if (event.type !== "permission.asked" && event.type !== "question.asked") {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.properties.sessionID !== input.sessionID &&
|
||||
!state.subagent.tabs.has(event.properties.sessionID)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
seedBlocker(event.properties.id)
|
||||
}
|
||||
|
||||
const releaseBlocker = (event: Event) => {
|
||||
if (
|
||||
event.type !== "permission.replied" &&
|
||||
event.type !== "question.replied" &&
|
||||
event.type !== "question.rejected"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
state.blockers.delete(event.properties.requestID)
|
||||
}
|
||||
|
||||
const syncFooter = (commits: StreamCommit[], patch?: FooterPatch, nextSubagent?: FooterSubagentState) => {
|
||||
const current = pickView(state.data, state.subagent, state.blockers)
|
||||
const footer = composeFooter({
|
||||
patch,
|
||||
subagent: nextSubagent,
|
||||
current,
|
||||
previous: state.footerView,
|
||||
})
|
||||
|
||||
if (commits.length === 0 && !footer) {
|
||||
state.footerView = current
|
||||
return
|
||||
}
|
||||
|
||||
input.trace?.write("reduce.output", {
|
||||
commits,
|
||||
footer: traceFooterOutput(footer),
|
||||
})
|
||||
writeSessionOutput(
|
||||
{
|
||||
footer: input.footer,
|
||||
trace: input.trace,
|
||||
},
|
||||
{
|
||||
commits,
|
||||
footer,
|
||||
},
|
||||
)
|
||||
state.footerView = current
|
||||
}
|
||||
|
||||
const messages = (sessionID: string, limit: number) =>
|
||||
Effect.promise(() =>
|
||||
input.sdk.session.messages({
|
||||
sessionID,
|
||||
limit,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.map((item) => item.data ?? []),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
|
||||
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
|
||||
const [messagesList, children, permissions, questions] = yield* Effect.all(
|
||||
[
|
||||
messages(input.sessionID, SUBAGENT_BOOTSTRAP_LIMIT),
|
||||
Effect.promise(() =>
|
||||
input.sdk.session.children({
|
||||
sessionID: input.sessionID,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.map((item) => item.data ?? []),
|
||||
Effect.orElseSucceed(() => []),
|
||||
),
|
||||
Effect.promise(() => input.sdk.permission.list()).pipe(
|
||||
Effect.map((item) => item.data ?? []),
|
||||
Effect.orElseSucceed(() => []),
|
||||
),
|
||||
Effect.promise(() => input.sdk.question.list()).pipe(
|
||||
Effect.map((item) => item.data ?? []),
|
||||
Effect.orElseSucceed(() => []),
|
||||
),
|
||||
],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
|
||||
bootstrapSessionData({
|
||||
data: state.data,
|
||||
messages: messagesList,
|
||||
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
|
||||
questions: questions.filter((item) => item.sessionID === input.sessionID),
|
||||
})
|
||||
bootstrapSubagentData({
|
||||
data: state.subagent,
|
||||
messages: messagesList,
|
||||
children,
|
||||
permissions,
|
||||
questions,
|
||||
})
|
||||
|
||||
const sessions = [
|
||||
...new Set(
|
||||
listSubagentPermissions(state.subagent)
|
||||
.filter((item) => item.tool && item.metadata?.input === undefined)
|
||||
.map((item) => item.sessionID),
|
||||
),
|
||||
]
|
||||
yield* Effect.forEach(
|
||||
sessions,
|
||||
(sessionID) =>
|
||||
messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe(
|
||||
Effect.tap((messagesList) =>
|
||||
Effect.sync(() => {
|
||||
bootstrapSubagentCalls({
|
||||
data: state.subagent,
|
||||
sessionID,
|
||||
messages: messagesList,
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
},
|
||||
)
|
||||
|
||||
for (const request of [
|
||||
...state.data.permissions,
|
||||
...listSubagentPermissions(state.subagent),
|
||||
...state.data.questions,
|
||||
...listSubagentQuestions(state.subagent),
|
||||
].sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
seedBlocker(request.id)
|
||||
}
|
||||
|
||||
const snapshot = currentSubagentState()
|
||||
traceTabs(input.trace, [], snapshot.tabs)
|
||||
syncFooter([], undefined, snapshot)
|
||||
})
|
||||
|
||||
const idle = Effect.fn("RunStreamTransport.idle")(() =>
|
||||
Effect.promise(() => input.sdk.session.status()).pipe(
|
||||
Effect.map((out) => {
|
||||
const item = out.data?.[input.sessionID]
|
||||
return !item || item.type === "idle"
|
||||
}),
|
||||
Effect.orElseSucceed(() => true),
|
||||
),
|
||||
)
|
||||
|
||||
const fail = Effect.fn("RunStreamTransport.fail")(function* (error: unknown) {
|
||||
if (state.fault) {
|
||||
return
|
||||
}
|
||||
|
||||
state.fault = error
|
||||
const next = state.wait
|
||||
state.wait = undefined
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
yield* Deferred.fail(next.done, error).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const touch = (event: Event) => {
|
||||
const next = state.wait
|
||||
if (!next || !active(event, input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
next.live = true
|
||||
}
|
||||
|
||||
const mark = Effect.fn("RunStreamTransport.mark")(function* (event: Event) {
|
||||
if (
|
||||
event.type !== "session.status" ||
|
||||
event.properties.sessionID !== input.sessionID ||
|
||||
event.properties.status.type !== "idle"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = state.wait
|
||||
if (!next || !next.armed || !next.live) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(yield* idle()) || state.wait !== next) {
|
||||
return
|
||||
}
|
||||
|
||||
state.tick = next.tick + 1
|
||||
state.wait = undefined
|
||||
yield* Deferred.succeed(next.done, undefined).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const flush = (type: "turn.abort" | "turn.cancel") => {
|
||||
const commits: StreamCommit[] = []
|
||||
flushInterrupted(state.data, commits)
|
||||
syncFooter(commits)
|
||||
input.trace?.write(type, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
const watch = Effect.fn("RunStreamTransport.watch")(() =>
|
||||
Stream.fromAsyncIterable(events.stream as AsyncIterable<unknown>, (error) =>
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
).pipe(
|
||||
Stream.takeUntil(() => input.footer.isClosed || abort.signal.aborted),
|
||||
Stream.runForEach(
|
||||
Effect.fn("RunStreamTransport.event")(function* (item: unknown) {
|
||||
if (input.footer.isClosed) {
|
||||
abort.abort()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEvent(item)) {
|
||||
return
|
||||
}
|
||||
|
||||
const event = item
|
||||
input.trace?.write("recv.event", event)
|
||||
trackBlocker(event)
|
||||
|
||||
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
|
||||
const next = reduceSessionData({
|
||||
data: state.data,
|
||||
event,
|
||||
sessionID: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
state.data = next.data
|
||||
|
||||
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.ensuring(
|
||||
Effect.gen(function* () {
|
||||
if (!abort.signal.aborted && !state.fault) {
|
||||
yield* fail(new Error("session event stream closed"))
|
||||
}
|
||||
closeStream()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
yield* bootstrap()
|
||||
yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
|
||||
|
||||
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
|
||||
if (closed || next.signal?.aborted || input.footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.fault) {
|
||||
yield* Effect.fail(state.fault)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.wait) {
|
||||
yield* Effect.fail(new Error("prompt already running"))
|
||||
return
|
||||
}
|
||||
|
||||
const prev = listSubagentTabs(state.subagent)
|
||||
if (clearFinishedSubagents(state.subagent)) {
|
||||
const snapshot = currentSubagentState()
|
||||
traceTabs(input.trace, prev, snapshot.tabs)
|
||||
syncFooter([], undefined, snapshot)
|
||||
}
|
||||
|
||||
const item: Wait = {
|
||||
tick: state.tick,
|
||||
armed: false,
|
||||
live: false,
|
||||
done: yield* Deferred.make<void, unknown>(),
|
||||
}
|
||||
state.wait = item
|
||||
state.data.announced = false
|
||||
|
||||
const turn = new AbortController()
|
||||
const stop = () => {
|
||||
turn.abort()
|
||||
}
|
||||
next.signal?.addEventListener("abort", stop, { once: true })
|
||||
abort.signal.addEventListener("abort", stop, { once: true })
|
||||
|
||||
const req = {
|
||||
sessionID: input.sessionID,
|
||||
agent: next.agent,
|
||||
model: next.model,
|
||||
variant: next.variant,
|
||||
parts: [
|
||||
...(next.includeFiles ? next.files : []),
|
||||
{ type: "text" as const, text: next.prompt.text },
|
||||
...next.prompt.parts,
|
||||
],
|
||||
}
|
||||
input.trace?.write("send.prompt", req)
|
||||
|
||||
const send = Effect.promise(() =>
|
||||
input.sdk.session.promptAsync(req, {
|
||||
signal: turn.signal,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
input.trace?.write("send.prompt.ok", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
item.armed = true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* send.pipe(
|
||||
Effect.flatMap(() => {
|
||||
if (turn.signal.aborted || next.signal?.aborted || input.footer.isClosed || closed) {
|
||||
if (state.wait === item) {
|
||||
state.wait = undefined
|
||||
}
|
||||
flush("turn.abort")
|
||||
return Effect.void
|
||||
}
|
||||
|
||||
if (!input.footer.isClosed && !state.data.announced) {
|
||||
input.trace?.write("ui.patch", {
|
||||
phase: "running",
|
||||
status: "waiting for assistant",
|
||||
})
|
||||
input.footer.event({
|
||||
type: "turn.wait",
|
||||
})
|
||||
}
|
||||
|
||||
if (state.tick > item.tick) {
|
||||
if (state.wait === item) {
|
||||
state.wait = undefined
|
||||
}
|
||||
return Effect.void
|
||||
}
|
||||
|
||||
return waitTurn(item.done, turn.signal).pipe(
|
||||
Effect.flatMap((status) =>
|
||||
Effect.sync(() => {
|
||||
if (state.wait === item) {
|
||||
state.wait = undefined
|
||||
}
|
||||
|
||||
if (status === "abort") {
|
||||
flush("turn.abort")
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
Effect.catch((error) => {
|
||||
if (state.wait === item) {
|
||||
state.wait = undefined
|
||||
}
|
||||
|
||||
const canceled = turn.signal.aborted || next.signal?.aborted === true || input.footer.isClosed || closed
|
||||
if (canceled) {
|
||||
flush("turn.cancel")
|
||||
return Effect.void
|
||||
}
|
||||
|
||||
if (error === state.fault) {
|
||||
return Effect.fail(error)
|
||||
}
|
||||
|
||||
input.trace?.write("send.prompt.error", {
|
||||
sessionID: input.sessionID,
|
||||
error: formatUnknownError(error),
|
||||
})
|
||||
return Effect.fail(error)
|
||||
}),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
input.trace?.write("turn.end", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
next.signal?.removeEventListener("abort", stop)
|
||||
abort.signal.removeEventListener("abort", stop)
|
||||
}),
|
||||
),
|
||||
)
|
||||
return
|
||||
})
|
||||
|
||||
const selectSubagent = Effect.fn("RunStreamTransport.selectSubagent")((sessionID: string | undefined) =>
|
||||
Effect.sync(() => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = sessionID && state.subagent.tabs.has(sessionID) ? sessionID : undefined
|
||||
if (state.selectedSubagent === next) {
|
||||
return
|
||||
}
|
||||
|
||||
state.selectedSubagent = next
|
||||
syncFooter([], undefined, currentSubagentState())
|
||||
}),
|
||||
)
|
||||
|
||||
const close = Effect.fn("RunStreamTransport.close")(function* () {
|
||||
yield* closeScope()
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
runPromptTurn,
|
||||
selectSubagent,
|
||||
close,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Opens an SDK event subscription and returns a SessionTransport.
|
||||
//
|
||||
// The background `watch` loop consumes every SDK event, runs it through the
|
||||
// reducer, and writes output to the footer. When a session.status idle
|
||||
// event arrives, it resolves the current turn's Wait so runPromptTurn()
|
||||
// can return.
|
||||
//
|
||||
// The transport is single-turn: only one runPromptTurn() call can be active
|
||||
// at a time. The prompt queue enforces this from above.
|
||||
export async function createSessionTransport(input: StreamInput): Promise<SessionTransport> {
|
||||
const runtime = makeRuntime(Service, createLayer(input))
|
||||
await runtime.runPromise(() => Effect.void)
|
||||
|
||||
return {
|
||||
runPromptTurn: (next) => runtime.runPromise((svc) => svc.runPromptTurn(next)),
|
||||
selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)),
|
||||
close: () => runtime.runPromise((svc) => svc.close()),
|
||||
}
|
||||
}
|
||||
175
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
175
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// Thin bridge between reducer output and the footer API.
|
||||
//
|
||||
// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
|
||||
// view + subagent state). This module forwards them to footer.append() and
|
||||
// footer.event() respectively, adding trace writes along the way. It also
|
||||
// defaults status updates to phase "running" if the caller didn't set a
|
||||
// phase -- a convenience so reducer code doesn't have to repeat that.
|
||||
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
type OutputInput = {
|
||||
footer: FooterApi
|
||||
trace?: Trace
|
||||
}
|
||||
|
||||
type StreamOutput = {
|
||||
commits: StreamCommit[]
|
||||
footer?: FooterOutput
|
||||
}
|
||||
|
||||
// Default to "running" phase when a status string arrives without an explicit phase.
|
||||
function patch(next: FooterPatch): FooterPatch {
|
||||
if (typeof next.status === "string" && next.phase === undefined) {
|
||||
return {
|
||||
phase: "running",
|
||||
...next,
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function summarize(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
if (value.length <= 160) {
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
type: "string",
|
||||
length: value.length,
|
||||
preview: `${value.slice(0, 160)}...`,
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
type: "array",
|
||||
length: value.length,
|
||||
}
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
type: "object",
|
||||
keys: Object.keys(value),
|
||||
}
|
||||
}
|
||||
|
||||
function traceCommit(commit: StreamCommit) {
|
||||
return {
|
||||
...commit,
|
||||
text: summarize(commit.text),
|
||||
textLength: commit.text.length,
|
||||
part: commit.part
|
||||
? {
|
||||
id: commit.part.id,
|
||||
sessionID: commit.part.sessionID,
|
||||
messageID: commit.part.messageID,
|
||||
callID: commit.part.callID,
|
||||
tool: commit.part.tool,
|
||||
state: {
|
||||
status: commit.part.state.status,
|
||||
title: "title" in commit.part.state ? summarize(commit.part.state.title) : undefined,
|
||||
error: "error" in commit.part.state ? summarize(commit.part.state.error) : undefined,
|
||||
time: "time" in commit.part.state ? summarize(commit.part.state.time) : undefined,
|
||||
input: summarize(commit.part.state.input),
|
||||
metadata: "metadata" in commit.part.state ? summarize(commit.part.state.metadata) : undefined,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function traceSubagentState(state: FooterSubagentState) {
|
||||
return {
|
||||
tabs: state.tabs,
|
||||
details: Object.fromEntries(
|
||||
Object.entries(state.details).map(([sessionID, detail]) => [
|
||||
sessionID,
|
||||
{
|
||||
sessionID,
|
||||
commits: detail.commits.map(traceCommit),
|
||||
},
|
||||
]),
|
||||
),
|
||||
permissions: state.permissions.map((item) => ({
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
permission: item.permission,
|
||||
patterns: item.patterns,
|
||||
tool: item.tool,
|
||||
metadata: item.metadata
|
||||
? {
|
||||
keys: Object.keys(item.metadata),
|
||||
input: summarize(item.metadata.input),
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
questions: state.questions.map((item) => ({
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
questions: item.questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
options: question.options.length,
|
||||
multiple: question.multiple,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function traceFooterOutput(footer?: FooterOutput) {
|
||||
if (!footer?.subagent) {
|
||||
return footer
|
||||
}
|
||||
|
||||
return {
|
||||
...footer,
|
||||
subagent: traceSubagentState(footer.subagent),
|
||||
}
|
||||
}
|
||||
|
||||
// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
|
||||
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
|
||||
for (const commit of out.commits) {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
|
||||
if (out.footer?.patch) {
|
||||
const next = patch(out.footer.patch)
|
||||
input.trace?.write("ui.patch", next)
|
||||
input.footer.event({
|
||||
type: "stream.patch",
|
||||
patch: next,
|
||||
})
|
||||
}
|
||||
|
||||
if (out.footer?.subagent) {
|
||||
input.trace?.write("ui.subagent", traceSubagentState(out.footer.subagent))
|
||||
input.footer.event({
|
||||
type: "stream.subagent",
|
||||
state: out.footer.subagent,
|
||||
})
|
||||
}
|
||||
|
||||
if (!out.footer?.view) {
|
||||
return
|
||||
}
|
||||
|
||||
input.trace?.write("ui.patch", {
|
||||
view: out.footer.view,
|
||||
})
|
||||
input.footer.event({
|
||||
type: "stream.view",
|
||||
view: out.footer.view,
|
||||
})
|
||||
}
|
||||
746
packages/opencode/src/cli/cmd/run/subagent-data.ts
Normal file
746
packages/opencode/src/cli/cmd/run/subagent-data.ts
Normal file
@@ -0,0 +1,746 @@
|
||||
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import * as Locale from "@/util/locale"
|
||||
import {
|
||||
bootstrapSessionData,
|
||||
createSessionData,
|
||||
formatError,
|
||||
reduceSessionData,
|
||||
type SessionData,
|
||||
} from "./session-data"
|
||||
import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
||||
|
||||
export const SUBAGENT_BOOTSTRAP_LIMIT = 200
|
||||
export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80
|
||||
|
||||
const SUBAGENT_COMMIT_LIMIT = 80
|
||||
const SUBAGENT_CALL_LIMIT = 32
|
||||
const SUBAGENT_ROLE_LIMIT = 32
|
||||
const SUBAGENT_ERROR_LIMIT = 16
|
||||
const SUBAGENT_ECHO_LIMIT = 8
|
||||
|
||||
type SessionMessage = {
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type Frame = {
|
||||
key: string
|
||||
commit: StreamCommit
|
||||
}
|
||||
|
||||
type DetailState = {
|
||||
sessionID: string
|
||||
data: SessionData
|
||||
frames: Frame[]
|
||||
}
|
||||
|
||||
export type SubagentData = {
|
||||
tabs: Map<string, FooterSubagentTab>
|
||||
details: Map<string, DetailState>
|
||||
}
|
||||
|
||||
export type BootstrapSubagentInput = {
|
||||
data: SubagentData
|
||||
messages: SessionMessage[]
|
||||
children: Array<{ id: string; title?: string }>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
}
|
||||
|
||||
function createDetail(sessionID: string): DetailState {
|
||||
return {
|
||||
sessionID,
|
||||
data: createSessionData({
|
||||
includeUserText: true,
|
||||
}),
|
||||
frames: [],
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDetail(data: SubagentData, sessionID: string) {
|
||||
const current = data.details.get(sessionID)
|
||||
if (current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const next = createDetail(sessionID)
|
||||
data.details.set(sessionID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) {
|
||||
if (!a || !b) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
a.sessionID === b.sessionID &&
|
||||
a.partID === b.partID &&
|
||||
a.callID === b.callID &&
|
||||
a.label === b.label &&
|
||||
a.description === b.description &&
|
||||
a.status === b.status &&
|
||||
a.title === b.title &&
|
||||
a.toolCalls === b.toolCalls &&
|
||||
a.lastUpdatedAt === b.lastUpdatedAt
|
||||
)
|
||||
}
|
||||
|
||||
function sameQueue<T extends { id: string }>(left: T[], right: T[]) {
|
||||
return (
|
||||
left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index])
|
||||
)
|
||||
}
|
||||
|
||||
function queueSnapshot(data: SessionData) {
|
||||
return {
|
||||
permissions: data.permissions.slice(),
|
||||
questions: data.questions.slice(),
|
||||
}
|
||||
}
|
||||
|
||||
function queueChanged(data: SessionData, before: ReturnType<typeof queueSnapshot>) {
|
||||
return !sameQueue(before.permissions, data.permissions) || !sameQueue(before.questions, data.questions)
|
||||
}
|
||||
|
||||
function sameCommit(left: StreamCommit, right: StreamCommit) {
|
||||
return (
|
||||
left.kind === right.kind &&
|
||||
left.text === right.text &&
|
||||
left.phase === right.phase &&
|
||||
left.source === right.source &&
|
||||
left.messageID === right.messageID &&
|
||||
left.partID === right.partID &&
|
||||
left.tool === right.tool &&
|
||||
left.interrupted === right.interrupted &&
|
||||
left.toolState === right.toolState &&
|
||||
left.toolError === right.toolError
|
||||
)
|
||||
}
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const next = value.trim()
|
||||
return next || undefined
|
||||
}
|
||||
|
||||
function num(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inputLabel(input: Record<string, unknown>): string | undefined {
|
||||
const description = text(input.description)
|
||||
if (description) {
|
||||
return description
|
||||
}
|
||||
|
||||
const command = text(input.command)
|
||||
if (command) {
|
||||
return command
|
||||
}
|
||||
|
||||
const filePath = text(input.filePath) ?? text(input.filepath)
|
||||
if (filePath) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
const pattern = text(input.pattern)
|
||||
if (pattern) {
|
||||
return pattern
|
||||
}
|
||||
|
||||
const query = text(input.query)
|
||||
if (query) {
|
||||
return query
|
||||
}
|
||||
|
||||
const url = text(input.url)
|
||||
if (url) {
|
||||
return url
|
||||
}
|
||||
|
||||
const path = text(input.path)
|
||||
if (path) {
|
||||
return path
|
||||
}
|
||||
|
||||
const prompt = text(input.prompt)
|
||||
if (prompt) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function stateTitle(part: ToolPart) {
|
||||
return text("title" in part.state ? part.state.title : undefined)
|
||||
}
|
||||
|
||||
function callKey(messageID: string | undefined, callID: string | undefined): string | undefined {
|
||||
if (!messageID || !callID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return `${messageID}:${callID}`
|
||||
}
|
||||
|
||||
function compactToolState(part: ToolPart): ToolPart["state"] {
|
||||
if (part.state.status === "pending") {
|
||||
return {
|
||||
status: "pending",
|
||||
input: part.state.input,
|
||||
raw: part.state.raw,
|
||||
}
|
||||
}
|
||||
|
||||
if (part.state.status === "running") {
|
||||
return {
|
||||
status: "running",
|
||||
input: part.state.input,
|
||||
time: part.state.time,
|
||||
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
|
||||
...(part.state.title ? { title: part.state.title } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (part.state.status === "completed") {
|
||||
return {
|
||||
status: "completed",
|
||||
input: part.state.input,
|
||||
output: part.state.output,
|
||||
title: part.state.title,
|
||||
metadata: part.state.metadata,
|
||||
time: part.state.time,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
input: part.state.input,
|
||||
error: part.state.error,
|
||||
time: part.state.time,
|
||||
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function recent<T>(input: Iterable<T>, limit: number) {
|
||||
const list = [...input]
|
||||
return list.slice(Math.max(0, list.length - limit))
|
||||
}
|
||||
|
||||
function copyMap<K, V>(source: Map<K, V>, keep: Set<K>) {
|
||||
const out = new Map<K, V>()
|
||||
for (const [key, value] of source) {
|
||||
if (!keep.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.set(key, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function compactToolPart(part: ToolPart): ToolPart {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "tool",
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
callID: part.callID,
|
||||
tool: part.tool,
|
||||
state: compactToolState(part),
|
||||
...(part.metadata ? { metadata: part.metadata } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function compactCommit(commit: StreamCommit): StreamCommit {
|
||||
if (!commit.part) {
|
||||
return commit
|
||||
}
|
||||
|
||||
return {
|
||||
...commit,
|
||||
part: compactToolPart(commit.part),
|
||||
}
|
||||
}
|
||||
|
||||
function stateUpdatedAt(part: ToolPart) {
|
||||
if (!("time" in part.state)) {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
const time = part.state.time
|
||||
if (!("end" in time)) {
|
||||
return time.start ?? Date.now()
|
||||
}
|
||||
|
||||
return time.end ?? time.start ?? Date.now()
|
||||
}
|
||||
|
||||
function metadata(part: ToolPart, key: string) {
|
||||
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
|
||||
}
|
||||
|
||||
function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
|
||||
const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
|
||||
const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
|
||||
const status = part.state.status === "error" ? "error" : part.state.status === "completed" ? "completed" : "running"
|
||||
|
||||
return {
|
||||
sessionID,
|
||||
partID: part.id,
|
||||
callID: part.callID,
|
||||
label,
|
||||
description,
|
||||
status,
|
||||
title: stateTitle(part),
|
||||
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
|
||||
lastUpdatedAt: stateUpdatedAt(part),
|
||||
}
|
||||
}
|
||||
|
||||
function taskSessionID(part: ToolPart) {
|
||||
return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID"))
|
||||
}
|
||||
|
||||
function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set<string>) {
|
||||
if (part.tool !== "task") {
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionID = taskSessionID(part)
|
||||
if (!sessionID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (children && children.size > 0 && !children.has(sessionID)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = taskTab(part, sessionID)
|
||||
if (sameSubagentTab(data.tabs.get(sessionID), next)) {
|
||||
ensureDetail(data, sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
data.tabs.set(sessionID, next)
|
||||
ensureDetail(data, sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
function frameKey(commit: StreamCommit) {
|
||||
if (commit.partID) {
|
||||
return `${commit.kind}:${commit.partID}:${commit.phase}`
|
||||
}
|
||||
|
||||
if (commit.messageID) {
|
||||
return `${commit.kind}:${commit.messageID}:${commit.phase}`
|
||||
}
|
||||
|
||||
return `${commit.kind}:${commit.phase}:${commit.text}`
|
||||
}
|
||||
|
||||
function limitFrames(detail: DetailState) {
|
||||
if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) {
|
||||
return
|
||||
}
|
||||
|
||||
detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT)
|
||||
}
|
||||
|
||||
function mergeLiveCommit(current: StreamCommit, next: StreamCommit) {
|
||||
if (current.phase !== "progress" || next.phase !== "progress") {
|
||||
if (sameCommit(current, next)) {
|
||||
return current
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...current,
|
||||
...next,
|
||||
text: current.text + next.text,
|
||||
}
|
||||
|
||||
if (sameCommit(current, merged)) {
|
||||
return current
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function appendCommits(detail: DetailState, commits: StreamCommit[]) {
|
||||
let changed = false
|
||||
|
||||
for (const commit of commits.map(compactCommit)) {
|
||||
const key = frameKey(commit)
|
||||
const index = detail.frames.findIndex((item) => item.key === key)
|
||||
if (index === -1) {
|
||||
detail.frames.push({
|
||||
key,
|
||||
commit,
|
||||
})
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
|
||||
const next = mergeLiveCommit(detail.frames[index].commit, commit)
|
||||
if (sameCommit(detail.frames[index].commit, next)) {
|
||||
continue
|
||||
}
|
||||
|
||||
detail.frames[index] = {
|
||||
key,
|
||||
commit: next,
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
limitFrames(detail)
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
function ensureBlockerTab(
|
||||
data: SubagentData,
|
||||
sessionID: string,
|
||||
title: string | undefined,
|
||||
kind: "permission" | "question",
|
||||
) {
|
||||
if (data.tabs.has(sessionID)) {
|
||||
ensureDetail(data, sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
data.tabs.set(sessionID, {
|
||||
sessionID,
|
||||
partID: `bootstrap:${sessionID}`,
|
||||
callID: `bootstrap:${sessionID}`,
|
||||
label: text(title) ?? Locale.titlecase(kind),
|
||||
description: kind === "permission" ? "Pending permission" : "Pending question",
|
||||
status: "running",
|
||||
lastUpdatedAt: Date.now(),
|
||||
})
|
||||
ensureDetail(data, sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
function compactCallMap(detail: DetailState) {
|
||||
const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
|
||||
|
||||
for (const request of detail.data.permissions) {
|
||||
const key = callKey(request.tool?.messageID, request.tool?.callID)
|
||||
if (key) {
|
||||
keep.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of detail.frames) {
|
||||
const key = callKey(item.commit.part?.messageID, item.commit.part?.callID)
|
||||
if (key) {
|
||||
keep.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
return copyMap(detail.data.call, keep)
|
||||
}
|
||||
|
||||
function compactEchoMap(data: SessionData, messageIDs: Set<string>) {
|
||||
const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)])
|
||||
return copyMap(data.echo, keys)
|
||||
}
|
||||
|
||||
function compactIDs(detail: DetailState) {
|
||||
return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT))
|
||||
}
|
||||
|
||||
function compactDetail(detail: DetailState) {
|
||||
const next = createSessionData({
|
||||
includeUserText: true,
|
||||
})
|
||||
const activePartIDs = new Set(detail.data.part.keys())
|
||||
const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : [])))
|
||||
const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools])
|
||||
const messageIDs = new Set([
|
||||
...[...activePartIDs]
|
||||
.map((partID) => detail.data.msg.get(partID))
|
||||
.filter((item): item is string => typeof item === "string"),
|
||||
...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT),
|
||||
])
|
||||
|
||||
next.announced = detail.data.announced
|
||||
next.permissions = detail.data.permissions
|
||||
next.questions = detail.data.questions
|
||||
next.ids = compactIDs(detail)
|
||||
next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item)))
|
||||
next.call = compactCallMap(detail)
|
||||
next.role = copyMap(detail.data.role, messageIDs)
|
||||
next.msg = copyMap(detail.data.msg, activePartIDs)
|
||||
next.part = copyMap(detail.data.part, activePartIDs)
|
||||
next.text = copyMap(detail.data.text, activePartIDs)
|
||||
next.sent = copyMap(detail.data.sent, activePartIDs)
|
||||
next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item)))
|
||||
next.echo = compactEchoMap(detail.data, messageIDs)
|
||||
detail.data = next
|
||||
}
|
||||
|
||||
function applyChildEvent(input: {
|
||||
detail: DetailState
|
||||
event: Event
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}) {
|
||||
const before = queueSnapshot(input.detail.data)
|
||||
const out = reduceSessionData({
|
||||
data: input.detail.data,
|
||||
event: input.event,
|
||||
sessionID: input.detail.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits,
|
||||
})
|
||||
const changed = appendCommits(input.detail, out.commits)
|
||||
compactDetail(input.detail)
|
||||
|
||||
return changed || queueChanged(input.detail.data, before)
|
||||
}
|
||||
|
||||
function knownSession(data: SubagentData, sessionID: string) {
|
||||
return data.tabs.has(sessionID)
|
||||
}
|
||||
|
||||
export function listSubagentPermissions(data: SubagentData) {
|
||||
return [...data.details.values()].flatMap((detail) => detail.data.permissions)
|
||||
}
|
||||
|
||||
export function listSubagentQuestions(data: SubagentData) {
|
||||
return [...data.details.values()].flatMap((detail) => detail.data.questions)
|
||||
}
|
||||
|
||||
export function createSubagentData(): SubagentData {
|
||||
return {
|
||||
tabs: new Map(),
|
||||
details: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotDetail(detail: DetailState) {
|
||||
return {
|
||||
sessionID: detail.sessionID,
|
||||
commits: detail.frames.map((item) => item.commit),
|
||||
}
|
||||
}
|
||||
|
||||
export function listSubagentTabs(data: SubagentData) {
|
||||
return [...data.tabs.values()].sort((a, b) => {
|
||||
const active = Number(b.status === "running") - Number(a.status === "running")
|
||||
if (active !== 0) {
|
||||
return active
|
||||
}
|
||||
|
||||
return b.lastUpdatedAt - a.lastUpdatedAt
|
||||
})
|
||||
}
|
||||
|
||||
function snapshotQueues(data: SubagentData) {
|
||||
return {
|
||||
permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotState(data: SubagentData, details: FooterSubagentState["details"]): FooterSubagentState {
|
||||
return {
|
||||
tabs: listSubagentTabs(data),
|
||||
details,
|
||||
...snapshotQueues(data),
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotSubagentData(data: SubagentData): FooterSubagentState {
|
||||
return snapshotState(
|
||||
data,
|
||||
Object.fromEntries([...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)])),
|
||||
)
|
||||
}
|
||||
|
||||
export function snapshotSelectedSubagentData(
|
||||
data: SubagentData,
|
||||
selectedSessionID: string | undefined,
|
||||
): FooterSubagentState {
|
||||
const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined
|
||||
|
||||
return snapshotState(data, detail ? { [detail.sessionID]: snapshotDetail(detail) } : {})
|
||||
}
|
||||
|
||||
export function bootstrapSubagentData(input: BootstrapSubagentInput) {
|
||||
const child = new Map(input.children.map((item) => [item.id, item]))
|
||||
const children = new Set(child.keys())
|
||||
let changed = false
|
||||
|
||||
for (const message of input.messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool") {
|
||||
continue
|
||||
}
|
||||
|
||||
changed = syncTaskTab(input.data, part, children) || changed
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of input.permissions) {
|
||||
if (!children.has(item.sessionID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed
|
||||
}
|
||||
|
||||
for (const item of input.questions) {
|
||||
if (!children.has(item.sessionID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed
|
||||
}
|
||||
|
||||
for (const sessionID of input.data.tabs.keys()) {
|
||||
const detail = ensureDetail(input.data, sessionID)
|
||||
const before = queueSnapshot(detail.data)
|
||||
|
||||
bootstrapSessionData({
|
||||
data: detail.data,
|
||||
messages: [],
|
||||
permissions: input.permissions
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
questions: input.questions
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
})
|
||||
compactDetail(detail)
|
||||
|
||||
changed = queueChanged(detail.data, before) || changed
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) {
|
||||
if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const detail = ensureDetail(input.data, input.sessionID)
|
||||
const before = queueSnapshot(detail.data)
|
||||
const beforeCallCount = detail.data.call.size
|
||||
bootstrapSessionData({
|
||||
data: detail.data,
|
||||
messages: input.messages,
|
||||
permissions: detail.data.permissions,
|
||||
questions: detail.data.questions,
|
||||
})
|
||||
compactDetail(detail)
|
||||
|
||||
return beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
|
||||
}
|
||||
|
||||
export function clearFinishedSubagents(data: SubagentData) {
|
||||
let changed = false
|
||||
|
||||
for (const [sessionID, tab] of data.tabs.entries()) {
|
||||
if (tab.status === "running") {
|
||||
continue
|
||||
}
|
||||
|
||||
data.tabs.delete(sessionID)
|
||||
data.details.delete(sessionID)
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
export function reduceSubagentData(input: {
|
||||
data: SubagentData
|
||||
event: Event
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}) {
|
||||
const event = input.event
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID === input.sessionID) {
|
||||
if (part.type !== "tool") {
|
||||
return false
|
||||
}
|
||||
|
||||
return syncTaskTab(input.data, part)
|
||||
}
|
||||
}
|
||||
|
||||
const sessionID =
|
||||
event.type === "message.updated" ||
|
||||
event.type === "message.part.delta" ||
|
||||
event.type === "permission.asked" ||
|
||||
event.type === "permission.replied" ||
|
||||
event.type === "question.asked" ||
|
||||
event.type === "question.replied" ||
|
||||
event.type === "question.rejected" ||
|
||||
event.type === "session.error" ||
|
||||
event.type === "session.status"
|
||||
? event.properties.sessionID
|
||||
: event.type === "message.part.updated"
|
||||
? event.properties.part.sessionID
|
||||
: undefined
|
||||
|
||||
if (!sessionID || !knownSession(input.data, sessionID)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const detail = ensureDetail(input.data, sessionID)
|
||||
if (event.type === "session.status") {
|
||||
if (event.properties.status.type !== "retry") {
|
||||
return false
|
||||
}
|
||||
|
||||
return appendCommits(detail, [
|
||||
{
|
||||
kind: "error",
|
||||
text: event.properties.status.message,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: `retry:${event.properties.status.attempt}`,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (event.type === "session.error" && event.properties.error) {
|
||||
return appendCommits(detail, [
|
||||
{
|
||||
kind: "error",
|
||||
text: formatError(event.properties.error),
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
return applyChildEvent({
|
||||
detail,
|
||||
event,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits,
|
||||
})
|
||||
}
|
||||
641
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
641
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
// Theme resolution for direct interactive mode.
|
||||
//
|
||||
// Derives scrollback and footer colors from the terminal's actual palette.
|
||||
// resolveRunTheme() queries the renderer for the terminal's palette,
|
||||
// detects dark/light mode, builds a small system theme locally, and maps it to
|
||||
// the run footer + scrollback color model. Falls back to a hardcoded dark-mode
|
||||
// palette if detection fails.
|
||||
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
|
||||
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||
import type { EntryKind } from "./types"
|
||||
|
||||
type Tone = {
|
||||
body: ColorInput
|
||||
start?: ColorInput
|
||||
}
|
||||
|
||||
export type RunEntryTheme = Record<EntryKind, Tone>
|
||||
|
||||
export type RunSplashTheme = {
|
||||
left: ColorInput
|
||||
right: ColorInput
|
||||
leftShadow: ColorInput
|
||||
rightShadow: ColorInput
|
||||
}
|
||||
|
||||
export type RunFooterTheme = {
|
||||
highlight: ColorInput
|
||||
warning: ColorInput
|
||||
success: ColorInput
|
||||
error: ColorInput
|
||||
muted: ColorInput
|
||||
text: ColorInput
|
||||
shade: ColorInput
|
||||
surface: ColorInput
|
||||
pane: ColorInput
|
||||
border: ColorInput
|
||||
line: ColorInput
|
||||
}
|
||||
|
||||
export type RunBlockTheme = {
|
||||
text: ColorInput
|
||||
muted: ColorInput
|
||||
syntax?: SyntaxStyle
|
||||
subtleSyntax?: SyntaxStyle
|
||||
diffAdded: ColorInput
|
||||
diffRemoved: ColorInput
|
||||
diffAddedBg: ColorInput
|
||||
diffRemovedBg: ColorInput
|
||||
diffContextBg: ColorInput
|
||||
diffHighlightAdded: ColorInput
|
||||
diffHighlightRemoved: ColorInput
|
||||
diffLineNumber: ColorInput
|
||||
diffAddedLineNumberBg: ColorInput
|
||||
diffRemovedLineNumberBg: ColorInput
|
||||
}
|
||||
|
||||
export type RunTheme = {
|
||||
background: ColorInput
|
||||
footer: RunFooterTheme
|
||||
entry: RunEntryTheme
|
||||
splash: RunSplashTheme
|
||||
block: RunBlockTheme
|
||||
}
|
||||
|
||||
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||
type HexColor = `#${string}`
|
||||
type RefName = string
|
||||
type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
type ThemeJson = {
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
|
||||
type SharedSyntaxTheme = TuiThemeCurrent & {
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
|
||||
export const transparent = RGBA.fromValues(0, 0, 0, 0)
|
||||
|
||||
function alpha(color: RGBA, value: number): RGBA {
|
||||
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, value)), color.tag)
|
||||
}
|
||||
|
||||
function rgba(hex: string, value?: number): RGBA {
|
||||
const color = RGBA.fromHex(hex)
|
||||
return value === undefined ? color : alpha(color, value)
|
||||
}
|
||||
|
||||
function mode(bg: RGBA): "dark" | "light" {
|
||||
const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
|
||||
return lum > 0.5 ? "light" : "dark"
|
||||
}
|
||||
|
||||
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
|
||||
if (color.a === 0) {
|
||||
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, fallback)))
|
||||
}
|
||||
|
||||
const target = Math.min(limit, color.a * scale)
|
||||
const mix = Math.min(1, target / color.a)
|
||||
|
||||
return RGBA.fromValues(
|
||||
base.r + (color.r - base.r) * mix,
|
||||
base.g + (color.g - base.g) * mix,
|
||||
base.b + (color.b - base.b) * mix,
|
||||
color.a,
|
||||
)
|
||||
}
|
||||
|
||||
function ansiToRgba(code: number): RGBA {
|
||||
if (code < 16) {
|
||||
const ansi = [
|
||||
"#000000",
|
||||
"#800000",
|
||||
"#008000",
|
||||
"#808000",
|
||||
"#000080",
|
||||
"#800080",
|
||||
"#008080",
|
||||
"#c0c0c0",
|
||||
"#808080",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#ffff00",
|
||||
"#0000ff",
|
||||
"#ff00ff",
|
||||
"#00ffff",
|
||||
"#ffffff",
|
||||
]
|
||||
return RGBA.fromHex(ansi[code] ?? "#000000")
|
||||
}
|
||||
|
||||
if (code < 232) {
|
||||
const index = code - 16
|
||||
const b = index % 6
|
||||
const g = Math.floor(index / 6) % 6
|
||||
const r = Math.floor(index / 36)
|
||||
const value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
|
||||
return RGBA.fromInts(value(r), value(g), value(b))
|
||||
}
|
||||
|
||||
if (code < 256) {
|
||||
const gray = (code - 232) * 10 + 8
|
||||
return RGBA.fromInts(gray, gray, gray)
|
||||
}
|
||||
|
||||
return RGBA.fromInts(0, 0, 0)
|
||||
}
|
||||
|
||||
function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
return RGBA.fromInts(
|
||||
Math.round((base.r + (overlay.r - base.r) * value) * 255),
|
||||
Math.round((base.g + (overlay.g - base.g) * value) * 255),
|
||||
Math.round((base.b + (overlay.b - base.b) * value) * 255),
|
||||
)
|
||||
}
|
||||
|
||||
function blend(color: RGBA, bg: RGBA): RGBA {
|
||||
if (color.a >= 1) {
|
||||
return color
|
||||
}
|
||||
|
||||
return RGBA.fromValues(
|
||||
bg.r + (color.r - bg.r) * color.a,
|
||||
bg.g + (color.g - bg.g) * color.a,
|
||||
bg.b + (color.b - bg.b) * color.a,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
function luminance(color: RGBA) {
|
||||
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
}
|
||||
|
||||
function chroma(color: RGBA) {
|
||||
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
|
||||
if (!style) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return SyntaxStyle.fromStyles(
|
||||
Object.fromEntries(
|
||||
[...style.getAllStyles()].map(([name, value]) => [
|
||||
name,
|
||||
{
|
||||
...value,
|
||||
fg: value.fg ? blend(value.fg, bg) : value.fg,
|
||||
bg: value.bg ? blend(value.bg, bg) : value.bg,
|
||||
},
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
|
||||
return Array.from({ length: size }, (_, index) => {
|
||||
const value = colors.palette[index]
|
||||
return RGBA.fromIndex(index, value ? RGBA.fromHex(value) : ansiToRgba(index))
|
||||
})
|
||||
}
|
||||
|
||||
function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
|
||||
const hit = indexed.reduce(
|
||||
(best, item) => {
|
||||
const dr = item.r - rgba.r
|
||||
const dg = item.g - rgba.g
|
||||
const db = item.b - rgba.b
|
||||
const dist = dr * dr + dg * dg + db * db
|
||||
if (dist >= best.dist) return best
|
||||
return {
|
||||
dist,
|
||||
item,
|
||||
}
|
||||
},
|
||||
{
|
||||
dist: Number.POSITIVE_INFINITY,
|
||||
item: indexed[0]!,
|
||||
},
|
||||
)
|
||||
|
||||
return RGBA.clone(hit.item)
|
||||
}
|
||||
|
||||
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
const mixed = tint(base, overlay, value)
|
||||
return nearestIndexed(indexed, mixed)
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent {
|
||||
const defs = theme.defs ?? {}
|
||||
|
||||
const resolveColor = (value: ColorValue, chain: string[] = []): RGBA => {
|
||||
if (value instanceof RGBA) return value
|
||||
|
||||
if (typeof value === "number") {
|
||||
return RGBA.fromIndex(value, ansiToRgba(value))
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return resolveColor(value[pick], chain)
|
||||
}
|
||||
|
||||
if (value === "transparent" || value === "none") {
|
||||
return RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (value.startsWith("#")) {
|
||||
return RGBA.fromHex(value)
|
||||
}
|
||||
|
||||
if (chain.includes(value)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[value] ?? theme.theme[value as ThemeColor]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${value}" not found in defs or theme`)
|
||||
}
|
||||
|
||||
return resolveColor(next, [...chain, value])
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value as ColorValue)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
return {
|
||||
...(resolved as Record<ThemeColor, RGBA>),
|
||||
selectedListItemText:
|
||||
theme.theme.selectedListItemText === undefined
|
||||
? resolved.background!
|
||||
: resolveColor(theme.theme.selectedListItemText),
|
||||
backgroundMenu:
|
||||
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
}
|
||||
}
|
||||
|
||||
function pickPrimaryColor(
|
||||
bg: RGBA,
|
||||
candidates: Array<{
|
||||
key: string
|
||||
color: RGBA | undefined
|
||||
}>,
|
||||
) {
|
||||
return candidates
|
||||
.flatMap((item) => {
|
||||
if (!item.color) return []
|
||||
const contrast = Math.abs(luminance(item.color) - luminance(bg))
|
||||
const vivid = chroma(item.color)
|
||||
if (contrast < 0.16 || vivid < 0.12) return []
|
||||
return [{ key: item.key, color: item.color, score: vivid * 1.5 + contrast }]
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)[0]
|
||||
}
|
||||
|
||||
function generateGrayScale(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): Record<number, RGBA> {
|
||||
const r = bg.r * 255
|
||||
const g = bg.g * 255
|
||||
const b = bg.b * 255
|
||||
const lum = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
const cast = 0.25 * (1 - chroma(bg)) ** 2
|
||||
|
||||
const gray = (level: number) => {
|
||||
const factor = level / 12
|
||||
|
||||
if (isDark && lum < 10) {
|
||||
const value = Math.floor(factor * 0.4 * 255)
|
||||
return map(RGBA.fromInts(value, value, value))
|
||||
}
|
||||
|
||||
if (!isDark && lum > 245) {
|
||||
const value = Math.floor(255 - factor * 0.4 * 255)
|
||||
return map(RGBA.fromInts(value, value, value))
|
||||
}
|
||||
|
||||
const value = isDark ? lum + (255 - lum) * factor * 0.4 : lum * (1 - factor * 0.4)
|
||||
const tone = RGBA.fromInts(Math.floor(value), Math.floor(value), Math.floor(value))
|
||||
if (cast === 0) return map(tone)
|
||||
|
||||
const ratio = lum === 0 ? 0 : value / lum
|
||||
return map(
|
||||
tint(
|
||||
tone,
|
||||
RGBA.fromInts(
|
||||
Math.floor(Math.max(0, Math.min(r * ratio, 255))),
|
||||
Math.floor(Math.max(0, Math.min(g * ratio, 255))),
|
||||
Math.floor(Math.max(0, Math.min(b * ratio, 255))),
|
||||
),
|
||||
cast,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return Object.fromEntries(Array.from({ length: 12 }, (_, index) => [index + 1, gray(index + 1)]))
|
||||
}
|
||||
|
||||
function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): RGBA {
|
||||
const lum = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
|
||||
const gray = isDark
|
||||
? lum < 10
|
||||
? 180
|
||||
: Math.min(Math.floor(160 + lum * 0.3), 200)
|
||||
: lum > 245
|
||||
? 75
|
||||
: Math.max(Math.floor(100 - (255 - lum) * 0.2), 60)
|
||||
|
||||
return map(RGBA.fromInts(gray, gray, gray))
|
||||
}
|
||||
|
||||
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson {
|
||||
const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const bg = RGBA.defaultBackground(bg_snapshot)
|
||||
const fg = RGBA.defaultForeground(fg_snapshot)
|
||||
const isDark = pick === "dark"
|
||||
|
||||
const indexed = indexedPalette(colors)
|
||||
const color = (index: number) => RGBA.clone(indexed[index]!)
|
||||
const nearest = (rgba: RGBA) => nearestIndexed(indexed, rgba)
|
||||
|
||||
const grays = generateGrayScale(bg_snapshot, isDark, nearest)
|
||||
const menu_grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
|
||||
const textMuted = generateMutedTextColor(bg_snapshot, isDark, nearest)
|
||||
|
||||
const ansi = {
|
||||
red: color(1),
|
||||
green: color(2),
|
||||
yellow: color(3),
|
||||
blue: color(4),
|
||||
magenta: color(5),
|
||||
cyan: color(6),
|
||||
red_bright: color(9),
|
||||
green_bright: color(10),
|
||||
}
|
||||
|
||||
const diff_alpha = isDark ? 0.22 : 0.14
|
||||
const diff_context_bg = grays[2]
|
||||
const primary =
|
||||
pickPrimaryColor(bg_snapshot, [
|
||||
{
|
||||
key: "cursor",
|
||||
color: colors.cursorColor ? nearest(RGBA.fromHex(colors.cursorColor)) : undefined,
|
||||
},
|
||||
{
|
||||
key: "selection",
|
||||
color: colors.highlightBackground ? nearest(RGBA.fromHex(colors.highlightBackground)) : undefined,
|
||||
},
|
||||
{
|
||||
key: "blue",
|
||||
color: ansi.blue,
|
||||
},
|
||||
{
|
||||
key: "magenta",
|
||||
color: ansi.magenta,
|
||||
},
|
||||
]) ?? {
|
||||
key: "blue",
|
||||
color: ansi.blue,
|
||||
}
|
||||
|
||||
return {
|
||||
theme: {
|
||||
primary: primary.color,
|
||||
secondary: primary.key === "magenta" ? ansi.blue : ansi.magenta,
|
||||
accent: primary.color,
|
||||
error: ansi.red,
|
||||
warning: ansi.yellow,
|
||||
success: ansi.green,
|
||||
info: ansi.cyan,
|
||||
text: fg,
|
||||
textMuted,
|
||||
selectedListItemText: bg,
|
||||
background: alpha(bg, 0),
|
||||
backgroundPanel: grays[2],
|
||||
backgroundElement: grays[3],
|
||||
backgroundMenu: menu_grays[3],
|
||||
borderSubtle: grays[6],
|
||||
border: grays[7],
|
||||
borderActive: grays[8],
|
||||
diffAdded: ansi.green,
|
||||
diffRemoved: ansi.red,
|
||||
diffContext: grays[7],
|
||||
diffHunkHeader: grays[7],
|
||||
diffHighlightAdded: ansi.green_bright,
|
||||
diffHighlightRemoved: ansi.red_bright,
|
||||
diffAddedBg: nearest(tint(bg_snapshot, ansi.green, diff_alpha)),
|
||||
diffRemovedBg: nearest(tint(bg_snapshot, ansi.red, diff_alpha)),
|
||||
diffContextBg: diff_context_bg,
|
||||
diffLineNumber: textMuted,
|
||||
diffAddedLineNumberBg: nearest(tint(diff_context_bg, ansi.green, diff_alpha)),
|
||||
diffRemovedLineNumberBg: nearest(tint(diff_context_bg, ansi.red, diff_alpha)),
|
||||
markdownText: fg,
|
||||
markdownHeading: fg,
|
||||
markdownLink: ansi.blue,
|
||||
markdownLinkText: ansi.cyan,
|
||||
markdownCode: ansi.green,
|
||||
markdownBlockQuote: ansi.yellow,
|
||||
markdownEmph: ansi.yellow,
|
||||
markdownStrong: fg,
|
||||
markdownHorizontalRule: grays[7],
|
||||
markdownListItem: ansi.blue,
|
||||
markdownListEnumeration: ansi.cyan,
|
||||
markdownImage: ansi.blue,
|
||||
markdownImageText: ansi.cyan,
|
||||
markdownCodeBlock: fg,
|
||||
syntaxComment: textMuted,
|
||||
syntaxKeyword: ansi.magenta,
|
||||
syntaxFunction: ansi.blue,
|
||||
syntaxVariable: fg,
|
||||
syntaxString: ansi.green,
|
||||
syntaxNumber: ansi.yellow,
|
||||
syntaxType: ansi.cyan,
|
||||
syntaxOperator: ansi.cyan,
|
||||
syntaxPunctuation: fg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
|
||||
const left = nearestIndexed(indexed, theme.textMuted)
|
||||
const right = nearestIndexed(indexed, theme.text)
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
|
||||
rightShadow: splashShadow(indexed, theme.background, right, 0.14),
|
||||
}
|
||||
}
|
||||
|
||||
function map(
|
||||
theme: TuiThemeCurrent,
|
||||
splash: RunSplashTheme,
|
||||
syntax?: SyntaxStyle,
|
||||
subtleSyntax?: SyntaxStyle,
|
||||
): RunTheme {
|
||||
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, theme.background)
|
||||
subtleSyntax?.destroy()
|
||||
const shade = fade(theme.backgroundMenu, theme.background, 0.12, 0.56, 0.72)
|
||||
const surface = fade(theme.backgroundMenu, theme.background, 0.18, 0.76, 0.9)
|
||||
const line = fade(theme.backgroundMenu, theme.background, 0.24, 0.9, 0.98)
|
||||
|
||||
return {
|
||||
background: theme.background,
|
||||
footer: {
|
||||
highlight: theme.primary,
|
||||
warning: theme.warning,
|
||||
success: theme.success,
|
||||
error: theme.error,
|
||||
muted: theme.textMuted,
|
||||
text: theme.text,
|
||||
shade,
|
||||
surface,
|
||||
pane: theme.backgroundMenu,
|
||||
border: theme.border,
|
||||
line,
|
||||
},
|
||||
entry: {
|
||||
system: {
|
||||
body: theme.textMuted,
|
||||
},
|
||||
user: {
|
||||
body: theme.primary,
|
||||
},
|
||||
assistant: {
|
||||
body: theme.text,
|
||||
},
|
||||
reasoning: {
|
||||
body: theme.textMuted,
|
||||
},
|
||||
tool: {
|
||||
body: theme.text,
|
||||
start: theme.textMuted,
|
||||
},
|
||||
error: {
|
||||
body: theme.error,
|
||||
},
|
||||
},
|
||||
splash,
|
||||
block: {
|
||||
text: theme.text,
|
||||
muted: theme.textMuted,
|
||||
syntax,
|
||||
subtleSyntax: opaqueSubtleSyntax,
|
||||
diffAdded: theme.diffAdded,
|
||||
diffRemoved: theme.diffRemoved,
|
||||
diffAddedBg: theme.diffAddedBg,
|
||||
diffRemovedBg: theme.diffRemovedBg,
|
||||
diffContextBg: theme.diffContextBg,
|
||||
diffHighlightAdded: theme.diffHighlightAdded,
|
||||
diffHighlightRemoved: theme.diffHighlightRemoved,
|
||||
diffLineNumber: theme.diffLineNumber,
|
||||
diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
|
||||
diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const seed = {
|
||||
highlight: rgba("#38bdf8"),
|
||||
muted: rgba("#64748b"),
|
||||
text: rgba("#f8fafc"),
|
||||
panel: rgba("#0f172a"),
|
||||
success: rgba("#22c55e"),
|
||||
warning: rgba("#f59e0b"),
|
||||
error: rgba("#ef4444"),
|
||||
}
|
||||
|
||||
function tone(body: ColorInput, start?: ColorInput): Tone {
|
||||
return {
|
||||
body,
|
||||
start,
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackSplashIndexed = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index))
|
||||
const fallbackSplashLeft = RGBA.fromIndex(67)
|
||||
const fallbackSplashRight = RGBA.fromIndex(110)
|
||||
|
||||
export const RUN_THEME_FALLBACK: RunTheme = {
|
||||
background: RGBA.fromValues(0, 0, 0, 0),
|
||||
footer: {
|
||||
highlight: seed.highlight,
|
||||
warning: seed.warning,
|
||||
success: seed.success,
|
||||
error: seed.error,
|
||||
muted: seed.muted,
|
||||
text: seed.text,
|
||||
shade: alpha(seed.panel, 0.68),
|
||||
surface: alpha(seed.panel, 0.86),
|
||||
pane: seed.panel,
|
||||
border: seed.muted,
|
||||
line: alpha(seed.panel, 0.96),
|
||||
},
|
||||
entry: {
|
||||
system: tone(seed.muted),
|
||||
user: tone(seed.highlight),
|
||||
assistant: tone(seed.text),
|
||||
reasoning: tone(seed.muted),
|
||||
tool: tone(seed.text, seed.muted),
|
||||
error: tone(seed.error),
|
||||
},
|
||||
splash: {
|
||||
left: fallbackSplashLeft,
|
||||
right: fallbackSplashRight,
|
||||
leftShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashLeft, 0.14),
|
||||
rightShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashRight, 0.14),
|
||||
},
|
||||
block: {
|
||||
text: seed.text,
|
||||
muted: seed.muted,
|
||||
diffAdded: seed.success,
|
||||
diffRemoved: seed.error,
|
||||
diffAddedBg: alpha(seed.success, 0.18),
|
||||
diffRemovedBg: alpha(seed.error, 0.18),
|
||||
diffContextBg: alpha(seed.panel, 0.72),
|
||||
diffHighlightAdded: seed.success,
|
||||
diffHighlightRemoved: seed.error,
|
||||
diffLineNumber: seed.muted,
|
||||
diffAddedLineNumberBg: alpha(seed.success, 0.12),
|
||||
diffRemovedLineNumberBg: alpha(seed.error, 0.12),
|
||||
},
|
||||
}
|
||||
|
||||
export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
|
||||
try {
|
||||
const colors = await renderer.getPalette({
|
||||
size: 256,
|
||||
})
|
||||
const bg = colors.defaultBackground ?? colors.palette[0]
|
||||
if (!bg) {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
|
||||
const theme = resolveTheme(generateSystem(colors, pick), pick)
|
||||
const indexed = indexedPalette(colors, 256)
|
||||
const shared = await import("../tui/context/theme")
|
||||
const syntaxTheme: SharedSyntaxTheme = {
|
||||
...theme,
|
||||
_hasSelectedListItemText: true,
|
||||
}
|
||||
const syntax = shared.generateSyntax(syntaxTheme)
|
||||
return map(theme, splashTheme(theme, indexed), syntax, shared.generateSubtleSyntax(syntaxTheme))
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
}
|
||||
1434
packages/opencode/src/cli/cmd/run/tool.ts
Normal file
1434
packages/opencode/src/cli/cmd/run/tool.ts
Normal file
File diff suppressed because it is too large
Load Diff
94
packages/opencode/src/cli/cmd/run/trace.ts
Normal file
94
packages/opencode/src/cli/cmd/run/trace.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// Dev-only JSONL event trace for direct interactive mode.
|
||||
//
|
||||
// Enable with OPENCODE_DIRECT_TRACE=1. Writes one JSON line per event to
|
||||
// ~/.local/share/opencode/log/direct/<timestamp>-<pid>.jsonl. Also writes
|
||||
// a latest.json pointer so you can quickly find the most recent trace.
|
||||
//
|
||||
// The trace captures the full closed loop: outbound prompts, inbound SDK
|
||||
// events, reducer output, footer commits, and turn lifecycle markers.
|
||||
// Useful for debugging stream ordering, permission behavior, and
|
||||
// footer/transcript mismatches.
|
||||
//
|
||||
// Lazy-initialized: the first call to trace() decides whether tracing is
|
||||
// active based on the env var, and subsequent calls return the cached result.
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
export type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
let state: Trace | false | undefined
|
||||
|
||||
function stamp() {
|
||||
return new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d+Z$/, "Z")
|
||||
}
|
||||
|
||||
function file() {
|
||||
return path.join(Global.Path.log, "direct", `${stamp()}-${process.pid}.jsonl`)
|
||||
}
|
||||
|
||||
function latest() {
|
||||
return path.join(Global.Path.log, "direct", "latest.json")
|
||||
}
|
||||
|
||||
function text(data: unknown) {
|
||||
return JSON.stringify(
|
||||
data,
|
||||
(_key, value) => {
|
||||
if (typeof value === "bigint") {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
export function trace(): Trace | undefined {
|
||||
if (state !== undefined) {
|
||||
return state || undefined
|
||||
}
|
||||
|
||||
if (!process.env.OPENCODE_DIRECT_TRACE) {
|
||||
state = false
|
||||
return undefined
|
||||
}
|
||||
|
||||
const target = file()
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
latest(),
|
||||
text({
|
||||
time: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
argv: process.argv.slice(2),
|
||||
path: target,
|
||||
}) + "\n",
|
||||
)
|
||||
state = {
|
||||
write(type: string, data?: unknown) {
|
||||
fs.appendFileSync(
|
||||
target,
|
||||
text({
|
||||
time: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
type,
|
||||
data,
|
||||
}) + "\n",
|
||||
)
|
||||
},
|
||||
}
|
||||
state.write("trace.start", {
|
||||
argv: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
path: target,
|
||||
})
|
||||
return state
|
||||
}
|
||||
289
packages/opencode/src/cli/cmd/run/types.ts
Normal file
289
packages/opencode/src/cli/cmd/run/types.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// Shared type vocabulary for the direct interactive mode (`run --interactive`).
|
||||
//
|
||||
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
|
||||
// session transcript, and a mutable footer for prompt input, status, and
|
||||
// permission/question UI. Every module in run/* shares these types to stay
|
||||
// aligned on that two-lane model.
|
||||
//
|
||||
// Data flow through the system:
|
||||
//
|
||||
// SDK events → session-data reducer → StreamCommit[] + FooterOutput
|
||||
// → stream.ts bridges to footer API
|
||||
// → footer.ts queues commits and patches the footer view
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
|
||||
|
||||
export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
|
||||
|
||||
export type RunPrompt = {
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
}
|
||||
|
||||
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
|
||||
|
||||
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
|
||||
|
||||
export type RunResource = RunResourceMap[string]
|
||||
|
||||
export type RunInput = {
|
||||
sdk: OpencodeClient
|
||||
directory: string
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
resume?: boolean
|
||||
agent: string | undefined
|
||||
model: PromptModel | undefined
|
||||
variant: string | undefined
|
||||
files: RunFilePart[]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
demo?: RunDemo
|
||||
demoText?: string
|
||||
}
|
||||
|
||||
export type RunDemo = "on" | "permission" | "question" | "mix" | "text"
|
||||
|
||||
// The semantic role of a scrollback entry. Maps 1:1 to theme colors.
|
||||
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
|
||||
|
||||
// Whether the assistant is actively processing a turn.
|
||||
export type FooterPhase = "idle" | "running"
|
||||
|
||||
// Full snapshot of footer status bar state. Every update replaces the whole
|
||||
// object in the SolidJS signal so the view re-renders atomically.
|
||||
export type FooterState = {
|
||||
phase: FooterPhase
|
||||
status: string
|
||||
queue: number
|
||||
model: string
|
||||
duration: string
|
||||
usage: string
|
||||
first: boolean
|
||||
interrupt: number
|
||||
exit: number
|
||||
}
|
||||
|
||||
// A partial update to FooterState. The footer merges this onto the current state.
|
||||
export type FooterPatch = Partial<FooterState>
|
||||
|
||||
export type RunDiffStyle = "auto" | "stacked"
|
||||
|
||||
export type ScrollbackOptions = {
|
||||
diffStyle?: RunDiffStyle
|
||||
}
|
||||
|
||||
export type ToolCodeSnapshot = {
|
||||
kind: "code"
|
||||
title: string
|
||||
content: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type ToolDiffSnapshot = {
|
||||
kind: "diff"
|
||||
items: Array<{
|
||||
title: string
|
||||
diff: string
|
||||
file?: string
|
||||
deletions?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export type ToolTaskSnapshot = {
|
||||
kind: "task"
|
||||
title: string
|
||||
rows: string[]
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolTodoSnapshot = {
|
||||
kind: "todo"
|
||||
items: Array<{
|
||||
status: string
|
||||
content: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolQuestionSnapshot = {
|
||||
kind: "question"
|
||||
items: Array<{
|
||||
question: string
|
||||
answer: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolSnapshot =
|
||||
| ToolCodeSnapshot
|
||||
| ToolDiffSnapshot
|
||||
| ToolTaskSnapshot
|
||||
| ToolTodoSnapshot
|
||||
| ToolQuestionSnapshot
|
||||
|
||||
export type EntryLayout = "inline" | "block"
|
||||
|
||||
export type RunEntryBody =
|
||||
| { type: "none" }
|
||||
| { type: "text"; content: string }
|
||||
| { type: "code"; content: string; filetype?: string }
|
||||
| { type: "markdown"; content: string }
|
||||
| { type: "structured"; snapshot: ToolSnapshot }
|
||||
|
||||
// Which interactive surface the footer is showing. Only one view is active at
|
||||
// a time. The reducer drives transitions: when a permission arrives the view
|
||||
// switches to "permission", and when the permission resolves it falls back to
|
||||
// "prompt".
|
||||
export type FooterView =
|
||||
| { type: "prompt" }
|
||||
| { type: "permission"; request: PermissionRequest }
|
||||
| { type: "question"; request: QuestionRequest }
|
||||
|
||||
export type FooterPromptRoute = { type: "composer" } | { type: "subagent"; sessionID: string }
|
||||
|
||||
export type FooterSubagentTab = {
|
||||
sessionID: string
|
||||
partID: string
|
||||
callID: string
|
||||
label: string
|
||||
description: string
|
||||
status: "running" | "completed" | "error"
|
||||
title?: string
|
||||
toolCalls?: number
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
|
||||
export type FooterSubagentDetail = {
|
||||
sessionID: string
|
||||
commits: StreamCommit[]
|
||||
}
|
||||
|
||||
export type FooterSubagentState = {
|
||||
tabs: FooterSubagentTab[]
|
||||
details: Record<string, FooterSubagentDetail>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
}
|
||||
|
||||
// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
|
||||
export type FooterOutput = {
|
||||
patch?: FooterPatch
|
||||
view?: FooterView
|
||||
subagent?: FooterSubagentState
|
||||
}
|
||||
|
||||
// Typed messages sent to RunFooter.event(). The prompt queue and stream
|
||||
// transport both emit these to update footer state without reaching into
|
||||
// internal signals directly.
|
||||
export type FooterEvent =
|
||||
| {
|
||||
type: "catalog"
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
}
|
||||
| {
|
||||
type: "queue"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "first"
|
||||
first: boolean
|
||||
}
|
||||
| {
|
||||
type: "model"
|
||||
model: string
|
||||
}
|
||||
| {
|
||||
type: "turn.send"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "turn.wait"
|
||||
}
|
||||
| {
|
||||
type: "turn.idle"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "turn.duration"
|
||||
duration: string
|
||||
}
|
||||
| {
|
||||
type: "stream.patch"
|
||||
patch: FooterPatch
|
||||
}
|
||||
| {
|
||||
type: "stream.view"
|
||||
view: FooterView
|
||||
}
|
||||
| {
|
||||
type: "stream.subagent"
|
||||
state: FooterSubagentState
|
||||
}
|
||||
|
||||
export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
|
||||
export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
|
||||
|
||||
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
|
||||
|
||||
export type FooterKeybinds = {
|
||||
leader: string
|
||||
variantCycle: string
|
||||
interrupt: string
|
||||
historyPrevious: string
|
||||
historyNext: string
|
||||
inputSubmit: string
|
||||
inputNewline: string
|
||||
}
|
||||
|
||||
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
||||
// appends content (coalesced in the footer queue), "final" closes it.
|
||||
export type StreamPhase = "start" | "progress" | "final"
|
||||
|
||||
export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
|
||||
|
||||
export type StreamToolState = "running" | "completed" | "error"
|
||||
|
||||
// A single append-only commit to scrollback. The session-data reducer produces
|
||||
// these from SDK events, and RunFooter.append() queues them for the next
|
||||
// microtask flush. Once flushed, they become immutable terminal scrollback
|
||||
// rows -- they cannot be rewritten.
|
||||
export type StreamCommit = {
|
||||
kind: EntryKind
|
||||
text: string
|
||||
phase: StreamPhase
|
||||
source: StreamSource
|
||||
messageID?: string
|
||||
partID?: string
|
||||
tool?: string
|
||||
part?: ToolPart
|
||||
interrupted?: boolean
|
||||
toolState?: StreamToolState
|
||||
toolError?: string
|
||||
}
|
||||
|
||||
// The public contract between the stream transport / prompt queue and
|
||||
// the footer. RunFooter implements this. The transport and queue never
|
||||
// touch the renderer directly -- they go through this interface.
|
||||
export type FooterApi = {
|
||||
readonly isClosed: boolean
|
||||
onPrompt(fn: (input: RunPrompt) => void): () => void
|
||||
onClose(fn: () => void): () => void
|
||||
event(next: FooterEvent): void
|
||||
append(commit: StreamCommit): void
|
||||
idle(): Promise<void>
|
||||
close(): void
|
||||
destroy(): void
|
||||
}
|
||||
200
packages/opencode/src/cli/cmd/run/variant.shared.ts
Normal file
200
packages/opencode/src/cli/cmd/run/variant.shared.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
// Model variant resolution and persistence.
|
||||
//
|
||||
// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
|
||||
// Resolution priority: CLI --variant flag > saved preference > session history.
|
||||
//
|
||||
// The saved variant persists across sessions in ~/.local/state/opencode/model.json
|
||||
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
||||
// variant and the persisted file.
|
||||
import path from "path"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
|
||||
import type { RunInput } from "./types"
|
||||
|
||||
const MODEL_FILE = path.join(Global.Path.state, "model.json")
|
||||
|
||||
type ModelState = Record<string, unknown> & {
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
type VariantService = {
|
||||
readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined>
|
||||
readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void>
|
||||
}
|
||||
type VariantRuntime = {
|
||||
resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined>
|
||||
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
|
||||
}
|
||||
|
||||
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
|
||||
|
||||
function modelKey(provider: string, model: string): string {
|
||||
return `${provider}/${model}`
|
||||
}
|
||||
|
||||
function variantKey(model: NonNullable<RunInput["model"]>): string {
|
||||
return modelKey(model.providerID, model.modelID)
|
||||
}
|
||||
|
||||
export function formatModelLabel(model: NonNullable<RunInput["model"]>, variant: string | undefined): string {
|
||||
const label = variant ? ` · ${variant}` : ""
|
||||
return `${model.modelID} · ${model.providerID}${label}`
|
||||
}
|
||||
|
||||
export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
|
||||
if (variants.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return variants[0]
|
||||
}
|
||||
|
||||
const idx = variants.indexOf(current)
|
||||
if (idx === -1 || idx === variants.length - 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return variants[idx + 1]
|
||||
}
|
||||
|
||||
export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
|
||||
return sessionVariant(Array.isArray(input) ? createSession(input) : input, model)
|
||||
}
|
||||
|
||||
function fitVariant(value: string | undefined, variants: string[]): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (variants.length === 0 || variants.includes(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Picks the active variant. CLI flag wins, then saved preference, then session
|
||||
// history. fitVariant() checks saved and session values against the available
|
||||
// variants list -- if the provider doesn't offer a variant, it drops.
|
||||
export function resolveVariant(
|
||||
input: string | undefined,
|
||||
session: string | undefined,
|
||||
saved: string | undefined,
|
||||
variants: string[],
|
||||
): string | undefined {
|
||||
if (input !== undefined) {
|
||||
return input
|
||||
}
|
||||
|
||||
const fallback = fitVariant(saved, variants)
|
||||
const current = fitVariant(session, variants)
|
||||
if (current !== undefined) {
|
||||
return current
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function state(value: unknown): ModelState {
|
||||
if (!isRecord(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const variant = isRecord(value.variant)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value.variant).flatMap(([key, item]) => {
|
||||
if (typeof item !== "string") {
|
||||
return []
|
||||
}
|
||||
|
||||
return [[key, item] as const]
|
||||
}),
|
||||
)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...value,
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
function createLayer(fs = AppFileSystem.defaultLayer) {
|
||||
return Layer.fresh(
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const file = yield* AppFileSystem.Service
|
||||
|
||||
const read = Effect.fn("RunVariant.read")(function* () {
|
||||
return yield* file.readJson(MODEL_FILE).pipe(
|
||||
Effect.map(state),
|
||||
Effect.catchCause(() => Effect.succeed(state(undefined))),
|
||||
)
|
||||
})
|
||||
|
||||
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (yield* read()).variant?.[variantKey(model)]
|
||||
})
|
||||
|
||||
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
|
||||
model: RunInput["model"],
|
||||
variant: string | undefined,
|
||||
) {
|
||||
if (!model) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = yield* read()
|
||||
const next = {
|
||||
...current.variant,
|
||||
}
|
||||
const key = variantKey(model)
|
||||
if (variant) {
|
||||
next[key] = variant
|
||||
}
|
||||
|
||||
if (!variant) {
|
||||
delete next[key]
|
||||
}
|
||||
|
||||
yield* file.writeJson(MODEL_FILE, {
|
||||
...current,
|
||||
variant: next,
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
resolveSavedVariant,
|
||||
saveVariant,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(fs)),
|
||||
)
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime {
|
||||
const runtime = makeRuntime(Service, createLayer(fs))
|
||||
return {
|
||||
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
||||
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
|
||||
}
|
||||
}
|
||||
|
||||
const runtime = createVariantRuntime()
|
||||
|
||||
export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
|
||||
return runtime.resolveSavedVariant(model)
|
||||
}
|
||||
|
||||
export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
|
||||
void runtime.saveVariant(model, variant)
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "../../server/server"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export const ServeCommand = effectCmd({
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a headless opencode server",
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// need for an ambient project InstanceContext at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.serve")(function* (args) {
|
||||
handler: async (args) => {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = await Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
yield* Effect.never
|
||||
}),
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { Effect } from "effect"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionID } from "../../session/schema"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { UI } from "../ui"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -12,6 +11,7 @@ import { Process } from "@/util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
@@ -47,30 +47,36 @@ export const SessionCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const SessionDeleteCommand = effectCmd({
|
||||
export const SessionDeleteCommand = cmd({
|
||||
command: "delete <sessionID>",
|
||||
describe: "delete a session",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("sessionID", {
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.positional("sessionID", {
|
||||
describe: "session ID to delete",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.session.delete")(function* (args) {
|
||||
const svc = yield* Session.Service
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
// Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
|
||||
yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
|
||||
yield* svc.remove(sessionID)
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
}),
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
try {
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
} catch {
|
||||
UI.error(`Session not found: ${args.sessionID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const SessionListCommand = effectCmd({
|
||||
export const SessionListCommand = cmd({
|
||||
command: "list",
|
||||
describe: "list sessions",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.option("max-count", {
|
||||
alias: "n",
|
||||
describe: "limit to N most recent sessions",
|
||||
@@ -81,18 +87,28 @@ export const SessionListCommand = effectCmd({
|
||||
type: "string",
|
||||
choices: ["table", "json"],
|
||||
default: "table",
|
||||
}),
|
||||
handler: Effect.fn("Cli.session.list")(function* (args) {
|
||||
const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount }))
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const sessions = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })),
|
||||
)
|
||||
|
||||
if (sessions.length === 0) return
|
||||
if (sessions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions)
|
||||
let output: string
|
||||
if (args.format === "json") {
|
||||
output = formatSessionJSON(sessions)
|
||||
} else {
|
||||
output = formatSessionTable(sessions)
|
||||
}
|
||||
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
|
||||
if (shouldPaginate) {
|
||||
yield* Effect.promise(async () => {
|
||||
if (shouldPaginate) {
|
||||
const proc = Process.spawn(pagerCmd(), {
|
||||
stdin: "pipe",
|
||||
stdout: "inherit",
|
||||
@@ -107,11 +123,11 @@ export const SessionListCommand = effectCmd({
|
||||
proc.stdin.write(output)
|
||||
proc.stdin.end()
|
||||
await proc.exited
|
||||
})
|
||||
} else {
|
||||
console.log(output)
|
||||
}
|
||||
}),
|
||||
} else {
|
||||
console.log(output)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function formatSessionTable(sessions: Session.Info[]): string {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import type { Argv } from "yargs"
|
||||
import { cmd } from "./cmd"
|
||||
import { Session } from "@/session/session"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable } from "../../session/session.sql"
|
||||
import { Project } from "@/project/project"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
interface SessionStats {
|
||||
totalSessions: number
|
||||
@@ -45,11 +47,11 @@ interface SessionStats {
|
||||
medianTokensPerSession: number
|
||||
}
|
||||
|
||||
export const StatsCommand = effectCmd({
|
||||
export const StatsCommand = cmd({
|
||||
command: "stats",
|
||||
describe: "show token usage and cost statistics",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.option("days", {
|
||||
describe: "show stats for the last N days (default: all time)",
|
||||
type: "number",
|
||||
@@ -64,32 +66,35 @@ export const StatsCommand = effectCmd({
|
||||
.option("project", {
|
||||
describe: "filter by project (default: all projects, empty string: current project)",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.stats")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project)
|
||||
let modelLimit: number | undefined
|
||||
if (args.models === true) {
|
||||
modelLimit = Infinity
|
||||
} else if (typeof args.models === "number") {
|
||||
modelLimit = args.models
|
||||
}
|
||||
displayStats(stats, args.tools, modelLimit)
|
||||
}),
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const stats = await aggregateSessionStats(args.days, args.project)
|
||||
|
||||
let modelLimit: number | undefined
|
||||
if (args.models === true) {
|
||||
modelLimit = Infinity
|
||||
} else if (typeof args.models === "number") {
|
||||
modelLimit = args.models
|
||||
}
|
||||
|
||||
displayStats(stats, args.tools, modelLimit)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const getAllSessions = Effect.sync(() =>
|
||||
Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)),
|
||||
)
|
||||
async function getCurrentProject(): Promise<Project.Info> {
|
||||
return Instance.project
|
||||
}
|
||||
|
||||
const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
days?: number,
|
||||
projectFilter?: string,
|
||||
currentProject?: Project.Info,
|
||||
) {
|
||||
const svc = yield* Session.Service
|
||||
const sessions = yield* getAllSessions
|
||||
async function getAllSessions(): Promise<Session.Info[]> {
|
||||
const rows = Database.use((db) => db.select().from(SessionTable).all())
|
||||
return rows.map((row) => Session.fromRow(row))
|
||||
}
|
||||
|
||||
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
|
||||
const sessions = await getAllSessions()
|
||||
const MS_IN_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
const cutoffTime = (() => {
|
||||
@@ -112,7 +117,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
|
||||
if (projectFilter !== undefined) {
|
||||
if (projectFilter === "") {
|
||||
if (!currentProject) throw new Error("currentProject required when projectFilter is empty string")
|
||||
const currentProject = await getCurrentProject()
|
||||
filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
|
||||
} else {
|
||||
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
|
||||
@@ -158,111 +163,122 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
|
||||
const sessionTotalTokens: number[] = []
|
||||
|
||||
const results = yield* Effect.forEach(
|
||||
filteredSessions,
|
||||
(session) =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* svc.messages({ sessionID: session.id })
|
||||
const BATCH_SIZE = 20
|
||||
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
|
||||
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
|
||||
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
let sessionToolUsage: Record<string, number> = {}
|
||||
let sessionModelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: { input: number; output: number; cache: { read: number; write: number } }
|
||||
cost: number
|
||||
}
|
||||
> = {}
|
||||
const batchPromises = batch.map(async (session) => {
|
||||
const messages = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.messages({ sessionID: session.id })),
|
||||
)
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "assistant") {
|
||||
sessionCost += message.info.cost || 0
|
||||
|
||||
const modelKey = `${message.info.providerID}/${message.info.modelID}`
|
||||
if (!sessionModelUsage[modelKey]) {
|
||||
sessionModelUsage[modelKey] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
sessionModelUsage[modelKey].messages++
|
||||
sessionModelUsage[modelKey].cost += message.info.cost || 0
|
||||
|
||||
if (message.info.tokens) {
|
||||
sessionTokens.input += message.info.tokens.input || 0
|
||||
sessionTokens.output += message.info.tokens.output || 0
|
||||
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
||||
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
|
||||
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
|
||||
sessionModelUsage[modelKey].tokens.output +=
|
||||
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
|
||||
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
let sessionToolUsage: Record<string, number> = {}
|
||||
let sessionModelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
cache: {
|
||||
read: number
|
||||
write: number
|
||||
}
|
||||
}
|
||||
cost: number
|
||||
}
|
||||
> = {}
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "tool" && part.tool) {
|
||||
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "assistant") {
|
||||
sessionCost += message.info.cost || 0
|
||||
|
||||
const modelKey = `${message.info.providerID}/${message.info.modelID}`
|
||||
if (!sessionModelUsage[modelKey]) {
|
||||
sessionModelUsage[modelKey] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
sessionModelUsage[modelKey].messages++
|
||||
sessionModelUsage[modelKey].cost += message.info.cost || 0
|
||||
|
||||
if (message.info.tokens) {
|
||||
sessionTokens.input += message.info.tokens.input || 0
|
||||
sessionTokens.output += message.info.tokens.output || 0
|
||||
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
||||
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
|
||||
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
|
||||
sessionModelUsage[modelKey].tokens.output +=
|
||||
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
|
||||
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageCount: messages.length,
|
||||
sessionCost,
|
||||
sessionTokens,
|
||||
sessionTotalTokens:
|
||||
sessionTokens.input +
|
||||
sessionTokens.output +
|
||||
sessionTokens.reasoning +
|
||||
sessionTokens.cache.read +
|
||||
sessionTokens.cache.write,
|
||||
sessionToolUsage,
|
||||
sessionModelUsage,
|
||||
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
|
||||
latestTime: session.time.updated,
|
||||
}
|
||||
}),
|
||||
{ concurrency: 20 },
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
earliestTime = Math.min(earliestTime, result.earliestTime)
|
||||
latestTime = Math.max(latestTime, result.latestTime)
|
||||
sessionTotalTokens.push(result.sessionTotalTokens)
|
||||
|
||||
stats.totalMessages += result.messageCount
|
||||
stats.totalCost += result.sessionCost
|
||||
stats.totalTokens.input += result.sessionTokens.input
|
||||
stats.totalTokens.output += result.sessionTokens.output
|
||||
stats.totalTokens.reasoning += result.sessionTokens.reasoning
|
||||
stats.totalTokens.cache.read += result.sessionTokens.cache.read
|
||||
stats.totalTokens.cache.write += result.sessionTokens.cache.write
|
||||
|
||||
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
||||
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
||||
}
|
||||
|
||||
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
|
||||
if (!stats.modelUsage[model]) {
|
||||
stats.modelUsage[model] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "tool" && part.tool) {
|
||||
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.modelUsage[model].messages += usage.messages
|
||||
stats.modelUsage[model].tokens.input += usage.tokens.input
|
||||
stats.modelUsage[model].tokens.output += usage.tokens.output
|
||||
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
|
||||
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
|
||||
stats.modelUsage[model].cost += usage.cost
|
||||
|
||||
return {
|
||||
messageCount: messages.length,
|
||||
sessionCost,
|
||||
sessionTokens,
|
||||
sessionTotalTokens:
|
||||
sessionTokens.input +
|
||||
sessionTokens.output +
|
||||
sessionTokens.reasoning +
|
||||
sessionTokens.cache.read +
|
||||
sessionTokens.cache.write,
|
||||
sessionToolUsage,
|
||||
sessionModelUsage,
|
||||
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
|
||||
latestTime: session.time.updated,
|
||||
}
|
||||
})
|
||||
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
|
||||
for (const result of batchResults) {
|
||||
earliestTime = Math.min(earliestTime, result.earliestTime)
|
||||
latestTime = Math.max(latestTime, result.latestTime)
|
||||
sessionTotalTokens.push(result.sessionTotalTokens)
|
||||
|
||||
stats.totalMessages += result.messageCount
|
||||
stats.totalCost += result.sessionCost
|
||||
stats.totalTokens.input += result.sessionTokens.input
|
||||
stats.totalTokens.output += result.sessionTokens.output
|
||||
stats.totalTokens.reasoning += result.sessionTokens.reasoning
|
||||
stats.totalTokens.cache.read += result.sessionTokens.cache.read
|
||||
stats.totalTokens.cache.write += result.sessionTokens.cache.write
|
||||
|
||||
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
||||
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
||||
}
|
||||
|
||||
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
|
||||
if (!stats.modelUsage[model]) {
|
||||
stats.modelUsage[model] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
stats.modelUsage[model].messages += usage.messages
|
||||
stats.modelUsage[model].tokens.input += usage.tokens.input
|
||||
stats.modelUsage[model].tokens.output += usage.tokens.output
|
||||
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
|
||||
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
|
||||
stats.modelUsage[model].cost += usage.cost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +307,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
: sessionTotalTokens[mid]
|
||||
|
||||
return stats
|
||||
})
|
||||
}
|
||||
|
||||
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
|
||||
const width = 56
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useEvent } from "@tui/context/event"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { SyncProviderV2 } from "@tui/context/sync-v2"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel } from "@tui/component/dialog-model"
|
||||
import { useConnected } from "@tui/component/use-connected"
|
||||
@@ -134,8 +133,6 @@ export function tui(input: {
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
|
||||
await render(() => {
|
||||
@@ -169,29 +166,27 @@ export function tui(input: {
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cmd } from "../cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { tui } from "./app"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { errorMessage } from "@/util/error"
|
||||
@@ -67,6 +66,7 @@ export const AttachCommand = cmd({
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const config = await TuiConfig.get()
|
||||
const { tui } = await import("./app")
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
|
||||
@@ -17,7 +17,6 @@ import { MessageID, PartID } from "@/session/schema"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { computePromptTraits } from "./traits"
|
||||
import { assign } from "./part"
|
||||
import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
@@ -558,11 +557,17 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.traits = computePromptTraits({
|
||||
mode: store.mode,
|
||||
disabled: !!props.disabled,
|
||||
autocompleteVisible: !!auto()?.visible,
|
||||
})
|
||||
const capture =
|
||||
store.mode === "normal"
|
||||
? auto()?.visible
|
||||
? (["escape", "navigate", "submit", "tab"] as const)
|
||||
: (["tab"] as const)
|
||||
: undefined
|
||||
input.traits = {
|
||||
capture,
|
||||
suspend: !!props.disabled || store.mode === "shell",
|
||||
status: store.mode === "shell" ? "SHELL" : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
||||
@@ -750,18 +755,9 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
const variant = local.model.variant.current()
|
||||
let sessionID = props.sessionID
|
||||
if (sessionID == null) {
|
||||
const res = await sdk.client.session.create({
|
||||
workspace: props.workspaceID,
|
||||
agent: agent.name,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
id: selectedModel.modelID,
|
||||
variant,
|
||||
},
|
||||
})
|
||||
const res = await sdk.client.session.create({ workspace: props.workspaceID })
|
||||
|
||||
if (res.error) {
|
||||
console.log("Creating a session failed:", res.error)
|
||||
@@ -801,6 +797,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
const variant = local.model.variant.current()
|
||||
const editorSelection = editorContext()
|
||||
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
|
||||
const editorParts =
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { EditorTraits } from "@opentui/core"
|
||||
|
||||
export type PromptMode = "normal" | "shell"
|
||||
|
||||
export interface PromptTraitsInput {
|
||||
mode: PromptMode
|
||||
disabled: boolean
|
||||
autocompleteVisible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the textarea editor traits for the prompt.
|
||||
*
|
||||
* `traits.suspend` gates the textarea's keybinding actions (backspace,
|
||||
* delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
|
||||
* editing mode — only `disabled` should suspend the textarea, otherwise
|
||||
* users can type in shell mode but cannot delete or move the cursor.
|
||||
*/
|
||||
export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
|
||||
const capture =
|
||||
input.mode === "normal"
|
||||
? input.autocompleteVisible
|
||||
? (["escape", "navigate", "submit", "tab"] as const)
|
||||
: (["tab"] as const)
|
||||
: undefined
|
||||
return {
|
||||
capture,
|
||||
suspend: input.disabled,
|
||||
status: input.mode === "shell" ? "SHELL" : undefined,
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { JSX } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import "opentui-spinner/solid"
|
||||
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const { theme } = useTheme()
|
||||
@@ -14,7 +14,7 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
return (
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={frames} interval={80} color={color()} />
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import type {
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
function activeAssistant(messages: SessionMessage[]) {
|
||||
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
if (index < 0) return
|
||||
const assistant = messages[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
}
|
||||
|
||||
function activeCompaction(messages: SessionMessage[]) {
|
||||
const index = messages.findLastIndex((message) => message.type === "compaction")
|
||||
if (index < 0) return
|
||||
const compaction = messages[index]
|
||||
return compaction?.type === "compaction" ? compaction : undefined
|
||||
}
|
||||
|
||||
function activeShell(messages: SessionMessage[], callID: string) {
|
||||
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
if (index < 0) return
|
||||
const shell = messages[index]
|
||||
return shell?.type === "shell" ? shell : undefined
|
||||
}
|
||||
|
||||
function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
}
|
||||
|
||||
function latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
}
|
||||
|
||||
function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
||||
)
|
||||
}
|
||||
|
||||
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
|
||||
name: "SyncV2",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<{
|
||||
messages: {
|
||||
[sessionID: string]: SessionMessage[]
|
||||
}
|
||||
}>({
|
||||
messages: {},
|
||||
})
|
||||
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
|
||||
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
||||
setStore(
|
||||
"messages",
|
||||
produce((draft) => {
|
||||
fn((draft[sessionID] ??= []))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "session.next.prompted": {
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
agents: event.properties.prompt.agents,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.next.synthetic":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "synthetic",
|
||||
sessionID: event.properties.sessionID,
|
||||
text: event.properties.text,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.shell.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "shell",
|
||||
callID: event.properties.callID,
|
||||
command: event.properties.command,
|
||||
output: "",
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.shell.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeShell(draft, event.properties.callID)
|
||||
if (!match) return
|
||||
match.output = event.properties.output
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.step.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
agent: event.properties.agent,
|
||||
model: event.properties.model,
|
||||
content: [],
|
||||
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.step.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.properties.timestamp
|
||||
currentAssistant.finish = event.properties.finish
|
||||
currentAssistant.cost = event.properties.cost
|
||||
currentAssistant.tokens = event.properties.tokens
|
||||
if (event.properties.snapshot)
|
||||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
|
||||
})
|
||||
break
|
||||
case "session.next.text.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({ type: "text", text: "" })
|
||||
})
|
||||
break
|
||||
case "session.next.text.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft))
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.text.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft))
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({
|
||||
type: "tool",
|
||||
id: event.properties.callID,
|
||||
name: event.properties.name,
|
||||
time: { created: event.properties.timestamp },
|
||||
state: { status: "pending", input: "" },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status === "pending") match.state.input += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.ended":
|
||||
break
|
||||
case "session.next.tool.called":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (!match) return
|
||||
match.time.ran = event.properties.timestamp
|
||||
match.provider = event.properties.provider
|
||||
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
|
||||
})
|
||||
break
|
||||
case "session.next.tool.progress":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.structured = event.properties.structured
|
||||
match.state.content = [...event.properties.content]
|
||||
})
|
||||
break
|
||||
case "session.next.tool.success":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.properties.structured,
|
||||
content: [...event.properties.content],
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.tool.error":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.properties.error,
|
||||
input: match.state.input,
|
||||
structured: match.state.structured,
|
||||
content: match.state.content,
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.properties.reasoningID,
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.retried":
|
||||
break
|
||||
case "session.next.compaction.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
summary: "",
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.compaction.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeCompaction(draft)
|
||||
if (match) match.summary += event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.compaction.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeCompaction(draft)
|
||||
if (!match) return
|
||||
match.summary = event.properties.text
|
||||
match.include = event.properties.include
|
||||
})
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
session: {
|
||||
message: {
|
||||
async sync(sessionID: string) {
|
||||
const response = await sdk.client.v2.session.messages({ sessionID })
|
||||
setStore("messages", sessionID, reconcile(response.data?.items ?? []))
|
||||
},
|
||||
fromSession(sessionID: string) {
|
||||
const messages = store.messages[sessionID]
|
||||
if (!messages) return []
|
||||
return messages
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -416,16 +416,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
|
||||
const values = createMemo(() => {
|
||||
const active = store.themes[store.active]
|
||||
if (active) {
|
||||
return resolveTheme(active, store.mode)
|
||||
}
|
||||
if (active) return resolveTheme(active, store.mode)
|
||||
|
||||
const saved = kv.get("theme")
|
||||
if (typeof saved === "string") {
|
||||
const theme = store.themes[saved]
|
||||
if (theme) {
|
||||
return resolveTheme(theme, store.mode)
|
||||
}
|
||||
if (theme) return resolveTheme(theme, store.mode)
|
||||
}
|
||||
|
||||
return resolveTheme(store.themes.opencode, store.mode)
|
||||
@@ -518,7 +514,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
||||
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
|
||||
}
|
||||
|
||||
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
|
||||
@@ -714,11 +710,11 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
|
||||
return RGBA.fromInts(grayValue, grayValue, grayValue)
|
||||
}
|
||||
|
||||
function generateSyntax(theme: Theme) {
|
||||
export function generateSyntax(theme: Theme) {
|
||||
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
|
||||
}
|
||||
|
||||
function generateSubtleSyntax(theme: Theme) {
|
||||
export function generateSubtleSyntax(theme: Theme) {
|
||||
const rules = getSyntaxRules(theme)
|
||||
return SyntaxStyle.fromTheme(
|
||||
rules.map((rule) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,7 @@ import SidebarTodo from "../feature-plugins/sidebar/todo"
|
||||
import SidebarFiles from "../feature-plugins/sidebar/files"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import PluginManager from "../feature-plugins/system/plugins"
|
||||
import SessionV2Debug from "../feature-plugins/system/session-v2"
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export type InternalTuiPlugin = TuiPluginModule & {
|
||||
id: string
|
||||
@@ -26,5 +24,4 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||
SidebarFiles,
|
||||
SidebarFooter,
|
||||
PluginManager,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user