diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index c0e3a5deb15..44bfeb33661 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -64,7 +64,7 @@ jobs: Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage. - When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts) + When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts) Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block. If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors. diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 00000000000..d41e8e60c50 --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,29 @@ +name: "Auto-close stale issues" + +on: + schedule: + - cron: "30 1 * * *" # Daily at 1:30 AM + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/stale@v10 + with: + days-before-stale: 90 + days-before-close: 7 + stale-issue-label: "stale" + close-issue-message: | + [automated] Closing due to 90+ days of inactivity. + + Feel free to reopen if you still need this! + stale-issue-message: | + [automated] This issue has had no activity for 90 days. + + It will be closed in 7 days if there's no new activity. + remove-stale-when-updated: true + exempt-issue-labels: "pinned,security,feature-request,on-hold" + start-date: "2025-12-27" diff --git a/.github/workflows/sync-zed-extension.yml b/.github/workflows/sync-zed-extension.yml index a504582c3c8..9e647b8d941 100644 --- a/.github/workflows/sync-zed-extension.yml +++ b/.github/workflows/sync-zed-extension.yml @@ -2,8 +2,8 @@ name: "sync-zed-extension" on: workflow_dispatch: - # release: - # types: [published] + release: + types: [published] jobs: zed: @@ -31,4 +31,4 @@ jobs: run: | ./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }} env: - GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }} + ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }} diff --git a/.opencode/agent/docs.md b/.opencode/agent/docs.md index 7e5307ea0b2..21cfc6a16e0 100644 --- a/.opencode/agent/docs.md +++ b/.opencode/agent/docs.md @@ -1,5 +1,6 @@ --- description: ALWAYS use this when writing docs +color: "#38A3EE" --- You are an expert technical documentation writer diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index b2db100e9cf..539be154917 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -2,6 +2,7 @@ mode: primary hidden: true model: opencode/claude-haiku-4-5 +color: "#44BA81" tools: "*": false "github-triage": true diff --git a/README.md b/README.md index 5295810b6f0..b68195abdbe 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ you can switch between these using the `Tab` key. - Asks permission before running bash commands - Ideal for exploring unfamiliar codebases or planning changes -Also, included is a **general** subagent for complex searches and multi-step tasks. +Also, included is a **general** subagent for complex searches and multistep tasks. This is used internally and can be invoked using `@general` in messages. Learn more about [agents](https://opencode.ai/docs/agents). @@ -98,7 +98,7 @@ If you are working on a project that's related to OpenCode and is using "opencod ### FAQ -#### How is this different than Claude Code? +#### How is this different from Claude Code? It's very similar to Claude Code in terms of capability. Here are the key differences: diff --git a/STATS.md b/STATS.md index 0a187668954..757a03db5e3 100644 --- a/STATS.md +++ b/STATS.md @@ -180,3 +180,8 @@ | 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) | | 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) | | 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) | +| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | +| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | +| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | +| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | +| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | diff --git a/bun.lock b/bun.lock index 93aea8d21df..0736caecea1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,6 @@ "": { "name": "opencode", "dependencies": { - "@ai-sdk/cerebras": "1.0.33", - "@ai-sdk/cohere": "2.0.21", - "@ai-sdk/deepinfra": "1.0.30", - "@ai-sdk/gateway": "2.0.23", - "@ai-sdk/groq": "2.0.33", - "@ai-sdk/perplexity": "2.0.22", - "@ai-sdk/togetherai": "1.0.30", "@aws-sdk/client-s3": "3.933.0", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", @@ -29,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -77,7 +70,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -105,7 +98,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -132,7 +125,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -156,7 +149,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -180,7 +173,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@opencode-ai/app": "workspace:*", "@solid-primitives/storage": "catalog:", @@ -207,7 +200,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -236,7 +229,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -252,7 +245,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.198", + "version": "1.0.207", "bin": { "opencode": "./bin/opencode", }, @@ -263,14 +256,21 @@ "@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/azure": "2.0.73", + "@ai-sdk/cerebras": "1.0.33", + "@ai-sdk/cohere": "2.0.21", + "@ai-sdk/deepinfra": "1.0.30", + "@ai-sdk/gateway": "2.0.23", "@ai-sdk/google": "2.0.44", "@ai-sdk/google-vertex": "3.0.81", + "@ai-sdk/groq": "2.0.33", "@ai-sdk/mcp": "0.0.8", "@ai-sdk/mistral": "2.0.26", "@ai-sdk/openai": "2.0.71", "@ai-sdk/openai-compatible": "1.0.27", + "@ai-sdk/perplexity": "2.0.22", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", + "@ai-sdk/togetherai": "1.0.30", "@ai-sdk/xai": "2.0.42", "@clack/prompts": "1.0.0-alpha.1", "@hono/standard-validator": "0.1.5", @@ -292,6 +292,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", @@ -346,7 +347,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -366,7 +367,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.198", + "version": "1.0.207", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -377,7 +378,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -390,7 +391,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -402,8 +403,10 @@ "@solidjs/meta": "catalog:", "@typescript/native-preview": "catalog:", "fuzzysort": "catalog:", + "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", + "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", "remeda": "catalog:", "shiki": "catalog:", @@ -415,6 +418,7 @@ "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", + "@types/katex": "0.16.7", "@types/luxon": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", @@ -425,7 +429,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "zod": "catalog:", }, @@ -436,7 +440,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1081,6 +1085,8 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -1771,6 +1777,8 @@ "@types/jsonwebtoken": ["@types/jsonwebtoken@8.5.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg=="], + "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], + "@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -2003,6 +2011,8 @@ "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], + "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], @@ -2247,6 +2257,8 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -2779,6 +2791,8 @@ "jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="], + "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -2869,6 +2883,8 @@ "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], + "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -3023,6 +3039,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="], + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], "mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="], @@ -3595,6 +3613,8 @@ "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -4273,6 +4293,8 @@ "jsonwebtoken/jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], diff --git a/flake.lock b/flake.lock index 4ff2c1d0e11..6cb2cf91971 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1766532406, - "narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=", + "lastModified": 1766870016, + "narHash": "sha256-fHmxAesa6XNqnIkcS6+nIHuEmgd/iZSP/VXxweiEuQw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8142186f001295e5a3239f485c8a49bf2de2695a", + "rev": "5c2bc52fb9f8c264ed6c93bd20afa2ff5e763dce", "type": "github" }, "original": { diff --git a/install b/install index e89ca9fb70f..702fb4a534c 100755 --- a/install +++ b/install @@ -155,8 +155,18 @@ if [ -z "$requested_version" ]; then exit 1 fi else + # Strip leading 'v' if present + requested_version="${requested_version#v}" url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename" specific_version=$requested_version + + # Verify the release exists before downloading + http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/sst/opencode/releases/tag/v${requested_version}") + if [ "$http_status" = "404" ]; then + echo -e "${RED}Error: Release v${requested_version} not found${NC}" + echo -e "${MUTED}Available releases: https://github.com/sst/opencode/releases${NC}" + exit 1 + fi fi print_message() { diff --git a/nix/hashes.json b/nix/hashes.json index 66c0baaf791..62d9ba24849 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-hotsyeWJA6/dP6DvZTN1Ak2RSKcsyvXlXPI/jexBHME=" + "nodeModules": "sha256-SB9slGD8Vd1hgvm1AsuPzUi3yBPUCDGeha0CABjZdCY=" } diff --git a/package.json b/package.json index 7346a4ca853..aa7031bec72 100644 --- a/package.json +++ b/package.json @@ -67,13 +67,6 @@ "turbo": "2.5.6" }, "dependencies": { - "@ai-sdk/cerebras": "1.0.33", - "@ai-sdk/cohere": "2.0.21", - "@ai-sdk/deepinfra": "1.0.30", - "@ai-sdk/gateway": "2.0.23", - "@ai-sdk/groq": "2.0.33", - "@ai-sdk/perplexity": "2.0.22", - "@ai-sdk/togetherai": "1.0.30", "@aws-sdk/client-s3": "3.933.0", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", diff --git a/packages/app/index.html b/packages/app/index.html index 2c3a0eabd40..ea423780448 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -1,5 +1,5 @@ - + @@ -13,14 +13,39 @@ - - - + +
diff --git a/packages/app/package.json b/packages/app/package.json index 2dfa55cddbc..01721f6e498 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.0.198", + "version": "1.0.207", "description": "", "type": "module", "exports": { diff --git a/packages/app/public/_headers b/packages/app/public/_headers new file mode 100644 index 00000000000..f5157b1debc --- /dev/null +++ b/packages/app/public/_headers @@ -0,0 +1,17 @@ +/assets/*.js + Content-Type: application/javascript + +/assets/*.mjs + Content-Type: application/javascript + +/assets/*.css + Content-Type: text/css + +/*.js + Content-Type: application/javascript + +/*.mjs + Content-Type: application/javascript + +/*.css + Content-Type: text/css diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 11216643e5b..9e38d5e98de 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -8,6 +8,7 @@ import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { CodeComponentProvider } from "@opencode-ai/ui/context/code" import { Diff } from "@opencode-ai/ui/diff" import { Code } from "@opencode-ai/ui/code" +import { ThemeProvider } from "@opencode-ai/ui/theme" import { GlobalSyncProvider } from "@/context/global-sync" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" @@ -38,55 +39,57 @@ const url = iife(() => { if (import.meta.env.DEV) return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - return "http://localhost:4096" + return window.location.origin }) export function App() { return ( - }> - - - - - - - - - ( - - {props.children} - - )} - > - - - } /> - ( - - - - - - - - )} - /> - - - - - - - - - - - + + }> + + + + + + + + + ( + + {props.children} + + )} + > + + + } /> + ( + + + + + + + + )} + /> + + + + + + + + + + + + ) } diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx new file mode 100644 index 00000000000..27ce3903cdc --- /dev/null +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -0,0 +1,180 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { Icon } from "@opencode-ai/ui/icon" +import { createMemo, createSignal, For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobalSDK } from "@/context/global-sdk" +import { type LocalProject, getAvatarColors } from "@/context/layout" +import { Avatar } from "@opencode-ai/ui/avatar" + +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const + +function getFilename(input: string) { + const parts = input.split("/") + return parts[parts.length - 1] || input +} + +export function DialogEditProject(props: { project: LocalProject }) { + const dialog = useDialog() + const globalSDK = useGlobalSDK() + + const folderName = createMemo(() => getFilename(props.project.worktree)) + const defaultName = createMemo(() => props.project.name || folderName()) + + const [store, setStore] = createStore({ + name: defaultName(), + color: props.project.icon?.color || "pink", + iconUrl: props.project.icon?.url || "", + saving: false, + }) + + const [dragOver, setDragOver] = createSignal(false) + + function handleFileSelect(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (e) => setStore("iconUrl", e.target?.result as string) + reader.readAsDataURL(file) + } + + function handleDrop(e: DragEvent) { + e.preventDefault() + setDragOver(false) + const file = e.dataTransfer?.files[0] + if (file) handleFileSelect(file) + } + + function handleDragOver(e: DragEvent) { + e.preventDefault() + setDragOver(true) + } + + function handleDragLeave() { + setDragOver(false) + } + + function handleInputChange(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file) handleFileSelect(file) + } + + function clearIcon() { + setStore("iconUrl", "") + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + if (!props.project.id) return + + setStore("saving", true) + const name = store.name.trim() === folderName() ? "" : store.name.trim() + await globalSDK.client.project.update({ + projectID: props.project.id, + name, + icon: { color: store.color, url: store.iconUrl }, + }) + setStore("saving", false) + dialog.close() + } + + return ( + +
+
+ setStore("name", v)} + /> + +
+ +
+
+
document.getElementById("icon-upload")?.click()} + > + + +
+ } + > + Project icon + +
+ + + +
+ +
+ Click or drag an image + Recommended: 128x128px +
+
+
+ + +
+ +
+ + {(color) => ( + + )} + +
+
+
+ + +
+ + +
+
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx new file mode 100644 index 00000000000..c29cd827e3b --- /dev/null +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -0,0 +1,91 @@ +import { Component, createMemo, createSignal, Show } from "solid-js" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" + +export const DialogSelectMcp: Component = () => { + const sync = useSync() + const sdk = useSDK() + const [loading, setLoading] = createSignal(null) + + const items = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + + const toggle = async (name: string) => { + if (loading()) return + setLoading(name) + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + setLoading(null) + } + + const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) + const totalCount = createMemo(() => items().length) + + return ( + + x?.name ?? ""} + items={items} + filterKeys={["name", "status"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + onSelect={(x) => { + if (x) toggle(x.name) + }} + > + {(i) => { + const mcpStatus = () => sync.data.mcp[i.name] + const status = () => mcpStatus()?.status + const error = () => { + const s = mcpStatus() + return s?.status === "failed" ? s.error : undefined + } + const enabled = () => status() === "connected" + return ( +
+
+
+ {i.name} + + connected + + + failed + + + needs auth + + + disabled + + + ... + +
+ + {error()} + +
+
e.stopPropagation()}> + toggle(i.name)} /> +
+
+ ) + }} +
+
+ ) +} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 0841c71d1d9..3439d366cee 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -2,7 +2,7 @@ import { useLocal, type LocalFile } from "@/context/local" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js" +import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js" import { Dynamic } from "solid-js/web" export default function FileTree(props: { @@ -57,14 +57,14 @@ export default function FileTree(props: { "text-text-muted/40": p.node.ignored, "text-text-muted/80": !p.node.ignored, // "!text-text": local.file.active()?.path === p.node.path, - "!text-primary": local.file.changed(p.node.path), + // "!text-primary": local.file.changed(p.node.path), }} > {p.node.name} - - - + {/* */} + {/* */} + {/* */} ) diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx index 3eae0e05d41..74c49f07ac6 100644 --- a/packages/app/src/components/header.tsx +++ b/packages/app/src/components/header.tsx @@ -188,6 +188,10 @@ export function Header(props: { shareURL = await globalSDK.client.session .share({ sessionID: session.id, directory: currentDirectory() }) .then((r) => r.data?.share?.url) + .catch((e) => { + console.error("Failed to share session", e) + return undefined + }) } return shareURL }, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 94d4ae97e8a..3c3225137da 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -82,6 +82,37 @@ export const PromptInput: Component = (props) => { const command = useCommand() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement + let scrollRef!: HTMLDivElement + + const scrollCursorIntoView = () => { + const container = scrollRef + const selection = window.getSelection() + if (!container || !selection || selection.rangeCount === 0) return + + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return + + const rect = range.getBoundingClientRect() + if (!rect.height) return + + const containerRect = container.getBoundingClientRect() + const top = rect.top - containerRect.top + container.scrollTop + const bottom = rect.bottom - containerRect.top + container.scrollTop + const padding = 12 + + if (top < container.scrollTop + padding) { + container.scrollTop = Math.max(0, top - padding) + return + } + + if (bottom > container.scrollTop + container.clientHeight - padding) { + container.scrollTop = bottom - container.clientHeight + padding + } + } + + const queueScroll = () => { + requestAnimationFrame(scrollCursorIntoView) + } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -103,7 +134,6 @@ export const PromptInput: Component = (props) => { imageAttachments: ImageAttachmentPart[] mode: "normal" | "shell" applyingHistory: boolean - userHasEdited: boolean }>({ popover: null, historyIndex: -1, @@ -113,7 +143,6 @@ export const PromptInput: Component = (props) => { imageAttachments: [], mode: "normal", applyingHistory: false, - userHasEdited: false, }) const MAX_HISTORY = 100 @@ -150,12 +179,12 @@ export const PromptInput: Component = (props) => { const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) - setStore("userHasEdited", false) prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, length) setStore("applyingHistory", false) + queueScroll() }) } @@ -219,6 +248,7 @@ export const PromptInput: Component = (props) => { } const handlePaste = async (event: ClipboardEvent) => { + if (!isFocused()) return const clipboardData = event.clipboardData if (!clipboardData) return @@ -241,7 +271,7 @@ export const PromptInput: Component = (props) => { addPart({ type: "text", content: plainText, start: 0, end: 0 }) } - const handleDragOver = (event: DragEvent) => { + const handleGlobalDragOver = (event: DragEvent) => { event.preventDefault() const hasFiles = event.dataTransfer?.types.includes("Files") if (hasFiles) { @@ -249,15 +279,14 @@ export const PromptInput: Component = (props) => { } } - const handleDragLeave = (event: DragEvent) => { - const related = event.relatedTarget as Node | null - const form = event.currentTarget as HTMLElement - if (!related || !form.contains(related)) { + const handleGlobalDragLeave = (event: DragEvent) => { + // relatedTarget is null when leaving the document window + if (!event.relatedTarget) { setStore("dragging", false) } } - const handleDrop = async (event: DragEvent) => { + const handleGlobalDrop = async (event: DragEvent) => { event.preventDefault() setStore("dragging", false) @@ -273,17 +302,19 @@ export const PromptInput: Component = (props) => { onMount(() => { editorRef.addEventListener("paste", handlePaste) + document.addEventListener("dragover", handleGlobalDragOver) + document.addEventListener("dragleave", handleGlobalDragLeave) + document.addEventListener("drop", handleGlobalDrop) }) onCleanup(() => { editorRef.removeEventListener("paste", handlePaste) + document.removeEventListener("dragover", handleGlobalDragOver) + document.removeEventListener("dragleave", handleGlobalDragLeave) + document.removeEventListener("drop", handleGlobalDrop) }) createEffect(() => { - if (isFocused()) { - handleInput() - } else { - setStore("popover", null) - } + if (!isFocused()) setStore("popover", null) }) const handleFileSelect = (path: string | undefined) => { @@ -363,7 +394,26 @@ export const PromptInput: Component = (props) => { () => prompt.current(), (currentParts) => { const domParts = parseFromDOM() - if (isPromptEqual(currentParts, domParts)) return + const normalized = Array.from(editorRef.childNodes).every((node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" + if (!prevIsBr && !nextIsBr) return false + if (nextIsBr && !prevIsBr && prev) return false + return true + } + if (node.nodeType !== Node.ELEMENT_NODE) return false + const el = node as HTMLElement + if (el.dataset.type === "file") return true + return el.tagName === "BR" + }) + if (normalized && isPromptEqual(currentParts, domParts)) return const selection = window.getSelection() let cursorPosition: number | null = null @@ -374,7 +424,7 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" currentParts.forEach((part) => { if (part.type === "text") { - editorRef.appendChild(document.createTextNode(part.content)) + editorRef.appendChild(createTextFragment(part.content)) } else if (part.type === "file") { const pill = document.createElement("span") pill.textContent = part.content @@ -395,34 +445,21 @@ export const PromptInput: Component = (props) => { ) const parseFromDOM = (): Prompt => { - const newParts: Prompt = [] + const parts: Prompt = [] let position = 0 + let buffer = "" - const pushText = (content: string) => { + const flushText = () => { + const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "") + buffer = "" if (!content) return - newParts.push({ type: "text", content, start: position, end: position + content.length }) + parts.push({ type: "text", content, start: position, end: position + content.length }) position += content.length } - const rangeText = (range: Range) => { - const fragment = range.cloneContents() - const container = document.createElement("div") - container.append(fragment) - return container.innerText - } - - const files = Array.from(editorRef.querySelectorAll("[data-type=file]")) - let last: HTMLElement | undefined - - files.forEach((file) => { - const before = document.createRange() - before.selectNodeContents(editorRef) - if (last) before.setStartAfter(last) - before.setEndBefore(file) - pushText(rangeText(before)) - + const pushFile = (file: HTMLElement) => { const content = file.textContent ?? "" - newParts.push({ + parts.push({ type: "file", path: file.dataset.path!, content, @@ -430,16 +467,44 @@ export const PromptInput: Component = (props) => { end: position + content.length, }) position += content.length - last = file + } + + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + buffer += node.textContent ?? "" + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + + const el = node as HTMLElement + if (el.dataset.type === "file") { + flushText() + pushFile(el) + return + } + if (el.tagName === "BR") { + buffer += "\n" + return + } + + for (const child of Array.from(el.childNodes)) { + visit(child) + } + } + + const children = Array.from(editorRef.childNodes) + children.forEach((child, index) => { + const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) + visit(child) + if (isBlock && index < children.length - 1) { + buffer += "\n" + } }) - const after = document.createRange() - after.selectNodeContents(editorRef) - if (last) after.setStartAfter(last) - pushText(rangeText(after)) + flushText() - if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT) - return newParts + if (parts.length === 0) parts.push(...DEFAULT_PROMPT) + return parts } const handleInput = () => { @@ -452,7 +517,6 @@ export const PromptInput: Component = (props) => { if (shouldReset) { setStore("popover", null) - setStore("userHasEdited", false) if (store.historyIndex >= 0 && !store.applyingHistory) { setStore("historyIndex", -1) setStore("savedPrompt", null) @@ -460,6 +524,7 @@ export const PromptInput: Component = (props) => { if (prompt.dirty()) { prompt.set(DEFAULT_PROMPT, 0) } + queueScroll() return } @@ -487,11 +552,8 @@ export const PromptInput: Component = (props) => { setStore("savedPrompt", null) } - if (!store.applyingHistory) { - setStore("userHasEdited", true) - } - prompt.set(rawParts, cursorPosition) + queueScroll() } const addPart = (part: ContentPart) => { @@ -516,27 +578,40 @@ export const PromptInput: Component = (props) => { const gap = document.createTextNode(" ") const range = selection.getRangeAt(0) - if (atMatch) { - let runningLength = 0 - - const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) - let currentNode = walker.nextNode() - while (currentNode) { - const textContent = currentNode.textContent || "" - if (runningLength + textContent.length >= atMatch.index!) { - const localStart = atMatch.index! - runningLength - const localEnd = cursorPosition - runningLength - if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) { - range.setStart(currentNode, localStart) - range.setEnd(currentNode, Math.min(localEnd, textContent.length)) - break - } + const setEdge = (edge: "start" | "end", offset: number) => { + let remaining = offset + const nodes = Array.from(editorRef.childNodes) + + for (const node of nodes) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + if (edge === "start") range.setStart(node, remaining) + if (edge === "end") range.setEnd(node, remaining) + return + } + + if ((isFile || isBreak) && remaining <= length) { + if (edge === "start" && remaining === 0) range.setStartBefore(node) + if (edge === "start" && remaining > 0) range.setStartAfter(node) + if (edge === "end" && remaining === 0) range.setEndBefore(node) + if (edge === "end" && remaining > 0) range.setEndAfter(node) + return } - runningLength += textContent.length - currentNode = walker.nextNode() + + remaining -= length } } + if (atMatch) { + const start = atMatch.index ?? cursorPosition - atMatch[0].length + setEdge("start", start) + setEdge("end", cursorPosition) + } + range.deleteContents() range.insertNode(gap) range.insertNode(pill) @@ -545,11 +620,25 @@ export const PromptInput: Component = (props) => { selection.removeAllRanges() selection.addRange(range) } else if (part.type === "text") { - const textNode = document.createTextNode(part.content) const range = selection.getRangeAt(0) + const fragment = createTextFragment(part.content) + const last = fragment.lastChild range.deleteContents() - range.insertNode(textNode) - range.setStartAfter(textNode) + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(last) + } + } range.collapse(true) selection.removeAllRanges() selection.addRange(range) @@ -560,9 +649,11 @@ export const PromptInput: Component = (props) => { } const abort = () => - sdk.client.session.abort({ - sessionID: params.id!, - }) + sdk.client.session + .abort({ + sessionID: params.id!, + }) + .catch(() => {}) const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const text = prompt @@ -584,8 +675,6 @@ export const PromptInput: Component = (props) => { } const navigateHistory = (direction: "up" | "down") => { - if (store.userHasEdited) return false - const entries = store.mode === "shell" ? shellHistory.entries : history.entries const current = store.historyIndex @@ -628,6 +717,24 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Backspace") { + const selection = window.getSelection() + if (selection && selection.isCollapsed) { + const node = selection.anchorNode + const offset = selection.anchorOffset + if (node && node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (/^\u200B+$/.test(text) && offset > 0) { + const range = document.createRange() + range.setStart(node, 0) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + } + } + } + if (event.key === "!" && store.mode === "normal") { const cursorPosition = getCursorPosition(editorRef) if (cursorPosition === 0) { @@ -668,7 +775,10 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const textLength = promptLength(prompt.current()) - const textContent = editorRef.textContent ?? "" + const textContent = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") const isEmpty = textContent.trim() === "" || textLength <= 1 const hasNewlines = textContent.includes("\n") const inHistory = store.historyIndex >= 0 @@ -692,6 +802,11 @@ export const PromptInput: Component = (props) => { return } + if (event.key === "Enter" && event.shiftKey) { + addPart({ type: "text", content: "\n", start: 0, end: 0 }) + event.preventDefault() + return + } if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } @@ -717,7 +832,6 @@ export const PromptInput: Component = (props) => { addToHistory(currentPrompt, store.mode) setStore("historyIndex", -1) setStore("savedPrompt", null) - setStore("userHasEdited", false) let existing = info() if (!existing) { @@ -777,12 +891,16 @@ export const PromptInput: Component = (props) => { const agent = local.agent.current()!.name if (isShellMode) { - sdk.client.session.shell({ - sessionID: existing.id, - agent, - model, - command: text, - }) + sdk.client.session + .shell({ + sessionID: existing.id, + agent, + model, + command: text, + }) + .catch((e) => { + console.error("Failed to send shell command", e) + }) return } @@ -791,13 +909,17 @@ export const PromptInput: Component = (props) => { const commandName = cmdName.slice(1) const customCommand = sync.data.command.find((c) => c.name === commandName) if (customCommand) { - sdk.client.session.command({ - sessionID: existing.id, - command: commandName, - arguments: args.join(" "), - agent, - model: `${model.providerID}/${model.modelID}`, - }) + sdk.client.session + .command({ + sessionID: existing.id, + command: commandName, + arguments: args.join(" "), + agent, + model: `${model.providerID}/${model.modelID}`, + }) + .catch((e) => { + console.error("Failed to send command", e) + }) return } } @@ -823,13 +945,17 @@ export const PromptInput: Component = (props) => { model, }) - sdk.client.session.prompt({ - sessionID: existing.id, - agent, - model, - messageID, - parts: requestParts, - }) + sdk.client.session + .prompt({ + sessionID: existing.id, + agent, + model, + messageID, + parts: requestParts, + }) + .catch((e) => { + console.error("Failed to send prompt", e) + }) } return ( @@ -904,9 +1030,6 @@ export const PromptInput: Component = (props) => {
= (props) => { -
+
(scrollRef = el)}>
{ @@ -967,18 +1090,21 @@ export const PromptInput: Component = (props) => { onInput={handleInput} onKeyDown={handleKeyDown} classList={{ - "w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, - "[&>[data-type=file]]:text-icon-info-active": true, + "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "[&_[data-type=file]]:text-icon-info-active": true, "font-mono!": store.mode === "shell", }} /> -
+
{store.mode === "shell" ? "Enter shell command..." : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
+
+ +
@@ -1035,7 +1161,6 @@ export const PromptInput: Component = (props) => { -
= (props) => { ) } +function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + const segments = content.split("\n") + segments.forEach((segment, index) => { + if (segment) { + fragment.appendChild(document.createTextNode(segment)) + } else if (segments.length > 1) { + fragment.appendChild(document.createTextNode("\u200B")) + } + if (index < segments.length - 1) { + fragment.appendChild(document.createElement("br")) + } + }) + return fragment +} + +function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let length = 0 + for (const child of Array.from(node.childNodes)) { + length += getTextLength(child) + } + return length +} + function getCursorPosition(parent: HTMLElement): number { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 const preCaretRange = range.cloneRange() preCaretRange.selectNodeContents(parent) preCaretRange.setEnd(range.startContainer, range.startOffset) - return preCaretRange.toString().length + return getTextLength(preCaretRange.cloneContents()) } function setCursorPosition(parent: HTMLElement, position: number) { let remaining = position let node = parent.firstChild while (node) { - const length = node.textContent ? node.textContent.length : 0 + const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { const range = document.createRange() @@ -1123,10 +1281,24 @@ function setCursorPosition(parent: HTMLElement, position: number) { return } - if (isFile && remaining <= length) { + if ((isFile || isBreak) && remaining <= length) { const range = document.createRange() const selection = window.getSelection() - range.setStartAfter(node) + if (remaining === 0) { + range.setStartBefore(node) + } + if (remaining > 0 && isFile) { + range.setStartAfter(node) + } + if (remaining > 0 && isBreak) { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) { + range.setStart(next, 0) + } + if (!next || next.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(node) + } + } range.collapse(true) selection?.removeAllRanges() selection?.addRange(range) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 5474005c716..0fa6bc99c98 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { useSync } from "@/context/sync" import { useParams } from "@solidjs/router" -import { AssistantMessage } from "@opencode-ai/sdk/v2" +import { AssistantMessage } from "@opencode-ai/sdk/v2/client" export function SessionContextUsage() { const sync = useSync() @@ -34,28 +34,21 @@ export function SessionContextUsage() { {(ctx) => ( -
- Tokens - {ctx().tokens} -
-
- Usage - {ctx().percentage ?? 0}% -
-
- Cost - {cost()} -
+
+ Tokens + {ctx().tokens} + Usage + {ctx().percentage ?? 0}% + Cost + {cost()}
} placement="top" > -
- {`${ctx().percentage ?? 0}%`} +
+ {/* {`${ctx().percentage ?? 0}%`} */}
)} diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx new file mode 100644 index 00000000000..98d6d6dfd76 --- /dev/null +++ b/packages/app/src/components/session-lsp-indicator.tsx @@ -0,0 +1,40 @@ +import { createMemo, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { useSync } from "@/context/sync" +import { Tooltip } from "@opencode-ai/ui/tooltip" + +export function SessionLspIndicator() { + const sync = useSync() + + const lspStats = createMemo(() => { + const lsp = sync.data.lsp ?? [] + const connected = lsp.filter((s) => s.status === "connected").length + const hasError = lsp.some((s) => s.status === "error") + const total = lsp.length + return { connected, hasError, total } + }) + + const tooltipContent = createMemo(() => { + const lsp = sync.data.lsp ?? [] + if (lsp.length === 0) return "No LSP servers" + return lsp.map((s) => s.name).join(", ") + }) + + return ( + 0}> + +
+ 0, + }} + /> + {lspStats().connected} LSP +
+
+
+ ) +} diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx new file mode 100644 index 00000000000..17a6f2e1af0 --- /dev/null +++ b/packages/app/src/components/session-mcp-indicator.tsx @@ -0,0 +1,36 @@ +import { createMemo, Show } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSync } from "@/context/sync" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" + +export function SessionMcpIndicator() { + const sync = useSync() + const dialog = useDialog() + + const mcpStats = createMemo(() => { + const mcp = sync.data.mcp ?? {} + const entries = Object.entries(mcp) + const enabled = entries.filter(([, status]) => status.status === "connected").length + const failed = entries.some(([, status]) => status.status === "failed") + const total = entries.length + return { enabled, failed, total } + }) + + return ( + 0}> + + + ) +} diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx new file mode 100644 index 00000000000..d8a88503f20 --- /dev/null +++ b/packages/app/src/components/status-bar.tsx @@ -0,0 +1,32 @@ +import { createMemo, Show, type ParentProps } from "solid-js" +import { usePlatform } from "@/context/platform" +import { useSync } from "@/context/sync" +import { useGlobalSync } from "@/context/global-sync" + +export function StatusBar(props: ParentProps) { + const platform = usePlatform() + const sync = useSync() + const globalSync = useGlobalSync() + + const directoryDisplay = createMemo(() => { + const directory = sync.data.path.directory || "" + const home = globalSync.data.path.home || "" + const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory + const branch = sync.data.vcs?.branch + return branch ? `${short}:${branch}` : short + }) + + return ( +
+
+ + v{platform.version} + + + {directoryDisplay()} + +
+
{props.children}
+
+ ) +} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index c05ddfbf635..03251fe5f5e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,9 +1,9 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" -import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" +import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/terminal" -import { usePrefersDark } from "@solid-primitives/media" +import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY @@ -12,8 +12,28 @@ export interface TerminalProps extends ComponentProps<"div"> { onConnectError?: (error: unknown) => void } +type TerminalColors = { + background: string + foreground: string + cursor: string +} + +const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { + light: { + background: "#fcfcfc", + foreground: "#211e1e", + cursor: "#211e1e", + }, + dark: { + background: "#191515", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + }, +} + export const Terminal = (props: TerminalProps) => { const sdk = useSDK() + const theme = useTheme() let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) let ws: WebSocket @@ -22,7 +42,64 @@ export const Terminal = (props: TerminalProps) => { let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void - const prefersDark = usePrefersDark() + + const getTerminalColors = (): TerminalColors => { + const mode = theme.mode() + const fallback = DEFAULT_TERMINAL_COLORS[mode] + const currentTheme = theme.themes()[theme.themeId()] + if (!currentTheme) return fallback + const variant = mode === "dark" ? currentTheme.dark : currentTheme.light + if (!variant?.seeds) return fallback + const resolved = resolveThemeVariant(variant, mode === "dark") + const text = resolved["text-base"] ?? fallback.foreground + const background = resolved["background-stronger"] ?? fallback.background + return { + background, + foreground: text, + cursor: text, + } + } + + const [terminalColors, setTerminalColors] = createSignal(getTerminalColors()) + + createEffect(() => { + const colors = getTerminalColors() + setTerminalColors(colors) + if (!term) return + const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption + if (!setOption) return + setOption("theme", colors) + }) + + const focusTerminal = () => term?.focus() + const copySelection = () => { + if (!term || !term.hasSelection()) return false + const selection = term.getSelection() + if (!selection) return false + const clipboard = navigator.clipboard + if (clipboard?.writeText) { + clipboard.writeText(selection).catch(() => {}) + return true + } + if (!document.body) return false + const textarea = document.createElement("textarea") + textarea.value = selection + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + document.body.removeChild(textarea) + return copied + } + const handlePointerDown = () => { + const activeElement = document.activeElement + if (activeElement instanceof HTMLElement && activeElement !== container) { + activeElement.blur() + } + focusTerminal() + } onMount(async () => { ghostty = await Ghostty.load() @@ -33,23 +110,22 @@ export const Terminal = (props: TerminalProps) => { fontSize: 14, fontFamily: "IBM Plex Mono, monospace", allowTransparency: true, - theme: prefersDark() - ? { - background: "#191515", - foreground: "#d4d4d4", - cursor: "#d4d4d4", - } - : { - background: "#fcfcfc", - foreground: "#211e1e", - cursor: "#211e1e", - }, + theme: terminalColors(), scrollback: 10_000, ghostty, }) term.attachCustomKeyEventHandler((event) => { + const key = event.key.toLowerCase() + if (key === "c") { + const macCopy = event.metaKey && !event.ctrlKey && !event.altKey + const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey + if ((macCopy || linuxCopy) && copySelection()) { + event.preventDefault() + return true + } + } // allow for ctrl-` to toggle terminal in parent - if (event.ctrlKey && event.key.toLowerCase() === "`") { + if (event.ctrlKey && key === "`") { event.preventDefault() return true } @@ -62,6 +138,8 @@ export const Terminal = (props: TerminalProps) => { term.loadAddon(fitAddon) term.open(container) + container.addEventListener("pointerdown", handlePointerDown) + focusTerminal() if (local.pty.buffer) { if (local.pty.rows && local.pty.cols) { @@ -75,20 +153,20 @@ export const Terminal = (props: TerminalProps) => { fitAddon.fit() } - container.focus() - fitAddon.observeResize() handleResize = () => fitAddon.fit() window.addEventListener("resize", handleResize) term.onResize(async (size) => { if (ws && ws.readyState === WebSocket.OPEN) { - await sdk.client.pty.update({ - ptyID: local.pty.id, - size: { - cols: size.cols, - rows: size.rows, - }, - }) + await sdk.client.pty + .update({ + ptyID: local.pty.id, + size: { + cols: size.cols, + rows: size.rows, + }, + }) + .catch(() => {}) } }) term.onData((data) => { @@ -106,13 +184,15 @@ export const Terminal = (props: TerminalProps) => { // }) ws.addEventListener("open", () => { console.log("WebSocket connected") - sdk.client.pty.update({ - ptyID: local.pty.id, - size: { - cols: term.cols, - rows: term.rows, - }, - }) + sdk.client.pty + .update({ + ptyID: local.pty.id, + size: { + cols: term.cols, + rows: term.rows, + }, + }) + .catch(() => {}) }) ws.addEventListener("message", (event) => { term.write(event.data) @@ -130,6 +210,7 @@ export const Terminal = (props: TerminalProps) => { if (handleResize) { window.removeEventListener("resize", handleResize) } + container.removeEventListener("pointerdown", handlePointerDown) if (serializeAddon && props.onCleanup) { const buffer = serializeAddon.serialize() props.onCleanup({ @@ -149,6 +230,7 @@ export const Terminal = (props: TerminalProps) => { ref={container} data-component="terminal" data-prevent-autofocus + style={{ "background-color": terminalColors().background }} classList={{ ...(local.classList ?? {}), "size-full px-6 py-3 font-mono": true, diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index f91a1cf052f..efd83bec861 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -26,6 +26,7 @@ export interface CommandOption { suggested?: boolean disabled?: boolean onSelect?: (source?: "palette" | "keybind" | "slash") => void + onHighlight?: () => (() => void) | void } export function parseKeybind(config: string): Keybind[] { @@ -115,6 +116,28 @@ export function formatKeybind(config: string): string { function DialogCommand(props: { options: CommandOption[] }) { const dialog = useDialog() + let cleanup: (() => void) | void + let committed = false + + const handleMove = (option: CommandOption | undefined) => { + cleanup?.() + cleanup = option?.onHighlight?.() + } + + const handleSelect = (option: CommandOption | undefined) => { + if (option) { + committed = true + cleanup = undefined + dialog.close() + option.onSelect?.("palette") + } + } + + onCleanup(() => { + if (!committed) { + cleanup?.() + } + }) return ( @@ -125,12 +148,8 @@ function DialogCommand(props: { options: CommandOption[] }) { key={(x) => x?.id} filterKeys={["title", "description", "category"]} groupBy={(x) => x.category ?? ""} - onSelect={(option) => { - if (option) { - dialog.close() - option.onSelect?.("palette") - } - }} + onMove={handleMove} + onSelect={handleSelect} > {(option) => (
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b7ad1189c0a..a6851aecbf1 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -5,8 +5,6 @@ import { type Part, type Config, type Path, - type File, - type FileNode, type Project, type FileDiff, type Todo, @@ -14,6 +12,10 @@ import { type ProviderListResponse, type ProviderAuthResponse, type Command, + type McpStatus, + type LspStatus, + type VcsInfo, + type Permission, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -21,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { useGlobalSDK } from "./global-sdk" import { ErrorPage, type InitError } from "../pages/error" -import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js" +import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" @@ -43,6 +45,14 @@ type State = { todo: { [sessionID: string]: Todo[] } + permission: { + [sessionID: string]: Permission[] + } + mcp: { + [name: string]: McpStatus + } + lsp: LspStatus[] + vcs: VcsInfo | undefined limit: number message: { [sessionID: string]: Message[] @@ -50,8 +60,6 @@ type State = { part: { [messageID: string]: Part[] } - node: FileNode[] - changes: File[] } function createGlobalSync() { @@ -63,21 +71,19 @@ function createGlobalSync() { project: Project[] provider: ProviderListResponse provider_auth: ProviderAuthResponse - children: Record }>({ ready: false, path: { state: "", config: "", worktree: "", directory: "", home: "" }, project: [], provider: { all: [], connected: [], default: {} }, provider_auth: {}, - children: {}, }) const children: Record>> = {} function child(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { - setGlobalStore("children", directory, { + children[directory] = createStore({ project: "", provider: { all: [], connected: [], default: {} }, config: {}, @@ -89,13 +95,14 @@ function createGlobalSync() { session_status: {}, session_diff: {}, todo: {}, + permission: {}, + mcp: {}, + lsp: [], + vcs: undefined, limit: 5, message: {}, part: {}, - node: [], - changes: [], }) - children[directory] = createStore(globalStore.children[directory]) bootstrapInstance(directory) } return children[directory] @@ -117,7 +124,7 @@ function createGlobalSync() { const updated = new Date(s.time.updated).getTime() return updated > fourHoursAgo }) - setStore("session", sessions) + setStore("session", reconcile(sessions, { key: "id" })) }) .catch((err) => { console.error("Failed to load sessions", err) @@ -128,7 +135,7 @@ function createGlobalSync() { async function bootstrapInstance(directory: string) { if (!directory) return - const [, setStore] = child(directory) + const [store, setStore] = child(directory) const sdk = createOpencodeClient({ baseUrl: globalSDK.url, directory, @@ -155,8 +162,38 @@ function createGlobalSync() { session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), - changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), - node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)), + mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})), + lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])), + vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)), + permission: () => + sdk.permission.list().then((x) => { + const grouped: Record = {} + for (const perm of x.data ?? []) { + const existing = grouped[perm.sessionID] + if (existing) { + existing.push(perm) + continue + } + grouped[perm.sessionID] = [perm] + } + + batch(() => { + for (const sessionID of Object.keys(store.permission)) { + if (grouped[sessionID]) continue + setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + setStore( + "permission", + sessionID, + reconcile( + permissions.slice().sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) + }), } await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) .then(() => setStore("ready", true)) @@ -223,13 +260,13 @@ function createGlobalSync() { break } case "session.diff": - setStore("session_diff", event.properties.sessionID, event.properties.diff) + setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" })) break case "todo.updated": - setStore("todo", event.properties.sessionID, event.properties.todos) + setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" })) break case "session.status": { - setStore("session_status", event.properties.sessionID, event.properties.status) + setStore("session_status", event.properties.sessionID, reconcile(event.properties.status)) break } case "message.updated": { @@ -303,11 +340,64 @@ function createGlobalSync() { } break } + case "vcs.branch.updated": { + setStore("vcs", { branch: event.properties.branch }) + break + } + case "permission.updated": { + const sessionID = event.properties.sessionID + const permissions = store.permission[sessionID] + if (!permissions) { + setStore("permission", sessionID, [event.properties]) + break + } + + const result = Binary.search(permissions, event.properties.id, (p) => p.id) + if (result.found) { + setStore("permission", sessionID, result.index, reconcile(event.properties)) + break + } + + setStore( + "permission", + sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) + break + } + case "permission.replied": { + const permissions = store.permission[event.properties.sessionID] + if (!permissions) break + const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id) + if (!result.found) break + setStore( + "permission", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } + case "lsp.updated": { + const sdk = createOpencodeClient({ + baseUrl: globalSDK.url, + directory, + throwOnError: true, + }) + sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])) + break + } } }) async function bootstrap() { - const health = await globalSDK.client.global.health().then((x) => x.data) + const health = await globalSDK.client.global + .health() + .then((x) => x.data) + .catch(() => undefined) if (!health?.healthy) { setGlobalStore( "error", diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index c6ba5fef5a1..4ccab98e3ff 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -70,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( { ...project, ...(metadata ?? {}), + icon: { url: metadata?.icon?.url, color: metadata?.icon?.color }, }, ] } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 69807a2f443..49217b82be8 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,5 +1,5 @@ import { createStore, produce, reconcile } from "solid-js/store" -import { batch, createEffect, createMemo } from "solid-js" +import { batch, createMemo } from "solid-js" import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -9,6 +9,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { useProviders } from "@/hooks/use-providers" import { DateTime } from "luxon" import { persisted } from "@/utils/persist" +import { showToast } from "@opencode-ai/ui/toast" export type LocalFile = FileNode & Partial<{ @@ -61,24 +62,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - // Automatically update model when agent changes - createEffect(() => { - const value = agent.current() - if (value.model) { - if (isModelValid(value.model)) - model.set({ - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - // else - // toast.show({ - // type: "warning", - // message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, - // duration: 3000, - // }) - } - }) - const agent = (() => { const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [store, setStore] = createStore<{ @@ -276,11 +259,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const [store, setStore] = createStore<{ node: Record }>({ - node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])), + node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])), }) - const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path))) - const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b))) + // const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path))) + // const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b))) // createEffect((prev: FileStatus[]) => { // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path)) @@ -308,16 +291,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // return sync.data.changes // }, sync.data.changes) - const changed = (path: string) => { - const node = store.node[path] - if (node?.status) return true - const set = changeset() - if (set.has(path)) return true - for (const p of set) { - if (p.startsWith(path ? path + "/" : "")) return true - } - return false - } + // const changed = (path: string) => { + // const node = store.node[path] + // if (node?.status) return true + // const set = changeset() + // if (set.has(path)) return true + // for (const p of set) { + // if (p.startsWith(path ? path + "/" : "")) return true + // } + // return false + // } // const resetNode = (path: string) => { // setStore("node", path, { @@ -336,17 +319,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const load = async (path: string) => { const relativePath = relative(path) - await sdk.client.file.read({ path: relativePath }).then((x) => { - if (!store.node[relativePath]) return - setStore( - "node", - relativePath, - produce((draft) => { - draft.loaded = true - draft.content = x.data - }), - ) - }) + await sdk.client.file + .read({ path: relativePath }) + .then((x) => { + if (!store.node[relativePath]) return + setStore( + "node", + relativePath, + produce((draft) => { + draft.loaded = true + draft.content = x.data + }), + ) + }) + .catch((e) => { + showToast({ + variant: "error", + title: "Failed to load file", + description: e.message, + }) + }) } const fetch = async (path: string) => { @@ -385,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const list = async (path: string) => { - return sdk.client.file.list({ path: path + "/" }).then((x) => { - setStore( - "node", - produce((draft) => { - x.data!.forEach((node) => { - if (node.path in draft) return - draft[node.path] = node - }) - }), - ) - }) + return sdk.client.file + .list({ path: path + "/" }) + .then((x) => { + setStore( + "node", + produce((draft) => { + x.data!.forEach((node) => { + if (node.path in draft) return + draft[node.path] = node + }) + }), + ) + }) + .catch(() => {}) } const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!) @@ -466,8 +461,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setChangeIndex(path: string, index: number | undefined) { setStore("node", path, "selectedChange", index) }, - changes, - changed, + // changes, + // changed, children(path: string) { return Object.values(store.node).filter( (x) => diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 73d4c7f3ed9..2b710e6f2b1 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,6 +5,9 @@ export type Platform = { /** Platform discriminator */ platform: "web" | "tauri" + /** App version */ + version?: string + /** Open a URL in the default browser */ openLink(url: string): void diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 941b8b629f3..b45936a9162 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,5 +1,5 @@ -import { produce } from "solid-js/store" -import { createMemo } from "solid-js" +import { batch, createMemo } from "solid-js" +import { produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -56,7 +56,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const result = Binary.search(messages, input.messageID, (m) => m.id) messages.splice(result.index, 0, message) } - draft.part[input.messageID] = input.parts.slice() + draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) }), ) }, @@ -67,22 +67,46 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ retry(() => sdk.client.session.todo({ sessionID })), retry(() => sdk.client.session.diff({ sessionID })), ]) - setStore( - produce((draft) => { - const match = Binary.search(draft.session, sessionID, (s) => s.id) - if (match.found) draft.session[match.index] = session.data! - if (!match.found) draft.session.splice(match.index, 0, session.data!) - draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages - .data!.map((x) => x.info) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)) - for (const message of messages.data!) { - draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) - } - draft.session_diff[sessionID] = diff.data ?? [] - }), - ) + + batch(() => { + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) { + draft[match.index] = session.data! + return + } + draft.splice(match.index, 0, session.data!) + }), + ) + + setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) + setStore( + "message", + sessionID, + reconcile( + (messages.data ?? []) + .map((x) => x.info) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + + for (const message of messages.data ?? []) { + setStore( + "part", + message.info.id, + reconcile( + message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + + setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) + }) }, fetch: async (count = 10) => { setStore("limit", (x) => x + count) @@ -91,7 +115,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .slice() .sort((a, b) => a.id.localeCompare(b.id)) .slice(0, store.limit) - setStore("session", sessions) + setStore("session", reconcile(sessions, { key: "id" })) }) }, more: createMemo(() => store.session.length >= store.limit), diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 6f7c11dea8c..e9a07077cef 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont all: createMemo(() => Object.values(store.all)), active: createMemo(() => store.active), new() { - sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("all", [ - ...store.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("active", id) - }) + sdk.client.pty + .create({ title: `Terminal ${store.all.length + 1}` }) + .then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + .catch((e) => { + console.error("Failed to create terminal", e) + }) }, update(pty: Partial & { id: string }) { setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) + sdk.client.pty + .update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + .catch((e) => { + console.error("Failed to update terminal", e) + }) }, async clone(id: string) { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const clone = await sdk.client.pty.create({ - title: pty.title, - }) - if (!clone.data) return + const clone = await sdk.client.pty + .create({ + title: pty.title, + }) + .catch((e) => { + console.error("Failed to clone terminal", e) + return undefined + }) + if (!clone?.data) return setStore("all", index, { ...pty, ...clone.data, @@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont setStore("active", previous?.id) } }) - await sdk.client.pty.remove({ ptyID: id }) + await sdk.client.pty.remove({ ptyID: id }).catch((e) => { + console.error("Failed to close terminal", e) + }) }, move(id: string, to: number) { const index = store.all.findIndex((f) => f.id === id) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index ecbce9815b9..cbcac355fff 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -2,6 +2,7 @@ import { render } from "solid-js/web" import { App } from "@/app" import { Platform, PlatformProvider } from "@/context/platform" +import pkg from "../package.json" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -12,6 +13,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { const platform: Platform = { platform: "web", + version: pkg.version, openLink(url: string) { window.open(url, "_blank") }, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index c909a373d56..04f90bdcbf6 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,6 +1,6 @@ import { createMemo, Show, type ParentProps } from "solid-js" import { useParams } from "@solidjs/router" -import { SDKProvider } from "@/context/sdk" +import { SDKProvider, useSDK } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" @@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) { {iife(() => { const sync = useSync() + const sdk = useSDK() return ( - + { + sdk.client.permission.respond(input) + }} + > {props.children} ) diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 9914279adad..37bd5ccd3cb 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -1,7 +1,7 @@ import { TextField } from "@opencode-ai/ui/text-field" import { Logo } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" -import { Component } from "solid-js" +import { Component, Show } from "solid-js" import { usePlatform } from "@/context/platform" import { Icon } from "@opencode-ai/ui/icon" @@ -138,16 +138,21 @@ export const ErrorPage: Component = (props) => { -
- Please report this error to the OpenCode team - +
+
+ Please report this error to the OpenCode team + +
+ +

Version: {platform.version}

+
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 838b8ee947d..0b917894809 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -9,6 +9,7 @@ import { ParentProps, Show, Switch, + untrack, type JSX, } from "solid-js" import { DateTime } from "luxon" @@ -40,14 +41,16 @@ import { } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" -import { showToast, Toast } from "@opencode-ai/ui/toast" +import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { useCommand } from "@/context/command" +import { DialogEditProject } from "@/components/dialog-edit-project" +import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" export default function Layout(props: ParentProps) { @@ -88,6 +91,41 @@ export default function Layout(props: ParentProps) { const providers = useProviders() const dialog = useDialog() const command = useCommand() + const theme = useTheme() + const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] + const colorSchemeLabel: Record = { + system: "System", + light: "Light", + dark: "Dark", + } + + function cycleTheme(direction = 1) { + const ids = availableThemeEntries().map(([id]) => id) + if (ids.length === 0) return + const currentIndex = ids.indexOf(theme.themeId()) + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length + const nextThemeId = ids[nextIndex] + theme.setTheme(nextThemeId) + const nextTheme = theme.themes()[nextThemeId] + showToast({ + title: "Theme switched", + description: nextTheme?.name ?? nextThemeId, + }) + } + + function cycleColorScheme(direction = 1) { + const current = theme.colorScheme() + const currentIndex = colorSchemeOrder.indexOf(current) + const nextIndex = + currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length + const next = colorSchemeOrder[nextIndex] + theme.setColorScheme(next) + showToast({ + title: "Color scheme", + description: colorSchemeLabel[next], + }) + } onMount(async () => { if (platform.checkUpdate && platform.update && platform.restart) { @@ -116,6 +154,71 @@ export default function Layout(props: ParentProps) { } }) + onMount(() => { + const seenSessions = new Set() + const toastBySession = new Map() + const unsub = globalSDK.event.listen((e) => { + if (e.details?.type !== "permission.updated") return + const directory = e.name + const permission = e.details.properties + const sessionKey = `${directory}:${permission.sessionID}` + if (seenSessions.has(sessionKey)) return + seenSessions.add(sessionKey) + const currentDir = params.dir ? base64Decode(params.dir) : undefined + const currentSession = params.id + if (directory === currentDir && permission.sessionID === currentSession) return + const [store] = globalSync.child(directory) + const session = store.session.find((s) => s.id === permission.sessionID) + if (directory === currentDir && session?.parentID === currentSession) return + const sessionTitle = session?.title ?? "New session" + const projectName = getFilename(directory) + const toastId = showToast({ + persistent: true, + icon: "checklist", + title: "Permission required", + description: `${sessionTitle} in ${projectName} needs permission`, + actions: [ + { + label: "Go to session", + onClick: () => { + navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`) + }, + }, + { + label: "Dismiss", + onClick: "dismiss", + }, + ], + }) + toastBySession.set(sessionKey, toastId) + }) + onCleanup(unsub) + + createEffect(() => { + const currentDir = params.dir ? base64Decode(params.dir) : undefined + const currentSession = params.id + if (!currentDir || !currentSession) return + const sessionKey = `${currentDir}:${currentSession}` + const toastId = toastBySession.get(sessionKey) + if (toastId !== undefined) { + toaster.dismiss(toastId) + toastBySession.delete(sessionKey) + seenSessions.delete(sessionKey) + } + const [store] = globalSync.child(currentDir) + const childSessions = store.session.filter((s) => s.parentID === currentSession) + for (const child of childSessions) { + const childKey = `${currentDir}:${child.id}` + const childToastId = toastBySession.get(childKey) + if (childToastId !== undefined) { + toaster.dismiss(childToastId) + toastBySession.delete(childKey) + seenSessions.delete(childKey) + } + } + }) + }) + function sortSessions(a: Session, b: Session) { const now = Date.now() const oneMinuteAgo = now - 60 * 1000 @@ -220,57 +323,102 @@ export default function Layout(props: ParentProps) { } } - command.register(() => [ - { - id: "sidebar.toggle", - title: "Toggle sidebar", - category: "View", - keybind: "mod+b", - onSelect: () => layout.sidebar.toggle(), - }, - ...(platform.openDirectoryPickerDialog - ? [ - { - id: "project.open", - title: "Open project", - category: "Project", - keybind: "mod+o", - onSelect: () => chooseProject(), - }, - ] - : []), - { - id: "provider.connect", - title: "Connect provider", - category: "Provider", - onSelect: () => connectProvider(), - }, - { - id: "session.previous", - title: "Previous session", - category: "Session", - keybind: "alt+arrowup", - onSelect: () => navigateSessionByOffset(-1), - }, - { - id: "session.next", - title: "Next session", - category: "Session", - keybind: "alt+arrowdown", - onSelect: () => navigateSessionByOffset(1), - }, - { - id: "session.archive", - title: "Archive session", - category: "Session", - keybind: "mod+shift+backspace", - disabled: !params.dir || !params.id, - onSelect: () => { - const session = currentSessions().find((s) => s.id === params.id) - if (session) archiveSession(session) + command.register(() => { + const commands: CommandOption[] = [ + { + id: "sidebar.toggle", + title: "Toggle sidebar", + category: "View", + keybind: "mod+b", + onSelect: () => layout.sidebar.toggle(), + }, + ...(platform.openDirectoryPickerDialog + ? [ + { + id: "project.open", + title: "Open project", + category: "Project", + keybind: "mod+o", + onSelect: () => chooseProject(), + }, + ] + : []), + { + id: "provider.connect", + title: "Connect provider", + category: "Provider", + onSelect: () => connectProvider(), + }, + { + id: "session.previous", + title: "Previous session", + category: "Session", + keybind: "alt+arrowup", + onSelect: () => navigateSessionByOffset(-1), }, - }, - ]) + { + id: "session.next", + title: "Next session", + category: "Session", + keybind: "alt+arrowdown", + onSelect: () => navigateSessionByOffset(1), + }, + { + id: "session.archive", + title: "Archive session", + category: "Session", + keybind: "mod+shift+backspace", + disabled: !params.dir || !params.id, + onSelect: () => { + const session = currentSessions().find((s) => s.id === params.id) + if (session) archiveSession(session) + }, + }, + { + id: "theme.cycle", + title: "Cycle theme", + category: "Theme", + keybind: "mod+shift+t", + onSelect: () => cycleTheme(1), + }, + ] + + for (const [id, definition] of availableThemeEntries()) { + commands.push({ + id: `theme.set.${id}`, + title: `Use theme: ${definition.name ?? id}`, + category: "Theme", + onSelect: () => theme.commitPreview(), + onHighlight: () => { + theme.previewTheme(id) + return () => theme.cancelPreview() + }, + }) + } + + commands.push({ + id: "theme.scheme.cycle", + title: "Cycle color scheme", + category: "Theme", + keybind: "mod+shift+s", + onSelect: () => cycleColorScheme(1), + }) + + for (const scheme of colorSchemeOrder) { + commands.push({ + id: `theme.scheme.${scheme}`, + title: `Use color scheme: ${colorSchemeLabel[scheme]}`, + category: "Theme", + onSelect: () => theme.commitPreview(), + onHighlight: () => { + theme.previewColorScheme(scheme) + return () => theme.cancelPreview() + }, + }) + } + + return commands + }) function connectProvider() { dialog.show(() => ) @@ -323,7 +471,7 @@ export default function Layout(props: ParentProps) { const id = params.id setStore("lastSession", directory, id) notification.session.markViewed(id) - layout.projects.expand(directory) + untrack(() => layout.projects.expand(directory)) requestAnimationFrame(() => scrollToSession(id)) }) @@ -375,7 +523,7 @@ export default function Layout(props: ParentProps) { const notification = useNotification() const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -411,7 +559,7 @@ export default function Layout(props: ParentProps) { } const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const current = createMemo(() => base64Decode(params.dir ?? "")) return ( @@ -453,8 +601,20 @@ export default function Layout(props: ParentProps) { const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const hasPermissions = createMemo(() => { + const store = globalSync.child(props.project.worktree)[0] + const permissions = store.permission?.[props.session.id] ?? [] + if (permissions.length > 0) return true + const childSessions = store.session.filter((s) => s.parentID === props.session.id) + for (const child of childSessions) { + const childPermissions = store.permission?.[child.id] ?? [] + if (childPermissions.length > 0) return true + } + return false + }) const isWorking = createMemo(() => { if (props.session.id === params.id) return false + if (hasPermissions()) return false const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) @@ -472,7 +632,12 @@ export default function Layout(props: ParentProps) { class="flex flex-col min-w-0 text-left w-full focus:outline-none" >
- + {props.session.title}
@@ -480,6 +645,9 @@ export default function Layout(props: ParentProps) { + +
+
@@ -513,7 +681,15 @@ export default function Layout(props: ParentProps) { + } + > archiveSession(props.session)} />
@@ -526,7 +702,7 @@ export default function Layout(props: ParentProps) { const sortable = createSortable(props.project.worktree) const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) const slug = createMemo(() => base64Encode(props.project.worktree)) - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const [store, setProjectStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session.toSorted(sortSessions)) const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) @@ -572,13 +748,26 @@ export default function Layout(props: ParentProps) { + dialog.show(() => )} + > + Edit project + closeProject(props.project.worktree)}> - Close Project + Close project - + + New session + {command.keybind("session.new")} +
+ } + >
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d7eaccc2ad9..6bc39daca2b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -49,6 +49,7 @@ import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" @@ -56,6 +57,9 @@ import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" import { extractPromptFromParts } from "@/utils/prompt" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" +import { StatusBar } from "@/components/status-bar" +import { SessionMcpIndicator } from "@/components/session-mcp-indicator" +import { SessionLspIndicator } from "@/components/session-lsp-indicator" export default function Page() { const layout = useLayout() @@ -75,11 +79,7 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => a.id.localeCompare(b.id)), - ) + const userMessages = createMemo(() => messages().filter((m) => m.role === "user")) const visibleUserMessages = createMemo(() => { const revert = revertMessageID() if (!revert) return userMessages() @@ -87,6 +87,18 @@ export default function Page() { }) const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1)) + createEffect( + on( + () => lastUserMessage()?.id, + () => { + const msg = lastUserMessage() + if (!msg) return + if (msg.agent) local.agent.set(msg.agent) + if (msg.model) local.model.set(msg.model) + }, + ), + ) + const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, @@ -274,6 +286,15 @@ export default function Page() { slash: "model", onSelect: () => dialog.show(() => ), }, + { + id: "mcp.toggle", + title: "Toggle MCPs", + description: "Toggle MCPs", + category: "MCP", + keybind: "mod+;", + slash: "mcp", + onSelect: () => dialog.show(() => ), + }, { id: "agent.cycle", title: "Cycle agent", @@ -562,6 +583,7 @@ export default function Page() { setStore("mobileStepsExpanded", message.id, (x) => !x)} onUserInteracted={() => setStore("userInteracted", true)} @@ -618,6 +640,7 @@ export default function Page() { setStore("stepsExpanded", (x) => !x)} onUserInteracted={() => setStore("userInteracted", true)} @@ -921,6 +944,10 @@ export default function Page() {
+ + + +
) } diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index db04f79cae5..e2a27dd5d8a 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -11,6 +11,7 @@ "jsx": "preserve", "jsxImportSource": "solid-js", "allowJs": true, + "resolveJsonModule": true, "strict": true, "noEmit": false, "emitDeclarationOnly": true, @@ -20,5 +21,6 @@ "@/*": ["./src/*"] } }, + "include": ["src", "package.json"], "exclude": ["dist", "ts-dist"] } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 3f211edb6ac..9e88c92e82b 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.198", + "version": "1.0.207", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/callback.ts index a793b85962a..2f8781e9882 100644 --- a/packages/console/app/src/routes/auth/callback.ts +++ b/packages/console/app/src/routes/auth/callback.ts @@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session" export async function GET(input: APIEvent) { const url = new URL(input.request.url) - const code = url.searchParams.get("code") - if (!code) throw new Error("No code found") - const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`) - if (result.err) { - throw new Error(result.err.message) - } - const decoded = AuthClient.decode(result.tokens.access, {} as any) - if (decoded.err) throw new Error(decoded.err.message) - const session = await useAuthSession() - const id = decoded.subject.properties.accountID - await session.update((value) => { - return { - ...value, - account: { - ...value.account, - [id]: { - id, - email: decoded.subject.properties.email, + try { + const code = url.searchParams.get("code") + if (!code) throw new Error("No code found") + const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`) + if (result.err) throw new Error(result.err.message) + const decoded = AuthClient.decode(result.tokens.access, {} as any) + if (decoded.err) throw new Error(decoded.err.message) + const session = await useAuthSession() + const id = decoded.subject.properties.accountID + await session.update((value) => { + return { + ...value, + account: { + ...value.account, + [id]: { + id, + email: decoded.subject.properties.email, + }, }, - }, - current: id, - } - }) - return redirect("/auth") + current: id, + } + }) + return redirect("/auth") + } catch (e: any) { + return new Response( + JSON.stringify({ + error: e.message, + cause: Object.fromEntries(url.searchParams.entries()), + }), + { status: 500 }, + ) + } } diff --git a/packages/console/app/src/routes/bench/[id].tsx b/packages/console/app/src/routes/bench/[id].tsx new file mode 100644 index 00000000000..4586eef9bf9 --- /dev/null +++ b/packages/console/app/src/routes/bench/[id].tsx @@ -0,0 +1,365 @@ +import { Title } from "@solidjs/meta" +import { createAsync, query, useParams } from "@solidjs/router" +import { createSignal, For, Show } from "solid-js" +import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js" +import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js" + +interface TaskSource { + repo: string + from: string + to: string +} + +interface Judge { + score: number + rationale: string + judge: string +} + +interface ScoreDetail { + criterion: string + weight: number + average: number + variance?: number + judges?: Judge[] +} + +interface RunUsage { + input: number + output: number + cost: number +} + +interface Run { + task: string + model: string + agent: string + score: { + final: number + base: number + penalty: number + } + scoreDetails: ScoreDetail[] + usage?: RunUsage + duration?: number +} + +interface Prompt { + commit: string + prompt: string +} + +interface AverageUsage { + input: number + output: number + cost: number +} + +interface Task { + averageScore: number + averageDuration?: number + averageUsage?: AverageUsage + model?: string + agent?: string + summary?: string + runs?: Run[] + task: { + id: string + source: TaskSource + prompts?: Prompt[] + } +} + +interface BenchmarkResult { + averageScore: number + tasks: Task[] +} + +async function getTaskDetail(benchmarkId: string, taskId: string) { + "use server" + const rows = await Database.use((tx) => + tx.select().from(BenchmarkTable).where(eq(BenchmarkTable.id, benchmarkId)).limit(1), + ) + if (!rows[0]) return null + const parsed = JSON.parse(rows[0].result) as BenchmarkResult + const task = parsed.tasks.find((t) => t.task.id === taskId) + return task ?? null +} + +const queryTaskDetail = query(getTaskDetail, "benchmark.task.detail") + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000) + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s` + } + return `${remainingSeconds}s` +} + +export default function BenchDetail() { + const params = useParams() + const [benchmarkId, taskId] = (params.id ?? "").split(":") + const task = createAsync(() => queryTaskDetail(benchmarkId, taskId)) + + return ( +
+ Benchmark - {taskId} +
+ Task not found

}> +
+
+ Agent: + {task()?.agent ?? "N/A"} +
+
+ Model: + {task()?.model ?? "N/A"} +
+
+ Task: + {task()!.task.id} +
+
+ + + + 0}> +
+ Prompt: + + {(p) => ( +
+
Commit: {p.commit.slice(0, 7)}
+

{p.prompt}

+
+ )} +
+
+
+ +
+ +
+
+ Average Duration: + {task()?.averageDuration ? formatDuration(task()!.averageDuration!) : "N/A"} +
+
+ Average Score: + {task()?.averageScore?.toFixed(3) ?? "N/A"} +
+
+ Average Cost: + {task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : "N/A"} +
+
+ + +
+ Summary: +

{task()!.summary}

+
+
+ + 0}> +
+ Runs: + + + + + + + + + {(detail) => ( + + )} + + + + + + {(run, index) => ( + + + + + + + {(detail) => ( + + )} + + + )} + + +
Run + Score (Base - Penalty) + CostDuration + {detail.criterion} ({detail.weight}) +
{index() + 1} + {run.score.final.toFixed(3)} ({run.score.base.toFixed(3)} - {run.score.penalty.toFixed(3)}) + + {run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : "N/A"} + + {run.duration ? formatDuration(run.duration) : "N/A"} + + + {(judge) => ( + + {judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score} + + )} + +
+ + {(run, index) => ( +
+

Run {index() + 1}

+
+ Score: + {run.score.final.toFixed(3)} (Base: {run.score.base.toFixed(3)} - Penalty:{" "} + {run.score.penalty.toFixed(3)}) +
+ + {(detail) => ( +
+
+ {detail.criterion} (weight: {detail.weight}){" "} + + {(judge) => ( + + {judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score} + + )} + +
+ 0}> + + {(judge) => { + const [expanded, setExpanded] = createSignal(false) + return ( +
+
setExpanded(!expanded())} + > + {expanded() ? "▼" : "▶"} + + {judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score} + {" "} + {judge.judge} +
+ +

+ {judge.rationale} +

+
+
+ ) + }} +
+
+
+ )} +
+
+ )} +
+
+
+ + {(() => { + const [jsonExpanded, setJsonExpanded] = createSignal(false) + return ( +
+ + +
{JSON.stringify(task(), null, 2)}
+
+
+ ) + })()} +
+
+
+ ) +} diff --git a/packages/console/app/src/routes/bench/index.tsx b/packages/console/app/src/routes/bench/index.tsx new file mode 100644 index 00000000000..9b8d0b8f24f --- /dev/null +++ b/packages/console/app/src/routes/bench/index.tsx @@ -0,0 +1,86 @@ +import { Title } from "@solidjs/meta" +import { A, createAsync, query } from "@solidjs/router" +import { createMemo, For, Show } from "solid-js" +import { Database, desc } from "@opencode-ai/console-core/drizzle/index.js" +import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js" + +interface BenchmarkResult { + averageScore: number + tasks: { averageScore: number; task: { id: string } }[] +} + +async function getBenchmarks() { + "use server" + const rows = await Database.use((tx) => + tx.select().from(BenchmarkTable).orderBy(desc(BenchmarkTable.timeCreated)).limit(100), + ) + return rows.map((row) => { + const parsed = JSON.parse(row.result) as BenchmarkResult + const taskScores: Record = {} + for (const t of parsed.tasks) { + taskScores[t.task.id] = t.averageScore + } + return { + id: row.id, + agent: row.agent, + model: row.model, + averageScore: parsed.averageScore, + taskScores, + } + }) +} + +const queryBenchmarks = query(getBenchmarks, "benchmarks.list") + +export default function Bench() { + const benchmarks = createAsync(() => queryBenchmarks()) + + const taskIds = createMemo(() => { + const ids = new Set() + for (const row of benchmarks() ?? []) { + for (const id of Object.keys(row.taskScores)) { + ids.add(id) + } + } + return [...ids].sort() + }) + + return ( +
+ Benchmark +

Benchmarks

+ + + + + + + {(id) => } + + + + + {(row) => ( + + + + + + {(id) => ( + + )} + + + )} + + +
AgentModelScore{id}
{row.agent}{row.model}{row.averageScore.toFixed(3)} + + + {row.taskScores[id]?.toFixed(3)} + + +
+
+ ) +} diff --git a/packages/console/app/src/routes/bench/submission.ts b/packages/console/app/src/routes/bench/submission.ts new file mode 100644 index 00000000000..94639439b11 --- /dev/null +++ b/packages/console/app/src/routes/bench/submission.ts @@ -0,0 +1,29 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Database } from "@opencode-ai/console-core/drizzle/index.js" +import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js" +import { Identifier } from "@opencode-ai/console-core/identifier.js" + +interface SubmissionBody { + model: string + agent: string + result: string +} + +export async function POST(event: APIEvent) { + const body = (await event.request.json()) as SubmissionBody + + if (!body.model || !body.agent || !body.result) { + return Response.json({ error: "All fields are required" }, { status: 400 }) + } + + await Database.use((tx) => + tx.insert(BenchmarkTable).values({ + id: Identifier.create("benchmark"), + model: body.model, + agent: body.agent, + result: body.result, + }), + ) + + return Response.json({ success: true }, { status: 200 }) +} diff --git a/packages/console/core/migrations/0039_striped_forge.sql b/packages/console/core/migrations/0039_striped_forge.sql new file mode 100644 index 00000000000..ad823197fb1 --- /dev/null +++ b/packages/console/core/migrations/0039_striped_forge.sql @@ -0,0 +1,12 @@ +CREATE TABLE `benchmark` ( + `id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `model` varchar(64) NOT NULL, + `agent` varchar(64) NOT NULL, + `result` mediumtext NOT NULL, + CONSTRAINT `benchmark_id_pk` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE INDEX `time_created` ON `benchmark` (`time_created`); \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0039_snapshot.json b/packages/console/core/migrations/meta/0039_snapshot.json new file mode 100644 index 00000000000..ba34f1ac490 --- /dev/null +++ b/packages/console/core/migrations/meta/0039_snapshot.json @@ -0,0 +1,1053 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "49a1ac05-78ab-4aae-908e-d4aeeb8196fc", + "prevId": "9d5d9885-7ec5-45f6-ac53-45a8e25dede7", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": ["provider", "subject"], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": ["time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": ["ip"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": ["workspace_id", "model"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": ["workspace_id", "provider"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": ["workspace_id", "account_id"], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": ["account_id"], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": ["email"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index 8a1a38551fb..e96bf52ed09 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1764110043942, "tag": "0038_famous_magik", "breakpoints": true + }, + { + "idx": 39, + "version": "5", + "when": 1766946179892, + "tag": "0039_striped_forge", + "breakpoints": true } ] } diff --git a/packages/console/core/package.json b/packages/console/core/package.json index dba4fa92c60..1f205090fe1 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.198", + "version": "1.0.207", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/core/src/identifier.ts b/packages/console/core/src/identifier.ts index 8fdf79cde28..f94765ec702 100644 --- a/packages/console/core/src/identifier.ts +++ b/packages/console/core/src/identifier.ts @@ -5,6 +5,7 @@ export namespace Identifier { const prefixes = { account: "acc", auth: "aut", + benchmark: "ben", billing: "bil", key: "key", model: "mod", diff --git a/packages/console/core/src/schema/benchmark.sql.ts b/packages/console/core/src/schema/benchmark.sql.ts new file mode 100644 index 00000000000..8d435eddfd8 --- /dev/null +++ b/packages/console/core/src/schema/benchmark.sql.ts @@ -0,0 +1,14 @@ +import { index, mediumtext, mysqlTable, primaryKey, varchar } from "drizzle-orm/mysql-core" +import { id, timestamps } from "../drizzle/types" + +export const BenchmarkTable = mysqlTable( + "benchmark", + { + id: id(), + ...timestamps, + model: varchar("model", { length: 64 }).notNull(), + agent: varchar("agent", { length: 64 }).notNull(), + result: mediumtext("result").notNull(), + }, + (table) => [primaryKey({ columns: [table.id] }), index("time_created").on(table.timeCreated)], +) diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 8b50ffbc471..e769f1a0e9f 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.198", + "version": "1.0.207", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts index 742e0d567ce..082564b21ce 100644 --- a/packages/console/function/src/auth.ts +++ b/packages/console/function/src/auth.ts @@ -123,7 +123,11 @@ export default { }, }).then((x) => x.json())) as any subject = user.id.toString() - email = emails.find((x: any) => x.primary && x.verified)?.email + + const primaryEmail = emails.find((x: any) => x.primary) + if (!primaryEmail) throw new Error("No primary email found for GitHub user") + if (!primaryEmail.verified) throw new Error("Primary email for GitHub user not verified") + email = primaryEmail.email } else if (response.provider === "google") { if (!response.id.email_verified) throw new Error("Google email not verified") subject = response.id.sub as string diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d20fdaa26ed..74cf1440e2b 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.198", + "version": "1.0.207", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/index.html b/packages/desktop/index.html index faeb1a1fde0..83826b602ca 100644 --- a/packages/desktop/index.html +++ b/packages/desktop/index.html @@ -1,5 +1,5 @@ - + @@ -13,14 +13,39 @@ - - - + +
diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1997ac4625d..dc619cf198e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.0.198", + "version": "1.0.207", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index b3dbebd04c4..58aca8fd172 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -13,6 +13,7 @@ import { createMenu } from "./menu" import { check, Update } from "@tauri-apps/plugin-updater" import { invoke } from "@tauri-apps/api/core" import { relaunch } from "@tauri-apps/plugin-process" +import pkg from "../package.json" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -25,6 +26,7 @@ let update: Update | null = null const platform: Platform = { platform: "tauri", + version: pkg.version, async openDirectoryPickerDialog(opts) { const result = await open({ diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json index 64a6bc35783..62cbe4ee4b5 100644 --- a/packages/desktop/tsconfig.json +++ b/packages/desktop/tsconfig.json @@ -9,6 +9,7 @@ "jsx": "preserve", "jsxImportSource": "solid-js", "allowJs": true, + "resolveJsonModule": true, "strict": true, "isolatedModules": true, "noEmit": true, @@ -16,5 +17,5 @@ "outDir": "node_modules/.ts-dist" }, "references": [{ "path": "../app" }], - "include": ["src"] + "include": ["src", "package.json"] } diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index 123a2028c91..6d4f62dc2cb 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -10,6 +10,9 @@ export default defineConfig({ // // 1. prevent Vite from obscuring rust errors clearScreen: false, + build: { + sourcemap: true, + }, // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420, diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 31fd64c57e1..8c8336e3868 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.198", + "version": "1.0.207", "private": true, "type": "module", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 2b0261f833a..70da5b4bd8a 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.198" +version = "1.0.207" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.198/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.198/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.198/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.198/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.198/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 1331f1c4360..1fa670dea65 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.198", + "version": "1.0.207", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index d7b987cbb94..9afe227b326 100644 --- a/packages/opencode/bunfig.toml +++ b/packages/opencode/bunfig.toml @@ -2,3 +2,5 @@ preload = ["@opentui/solid/preload"] [test] preload = ["./test/preload.ts"] +# Enable code coverage +coverage = true diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a76e20b6fe1..d6ab63172e8 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.198", + "version": "1.0.207", "name": "opencode", "type": "module", "private": true, @@ -52,14 +52,21 @@ "@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/azure": "2.0.73", + "@ai-sdk/cerebras": "1.0.33", + "@ai-sdk/cohere": "2.0.21", + "@ai-sdk/deepinfra": "1.0.30", + "@ai-sdk/gateway": "2.0.23", "@ai-sdk/google": "2.0.44", "@ai-sdk/google-vertex": "3.0.81", + "@ai-sdk/groq": "2.0.33", "@ai-sdk/mcp": "0.0.8", "@ai-sdk/mistral": "2.0.26", "@ai-sdk/openai": "2.0.71", "@ai-sdk/openai-compatible": "1.0.27", + "@ai-sdk/perplexity": "2.0.22", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", + "@ai-sdk/togetherai": "1.0.30", "@ai-sdk/xai": "2.0.42", "@clack/prompts": "1.0.0-alpha.1", "@hono/standard-validator": "0.1.5", @@ -81,6 +88,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", diff --git a/packages/opencode/src/agent/prompt/summary.txt b/packages/opencode/src/agent/prompt/summary.txt index c9264db1807..1cb2aedbd7e 100644 --- a/packages/opencode/src/agent/prompt/summary.txt +++ b/packages/opencode/src/agent/prompt/summary.txt @@ -7,4 +7,5 @@ Rules: - Do not explain what the user asked for - Write in first person (I added..., I fixed...) - Never ask questions or add new questions -- Only exception: if the conversation ends with an unanswered question to the user, preserve that exact question +- If the conversation ends with an unanswered question to the user, preserve that exact question +- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary diff --git a/packages/opencode/src/agent/prompt/title.txt b/packages/opencode/src/agent/prompt/title.txt index f67aaa95bac..7e927b797ce 100644 --- a/packages/opencode/src/agent/prompt/title.txt +++ b/packages/opencode/src/agent/prompt/title.txt @@ -22,7 +22,7 @@ Your output must be: - The title should NEVER include "summarizing" or "generating" when generating a title - DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT - Always output something meaningful, even if the input is minimal. -- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"): +- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"): → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index c607e5f5bb7..060d0d5a156 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -5,6 +5,7 @@ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) @@ -19,29 +20,16 @@ export const AcpCommand = cmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { - return yargs - .option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }) + return withNetworkOptions(yargs).option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const server = Server.listen({ - port: args.port, - hostname: args.hostname, - }) + const opts = await resolveNetworkOptions(args) + const server = Server.listen(opts) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 37aed2426d1..26e0fb73dc3 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -9,7 +9,9 @@ import * as github from "@actions/github" import type { Context } from "@actions/github/lib/context" import type { IssueCommentEvent, + IssuesEvent, PullRequestReviewCommentEvent, + WorkflowDispatchEvent, WorkflowRunEvent, PullRequestEvent, } from "@octokit/webhooks-types" @@ -132,7 +134,16 @@ type IssueQueryResponse = { const AGENT_USERNAME = "opencode-agent[bot]" const AGENT_REACTION = "eyes" const WORKFLOW_FILE = ".github/workflows/opencode.yml" -const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule", "pull_request"] as const + +// Event categories for routing +// USER_EVENTS: triggered by user actions, have actor/issueId, support reactions/comments +// REPO_EVENTS: triggered by automation, no actor/issueId, output to logs/PR only +const USER_EVENTS = ["issue_comment", "pull_request_review_comment", "issues", "pull_request"] as const +const REPO_EVENTS = ["schedule", "workflow_dispatch"] as const +const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const + +type UserEvent = (typeof USER_EVENTS)[number] +type RepoEvent = (typeof REPO_EVENTS)[number] // Parses GitHub remote URLs in various formats: // - https://github.com/owner/repo.git @@ -147,6 +158,29 @@ export function parseGitHubRemote(url: string): { owner: string; repo: string } return { owner: match[1], repo: match[2] } } +/** + * Extracts displayable text from assistant response parts. + * Returns null for tool-only or reasoning-only responses (signals summary needed). + * Throws for truly unusable responses (empty, step-start only, etc.). + */ +export function extractResponseText(parts: MessageV2.Part[]): string | null { + // Priority 1: Look for text parts + const textPart = parts.findLast((p) => p.type === "text") + if (textPart) return textPart.text + + // Priority 2: Reasoning-only - return null to signal summary needed + const reasoningPart = parts.findLast((p) => p.type === "reasoning") + if (reasoningPart) return null + + // Priority 3: Tool-only - return null to signal summary needed + const toolParts = parts.filter((p) => p.type === "tool" && p.state.status === "completed") + if (toolParts.length > 0) return null + + // No usable parts - throw with debug info + const partTypes = parts.map((p) => p.type).join(", ") || "none" + throw new Error(`Failed to parse response. Part types found: [${partTypes}]`) +} + export const GithubCommand = cmd({ command: "github", describe: "manage GitHub agent", @@ -397,27 +431,38 @@ export const GithubRunCommand = cmd({ core.setFailed(`Unsupported event type: ${context.eventName}`) process.exit(1) } + + // Determine event category for routing + // USER_EVENTS: have actor, issueId, support reactions/comments + // REPO_EVENTS: no actor/issueId, output to logs/PR only + const isUserEvent = USER_EVENTS.includes(context.eventName as UserEvent) + const isRepoEvent = REPO_EVENTS.includes(context.eventName as RepoEvent) const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName) + const isIssuesEvent = context.eventName === "issues" const isScheduleEvent = context.eventName === "schedule" + const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch" const { providerID, modelID } = normalizeModel() const runId = normalizeRunId() const share = normalizeShare() const oidcBaseUrl = normalizeOidcBaseUrl() const { owner, repo } = context.repo - // For schedule events, payload has no issue/comment data + // For repo events (schedule, workflow_dispatch), payload has no issue/comment data const payload = context.payload as | IssueCommentEvent + | IssuesEvent | PullRequestReviewCommentEvent + | WorkflowDispatchEvent | WorkflowRunEvent | PullRequestEvent const issueEvent = isIssueCommentEvent(payload) ? payload : undefined + // workflow_dispatch has an actor (the user who triggered it), schedule does not const actor = isScheduleEvent ? undefined : context.actor - const issueId = isScheduleEvent + const issueId = isRepoEvent ? undefined - : context.eventName === "issue_comment" - ? (payload as IssueCommentEvent).issue.number + : context.eventName === "issue_comment" || context.eventName === "issues" + ? (payload as IssueCommentEvent | IssuesEvent).issue.number : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number const runUrl = `/${owner}/${repo}/actions/runs/${runId}` const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" @@ -462,8 +507,8 @@ export const GithubRunCommand = cmd({ if (!useGithubToken) { await configureGit(appToken) } - // Skip permission check for schedule events (no actor to check) - if (!isScheduleEvent) { + // Skip permission check and reactions for repo events (no actor to check, no issue to react to) + if (isUserEvent) { await assertPermissions() await addReaction(commentType) } @@ -480,25 +525,30 @@ export const GithubRunCommand = cmd({ })() console.log("opencode session", session.id) - // Handle 4 cases - // 1. Schedule (no issue/PR context) - // 2. Issue - // 3. Local PR - // 4. Fork PR - if (isScheduleEvent) { - // Schedule event - no issue/PR context, output goes to logs - const branch = await checkoutNewBranch("schedule") + // Handle event types: + // REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only + // USER_EVENTS on PR (pull_request, pull_request_review_comment, issue_comment on PR): work on PR branch + // USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR + if (isRepoEvent) { + // Repo event - no issue/PR context, output goes to logs + if (isWorkflowDispatchEvent && actor) { + console.log(`Triggered by: ${actor}`) + } + const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule" + const branch = await checkoutNewBranch(branchPrefix) const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const response = await chat(userPrompt, promptFiles) const { dirty, uncommittedChanges } = await branchIsDirty(head) if (dirty) { const summary = await summarize(response) - await pushToNewBranch(summary, branch, uncommittedChanges, true) + // workflow_dispatch has an actor for co-author attribution, schedule does not + await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent) + const triggerType = isWorkflowDispatchEvent ? "workflow_dispatch" : "scheduled workflow" const pr = await createPR( repoData.data.default_branch, branch, summary, - `${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`, + `${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`, ) console.log(`Created PR #${pr}`) } else { @@ -573,7 +623,7 @@ export const GithubRunCommand = cmd({ } else if (e instanceof Error) { msg = e.message } - if (!isScheduleEvent) { + if (isUserEvent) { await createComment(`${msg}${footer()}`) await removeReaction(commentType) } @@ -628,9 +678,15 @@ export const GithubRunCommand = cmd({ } function isIssueCommentEvent( - event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent, + event: + | IssueCommentEvent + | IssuesEvent + | PullRequestReviewCommentEvent + | WorkflowDispatchEvent + | WorkflowRunEvent + | PullRequestEvent, ): event is IssueCommentEvent { - return "issue" in event + return "issue" in event && "comment" in event } function getReviewCommentContext() { @@ -652,10 +708,11 @@ export const GithubRunCommand = cmd({ async function getUserPrompt() { const customPrompt = process.env["PROMPT"] - // For schedule events, PROMPT is required since there's no comment to extract from - if (isScheduleEvent) { + // For repo events and issues events, PROMPT is required since there's no comment to extract from + if (isRepoEvent || isIssuesEvent) { if (!customPrompt) { - throw new Error("PROMPT input is required for scheduled events") + const eventType = isRepoEvent ? "scheduled and workflow_dispatch" : "issues" + throw new Error(`PROMPT input is required for ${eventType} events`) } return { userPrompt: customPrompt, promptFiles: [] } } @@ -856,10 +913,41 @@ export const GithubRunCommand = cmd({ ) } - const match = result.parts.findLast((p) => p.type === "text") - if (!match) throw new Error("Failed to parse the text response") + const text = extractResponseText(result.parts) + if (text) return text + + // No text part (tool-only or reasoning-only) - ask agent to summarize + console.log("Requesting summary from agent...") + const summary = await SessionPrompt.prompt({ + sessionID: session.id, + messageID: Identifier.ascending("message"), + model: { + providerID, + modelID, + }, + tools: { "*": false }, // Disable all tools to force text response + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.", + }, + ], + }) + + if (summary.info.role === "assistant" && summary.info.error) { + console.error(summary.info) + throw new Error( + `${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`, + ) + } + + const summaryText = extractResponseText(summary.parts) + if (!summaryText) { + throw new Error("Failed to get summary from agent") + } - return match.text + return summaryText } async function getOidcToken() { @@ -923,7 +1011,7 @@ export const GithubRunCommand = cmd({ await $`git config --local ${config} "${gitConfig}"` } - async function checkoutNewBranch(type: "issue" | "schedule") { + async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") { console.log("Checking out new branch...") const branch = generateBranchName(type) await $`git checkout -b ${branch}` @@ -952,16 +1040,16 @@ export const GithubRunCommand = cmd({ await $`git checkout -b ${localBranch} fork/${remoteBranch}` } - function generateBranchName(type: "issue" | "pr" | "schedule") { + function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") { const timestamp = new Date() .toISOString() .replace(/[:-]/g, "") .replace(/\.\d{3}Z/, "") .split("T") .join("") - if (type === "schedule") { + if (type === "schedule" || type === "dispatch") { const hex = crypto.randomUUID().slice(0, 6) - return `opencode/scheduled-${hex}-${timestamp}` + return `opencode/${type}-${hex}-${timestamp}` } return `opencode/${type}${issueId}-${timestamp}` } diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 3af3316a9d3..657f9196c96 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,29 +1,14 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" +import { withNetworkOptions, resolveNetworkOptions } from "../network" export const ServeCommand = cmd({ command: "serve", - builder: (yargs) => - yargs - .option("port", { - alias: ["p"], - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - const hostname = args.hostname - const port = args.port - const server = Server.listen({ - port, - hostname, - }) + const opts = await resolveNetworkOptions(args) + const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) await new Promise(() => {}) await server.stop() diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index f41b23ee971..94f1b549f40 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -82,12 +82,21 @@ async function getAllSessions(): Promise { return sessions } -async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { +export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { const sessions = await getAllSessions() - const DAYS_IN_SECOND = 24 * 60 * 60 * 1000 - const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0 + const MS_IN_DAY = 24 * 60 * 60 * 1000 + + const cutoffTime = (() => { + if (days === undefined) return 0 + if (days === 0) { + const now = new Date() + now.setHours(0, 0, 0, 0) + return now.getTime() + } + return Date.now() - days * MS_IN_DAY + })() - let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions + let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions if (projectFilter !== undefined) { if (projectFilter === "") { @@ -198,7 +207,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro } } - const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND)) + const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY)) stats.dateRange = { earliest: earliestTime, latest: latestTime, diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 13c95d9b9ea..5214b0c1a9a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -539,7 +539,7 @@ function App() { sdk.event.on(SessionApi.Event.Error.type, (evt) => { const error = evt.properties.error const message = (() => { - if (!error) return "An error occured" + if (!error) return "An error occurred" if (typeof error === "object") { const data = error.data diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index fc0559cd686..bc90dbb5c6e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -37,11 +37,9 @@ export function DialogModel(props: { providerID?: string }) { const recents = local.model.recent() const recentList = showExtra() - ? recents - .filter( - (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), - ) - .slice(0, 5) + ? recents.filter( + (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), + ) : [] const favoriteOptions = favorites.flatMap((item) => { @@ -182,7 +180,10 @@ export function DialogModel(props: { providerID?: string }) { // Apply fuzzy filtering to each section separately, maintaining section order if (q) { const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) - const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj) + const filteredRecents = fuzzysort + .go(q, recentOptions, { keys: ["title"] }) + .map((x) => x.obj) + .slice(0, 5) const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj) const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj) return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular] diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 5cc114f92f0..d976485319f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -5,6 +5,7 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { useSDK } from "../context/sdk" import { DialogPrompt } from "../ui/dialog-prompt" +import { Link } from "../ui/link" import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2" @@ -128,7 +129,7 @@ function AutoMethod(props: AutoMethodProps) { esc - {props.authorization.url} + {props.authorization.instructions} Waiting for authorization... @@ -170,7 +171,7 @@ function CodeMethod(props: CodeMethodProps) { description={() => ( {props.authorization.instructions} - {props.authorization.url} + Invalid code diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 1217bb54ae0..cb7b5d282ee 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,12 +2,13 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createEffect, createMemo, createSignal, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { Locale } from "@/util/locale" import { Keybind } from "@/util/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" +import { useKV } from "../context/kv" import "opentui-spinner/solid" export function DialogSessionList() { @@ -16,6 +17,7 @@ export function DialogSessionList() { const { theme } = useTheme() const route = useRoute() const sdk = useSDK() + const kv = useKV() const [toDelete, setToDelete] = createSignal() @@ -45,7 +47,11 @@ export function DialogSessionList() { value: x.id, category, footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, + gutter: isWorking ? ( + [⋯]}> + + + ) : undefined, } }) .slice(0, 150) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index cef083ad734..a5823289505 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,6 +12,38 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" +function removeLineRange(input: string) { + const hashIndex = input.lastIndexOf("#") + return hashIndex !== -1 ? input.substring(0, hashIndex) : input +} + +function extractLineRange(input: string) { + const hashIndex = input.lastIndexOf("#") + if (hashIndex === -1) { + return { baseQuery: input } + } + + const baseName = input.substring(0, hashIndex) + const linePart = input.substring(hashIndex + 1) + const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/) + + if (!lineMatch) { + return { baseQuery: baseName } + } + + const startLine = Number(lineMatch[1]) + const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined + + return { + lineRange: { + baseName, + startLine, + endLine, + }, + baseQuery: baseName, + } +} + export type AutocompleteRef = { onInput: (value: string) => void onKeyDown: (e: KeyEvent) => void @@ -142,9 +174,11 @@ export function Autocomplete(props: { async (query) => { if (!store.visible || store.visible === "/") return [] + const { lineRange, baseQuery } = extractLineRange(query ?? "") + // Get files from SDK const result = await sdk.client.find.files({ - query: query ?? "", + query: baseQuery, }) const options: AutocompleteOption[] = [] @@ -153,15 +187,27 @@ export function Autocomplete(props: { if (!result.error && result.data) { const width = props.anchor().width - 4 options.push( - ...result.data.map( - (item): AutocompleteOption => ({ - display: Locale.truncateMiddle(item, width), + ...result.data.map((item): AutocompleteOption => { + let url = `file://${process.cwd()}/${item}` + let filename = item + if (lineRange && !item.endsWith("/")) { + filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + const urlObj = new URL(url) + urlObj.searchParams.set("start", String(lineRange.startLine)) + if (lineRange.endLine !== undefined) { + urlObj.searchParams.set("end", String(lineRange.endLine)) + } + url = urlObj.toString() + } + + return { + display: Locale.truncateMiddle(filename, width), onSelect: () => { - insertPart(item, { + insertPart(filename, { type: "file", mime: "text/plain", - filename: item, - url: `file://${process.cwd()}/${item}`, + filename, + url, source: { type: "file", text: { @@ -173,8 +219,8 @@ export function Autocomplete(props: { }, }) }, - }), - ), + } + }), ) } @@ -383,8 +429,8 @@ export function Autocomplete(props: { return prev } - const result = fuzzysort.go(currentFilter, mixed, { - keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""], + const result = fuzzysort.go(removeLineRange(currentFilter), mixed, { + keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""], limit: 10, scoreFn: (objResults) => { const displayResult = objResults[0] diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 9494b81cb10..f819746d53c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -29,6 +29,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" +import { useKV } from "../../context/kv" export type PromptProps = { sessionID?: string @@ -124,6 +125,7 @@ export function Prompt(props: PromptProps) { const command = useCommandDialog() const renderer = useRenderer() const { theme, syntax } = useTheme() + const kv = useKV() function promptModelWarning() { toast.show({ @@ -996,8 +998,11 @@ export function Prompt(props: PromptProps) { justifyContent={status().type === "retry" ? "space-between" : "flex-start"} > - {/* @ts-ignore // SpinnerOptions doesn't support marginLeft */} - + + [⋯]}> + + + {(() => { const retry = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 818b96da43b..d5298518700 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -52,7 +52,6 @@ import { DialogMessage } from "./dialog-message" import type { PromptInfo } from "../../component/prompt/history" import { iife } from "@/util/iife" import { DialogConfirm } from "@tui/ui/dialog-confirm" -import { DialogPrompt } from "@tui/ui/dialog-prompt" import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" @@ -67,7 +66,7 @@ import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" -import { DialogSubagent } from "./dialog-subagent.tsx" +import { DialogExportOptions } from "../../ui/dialog-export-options" addDefaultParsers(parsers.parsers) @@ -128,6 +127,7 @@ export function Session() { const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true)) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") + const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true)) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -584,6 +584,19 @@ export function Session() { dialog.clear() }, }, + { + title: animationsEnabled() ? "Disable animations" : "Enable animations", + value: "session.toggle.animations", + category: "Session", + onSelect: (dialog) => { + setAnimationsEnabled((prev) => { + const next = !prev + kv.set("animations_enabled", next) + return next + }) + dialog.clear() + }, + }, { title: "Page up", value: "session.page.up", @@ -770,8 +783,22 @@ export function Session() { for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (showThinking()) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (showDetails() && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (showDetails() && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (showDetails() && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` } } @@ -798,6 +825,14 @@ export function Session() { const sessionData = session() const sessionMessages = messages() + const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` + + const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails()) + + if (options === null) return + + const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options + let transcript = `# ${sessionData.title}\n\n` transcript += `**Session ID:** ${sessionData.id}\n` transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` @@ -812,22 +847,28 @@ export function Session() { for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (includeThinking) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (includeToolDetails && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` } } transcript += `---\n\n` } - // Prompt for optional filename - const customFilename = await DialogPrompt.show(dialog, "Export filename", { - value: `session-${sessionData.id.slice(0, 8)}.md`, - }) - - // Cancel if user pressed escape - if (customFilename === null) return - // Save to file in current working directory const exportDir = process.cwd() const filename = customFilename.trim() diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts index fa679529890..ef359e6f40e 100644 --- a/packages/opencode/src/cli/cmd/tui/spawn.ts +++ b/packages/opencode/src/cli/cmd/tui/spawn.ts @@ -3,31 +3,19 @@ import { Instance } from "@/project/instance" import path from "path" import { Server } from "@/server/server" import { upgrade } from "@/cli/upgrade" +import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" export const TuiSpawnCommand = cmd({ command: "spawn [project]", builder: (yargs) => - yargs - .positional("project", { - type: "string", - describe: "path to start opencode in", - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + withNetworkOptions(yargs).positional("project", { + type: "string", + describe: "path to start opencode in", + }), handler: async (args) => { upgrade() - const server = Server.listen({ - port: args.port, - hostname: "127.0.0.1", - }) + const opts = await resolveNetworkOptions(args) + const server = Server.listen(opts) const bin = process.execPath const cmd = [] let cwd = process.cwd() diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 3cf8937a974..280f40fb90b 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -6,6 +6,7 @@ import path from "path" import { UI } from "@/cli/ui" import { iife } from "@/util/iife" import { Log } from "@/util/log" +import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" declare global { const OPENCODE_WORKER_PATH: string @@ -15,7 +16,7 @@ export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", builder: (yargs) => - yargs + withNetworkOptions(yargs) .positional("project", { type: "string", describe: "path to start opencode in", @@ -36,23 +37,12 @@ export const TuiThreadCommand = cmd({ describe: "session id to continue", }) .option("prompt", { - alias: ["p"], type: "string", describe: "prompt to use", }) .option("agent", { type: "string", describe: "agent to use", - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", }), handler: async (args) => { // Resolve relative paths against PWD to preserve behavior when using --cwd flag @@ -87,10 +77,8 @@ export const TuiThreadCommand = cmd({ process.on("unhandledRejection", (e) => { Log.Default.error(e) }) - const server = await client.call("server", { - port: args.port, - hostname: args.hostname, - }) + const opts = await resolveNetworkOptions(args) + const server = await client.call("server", opts) const prompt = await iife(async () => { const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined if (!args.prompt) return piped diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx new file mode 100644 index 00000000000..874a236ee4c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -0,0 +1,148 @@ +import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { useTheme } from "../context/theme" +import { useDialog, type DialogContext } from "./dialog" +import { createStore } from "solid-js/store" +import { onMount, Show, type JSX } from "solid-js" +import { useKeyboard } from "@opentui/solid" + +export type DialogExportOptionsProps = { + defaultFilename: string + defaultThinking: boolean + defaultToolDetails: boolean + onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void + onCancel?: () => void +} + +export function DialogExportOptions(props: DialogExportOptionsProps) { + const dialog = useDialog() + const { theme } = useTheme() + let textarea: TextareaRenderable + const [store, setStore] = createStore({ + thinking: props.defaultThinking, + toolDetails: props.defaultToolDetails, + active: "filename" as "filename" | "thinking" | "toolDetails", + }) + + useKeyboard((evt) => { + if (evt.name === "return") { + props.onConfirm?.({ + filename: textarea.plainText, + thinking: store.thinking, + toolDetails: store.toolDetails, + }) + } + if (evt.name === "tab") { + const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"] + const currentIndex = order.indexOf(store.active) + const nextIndex = (currentIndex + 1) % order.length + setStore("active", order[nextIndex]) + evt.preventDefault() + } + if (evt.name === "space") { + if (store.active === "thinking") setStore("thinking", !store.thinking) + if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + evt.preventDefault() + } + }) + + onMount(() => { + dialog.setSize("medium") + setTimeout(() => { + textarea.focus() + }, 1) + textarea.gotoLineEnd() + }) + + return ( + + + + Export Options + + esc + + + + Filename: + +