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 (
+
+ )
+}
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 (
+
+ )
+}
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) => {