Compare commits

...

78 Commits

Author SHA1 Message Date
Dax a1a32f22c9 chore: rename repo references from sst/opencode to anomalyco/opencode (#6687)
publish-github-action / publish (push) Waiting to run
Update Nix Hashes / update (push) Waiting to run
Co-authored-by: Github Action <action@github.com>
2026-01-02 16:02:52 -05:00
Matt Silverlock 613ddb3be4 fix: merge instructions arrays across config files (#6663) 2026-01-02 13:55:40 -06:00
Matt Silverlock e44e5289b1 fix: handle actions/checkout v6 credential storage change (#6667) 2026-01-02 13:54:43 -06:00
Adam a4a6fd2809 feat: add /compact session command 2026-01-02 12:40:37 -06:00
Adam 6eadff2642 fix(desktop): tweak window drag region layout 2026-01-02 12:40:36 -06:00
Adam 6cfd567da5 fix: relax request timeouts 2026-01-02 12:40:36 -06:00
Aiden Cline 2c5703aa19 test: fix transform test 2026-01-02 12:38:44 -06:00
Aiden Cline 55fb243f82 test: add message-v2 test 2026-01-02 12:28:40 -06:00
Aiden Cline 76a1ff730c fix: variant for minimal 2026-01-02 12:28:40 -06:00
Aiden Cline 0f22b6e67a chore: ensure empty message isnt sent 2026-01-02 12:28:40 -06:00
Mike English 9afc2905c5 fix: cloudflare-ai-gateway sdk.chat undefined error (#6407) 2026-01-02 11:24:13 -06:00
rari404 b5eabf7e4e refactor: use Bun.sleep instead of Promise setTimeout (#6620)
publish-github-action / publish (push) Waiting to run
Update Nix Hashes / update (push) Waiting to run
2026-01-02 11:12:02 -06:00
steez 9cfef101cb feat(theme): add Osaka Jade theme (#6609) 2026-01-02 10:26:01 -06:00
Albin Groen b4e0c3fdd2 fix(ui): fix slight vertical overflow in project selector (#6589) 2026-01-02 06:24:20 -06:00
OpeOginni 5b162229e1 fix(desktop): Properly decode session id for permission context (#6580) 2026-01-02 06:22:50 -06:00
GitHub Action 181e18f539 ignore: update download stats 2026-01-02 2026-01-02 12:04:42 +00:00
Luke Parker 0276cc82ad fix(share): handle NotFoundError for non-shared sessions in sync (#6634) 2026-01-02 04:16:12 -06:00
Dax Raad d756749f15 core: improve plugin loading to handle builtin plugin failures gracefully 2026-01-01 23:15:04 -05:00
Dax Raad a6f1a08e15 ci 2026-01-01 22:44:22 -05:00
GitHub Action a61c70de34 chore: generate 2026-01-02 03:12:21 +00:00
Dax Raad c0c2d7fea3 tui: add reload functionality and improve lazy utility with reset capability 2026-01-01 22:11:43 -05:00
opencode 3a5480b2b0 release: v1.0.224
Update Nix Hashes / update (push) Waiting to run
2026-01-02 03:05:57 +00:00
Adam f90ac0b99c wip(app): progress 2026-01-01 21:03:08 -06:00
Adam e7dd6886de fix(app): startup time 2026-01-01 21:03:08 -06:00
Adam dfdb03cb86 chore: cleanup 2026-01-01 21:03:07 -06:00
Adam c58a4d14ab chore: cleanup 2026-01-01 21:03:07 -06:00
Adam e29284f8c8 wip(app): progress 2026-01-01 21:03:06 -06:00
Adam 781b0c1122 wip(app): progress 2026-01-01 21:03:06 -06:00
Adam bf7feecd56 wip(app): progress 2026-01-01 21:03:06 -06:00
Adam ac52a9261c wip(app): progress 2026-01-01 21:03:05 -06:00
Adam e04c380c8e wip(desktop): progress 2026-01-01 21:03:05 -06:00
Adam 678d448675 wip(desktop): progress 2026-01-01 21:03:04 -06:00
Adam 4e728c22cb wip(desktop): progress 2026-01-01 21:03:04 -06:00
Adam 0b9650cc1d wip(desktop): progress 2026-01-01 21:03:03 -06:00
Adam 604267c134 wip(app): file context 2026-01-01 21:03:03 -06:00
Dax Raad cc43462bcb tui: remove memory leak fixes documentation after implementation 2026-01-01 21:40:28 -05:00
GitHub Action 33d69b5175 chore: generate 2026-01-02 02:28:48 +00:00
Dax Raad 563aa49f43 tui: fix memory leaks in session management and improve permission error handling 2026-01-01 21:28:11 -05:00
Dax Raad 8290bca068 tui: add heap snapshot option to system menu for debugging memory usage 2026-01-01 21:18:28 -05:00
Dax Raad 9d8325b7ec tui: fix permission tests for new evaluate function signature 2026-01-01 21:01:34 -05:00
Dax Raad 18dfcd89c1 tui: improve permission error handling and evaluation logic 2026-01-01 21:01:00 -05:00
Dax Raad b248c73361 ignore 2026-01-01 20:42:06 -05:00
Dillon Mulroy b535c3a5f8 feat: add assistant metadata to session export (#6611) 2026-01-01 18:56:23 -06:00
Mani Sundararajan adf81f4c58 fix: windows fallback for "less" cmd in session list (#6515) 2026-01-01 18:53:29 -06:00
Daniel Polito b67356b7a4 Desktop: Improve Resize Handle (#6608) 2026-01-01 18:26:34 -06:00
Aiden Cline 9c749cbd09 test: fix bash test 2026-01-01 17:53:20 -06:00
Aiden Cline fb2dbbab9b fix: ensure new permissions changes work for special case bash commands like rm, cd, etc 2026-01-01 17:27:23 -06:00
Aiden Cline 9835b5b323 ignore: comment out repo default for ask 2026-01-01 17:13:31 -06:00
GitHub Action ea13b61089 chore: generate 2026-01-01 22:54:43 +00:00
Dax e839c1522d Permission rework (#6319)
Co-authored-by: Github Action <action@github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-01-01 17:54:11 -05:00
Aiden Cline 8f7047e990 core: fix import command regex 2026-01-01 15:18:32 -06:00
Frank 9d496982b6 docs: update zen processing fee 2026-01-01 16:10:13 -05:00
alcpereira 787d27331d fix: remove outdated Haiku filter for GitHub Copilot (#6593) 2026-01-01 14:04:31 -06:00
Aiden Cline 3271b9a047 core: fix stats command day calculation and time filtering 2026-01-01 13:25:26 -06:00
Aiden Cline 18daa712e3 ignore: keep the process exit logic 2026-01-01 13:05:08 -06:00
Aiden Cline 19a1c0e8f4 core: make installdeps non blocking 2026-01-01 13:00:39 -06:00
Aiden Cline 2fab629722 core: use --no-cache when behind proxy to avoid hangs 2026-01-01 12:46:25 -06:00
Saatvik Arya a910143d53 fix(session): check for context overflow mid-turn in finish-step (#6480) 2026-01-01 12:03:18 -06:00
Lekë Dobruna acb5d524ed fix: display error if invalid agent is used in a command (#6578) 2026-01-01 11:39:21 -06:00
GitHub Action 6181e21c70 chore: generate 2026-01-01 17:35:37 +00:00
Tom 8a9a82890a fix(server): add Content-Type headers for proxied static assets (#6587) 2026-01-01 11:35:04 -06:00
GitHub Action be3b79a82e ignore: update download stats 2026-01-01 2026-01-01 12:04:57 +00:00
opencode 8d36be0beb release: v1.0.223
Update Nix Hashes / update (push) Waiting to run
2026-01-01 11:25:32 +00:00
Adam 3ce61074a5 feat(app): context window window 2026-01-01 05:23:07 -06:00
Adam 5858bbc094 fix(util): checksum defensiveness 2026-01-01 05:23:07 -06:00
Adam 734a8e0524 fix(util): checksum defensiveness 2026-01-01 05:23:06 -06:00
Adam 0675fadfcb fix(app): update primitive colors 2026-01-01 05:23:06 -06:00
Adam d5d722ed88 fix(app): update primitive colors 2026-01-01 05:23:05 -06:00
GitHub Action 9af83ec0bb chore: generate 2026-01-01 04:59:38 +00:00
Aiden Cline 98d3881f49 Reapply "fix(tui): don't show 'Agent not found' toast for subagents (#6528)"
This reverts commit f1567bc070.
2025-12-31 22:58:06 -06:00
Github Action 90c2c25cd5 Update Nix flake.lock and hashes 2026-01-01 01:21:05 +00:00
opencode 1c55bfc7a2 release: v1.0.222
Update Nix Hashes / update (push) Waiting to run
2026-01-01 01:21:04 +00:00
Luke Parker 39cedd20ea fix(desktop): Windows support for PTY and cross-platform build scripts (#6508) 2025-12-31 19:18:07 -06:00
Adam e4e7f20972 feat(desktop): in-app update toasts 2025-12-31 18:19:53 -06:00
Adam 7852fe8f8f feat(desktop): lose the summaries 2025-12-31 18:16:52 -06:00
Daniel Polito 7e0918bc5d Desktop: Add Subagents Mention Support (#6540) 2025-12-31 18:07:45 -06:00
GitHub Action 78702692fb chore: generate 2025-12-31 22:16:32 +00:00
Aiden Cline 804c2a3c22 core: prevent TimeoutOverflowWarning by capping setTimeout delay to max 32-bit signed integer 2025-12-31 16:15:49 -06:00
174 changed files with 8557 additions and 3701 deletions
+2 -4
View File
@@ -2,11 +2,9 @@ name: test
on:
push:
branches-ignore:
- production
branches:
- dev
pull_request:
branches-ignore:
- production
workflow_dispatch:
jobs:
test:
-10
View File
@@ -1,10 +0,0 @@
---
description: Use this agent when you are asked to commit and push code changes to a git repository.
mode: subagent
---
You commit and push to git
Commit messages should be brief since they are used to generate release notes.
Messages should say WHY the change was made and not WHAT was changed.
+11 -1
View File
@@ -10,7 +10,17 @@
"options": {},
},
},
"mcp": {},
"permission": {
"bash": {
"ls foo": "ask",
},
},
"mcp": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
},
},
"tools": {
"github-triage": false,
},
+2 -9
View File
@@ -1,11 +1,4 @@
## Debugging
- To test opencode in the `packages/opencode` directory you can run `bun dev`
## SDK
To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
## Tool Calling
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- the default branch in this repo is `dev`
+4 -4
View File
@@ -14,10 +14,10 @@ However, any UI or core product feature must go through a design review with the
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels:
- [`help wanted`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/sst/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
> [!NOTE]
> PRs that ignore these guardrails will likely be closed.
+3 -3
View File
@@ -11,7 +11,7 @@
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -31,7 +31,7 @@ choco install opencode # Windows
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
```
> [!TIP]
@@ -39,7 +39,7 @@ nix run nixpkgs#opencode # or github:sst/opencode for latest dev branc
### Desktop App (BETA)
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/sst/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/anomalyco/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
| Platform | Download |
| --------------------- | ------------------------------------- |
+4 -4
View File
@@ -11,7 +11,7 @@
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -30,8 +30,8 @@ scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS 與 Linux
paru -S opencode-bin # Arch Linux
mise use -g github:sst/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最新開發分支
mise use -g github:anomalyco/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
```
> [!TIP]
@@ -39,7 +39,7 @@ nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最
### 桌面應用程式 (BETA)
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/sst/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
| 平台 | 下載連結 |
| --------------------- | ------------------------------------- |
+2
View File
@@ -187,3 +187,5 @@
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
+17 -17
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,7 +173,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -201,7 +201,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -230,7 +230,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -246,7 +246,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.221",
"version": "1.0.224",
"bin": {
"opencode": "./bin/opencode",
},
@@ -294,7 +294,7 @@
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2",
"bun-pty": "0.4.4",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -348,7 +348,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -368,7 +368,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.221",
"version": "1.0.224",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -379,7 +379,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -392,7 +392,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -430,7 +430,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"zod": "catalog:",
},
@@ -441,7 +441,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -2044,7 +2044,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="],
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
+4
View File
@@ -1,2 +1,6 @@
[install]
exact = true
[test]
root = "./do-not-run-tests-from-root"
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767026758,
"narHash": "sha256-7fsac/f7nh/VaKJ/qm3I338+wAJa/3J57cOGpXi0Sbg=",
"lastModified": 1767273430,
"narHash": "sha256-kDpoFwQ8GLrPiS3KL+sAwreXrph2KhdXuJzo5+vSLoo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "346dd96ad74dc4457a9db9de4f4f57dab2e5731d",
"rev": "76eec3925eb9bbe193934987d3285473dbcfad50",
"type": "github"
},
"original": {
+2 -2
View File
@@ -87,7 +87,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
fetch-depth: 1
- name: Run opencode
uses: sst/opencode/github@latest
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
@@ -98,7 +98,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
## Support
This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/sst/opencode/issues.
This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/anomalyco/opencode/issues.
## Development
+1 -1
View File
@@ -41,7 +41,7 @@ runs:
id: version
shell: bash
run: |
VERSION=$(curl -sf https://api.github.com/repos/sst/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
VERSION=$(curl -sf https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT
- name: Cache opencode
+1 -1
View File
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await new Promise((resolve) => setTimeout(resolve, 300))
await Bun.sleep(300)
} while (retry++ < 30)
if (!connected) {
+5 -5
View File
@@ -147,8 +147,8 @@ INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then
url="https://github.com/sst/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
@@ -157,14 +157,14 @@ if [ -z "$requested_version" ]; then
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
url="https://github.com/anomalyco/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}")
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/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}"
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
exit 1
fi
fi
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
"nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw="
}
+1 -1
View File
@@ -125,7 +125,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
It combines a TypeScript/JavaScript core with a Go-based TUI
to provide an interactive AI coding experience.
'';
homepage = "https://github.com/sst/opencode";
homepage = "https://github.com/anomalyco/opencode";
license = lib.licenses.mit;
platforms = [
"aarch64-linux"
+3 -2
View File
@@ -10,7 +10,8 @@
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
"hello": "echo 'Hello World!'",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
"packages": [
@@ -75,7 +76,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/sst/opencode"
"url": "https://github.com/anomalyco/opencode"
},
"license": "MIT",
"prettier": {
+8 -23
View File
@@ -1,28 +1,13 @@
# Agent Guidelines for @opencode/app
## Debugging
## Build/Test Commands
- To test the opencode app, use the playwrite mcp server, the app is already
running at http://localhost:3000
- NEVER try to restart the app, or the server process, EVER.
- **Development**: `bun run dev` (starts Vite dev server on port 3000)
- **Build**: `bun run build` (production build)
- **Preview**: `bun run serve` (preview production build)
- **Validation**: Use `bun run typecheck` only - do not build or run project for validation
- **Testing**: Do not create or run automated tests
## SolidJS
## Code Style
- Always prefer `createStore` over multiple `createSignal` calls
- **Framework**: SolidJS with TypeScript
- **Imports**: Use `@/` alias for src/ directory (e.g., `import Button from "@/ui/button"`)
- **Formatting**: Prettier configured with semicolons disabled, 120 character line width
- **Components**: Use function declarations, splitProps for component props
- **Types**: Define interfaces for component props, avoid `any` type
- **CSS**: TailwindCSS with custom CSS variables theme system
- **Naming**: PascalCase for components, camelCase for variables/functions, snake_case for file names
- **File Structure**: UI primitives in `/ui/`, higher-level components in `/components/`, pages in `/pages/`, providers in `/providers/`
## Tool Calling
## Key Dependencies
- SolidJS, @solidjs/router, @kobalte/core (UI primitives)
- TailwindCSS 4.x with @tailwindcss/vite
- Custom theme system with CSS variables
No special rules files found.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.221",
"version": "1.0.224",
"description": "",
"type": "module",
"exports": {
+6 -3
View File
@@ -16,6 +16,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerProvider, useServer } from "@/context/server"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
@@ -88,9 +89,11 @@ export function App() {
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
<FileProvider>
<PromptProvider>
<Session />
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
@@ -6,11 +6,11 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { useFile } from "@/context/file"
export function DialogSelectFile() {
const layout = useLayout()
const local = useLocal()
const file = useFile()
const dialog = useDialog()
const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -20,11 +20,13 @@ export function DialogSelectFile() {
<List
search={{ placeholder: "Search files", autofocus: true }}
emptyMessage="No files found"
items={local.file.searchFiles}
items={file.searchFiles}
key={(x) => x}
onSelect={(path) => {
if (path) {
tabs().open("file://" + path)
const value = file.tab(path)
tabs().open(value)
file.load(path)
}
dialog.close()
}}
+300 -65
View File
@@ -3,7 +3,16 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
import { createStore, produce } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
import { useFile, type FileSelection } from "@/context/file"
import {
ContentPart,
DEFAULT_PROMPT,
isPromptEqual,
Prompt,
usePrompt,
ImageAttachmentPart,
AgentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useNavigate, useParams } from "@solidjs/router"
@@ -75,6 +84,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const sync = useSync()
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
const layout = useLayout()
const params = useParams()
@@ -118,6 +128,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const activeFile = createMemo(() => {
const tab = tabs().active()
if (!tab) return
return files.pathFromTab(tab)
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const status = createMemo(
() =>
@@ -128,7 +143,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const working = createMemo(() => status()?.type !== "idle")
const [store, setStore] = createStore<{
popover: "file" | "slash" | null
popover: "at" | "slash" | null
historyIndex: number
savedPrompt: Prompt | null
placeholder: number
@@ -171,6 +186,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
prompt.map((part) => {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: part.selection ? { ...part.selection } : undefined,
@@ -294,10 +310,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
event.preventDefault()
setStore("dragging", false)
const files = event.dataTransfer?.files
if (!files) return
const dropped = event.dataTransfer?.files
if (!dropped) return
for (const file of Array.from(files)) {
for (const file of Array.from(dropped)) {
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
await addImageAttachment(file)
}
@@ -321,15 +337,43 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!isFocused()) setStore("popover", null)
})
const handleFileSelect = (path: string | undefined) => {
if (!path) return
addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
const agentList = createMemo(() =>
sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)
const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
if (option.type === "agent") {
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
} else {
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
}
}
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
items: local.file.searchFilesAndDirectories,
key: (x) => x,
onSelect: handleFileSelect,
const atKey = (x: AtOption | undefined) => {
if (!x) return ""
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
}
const {
flat: atFlat,
active: atActive,
onInput: atOnInput,
onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({
items: async (query) => {
const agents = agentList()
const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths.map((path) => ({ type: "file", path, display: path }))
return [...agents, ...fileOptions]
},
key: atKey,
filterKeys: ["display"],
onSelect: handleAtSelect,
})
const slashCommands = createMemo<SlashCommand[]>(() => {
@@ -415,6 +459,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
if (el.dataset.type === "file") return true
if (el.dataset.type === "agent") return true
return el.tagName === "BR"
})
if (normalized && isPromptEqual(currentParts, domParts)) return
@@ -438,6 +483,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
}
})
@@ -473,6 +527,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
position += content.length
}
const pushAgent = (agent: HTMLElement) => {
const content = agent.textContent ?? ""
parts.push({
type: "agent",
name: agent.dataset.name!,
content,
start: position,
end: position + content.length,
})
position += content.length
}
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? ""
@@ -486,6 +552,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pushFile(el)
return
}
if (el.dataset.type === "agent") {
flushText()
pushAgent(el)
return
}
if (el.tagName === "BR") {
buffer += "\n"
return
@@ -539,8 +610,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const slashMatch = rawText.match(/^\/(\S*)$/)
if (atMatch) {
onInput(atMatch[1])
setStore("popover", "file")
atOnInput(atMatch[1])
setStore("popover", "at")
} else if (slashMatch) {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
@@ -560,6 +631,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
queueScroll()
}
const setRangeEdge = (range: Range, 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 isPill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
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 ((isPill || 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
}
remaining -= length
}
}
const addPart = (part: ContentPart) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
@@ -582,38 +683,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
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
}
remaining -= length
}
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
setRangeEdge(range, "start", start)
setRangeEdge(range, "end", cursorPosition)
}
range.deleteContents()
range.insertNode(gap)
range.insertNode(pill)
range.setStartAfter(gap)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
setEdge("start", start)
setEdge("end", cursorPosition)
setRangeEdge(range, "start", start)
setRangeEdge(range, "end", cursorPosition)
}
range.deleteContents()
@@ -834,8 +932,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "file") {
onKeyDown(event)
if (store.popover === "at") {
atOnKeyDown(event)
} else {
slashOnKeyDown(event)
}
@@ -1075,11 +1173,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!existing) return
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const attachments = currentPrompt.filter(
const fileAttachments = currentPrompt.filter(
(part) => part.type === "file",
) as import("@/context/prompt").FileAttachmentPart[]
const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
const fileAttachmentParts = attachments.map((attachment) => {
const fileAttachmentParts = fileAttachments.map((attachment) => {
const absolute = toAbsolutePath(attachment.path)
const query = attachment.selection
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -1102,6 +1201,52 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
})
const agentAttachmentParts = agentAttachments.map((attachment) => ({
id: Identifier.ascending("part"),
type: "agent" as const,
name: attachment.name,
source: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}))
const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
const contextFileParts: Array<{
id: string
type: "file"
mime: string
url: string
filename?: string
}> = []
const addContextFile = (path: string, selection?: FileSelection) => {
const absolute = toAbsolutePath(path)
const query = selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const url = `file://${absolute}${query}`
if (usedUrls.has(url)) return
usedUrls.add(url)
contextFileParts.push({
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(path),
})
}
const activePath = activeFile()
if (activePath && prompt.context.activeTab()) {
addContextFile(activePath)
}
for (const item of prompt.context.items()) {
if (item.type !== "file") continue
addContextFile(item.path, item.selection)
}
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
@@ -1111,7 +1256,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}))
const isShellMode = store.mode === "shell"
tabs().setActive(undefined)
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
setStore("imageAttachments", [])
@@ -1171,7 +1315,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "text" as const,
text,
}
const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
const requestParts = [
textPart,
...fileAttachmentParts,
...contextFileParts,
...agentAttachmentParts,
...imageAttachmentParts,
]
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: existing.id,
@@ -1209,24 +1359,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
>
<Switch>
<Match when={store.popover === "file"}>
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
<For each={flat()}>
{(i) => (
<Match when={store.popover === "at"}>
<Show
when={atFlat().length > 0}
fallback={<div class="text-text-weak px-2 py-1">No matching results</div>}
>
<For each={atFlat().slice(0, 10)}>
{(item) => (
<button
classList={{
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
"bg-surface-raised-base-hover": active() === i,
"bg-surface-raised-base-hover": atActive() === atKey(item),
}}
onClick={() => handleFileSelect(i)}
onClick={() => handleAtSelect(item)}
>
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
<Show when={!i.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</Show>
</div>
<Show
when={item.type === "agent"}
fallback={
<>
<FileIcon
node={{ path: (item as { type: "file"; path: string }).path, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
{getDirectory((item as { type: "file"; path: string }).path)}
</span>
<Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">
{getFilename((item as { type: "file"; path: string }).path)}
</span>
</Show>
</div>
</>
}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">
@{(item as { type: "agent"; name: string }).name}
</span>
</Show>
</button>
)}
</For>
@@ -1288,6 +1460,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</div>
</Show>
<Show when={false && (prompt.context.items().length > 0 || !!activeFile())}>
<div class="flex flex-wrap items-center gap-2 px-3 pt-3">
<Show when={prompt.context.activeTab() ? activeFile() : undefined}>
{(path) => (
<div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full">
<FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-12-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
<span class="text-text-weak whitespace-nowrap ml-1">active</span>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-6 w-6"
onClick={() => prompt.context.removeActive()}
/>
</div>
)}
</Show>
<Show when={!prompt.context.activeTab() && !!activeFile()}>
<button
type="button"
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-weak hover:bg-surface-raised-base-hover"
onClick={() => prompt.context.addActive()}
>
<Icon name="plus-small" size="small" />
<span>Include active file</span>
</button>
</Show>
<For each={prompt.context.items()}>
{(item) => (
<div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-12-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap ml-1">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-6 w-6"
onClick={() => prompt.context.remove(item.key)}
/>
</div>
)}
</For>
</div>
</Show>
<Show when={store.imageAttachments.length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={store.imageAttachments}>
@@ -1335,7 +1567,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{
"select-text": 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,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
@@ -1533,7 +1766,9 @@ function setCursorPosition(parent: HTMLElement, position: number) {
while (node) {
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 isPill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
@@ -1546,13 +1781,13 @@ function setCursorPosition(parent: HTMLElement, position: number) {
return
}
if ((isFile || isBreak) && remaining <= length) {
if ((isPill || isBreak) && remaining <= length) {
const range = document.createRange()
const selection = window.getSelection()
if (remaining === 0) {
range.setStartBefore(node)
}
if (remaining > 0 && isFile) {
if (remaining > 0 && isPill) {
range.setStartAfter(node)
}
if (remaining > 0 && isBreak) {
@@ -1,13 +1,25 @@
import { createMemo, Show } from "solid-js"
import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { useSync } from "@/context/sync"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
export function SessionContextUsage() {
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
}
export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const params = useParams()
const layout = useLayout()
const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const cost = createMemo(() => {
@@ -19,7 +31,11 @@ export function SessionContextUsage() {
})
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
const last = messages().findLast((x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
}) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
@@ -30,33 +46,57 @@ export function SessionContextUsage() {
}
})
return (
<Show when={context?.()}>
{(ctx) => (
<Tooltip
value={
<div class="">
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().tokens}</span>
<span class="text-text-invert-base">Tokens</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
<span class="text-text-invert-base">Usage</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">Cost</span>
</div>
const openContext = () => {
if (!params.id) return
layout.review.open()
tabs().open("context")
tabs().setActive("context")
}
const circle = () => (
<div class="p-1">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
</div>
)
const tooltipValue = () => (
<div>
<Show when={context()}>
{(ctx) => (
<>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().tokens}</span>
<span class="text-text-invert-base">Tokens</span>
</div>
}
placement="top"
>
<div class="p-1">
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
</div>
</Tooltip>
)}
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
<span class="text-text-invert-base">Usage</span>
</div>
</>
)}
</Show>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">Cost</span>
</div>
<Show when={variant() === "button"}>
<div class="text-11-regular text-text-invert-base mt-1">Click to view context</div>
</Show>
</div>
)
return (
<Show when={params.id}>
<Tooltip value={tooltipValue()} placement="top">
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={true}>
<Button type="button" variant="ghost" class="size-6" onClick={openContext}>
{circle()}
</Button>
</Match>
</Switch>
</Tooltip>
</Show>
)
}
@@ -0,0 +1,5 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
@@ -0,0 +1,419 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown"
import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
interface SessionContextTabProps {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
export function SessionContextTab(props: SessionContextTabProps) {
const params = useParams()
const sync = useSync()
const ctx = createMemo(() => {
const last = props.messages().findLast((x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
}) as AssistantMessage
if (!last) return
const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
const model = provider?.models[last.modelID]
const limit = model?.limit.context
const input = last.tokens.input
const output = last.tokens.output
const reasoning = last.tokens.reasoning
const cacheRead = last.tokens.cache.read
const cacheWrite = last.tokens.cache.write
const total = input + output + reasoning + cacheRead + cacheWrite
const usage = limit ? Math.round((total / limit) * 100) : null
return {
message: last,
provider,
model,
limit,
input,
output,
reasoning,
cacheRead,
cacheWrite,
total,
usage,
}
})
const cost = createMemo(() => {
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const counts = createMemo(() => {
const all = props.messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return {
all: all.length,
user,
assistant,
}
})
const systemPrompt = createMemo(() => {
const msg = props.visibleUserMessages().findLast((m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()
if (!trimmed) return
return trimmed
})
const number = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString()
}
const percent = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toString() + "%"
}
const time = (value: number | undefined) => {
if (!value) return "—"
return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED)
}
const providerLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
return c.provider?.name ?? c.message.providerID
})
const modelLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
if (c.model?.name) return c.model.name
return c.message.modelID
})
const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c) return []
const input = c.input
if (!input) return []
const out = {
system: systemPrompt()?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
}
for (const msg of props.messages()) {
const parts = (sync.data.part[msg.id] ?? []) as Part[]
if (msg.role === "user") {
for (const part of parts) {
if (part.type === "text") out.user += part.text.length
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
if (part.type === "agent") out.user += part.source?.value.length ?? 0
}
continue
}
if (msg.role === "assistant") {
for (const part of parts) {
if (part.type === "text") out.assistant += part.text.length
if (part.type === "reasoning") out.assistant += part.text.length
if (part.type === "tool") {
out.tool += Object.keys(part.state.input).length * 16
if (part.state.status === "pending") out.tool += part.state.raw.length
if (part.state.status === "completed") out.tool += part.state.output.length
if (part.state.status === "error") out.tool += part.state.error.length
}
}
}
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const system = estimateTokens(out.system)
const user = estimateTokens(out.user)
const assistant = estimateTokens(out.assistant)
const tool = estimateTokens(out.tool)
const estimated = system + user + assistant + tool
const pct = (tokens: number) => (tokens / input) * 100
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
return [
{
key: "system",
label: "System",
tokens: tokens.system,
width: pct(tokens.system),
percent: pctLabel(tokens.system),
color: "var(--syntax-info)",
},
{
key: "user",
label: "User",
tokens: tokens.user,
width: pct(tokens.user),
percent: pctLabel(tokens.user),
color: "var(--syntax-success)",
},
{
key: "assistant",
label: "Assistant",
tokens: tokens.assistant,
width: pct(tokens.assistant),
percent: pctLabel(tokens.assistant),
color: "var(--syntax-property)",
},
{
key: "tool",
label: "Tool Calls",
tokens: tokens.tool,
width: pct(tokens.tool),
percent: pctLabel(tokens.tool),
color: "var(--syntax-warning)",
},
{
key: "other",
label: "Other",
tokens: tokens.other,
width: pct(tokens.other),
percent: pctLabel(tokens.other),
color: "var(--syntax-comment)",
},
].filter((x) => x.tokens > 0)
}
if (estimated <= input) {
return build({ system, user, assistant, tool, other: input - estimated })
}
const scale = input / estimated
const scaled = {
system: Math.floor(system * scale),
user: Math.floor(user * scale),
assistant: Math.floor(assistant * scale),
tool: Math.floor(tool * scale),
}
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
},
),
)
function Stat(statProps: { label: string; value: JSX.Element }) {
return (
<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">{statProps.label}</div>
<div class="text-12-medium text-text-strong">{statProps.value}</div>
</div>
)
}
const stats = createMemo(() => {
const c = ctx()
const count = counts()
return [
{ label: "Session", value: props.info()?.title ?? params.id ?? "—" },
{ label: "Messages", value: count.all.toLocaleString() },
{ label: "Provider", value: providerLabel() },
{ label: "Model", value: modelLabel() },
{ label: "Context Limit", value: number(c?.limit) },
{ label: "Total Tokens", value: number(c?.total) },
{ label: "Usage", value: percent(c?.usage) },
{ label: "Input Tokens", value: number(c?.input) },
{ label: "Output Tokens", value: number(c?.output) },
{ label: "Reasoning Tokens", value: number(c?.reasoning) },
{ label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` },
{ label: "User Messages", value: count.user.toLocaleString() },
{ label: "Assistant Messages", value: count.assistant.toLocaleString() },
{ label: "Total Cost", value: cost() },
{ label: "Session Created", value: time(props.info()?.time.created) },
{ label: "Last Activity", value: time(c?.message.time.created) },
] satisfies { label: string; value: JSX.Element }[]
})
function RawMessageContent(msgProps: { message: Message }) {
const file = createMemo(() => {
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
return {
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
})
return <Code file={file()} overflow="wrap" class="select-text" />
}
function RawMessage(msgProps: { message: Message }) {
return (
<Accordion.Item value={msgProps.message.id}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{msgProps.message.role} <span class="text-text-base"> {msgProps.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={msgProps.message} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = props.view()?.scroll("context")
if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
pending = {
x: event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
}
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
const next = pending
pending = undefined
if (!next) return
props.view().setScroll("context", next)
})
}
createEffect(
on(
() => props.messages().length,
() => {
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
return (
<div
class="@container h-full overflow-y-auto no-scrollbar pb-10"
ref={(el) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<div class="px-6 pt-4 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
</div>
<Show when={breakdown().length > 0}>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">Context Breakdown</div>
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
<For each={breakdown()}>
{(segment) => (
<div
class="h-full"
style={{
width: `${segment.width}%`,
"background-color": segment.color,
}}
/>
)}
</For>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<For each={breakdown()}>
{(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
<div>{segment.label}</div>
<div class="text-text-weaker">{segment.percent}</div>
</div>
)}
</For>
</div>
<div class="hidden text-11-regular text-text-weaker">
Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.
</div>
</div>
</Show>
<Show when={systemPrompt()}>
{(prompt) => (
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">System Prompt</div>
<div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
<Markdown text={prompt()} class="text-12-regular" />
</div>
</div>
)}
</Show>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">Raw messages</div>
<Accordion multiple>
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
</Accordion>
</div>
</div>
</div>
)
}
@@ -0,0 +1,212 @@
import { createMemo, createResource, Show } from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Select } from "@opencode-ai/ui/select"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const command = useCommand()
const server = useServer()
const dialog = useDialog()
const sync = useSync()
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const branch = createMemo(() => sync.data.vcs?.branch)
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session.id}`)
}
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={layout.mobileSidebar.toggle}
>
<Icon name="menu" size="small" />
</button>
<div class="px-4 flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={sync.directory}
label={(x) => {
const name = getFilename(x)
const b = x === sync.directory ? branch() : undefined
return b ? `${name}:${b}` : name
}}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: sync.directory })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
)
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
})}
</Popover>
</Show>
</div>
</div>
</header>
)
}
@@ -0,0 +1,35 @@
import { Show } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
export function NewSessionView() {
const sync = useSync()
return (
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
<div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
)
}
@@ -0,0 +1,48 @@
import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { getFilename } from "@opencode-ai/util/path"
import { useFile } from "@/context/file"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
return (
<div class="flex items-center gap-x-1.5">
<FileIcon
node={{ path: props.path, type: "file" }}
classList={{
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
"grayscale-0": props.active,
}}
/>
<span class="text-14-medium">{getFilename(props.path)}</span>
</div>
)
}
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
const file = useFile()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
value={props.tab}
closeButton={
<Tooltip value="Close tab" placement="bottom">
<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
</Tooltip>
}
hideCloseButton
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useTerminal, type LocalPTY } from "@/context/terminal"
export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element {
const terminal = useTerminal()
const sortable = createSortable(props.terminal.id)
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
value={props.terminal.id}
closeButton={
terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
)
}
>
{props.terminal.title}
</Tabs.Trigger>
</div>
</div>
)
}
+282
View File
@@ -0,0 +1,282 @@
import { createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { persisted } from "@/utils/persist"
export type FileSelection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export type SelectedLineRange = {
start: number
end: number
side?: "additions" | "deletions"
endSide?: "additions" | "deletions"
}
export type FileViewState = {
scrollTop?: number
scrollLeft?: number
selectedLines?: SelectedLineRange | null
}
export type FileState = {
path: string
name: string
loaded?: boolean
loading?: boolean
error?: string
content?: FileContent
}
function stripFileProtocol(input: string) {
if (!input.startsWith("file://")) return input
return input.slice("file://".length)
}
function stripQueryAndHash(input: string) {
const hashIndex = input.indexOf("#")
const queryIndex = input.indexOf("?")
if (hashIndex !== -1 && queryIndex !== -1) {
return input.slice(0, Math.min(hashIndex, queryIndex))
}
if (hashIndex !== -1) return input.slice(0, hashIndex)
if (queryIndex !== -1) return input.slice(0, queryIndex)
return input
}
export function selectionFromLines(range: SelectedLineRange): FileSelection {
const startLine = Math.min(range.start, range.end)
const endLine = Math.max(range.start, range.end)
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return range
const startSide = range.side
const endSide = range.endSide ?? startSide
return {
...range,
start: range.end,
end: range.start,
side: endSide,
endSide: startSide !== endSide ? startSide : undefined,
}
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File",
init: () => {
const sdk = useSDK()
const sync = useSync()
const params = useParams()
const directory = createMemo(() => sync.data.path.directory)
function normalize(input: string) {
const root = directory()
const prefix = root.endsWith("/") ? root : root + "/"
let path = stripQueryAndHash(stripFileProtocol(input))
if (path.startsWith(prefix)) {
path = path.slice(prefix.length)
}
if (path.startsWith(root)) {
path = path.slice(root.length)
}
if (path.startsWith("./")) {
path = path.slice(2)
}
if (path.startsWith("/")) {
path = path.slice(1)
}
return path
}
function tab(input: string) {
const path = normalize(input)
return `file://${path}`
}
function pathFromTab(tabValue: string) {
if (!tabValue.startsWith("file://")) return
return normalize(tabValue)
}
const inflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{
file: Record<string, FileState>
}>({
file: {},
})
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const [view, setView, _, ready] = persisted(
viewKey(),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
function ensure(path: string) {
if (!path) return
if (store.file[path]) return
setStore("file", path, { path, name: getFilename(path) })
}
function load(input: string, options?: { force?: boolean }) {
const path = normalize(input)
if (!path) return Promise.resolve()
ensure(path)
const current = store.file[path]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = inflight.get(path)
if (pending) return pending
setStore(
"file",
path,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
const promise = sdk.client.file
.read({ path })
.then((x) => {
setStore(
"file",
path,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.content = x.data
}),
)
})
.catch((e) => {
setStore(
"file",
path,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
showToast({
variant: "error",
title: "Failed to load file",
description: e.message,
})
})
.finally(() => {
inflight.delete(path)
})
inflight.set(path, promise)
return promise
}
const stop = sdk.event.listen((e) => {
const event = e.details
if (event.type !== "file.watcher.updated") return
const path = normalize(event.properties.file)
if (!path) return
if (path.startsWith(".git/")) return
if (!store.file[path]) return
load(path, { force: true })
})
const get = (input: string) => store.file[normalize(input)]
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
const setScrollTop = (input: string, top: number) => {
const path = normalize(input)
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
}
const setScrollLeft = (input: string, left: number) => {
const path = normalize(input)
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
const path = normalize(input)
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
}
onCleanup(() => stop())
return {
ready,
normalize,
tab,
pathFromTab,
get,
load,
scrollTop,
scrollLeft,
setScrollTop,
setScrollLeft,
selectedLines,
setSelectedLines,
searchFiles: (query: string) =>
sdk.client.find.files({ query, dirs: "false" }).then((x) => (x.data ?? []).map(normalize)),
searchFilesAndDirectories: (query: string) =>
sdk.client.find.files({ query, dirs: "true" }).then((x) => (x.data ?? []).map(normalize)),
}
},
})
-1
View File
@@ -31,7 +31,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const sdk = createOpencodeClient({
baseUrl: server.url,
signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch,
throwOnError: true,
})
+56 -49
View File
@@ -15,7 +15,7 @@ import {
type McpStatus,
type LspStatus,
type VcsInfo,
type Permission,
type PermissionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -28,7 +28,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
type State = {
ready: boolean
status: "loading" | "partial" | "complete"
agent: Agent[]
command: Command[]
project: string
@@ -46,7 +46,7 @@ type State = {
[sessionID: string]: Todo[]
}
permission: {
[sessionID: string]: Permission[]
[sessionID: string]: PermissionRequest[]
}
mcp: {
[name: string]: McpStatus
@@ -88,7 +88,7 @@ function createGlobalSync() {
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
ready: false,
status: "loading" as const,
agent: [],
command: [],
session: [],
@@ -142,7 +142,8 @@ function createGlobalSync() {
directory,
throwOnError: true,
})
const load = {
const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () =>
sdk.provider.list().then((x) => {
@@ -157,51 +158,57 @@ function createGlobalSync() {
})),
})
}),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", 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<string, Permission[]> = {}
for (const perm of x.data ?? []) {
if (!perm?.id || !perm.sessionID) continue
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
.filter((p) => !!p?.id)
.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))
await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
Promise.all([
sdk.path.get().then((x) => setStore("path", x.data!)),
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.session.status().then((x) => setStore("session_status", x.data!)),
loadSessions(directory),
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.vcs.get().then((x) => setStore("vcs", x.data)),
sdk.permission.list().then((x) => {
const grouped: Record<string, PermissionRequest[]> = {}
for (const perm of x.data ?? []) {
if (!perm?.id || !perm.sessionID) continue
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
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
setStore("status", "complete")
})
})
.catch((e) => setGlobalStore("error", e))
}
@@ -349,7 +356,7 @@ function createGlobalSync() {
setStore("vcs", { branch: event.properties.branch })
break
}
case "permission.updated": {
case "permission.asked": {
const sessionID = event.properties.sessionID
const permissions = store.permission[sessionID]
if (!permissions) {
@@ -375,7 +382,7 @@ function createGlobalSync() {
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) break
const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
if (!result.found) break
setStore(
"permission",
+108 -26
View File
@@ -23,11 +23,28 @@ export function getAvatarColors(key?: string) {
}
}
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type SessionTabs = {
active?: string
all: string[]
}
type SessionScroll = {
x: number
y: number
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type ReviewDiffStyle = "unified" | "split"
@@ -39,7 +56,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync()
const server = useServer()
const [store, setStore, _, ready] = persisted(
"layout.v4",
"layout.v6",
createStore({
sidebar: {
opened: false,
@@ -56,7 +73,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
session: {
width: 600,
},
mobileSidebar: {
opened: false,
},
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
}),
)
@@ -182,11 +203,55 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
resize(width: number) {
if (!store.session) {
setStore("session", { width })
} else {
setStore("session", "width", width)
return
}
setStore("session", "width", width)
},
},
mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
hide() {
setStore("mobileSidebar", "opened", false)
},
toggle() {
setStore("mobileSidebar", "opened", (x) => !x)
},
},
view(sessionKey: string) {
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
return {
scroll(tab: string) {
return s().scroll?.[tab]
},
setScroll(tab: string, pos: SessionScroll) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
return
}
const prev = current.scroll?.[tab]
if (prev?.x === pos.x && prev?.y === pos.y) return
setStore("sessionView", sessionKey, "scroll", tab, pos)
},
review: {
open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
return
}
if (same(current.reviewOpen, open)) return
setStore("sessionView", sessionKey, "reviewOpen", open)
},
},
}
},
tabs(sessionKey: string) {
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
@@ -209,38 +274,55 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
async open(tab: string) {
const current = store.sessionTabs[sessionKey] ?? { all: [] }
if (tab !== "review") {
if (!current.all.includes(tab)) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
} else {
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
setStore("sessionTabs", sessionKey, "active", tab)
}
if (tab === "review") {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: tab })
return
}
}
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: tab })
} else {
setStore("sessionTabs", sessionKey, "active", tab)
return
}
if (tab === "context") {
const all = [tab, ...current.all.filter((x) => x !== tab)]
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all, active: tab })
return
}
setStore("sessionTabs", sessionKey, "all", all)
setStore("sessionTabs", sessionKey, "active", tab)
return
}
if (!current.all.includes(tab)) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
return
}
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
setStore("sessionTabs", sessionKey, "active", tab)
return
}
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
return
}
setStore("sessionTabs", sessionKey, "active", tab)
},
close(tab: string) {
const current = store.sessionTabs[sessionKey]
if (!current) return
const all = current.all.filter((x) => x !== tab)
batch(() => {
setStore(
"sessionTabs",
sessionKey,
"all",
current.all.filter((x) => x !== tab),
)
if (current.active === tab) {
const index = current.all.findIndex((f) => f === tab)
const previous = current.all[Math.max(0, index - 1)]
setStore("sessionTabs", sessionKey, "active", previous)
}
setStore("sessionTabs", sessionKey, "all", all)
if (current.active !== tab) return
const index = current.all.findIndex((f) => f === tab)
const next = all[index - 1] ?? all[0]
setStore("sessionTabs", sessionKey, "active", next)
})
},
move(tab: string, to: number) {
+1 -56
View File
@@ -430,7 +430,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
// ]
// })
// setStore("active", relativePath)
context.addActive()
// context.addActive()
if (options?.pinned) setStore("node", path, "pinned", true)
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
if (store.node[relativePath]?.loaded) return
@@ -538,66 +538,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})()
const context = (() => {
const [store, setStore] = createStore<{
activeTab: boolean
files: string[]
activeFile?: string
items: (ContextItem & { key: string })[]
}>({
activeTab: true,
files: [],
items: [],
})
const files = createMemo(() => store.files.map((x) => file.node(x)))
const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
return {
all() {
return store.items
},
// active() {
// return store.activeTab ? file.active() : undefined
// },
addActive() {
setStore("activeTab", true)
},
removeActive() {
setStore("activeTab", false)
},
add(item: ContextItem) {
let key = item.type
switch (item.type) {
case "file":
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
break
}
if (store.items.find((x) => x.key === key)) return
setStore("items", (x) => [...x, { key, ...item }])
},
remove(key: string) {
setStore("items", (x) => x.filter((x) => x.key !== key))
},
files,
openFile(path: string) {
file.init(path).then(() => {
setStore("files", (x) => [...x, path])
setStore("activeFile", path)
})
},
activeFile,
setActiveFile(path: string | undefined) {
setStore("activeFile", path)
},
}
})()
const result = {
slug: createMemo(() => base64Encode(sdk.directory)),
model,
agent,
file,
context,
}
return result
},
+9 -10
View File
@@ -1,11 +1,12 @@
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { Permission } from "@opencode-ai/sdk/v2/client"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { base64Decode } from "@opencode-ai/util/encode"
type PermissionRespondFn = (input: {
sessionID: string
@@ -14,10 +15,8 @@ type PermissionRespondFn = (input: {
directory?: string
}) => void
const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
function shouldAutoAccept(perm: Permission) {
return AUTO_ACCEPT_TYPES.has(perm.type)
function shouldAutoAccept(perm: PermissionRequest) {
return perm.permission === "edit"
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
@@ -28,8 +27,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => {
if (!params.dir) return false
const [store] = globalSync.child(params.dir)
if (!params.dir || !base64Decode(params.dir)) return false
const [store] = globalSync.child(base64Decode(params.dir))
return store.config.permission !== undefined
})
@@ -48,7 +47,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
}
function respondOnce(permission: Permission, directory?: string) {
function respondOnce(permission: PermissionRequest, directory?: string) {
if (responded.has(permission.id)) return
responded.add(permission.id)
respond({
@@ -65,7 +64,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const unsubscribe = globalSDK.event.listen((e) => {
const event = e.details
if (event?.type !== "permission.updated") return
if (event?.type !== "permission.asked") return
const perm = event.properties
if (!isAutoAccepting(perm.sessionID)) return
@@ -98,7 +97,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return {
ready,
respond,
autoResponds(permission: Permission) {
autoResponds(permission: PermissionRequest) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
},
isAutoAccepting,
+67 -6
View File
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { TextSelection } from "./local"
import type { FileSelection } from "@/context/file"
import { persisted } from "@/utils/persist"
interface PartBase {
@@ -18,7 +18,12 @@ export interface TextPart extends PartBase {
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: TextSelection
selection?: FileSelection
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
@@ -29,11 +34,27 @@ export interface ImageAttachmentPart {
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
}
export type ContextItem = FileContextItem
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
@@ -43,7 +64,13 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
return false
}
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
if (partA.type === "file") {
const fileA = partA as FileAttachmentPart
const fileB = partB as FileAttachmentPart
if (fileA.path !== fileB.path) return false
if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
}
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
return false
}
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
@@ -53,7 +80,7 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
return true
}
function cloneSelection(selection?: TextSelection) {
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
@@ -61,6 +88,7 @@ function cloneSelection(selection?: TextSelection) {
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
@@ -75,24 +103,57 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
name: "Prompt",
init: () => {
const params = useParams()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
const [store, setStore, _, ready] = persisted(
name(),
createStore<{
prompt: Prompt
cursor?: number
context: {
activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
activeTab: true,
items: [],
},
}),
)
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
}
return {
ready,
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
context: {
activeTab: createMemo(() => store.context.activeTab),
items: createMemo(() => store.context.items),
addActive() {
setStore("context", "activeTab", true)
},
removeActive() {
setStore("context", "activeTab", false)
},
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
-1
View File
@@ -11,7 +11,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const globalSDK = useGlobalSDK()
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch,
directory: props.directory,
throwOnError: true,
+1 -1
View File
@@ -100,7 +100,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(2000),
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
+4 -1
View File
@@ -18,8 +18,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return {
data: store,
set: setStore,
get status() {
return store.status
},
get ready() {
return store.ready
return store.status !== "loading"
},
get project() {
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
+33 -27
View File
@@ -62,17 +62,9 @@ export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
lastSession: {} as { [directory: string]: string },
activeDraggable: undefined as string | undefined,
mobileSidebarOpen: false,
mobileProjectsExpanded: {} as Record<string, boolean>,
})
const mobileSidebar = {
open: () => store.mobileSidebarOpen,
show: () => setStore("mobileSidebarOpen", true),
hide: () => setStore("mobileSidebarOpen", false),
toggle: () => setStore("mobileSidebarOpen", (x) => !x),
}
const mobileProjects = {
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
@@ -134,11 +126,15 @@ export default function Layout(props: ParentProps) {
})
}
onMount(async () => {
if (platform.checkUpdate && platform.update && platform.restart) {
const { updateAvailable, version } = await platform.checkUpdate()
if (updateAvailable) {
showToast({
onMount(() => {
if (!platform.checkUpdate || !platform.update || !platform.restart) return
let toastId: number | undefined
async function pollUpdate() {
const { updateAvailable, version } = await platform.checkUpdate!()
if (updateAvailable && toastId === undefined) {
toastId = showToast({
persistent: true,
icon: "download",
title: "Update available",
@@ -159,6 +155,10 @@ export default function Layout(props: ParentProps) {
})
}
}
pollUpdate()
const interval = setInterval(pollUpdate, 10 * 60 * 1000)
onCleanup(() => clearInterval(interval))
})
onMount(() => {
@@ -167,7 +167,7 @@ export default function Layout(props: ParentProps) {
const permissionAlertCooldownMs = 5000
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.updated") return
if (e.details?.type !== "permission.asked") return
const directory = e.name
const perm = e.details.properties
if (permission.autoResponds(perm)) return
@@ -460,13 +460,13 @@ export default function Layout(props: ParentProps) {
if (!directory) return
const lastSession = store.lastSession[directory]
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
mobileSidebar.hide()
layout.mobileSidebar.hide()
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session?.id}`)
mobileSidebar.hide()
layout.mobileSidebar.hide()
}
function openProject(directory: string, navigate = true) {
@@ -1026,13 +1026,20 @@ export default function Layout(props: ParentProps) {
<div class="flex-1 min-h-0 flex">
<div
classList={{
"hidden xl:flex": true,
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
"flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
"hidden xl:block": true,
"relative shrink-0": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
>
<div
classList={{
"@container w-full h-full pb-5 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
}}
>
<SidebarContent />
</div>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
@@ -1044,24 +1051,23 @@ export default function Layout(props: ParentProps) {
onCollapse={layout.sidebar.close}
/>
</Show>
<SidebarContent />
</div>
<div class="xl:hidden">
<div
classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": mobileSidebar.open(),
"opacity-0 pointer-events-none": !mobileSidebar.open(),
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) mobileSidebar.hide()
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<div
classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
"translate-x-0": mobileSidebar.open(),
"-translate-x-full": !mobileSidebar.open(),
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.221",
"version": "1.0.224",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+6 -6
View File
@@ -7,10 +7,10 @@ export const config = {
// GitHub
github: {
repoUrl: "https://github.com/sst/opencode",
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "41K",
full: "41,000",
compact: "45K",
full: "45,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "450",
commits: "6,000",
monthlyUsers: "400,000",
contributors: "500",
commits: "6,500",
monthlyUsers: "650,000",
},
} as const
+1 -1
View File
@@ -26,7 +26,7 @@ export default function NotFound() {
<a href="/docs">Docs</a>
</div>
<div data-slot="action">
<a href="https://github.com/sst/opencode">GitHub</a>
<a href="https://github.com/anomalyco/opencode">GitHub</a>
</div>
<div data-slot="action">
<a href="/discord">Discord</a>
@@ -21,7 +21,7 @@ export async function GET({ params: { platform } }: APIEvent) {
const assetName = assetNames[platform]
if (!assetName) return new Response("Not Found", { status: 404 })
const resp = await fetch(`https://github.com/sst/opencode/releases/latest/download/${assetName}`, {
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 60 * 24,
@@ -1,6 +1,6 @@
export async function GET() {
const response = await fetch(
"https://raw.githubusercontent.com/sst/opencode/refs/heads/dev/packages/sdk/openapi.json",
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json",
)
const json = await response.json()
return json
+1 -1
View File
@@ -151,7 +151,7 @@ export default function Home() {
<a href="https://x.com/opencode">X.com</a>
</div>
<div data-slot="cell">
<a href="https://github.com/sst/opencode">GitHub</a>
<a href="https://github.com/anomalyco/opencode">GitHub</a>
</div>
<div data-slot="cell">
<a href="https://opencode.ai/discord">Discord</a>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.221",
"version": "1.0.224",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.221",
"version": "1.0.224",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.221",
"version": "1.0.224",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
-1
View File
@@ -13,7 +13,6 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script">
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.221",
"version": "1.0.224",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
+1 -1
View File
@@ -6,7 +6,7 @@ const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`
await $`cd ../opencode && bun run build --single`
@@ -20,7 +20,7 @@
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK",
"endpoints": ["https://github.com/sst/opencode/releases/latest/download/latest.json"]
"endpoints": ["https://github.com/anomalyco/opencode/releases/latest/download/latest.json"]
}
}
}
+1 -1
View File
@@ -198,7 +198,7 @@ render(() => {
return (
<PlatformProvider value={platform}>
{ostype() === "macos" && (
<div class="bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<App />
</PlatformProvider>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.221",
"version": "1.0.224",
"private": true,
"type": "module",
"scripts": {
@@ -328,7 +328,7 @@ export default function () {
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
href="https://github.com/anomalyco/opencode"
target="_blank"
icon="github"
variant="ghost"
+7 -7
View File
@@ -1,36 +1,36 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.221"
version = "1.0.224"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
repository = "https://github.com/anomalyco/opencode"
[agent_servers.opencode]
name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.221/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.0.224/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.221/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.0.224/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.221/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.0.224/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.221/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.0.224/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.221/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.0.224/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.221",
"version": "1.0.224",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.221",
"version": "1.0.224",
"name": "opencode",
"type": "module",
"private": true,
@@ -89,7 +89,7 @@
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2",
"bun-pty": "0.4.4",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
+11 -11
View File
@@ -22,17 +22,17 @@ if (!Script.preview) {
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
"url='https://github.com/sst/opencode'",
"url='https://github.com/anomalyco/opencode'",
"arch=('aarch64' 'x86_64')",
"license=('MIT')",
"provides=('opencode')",
"conflicts=('opencode')",
"depends=('ripgrep')",
"",
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`,
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`,
`sha256sums_aarch64=('${arm64Sha}')`,
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`,
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`,
`sha256sums_x86_64=('${x64Sha}')`,
"",
"package() {",
@@ -52,15 +52,15 @@ if (!Script.preview) {
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
"url='https://github.com/sst/opencode'",
"url='https://github.com/anomalyco/opencode'",
"arch=('aarch64' 'x86_64')",
"license=('MIT')",
"provides=('opencode')",
"conflicts=('opencode-bin')",
"depends=('ripgrep')",
"makedepends=('git' 'bun-bin' 'go')",
"makedepends=('git' 'bun' 'go')",
"",
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
`source=("opencode-\${pkgver}.tar.gz::https://github.com/anomalyco/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
`sha256sums=('SKIP')`,
"",
"build() {",
@@ -133,14 +133,14 @@ if (!Script.preview) {
"# This file was generated by GoReleaser. DO NOT EDIT.",
"class Opencode < Formula",
` desc "The AI coding agent built for the terminal."`,
` homepage "https://github.com/sst/opencode"`,
` homepage "https://github.com/anomalyco/opencode"`,
` version "${Script.version.split("-")[0]}"`,
"",
` depends_on "ripgrep"`,
"",
" on_macos do",
" if Hardware::CPU.intel?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-darwin-x64.zip"`,
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-x64.zip"`,
` sha256 "${macX64Sha}"`,
"",
" def install",
@@ -148,7 +148,7 @@ if (!Script.preview) {
" end",
" end",
" if Hardware::CPU.arm?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-darwin-arm64.zip"`,
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-arm64.zip"`,
` sha256 "${macArm64Sha}"`,
"",
" def install",
@@ -159,14 +159,14 @@ if (!Script.preview) {
"",
" on_linux do",
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`,
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`,
` sha256 "${x64Sha}"`,
" def install",
' bin.install "opencode"',
" end",
" end",
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`,
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`,
` sha256 "${arm64Sha}"`,
" def install",
' bin.install "opencode"',
+1 -1
View File
@@ -62,7 +62,7 @@ if (!Script.preview) {
}
}
const image = "ghcr.io/sst/opencode"
const image = "ghcr.io/anomalyco/opencode"
const platforms = "linux/amd64,linux/arm64"
const tags = [`${image}:${Script.version}`, `${image}:latest`]
const tagFlags = tags.flatMap((t) => ["-t", t])
+14 -17
View File
@@ -71,19 +71,19 @@ export namespace ACP {
this.config.sdk.event.subscribe({ directory }).then(async (events) => {
for await (const event of events.stream) {
switch (event.type) {
case "permission.updated":
case "permission.asked":
try {
const permission = event.properties
const res = await this.connection
.requestPermission({
sessionId,
toolCall: {
toolCallId: permission.callID ?? permission.id,
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.title,
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.type),
locations: toLocations(permission.type, permission.metadata),
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options,
})
@@ -93,28 +93,25 @@ export namespace ACP {
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.config.sdk.permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "reject",
await this.config.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
directory,
})
return
})
if (!res) return
if (res.outcome.outcome !== "selected") {
await this.config.sdk.permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "reject",
await this.config.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
directory,
})
return
}
await this.config.sdk.permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: res.outcome.optionId as "once" | "always" | "reject",
await this.config.sdk.permission.reply({
requestID: permission.id,
reply: res.outcome.optionId as "once" | "always" | "reject",
directory,
})
} catch (err) {
+85 -237
View File
@@ -4,16 +4,14 @@ import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { mergeDeep } from "remeda"
import { Log } from "../util/log"
const log = Log.create({ service: "agent" })
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda"
export namespace Agent {
export const Info = z
@@ -23,18 +21,10 @@ export namespace Agent {
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
default: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: z.object({
edit: Config.Permission,
bash: z.record(z.string(), Config.Permission),
skill: z.record(z.string(), Config.Permission),
webfetch: Config.Permission.optional(),
doom_loop: Config.Permission.optional(),
external_directory: Config.Permission.optional(),
}),
permission: PermissionNext.Ruleset,
model: z
.object({
modelID: z.string(),
@@ -42,9 +32,8 @@ export namespace Agent {
})
.optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()),
options: z.record(z.string(), z.any()),
maxSteps: z.number().int().positive().optional(),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
@@ -53,113 +42,74 @@ export namespace Agent {
const state = Instance.state(async () => {
const cfg = await Config.get()
const defaultTools = cfg.tools ?? {}
const defaultPermission: Info["permission"] = {
edit: "allow",
bash: {
"*": "allow",
},
skill: {
"*": "allow",
},
webfetch: "allow",
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: "ask",
}
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})
const planPermission = mergeAgentPermissions(
{
edit: "deny",
bash: {
"cut*": "allow",
"diff*": "allow",
"du*": "allow",
"file *": "allow",
"find * -delete*": "ask",
"find * -exec*": "ask",
"find * -fprint*": "ask",
"find * -fls*": "ask",
"find * -fprintf*": "ask",
"find * -ok*": "ask",
"find *": "allow",
"git diff*": "allow",
"git log*": "allow",
"git show*": "allow",
"git status*": "allow",
"git branch": "allow",
"git branch -v": "allow",
"grep*": "allow",
"head*": "allow",
"less*": "allow",
"ls*": "allow",
"more*": "allow",
"pwd*": "allow",
"rg*": "allow",
"sort --output=*": "ask",
"sort -o *": "ask",
"sort*": "allow",
"stat*": "allow",
"tail*": "allow",
"tree -o *": "ask",
"tree*": "allow",
"uniq*": "allow",
"wc*": "allow",
"whereis*": "allow",
"which*": "allow",
"*": "ask",
},
webfetch: "allow",
},
cfg.permission ?? {},
)
})
const user = PermissionNext.fromConfig(cfg.permission ?? {})
const result: Record<string, Info> = {
build: {
name: "build",
tools: { ...defaultTools },
options: {},
permission: agentPermission,
permission: PermissionNext.merge(defaults, user),
mode: "primary",
native: true,
},
plan: {
name: "plan",
options: {},
permission: planPermission,
tools: {
...defaultTools,
},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
edit: {
"*": "deny",
".opencode/plan/*.md": "allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
tools: {
todoread: false,
todowrite: false,
...defaultTools,
},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
permission: agentPermission,
mode: "subagent",
native: true,
hidden: true,
},
explore: {
name: "explore",
tools: {
todoread: false,
todowrite: false,
edit: false,
write: false,
...defaultTools,
},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
permission: agentPermission,
mode: "subagent",
native: true,
},
@@ -169,11 +119,14 @@ export namespace Agent {
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
tools: {
"*": false,
},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
options: {},
permission: agentPermission,
},
title: {
name: "title",
@@ -181,9 +134,14 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
permission: agentPermission,
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
tools: {},
},
summary: {
name: "summary",
@@ -191,11 +149,17 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
permission: agentPermission,
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
tools: {},
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
@@ -206,74 +170,22 @@ export namespace Agent {
item = result[key] = {
name: key,
mode: "all",
permission: agentPermission,
permission: PermissionNext.merge(defaults, user),
options: {},
tools: {},
native: false,
}
const {
name,
model,
prompt,
tools,
description,
temperature,
top_p,
mode,
permission,
color,
maxSteps,
...extra
} = value
item.options = {
...item.options,
...extra,
}
if (model) item.model = Provider.parseModel(model)
if (prompt) item.prompt = prompt
if (tools)
item.tools = {
...item.tools,
...tools,
}
item.tools = {
...defaultTools,
...item.tools,
}
if (description) item.description = description
if (temperature != undefined) item.temperature = temperature
if (top_p != undefined) item.topP = top_p
if (mode) item.mode = mode
if (color) item.color = color
// just here for consistency & to prevent it from being added as an option
if (name) item.name = name
if (maxSteps != undefined) item.maxSteps = maxSteps
if (permission ?? cfg.permission) {
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
}
if (value.model) item.model = Provider.parseModel(value.model)
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.name = value.options?.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
}
// Mark the default agent
const defaultName = cfg.default_agent ?? "build"
const defaultCandidate = result[defaultName]
if (defaultCandidate && defaultCandidate.mode !== "subagent") {
defaultCandidate.default = true
} else {
// Fall back to "build" if configured default is invalid
if (result["build"]) {
result["build"].default = true
}
}
const hasPrimaryAgents = Object.values(result).filter((a) => a.mode !== "subagent" && !a.hidden).length > 0
if (!hasPrimaryAgents) {
throw new Config.InvalidError({
path: "config",
message: "No primary agents are available. Please configure at least one agent with mode 'primary' or 'all'.",
})
}
return result
})
@@ -282,13 +194,16 @@ export namespace Agent {
}
export async function list() {
return state().then((x) => Object.values(x))
const cfg = await Config.get()
return pipe(
await state(),
values(),
sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
)
}
export async function defaultAgent(): Promise<string> {
const agents = await state()
const defaultCandidate = Object.values(agents).find((a) => a.default)
return defaultCandidate?.name ?? "build"
export async function defaultAgent() {
return state().then((x) => Object.keys(x)[0])
}
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
@@ -329,70 +244,3 @@ export namespace Agent {
return result.object
}
}
function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
if (typeof basePermission.bash === "string") {
basePermission.bash = {
"*": basePermission.bash,
}
}
if (typeof overridePermission.bash === "string") {
overridePermission.bash = {
"*": overridePermission.bash,
}
}
if (typeof basePermission.skill === "string") {
basePermission.skill = {
"*": basePermission.skill,
}
}
if (typeof overridePermission.skill === "string") {
overridePermission.skill = {
"*": overridePermission.skill,
}
}
const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
let mergedBash
if (merged.bash) {
if (typeof merged.bash === "string") {
mergedBash = {
"*": merged.bash,
}
} else if (typeof merged.bash === "object") {
mergedBash = mergeDeep(
{
"*": "allow",
},
merged.bash,
)
}
}
let mergedSkill
if (merged.skill) {
if (typeof merged.skill === "string") {
mergedSkill = {
"*": merged.skill,
}
} else if (typeof merged.skill === "object") {
mergedSkill = mergeDeep(
{
"*": "allow",
},
merged.skill,
)
}
}
const result: Agent.Info["permission"] = {
edit: merged.edit ?? "allow",
webfetch: merged.webfetch ?? "allow",
bash: mergedBash ?? { "*": "allow" },
skill: mergedSkill ?? { "*": "allow" },
doom_loop: merged.doom_loop,
external_directory: merged.external_directory,
}
return result
}
+17 -1
View File
@@ -73,8 +73,24 @@ export namespace BunProc {
})
if (parsed.dependencies[pkg] === version) return mod
const proxied = !!(
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.https_proxy
)
// Build command arguments
const args = ["add", "--force", "--exact", "--cwd", Global.Path.cache, pkg + "@" + version]
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
-7
View File
@@ -9,13 +9,6 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
const log = Log.create({ service: "acp-command" })
process.on("unhandledRejection", (reason, promise) => {
log.error("Unhandled rejection", {
promise,
reason,
})
})
export const AcpCommand = cmd({
command: "acp",
describe: "start ACP (Agent Client Protocol) server",
+2 -1
View File
@@ -241,7 +241,8 @@ const AgentListCommand = cmd({
})
for (const agent of sortedAgents) {
process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
}
},
})
+1 -1
View File
@@ -36,7 +36,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await new Promise((resolve) => setTimeout(resolve, 10))
await Bun.sleep(10)
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
+1 -24
View File
@@ -1,9 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { ToolRegistry } from "../../../tool/registry"
import { Wildcard } from "../../../util/wildcard"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -25,27 +22,7 @@ export const AgentCommand = cmd({
)
process.exit(1)
}
const resolvedTools = await resolveTools(agent)
const output = {
...agent,
tools: resolvedTools,
toolOverrides: agent.tools,
}
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
})
},
})
async function resolveTools(agent: Agent.Info) {
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
const toolOverrides = {
...agent.tools,
...(await ToolRegistry.enabled(agent)),
}
const availableTools = await ToolRegistry.tools(providerID, agent)
const resolved: Record<string, boolean> = {}
for (const tool of availableTools) {
resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
}
return resolved
}
+9 -5
View File
@@ -348,7 +348,7 @@ export const GithubInstallCommand = cmd({
}
retries++
await new Promise((resolve) => setTimeout(resolve, 1000))
await Bun.sleep(1000)
} while (true)
s.stop("Installed GitHub app")
@@ -396,7 +396,7 @@ jobs:
uses: actions/checkout@v4
- name: Run opencode
uses: sst/opencode/github@latest${envStr}
uses: anomalyco/opencode/github@latest${envStr}
with:
model: ${provider}/${model}`,
)
@@ -994,12 +994,16 @@ export const GithubRunCommand = cmd({
console.log("Configuring git...")
const config = "http.https://github.com/.extraheader"
const ret = await $`git config --local --get ${config}`
gitConfig = ret.stdout.toString().trim()
// actions/checkout@v6 no longer stores credentials in .git/config,
// so this may not exist - use nothrow() to handle gracefully
const ret = await $`git config --local --get ${config}`.nothrow()
if (ret.exitCode === 0) {
gitConfig = ret.stdout.toString().trim()
await $`git config --local --unset-all ${config}`
}
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
await $`git config --local --unset-all ${config}`
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
await $`git config --global user.name "${AGENT_USERNAME}"`
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
+2 -2
View File
@@ -31,9 +31,9 @@ export const ImportCommand = cmd({
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
if (isUrl) {
const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/share\/([a-zA-Z0-9_-]+)/)
if (!urlMatch) {
process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/s/<slug>`)
process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/share/<slug>`)
process.stdout.write(EOL)
return
}
+3 -3
View File
@@ -202,14 +202,14 @@ export const RunCommand = cmd({
break
}
if (event.type === "permission.updated") {
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const result = await select({
message: `Permission required to run: ${permission.title}`,
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
options: [
{ value: "once", label: "Allow once" },
{ value: "always", label: "Always allow" },
{ value: "always", label: "Always allow: " + permission.always.join(", ") },
{ value: "reject", label: "Reject" },
],
initialValue: "once",
+30 -1
View File
@@ -4,7 +4,36 @@ import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "../../util/locale"
import { Flag } from "../../flag/flag"
import { EOL } from "os"
import path from "path"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
if (process.platform !== "win32") {
return ["less", ...lessOptions]
}
// user could have less installed via other options
const lessOnPath = Bun.which("less")
if (lessOnPath) {
if (Bun.file(lessOnPath).size) return [lessOnPath, ...lessOptions]
}
if (Flag.OPENCODE_GIT_BASH_PATH) {
const less = path.join(Flag.OPENCODE_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe")
if (Bun.file(less).size) return [less, ...lessOptions]
}
const git = Bun.which("git")
if (git) {
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
if (Bun.file(less).size) return [less, ...lessOptions]
}
// Fall back to Windows built-in more (via cmd.exe)
return ["cmd", "/c", "more"]
}
export const SessionCommand = cmd({
command: "session",
@@ -58,7 +87,7 @@ export const SessionListCommand = cmd({
if (shouldPaginate) {
const proc = Bun.spawn({
cmd: ["less", "-R", "-S"],
cmd: pagerCmd(),
stdin: "pipe",
stdout: "inherit",
stderr: "inherit",
+12 -4
View File
@@ -118,6 +118,12 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return Date.now() - days * MS_IN_DAY
})()
const windowDays = (() => {
if (days === undefined) return
if (days === 0) return 1
return days
})()
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
if (projectFilter !== undefined) {
@@ -159,6 +165,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
}
if (filteredSessions.length === 0) {
stats.days = windowDays ?? 0
return stats
}
@@ -231,7 +238,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
sessionToolUsage,
sessionModelUsage,
earliestTime: session.time.created,
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
latestTime: session.time.updated,
}
})
@@ -271,13 +278,14 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
}
}
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
const rangeDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
const effectiveDays = windowDays ?? rangeDays
stats.dateRange = {
earliest: earliestTime,
latest: latestTime,
}
stats.days = actualDays
stats.costPerDay = stats.totalCost / actualDays
stats.days = effectiveDays
stats.costPerDay = stats.totalCost / effectiveDays
const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning
stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0
sessionTotalTokens.sort((a, b) => a - b)
+16 -2
View File
@@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { Flag } from "@/flag/flag"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
@@ -34,6 +33,7 @@ import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
@@ -476,6 +476,20 @@ function App() {
dialog.clear()
},
},
{
title: "Write heap snapshot",
category: "System",
value: "app.heap_snapshot",
onSelect: (dialog) => {
const path = writeHeapSnapshot()
toast.show({
variant: "info",
message: `Heap snapshot written to ${path}`,
duration: 5000,
})
dialog.clear()
},
},
{
title: "Suspend terminal",
value: "terminal.suspend",
@@ -634,7 +648,7 @@ function ErrorComponent(props: {
})
const [copied, setCopied] = createSignal(false)
const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
// Choose safe fallback colors per mode since theme context may not be available
const isLight = props.mode === "light"
@@ -33,6 +33,7 @@ import { useKV } from "../../context/kv"
export type PromptProps = {
sessionID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
ref?: (ref: PromptRef) => void
@@ -202,7 +203,11 @@ export function Prompt(props: PromptProps) {
syncedSessionID = sessionID
if (msg.agent) local.agent.set(msg.agent)
// Only set agent if it's a primary agent (not a subagent)
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
if (msg.agent && isPrimaryAgent) {
local.agent.set(msg.agent)
}
if (msg.model) local.model.set(msg.model)
if (msg.variant) local.model.variant.set(msg.variant)
}
@@ -369,7 +374,8 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
input.focus()
if (props.visible !== false) input?.focus()
if (props.visible === false) input?.blur()
})
onMount(() => {
@@ -794,7 +800,7 @@ export function Prompt(props: PromptProps) {
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
<box ref={(r) => (anchor = r)}>
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}
@@ -92,7 +92,7 @@ export const TIPS = [
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use.",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use.",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
@@ -38,7 +38,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
current: agents().find((x) => x.default)?.name ?? agents()[0].name,
current: agents()[0].name,
})
const { theme } = useTheme()
const colors = createMemo(() => [
@@ -7,7 +7,7 @@ import type {
Config,
Todo,
Command,
Permission,
PermissionRequest,
LspStatus,
McpStatus,
FormatterStatus,
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: Agent[]
command: Command[]
permission: {
[sessionID: string]: Permission[]
[sessionID: string]: PermissionRequest[]
}
config: Config
session: Session[]
@@ -97,30 +97,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "permission.updated": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) {
setStore("permission", event.properties.sessionID, [event.properties])
break
}
const match = Binary.search(permissions, event.properties.id, (p) => p.id)
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
if (match.found) {
draft[match.index] = event.properties
return
}
draft.push(event.properties)
}),
)
case "server.instance.disposed":
bootstrap()
break
}
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
const requests = store.permission[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"permission",
@@ -132,6 +115,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "permission.asked": {
const request = event.properties
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
break
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("permission", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"permission",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
@@ -258,28 +263,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const args = useArgs()
async function bootstrap() {
const sessionListPromise = sdk.client.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
),
)
console.log("bootstrapping")
const sessionListPromise = sdk.client.session
.list()
.then((x) => setStore("session", reconcile((x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))))
// blocking - include session.list when continuing a session
const blockingRequests: Promise<unknown>[] = [
sdk.client.config.providers({}, { throwOnError: true }).then((x) => {
batch(() => {
setStore("provider", x.data!.providers)
setStore("provider_default", x.data!.default)
setStore("provider", reconcile(x.data!.providers))
setStore("provider_default", reconcile(x.data!.default))
})
}),
sdk.client.provider.list({}, { throwOnError: true }).then((x) => {
batch(() => {
setStore("provider_next", x.data!)
setStore("provider_next", reconcile(x.data!))
})
}),
sdk.client.app.agents({}, { throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get({}, { throwOnError: true }).then((x) => setStore("config", x.data!)),
sdk.client.app.agents({}, { throwOnError: true }).then((x) => setStore("agent", reconcile(x.data ?? []))),
sdk.client.config.get({}, { throwOnError: true }).then((x) => setStore("config", reconcile(x.data!))),
...(args.continue ? [sessionListPromise] : []),
]
@@ -289,14 +292,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
// non-blocking
Promise.all([
...(args.continue ? [] : [sessionListPromise]),
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
sdk.client.path.get().then((x) => setStore("path", x.data!)),
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status().then((x) => {
setStore("session_status", reconcile(x.data!))
}),
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
]).then(() => {
setStore("status", "complete")
})
@@ -22,6 +22,7 @@ import mercury from "./theme/mercury.json" with { type: "json" }
import monokai from "./theme/monokai.json" with { type: "json" }
import nightowl from "./theme/nightowl.json" with { type: "json" }
import nord from "./theme/nord.json" with { type: "json" }
import osakaJade from "./theme/osaka-jade.json" with { type: "json" }
import onedark from "./theme/one-dark.json" with { type: "json" }
import opencode from "./theme/opencode.json" with { type: "json" }
import orng from "./theme/orng.json" with { type: "json" }
@@ -155,6 +156,7 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
nightowl,
nord,
["one-dark"]: onedark,
["osaka-jade"]: osakaJade,
opencode,
orng,
["lucent-orng"]: lucentOrng,
@@ -283,6 +285,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
ready: false,
})
createEffect(() => {
const theme = sync.data.config.theme
console.log("theme", theme)
if (theme) setStore("active", theme)
})
createEffect(() => {
getCustomThemes()
.then((custom) => {
@@ -0,0 +1,93 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkBg0": "#111c18",
"darkBg1": "#1a2520",
"darkBg2": "#23372B",
"darkBg3": "#3d4a44",
"darkFg0": "#C1C497",
"darkFg1": "#9aa88a",
"darkGray": "#53685B",
"darkRed": "#FF5345",
"darkGreen": "#549e6a",
"darkYellow": "#459451",
"darkBlue": "#509475",
"darkMagenta": "#D2689C",
"darkCyan": "#2DD5B7",
"darkWhite": "#F6F5DD",
"darkRedBright": "#db9f9c",
"darkGreenBright": "#63b07a",
"darkYellowBright": "#E5C736",
"darkBlueBright": "#ACD4CF",
"darkMagentaBright": "#75bbb3",
"darkCyanBright": "#8CD3CB",
"lightBg0": "#F6F5DD",
"lightBg1": "#E8E7CC",
"lightBg2": "#D5D4B8",
"lightBg3": "#A8A78C",
"lightFg0": "#111c18",
"lightFg1": "#1a2520",
"lightGray": "#53685B",
"lightRed": "#c7392d",
"lightGreen": "#3d7a52",
"lightYellow": "#b5a020",
"lightBlue": "#3d7560",
"lightMagenta": "#a8527a",
"lightCyan": "#1faa90"
},
"theme": {
"primary": { "dark": "darkCyan", "light": "lightCyan" },
"secondary": { "dark": "darkMagenta", "light": "lightMagenta" },
"accent": { "dark": "darkGreen", "light": "lightGreen" },
"error": { "dark": "darkRed", "light": "lightRed" },
"warning": { "dark": "darkYellowBright", "light": "lightYellow" },
"success": { "dark": "darkGreen", "light": "lightGreen" },
"info": { "dark": "darkCyan", "light": "lightCyan" },
"text": { "dark": "darkFg0", "light": "lightFg0" },
"textMuted": { "dark": "darkGray", "light": "lightGray" },
"background": { "dark": "darkBg0", "light": "lightBg0" },
"backgroundPanel": { "dark": "darkBg1", "light": "lightBg1" },
"backgroundElement": { "dark": "darkBg2", "light": "lightBg2" },
"border": { "dark": "darkBg3", "light": "lightBg3" },
"borderActive": { "dark": "darkCyan", "light": "lightCyan" },
"borderSubtle": { "dark": "darkBg2", "light": "lightBg2" },
"diffAdded": { "dark": "darkGreen", "light": "lightGreen" },
"diffRemoved": { "dark": "darkRed", "light": "lightRed" },
"diffContext": { "dark": "darkGray", "light": "lightGray" },
"diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" },
"diffHighlightAdded": { "dark": "darkGreenBright", "light": "lightGreen" },
"diffHighlightRemoved": { "dark": "darkRedBright", "light": "lightRed" },
"diffAddedBg": { "dark": "#15241c", "light": "#e0eee5" },
"diffRemovedBg": { "dark": "#241515", "light": "#eee0e0" },
"diffContextBg": { "dark": "darkBg1", "light": "lightBg1" },
"diffLineNumber": { "dark": "darkBg3", "light": "lightBg3" },
"diffAddedLineNumberBg": { "dark": "#121f18", "light": "#d5e5da" },
"diffRemovedLineNumberBg": { "dark": "#1f1212", "light": "#e5d5d5" },
"markdownText": { "dark": "darkFg0", "light": "lightFg0" },
"markdownHeading": { "dark": "darkCyan", "light": "lightCyan" },
"markdownLink": { "dark": "darkCyanBright", "light": "lightCyan" },
"markdownLinkText": { "dark": "darkGreen", "light": "lightGreen" },
"markdownCode": { "dark": "darkGreenBright", "light": "lightGreen" },
"markdownBlockQuote": { "dark": "darkGray", "light": "lightGray" },
"markdownEmph": { "dark": "darkMagenta", "light": "lightMagenta" },
"markdownStrong": { "dark": "darkFg0", "light": "lightFg0" },
"markdownHorizontalRule": { "dark": "darkGray", "light": "lightGray" },
"markdownListItem": { "dark": "darkCyan", "light": "lightCyan" },
"markdownListEnumeration": {
"dark": "darkCyanBright",
"light": "lightCyan"
},
"markdownImage": { "dark": "darkCyanBright", "light": "lightCyan" },
"markdownImageText": { "dark": "darkGreen", "light": "lightGreen" },
"markdownCodeBlock": { "dark": "darkFg0", "light": "lightFg0" },
"syntaxComment": { "dark": "darkGray", "light": "lightGray" },
"syntaxKeyword": { "dark": "darkCyan", "light": "lightCyan" },
"syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" },
"syntaxVariable": { "dark": "darkFg0", "light": "lightFg0" },
"syntaxString": { "dark": "darkGreenBright", "light": "lightGreen" },
"syntaxNumber": { "dark": "darkMagenta", "light": "lightMagenta" },
"syntaxType": { "dark": "darkGreen", "light": "lightGreen" },
"syntaxOperator": { "dark": "darkYellow", "light": "lightYellow" },
"syntaxPunctuation": { "dark": "darkFg0", "light": "lightFg0" }
}
}
@@ -59,7 +59,7 @@ export function Footer() {
<Match when={connected()}>
<Show when={permissions().length > 0}>
<text fg={theme.warning}>
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,313 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Locale } from "@/util/locale"
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
return path.relative(process.cwd(), input) || "."
}
return input
}
function filetype(input?: string) {
if (!input) return "none"
const ext = path.extname(input)
const language = LANGUAGE_EXTENSIONS[ext]
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
return language
}
function EditBody(props: { request: PermissionRequest }) {
const { theme, syntax } = useTheme()
const sync = useSync()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
const view = createMemo(() => {
const diffStyle = sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})
const ft = createMemo(() => filetype(filepath()))
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{"→"}</text>
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
<diff
diff={diff()}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
</Show>
</box>
)
}
function TextBody(props: { title: string; description?: string; icon?: string }) {
const { theme } = useTheme()
return (
<>
<box flexDirection="row" gap={1} paddingLeft={1}>
<Show when={props.icon}>
<text fg={theme.textMuted} flexShrink={0}>
{props.icon}
</text>
</Show>
<text fg={theme.textMuted}>{props.title}</text>
</box>
<Show when={props.description}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.description}</text>
</box>
</Show>
</>
)
}
export function PermissionPrompt(props: { request: PermissionRequest }) {
const sdk = useSDK()
const sync = useSync()
const [store, setStore] = createStore({
always: false,
})
const input = createMemo(() => {
const tool = props.request.tool
if (!tool) return {}
const parts = sync.data.part[tool.messageID] ?? []
for (const part of parts) {
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
return part.state.input ?? {}
}
}
return {}
})
const { theme } = useTheme()
return (
<Switch>
<Match when={store.always}>
<Prompt
title="Always allow"
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<box>
<For each={props.request.always}>
{(pattern) => (
<text fg={theme.text}>
{"- "}
{pattern}
</text>
)}
</For>
</box>
</box>
</Match>
</Switch>
}
options={{ confirm: "Confirm", cancel: "Cancel" }}
onSelect={(option) => {
setStore("always", false)
if (option === "cancel") return
sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
}}
/>
</Match>
<Match when={!store.always}>
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
<TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
onSelect={(option) => {
if (option === "always") {
setStore("always", true)
return
}
sdk.client.permission.reply({
reply: option as "once" | "reject",
requestID: props.request.id,
})
}}
/>
</Match>
</Switch>
)
}
function Prompt<const T extends Record<string, string>>(props: {
title: string
body: JSX.Element
options: T
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
if (evt.name === "right" || evt.name == "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
if (evt.name === "return") {
evt.preventDefault()
props.onSelect(store.selected)
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
{props.body}
</box>
<box
flexDirection="row"
flexShrink={0}
gap={1}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent="space-between"
>
<box flexDirection="row" gap={1}>
<For each={keys}>
{(option) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
>
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
{props.options[option]}
</text>
</box>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
</box>
</box>
</box>
)
}
@@ -77,6 +77,9 @@ export const TuiThreadCommand = cmd({
process.on("unhandledRejection", (e) => {
Log.Default.error(e)
})
process.on("SIGUSR2", async () => {
await client.call("reload", undefined)
})
const opts = await resolveNetworkOptions(args)
const server = await client.call("server", opts)
const prompt = await iife(async () => {
@@ -9,7 +9,15 @@ export type DialogExportOptionsProps = {
defaultFilename: string
defaultThinking: boolean
defaultToolDetails: boolean
onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void
defaultAssistantMetadata: boolean
defaultOpenWithoutSaving: boolean
onConfirm?: (options: {
filename: string
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
openWithoutSaving: boolean
}) => void
onCancel?: () => void
}
@@ -20,7 +28,9 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const [store, setStore] = createStore({
thinking: props.defaultThinking,
toolDetails: props.defaultToolDetails,
active: "filename" as "filename" | "thinking" | "toolDetails",
assistantMetadata: props.defaultAssistantMetadata,
openWithoutSaving: props.defaultOpenWithoutSaving,
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
})
useKeyboard((evt) => {
@@ -29,10 +39,18 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
})
}
if (evt.name === "tab") {
const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"]
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
"filename",
"thinking",
"toolDetails",
"assistantMetadata",
"openWithoutSaving",
]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
@@ -41,6 +59,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
if (evt.name === "space") {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
evt.preventDefault()
}
})
@@ -71,6 +91,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
})
}}
height={3}
@@ -108,6 +130,30 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
</text>
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "assistantMetadata" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "assistantMetadata")}
>
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.textMuted}>
{store.assistantMetadata ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.text}>Include assistant metadata</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "openWithoutSaving" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "openWithoutSaving")}
>
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.textMuted}>
{store.openWithoutSaving ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.text}>Open without saving</text>
</box>
</box>
<Show when={store.active !== "filename"}>
<text fg={theme.textMuted} paddingBottom={1}>
@@ -130,14 +176,24 @@ DialogExportOptions.show = (
defaultFilename: string,
defaultThinking: boolean,
defaultToolDetails: boolean,
defaultAssistantMetadata: boolean,
defaultOpenWithoutSaving: boolean,
) => {
return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => {
return new Promise<{
filename: string
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
openWithoutSaving: boolean
} | null>((resolve) => {
dialog.replace(
() => (
<DialogExportOptions
defaultFilename={defaultFilename}
defaultThinking={defaultThinking}
defaultToolDetails={defaultToolDetails}
defaultAssistantMetadata={defaultAssistantMetadata}
defaultOpenWithoutSaving={defaultOpenWithoutSaving}
onConfirm={(options) => resolve(options)}
onCancel={() => resolve(null)}
/>
@@ -99,6 +99,7 @@ function init() {
replace(input: any, onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
for (const item of store.stack) {
if (item.onClose) item.onClose()
@@ -0,0 +1,98 @@
import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
export type TranscriptOptions = {
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
}
export type SessionInfo = {
id: string
title: string
time: {
created: number
updated: number
}
}
export type MessageWithParts = {
info: UserMessage | AssistantMessage
parts: Part[]
}
export function formatTranscript(
session: SessionInfo,
messages: MessageWithParts[],
options: TranscriptOptions,
): string {
let transcript = `# ${session.title}\n\n`
transcript += `**Session ID:** ${session.id}\n`
transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n`
transcript += `---\n\n`
for (const msg of messages) {
transcript += formatMessage(msg.info, msg.parts, options)
transcript += `---\n\n`
}
return transcript
}
export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[], options: TranscriptOptions): string {
let result = ""
if (msg.role === "user") {
result += `## User\n\n`
} else {
result += formatAssistantHeader(msg, options.assistantMetadata)
}
for (const part of parts) {
result += formatPart(part, options)
}
return result
}
export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: boolean): string {
if (!includeMetadata) {
return `## Assistant\n\n`
}
const duration =
msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : ""
return `## Assistant (${Locale.titlecase(msg.agent)} · ${msg.modelID}${duration ? ` · ${duration}` : ""})\n\n`
}
export function formatPart(part: Part, options: TranscriptOptions): string {
if (part.type === "text" && !part.synthetic) {
return `${part.text}\n\n`
}
if (part.type === "reasoning") {
if (options.thinking) {
return `_Thinking:_\n\n${part.text}\n\n`
}
return ""
}
if (part.type === "tool") {
let result = `\`\`\`\nTool: ${part.tool}\n`
if (options.toolDetails && part.state.input) {
result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
}
if (options.toolDetails && part.state.status === "completed" && part.state.output) {
result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
}
if (options.toolDetails && part.state.status === "error" && part.state.error) {
result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
}
result += `\n\`\`\`\n\n`
return result
}
return ""
}
@@ -6,6 +6,7 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import type { BunWebSocketData } from "hono/bun"
import { Config } from "@/config/config"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -51,6 +52,10 @@ export const rpc = {
},
})
},
async reload() {
Config.global.reset()
await Instance.disposeAll()
},
async shutdown() {
Log.Default.info("worker shutting down")
await Instance.disposeAll()
+115 -41
View File
@@ -22,13 +22,14 @@ import { ConfigMarkdown } from "./markdown"
export namespace Config {
const log = Log.create({ service: "config" })
// Custom merge function that concatenates plugin arrays instead of replacing them
function mergeConfigWithPlugins(target: Info, source: Info): Info {
// Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
// If both configs have plugin arrays, concatenate them instead of replacing
if (target.plugin && source.plugin) {
const pluginSet = new Set([...target.plugin, ...source.plugin])
merged.plugin = Array.from(pluginSet)
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
}
if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
}
return merged
}
@@ -39,19 +40,19 @@ export namespace Config {
// Override with custom config if provided
if (Flag.OPENCODE_CONFIG) {
result = mergeConfigWithPlugins(result, await loadFile(Flag.OPENCODE_CONFIG))
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
result = mergeConfigWithPlugins(result, await loadFile(resolved))
result = mergeConfigConcatArrays(result, await loadFile(resolved))
}
}
if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigWithPlugins(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
@@ -59,7 +60,7 @@ export namespace Config {
if (value.type === "wellknown") {
process.env[value.key] = value.token
const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
result = mergeConfigWithPlugins(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
result = mergeConfigConcatArrays(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
}
}
@@ -90,12 +91,11 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const promises: Promise<void>[] = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigWithPlugins(result, await loadFile(path.join(dir, file)))
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
@@ -103,13 +103,12 @@ export namespace Config {
}
}
promises.push(installDependencies(dir))
installDependencies(dir)
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
result.agent = mergeDeep(result.agent, await loadMode(dir))
result.plugin.push(...(await loadPlugin(dir)))
}
await Promise.allSettled(promises)
// Migrate deprecated mode field to agent field
for (const [name, mode] of Object.entries(result.mode)) {
@@ -125,13 +124,22 @@ export namespace Config {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (!result.username) result.username = os.userInfo().username
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
// Backwards compatibility: legacy top-level `tools` config
if (result.tools) {
const perms: Record<string, Config.PermissionAction> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: Config.PermissionAction = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
@@ -370,7 +378,45 @@ export namespace Config {
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
export const Permission = z.enum(["ask", "allow", "deny"])
export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({
ref: "PermissionActionConfig",
})
export type PermissionAction = z.infer<typeof PermissionAction>
export const PermissionObject = z.record(z.string(), PermissionAction).meta({
ref: "PermissionObjectConfig",
})
export type PermissionObject = z.infer<typeof PermissionObject>
export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({
ref: "PermissionRuleConfig",
})
export type PermissionRule = z.infer<typeof PermissionRule>
export const Permission = z
.object({
read: PermissionRule.optional(),
edit: PermissionRule.optional(),
glob: PermissionRule.optional(),
grep: PermissionRule.optional(),
list: PermissionRule.optional(),
bash: PermissionRule.optional(),
task: PermissionRule.optional(),
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),
lsp: PermissionRule.optional(),
doom_loop: PermissionAction.optional(),
})
.catchall(PermissionRule)
.or(PermissionAction)
.transform((x) => (typeof x === "string" ? { "*": x } : x))
.meta({
ref: "PermissionConfig",
})
export type Permission = z.infer<typeof Permission>
export const Command = z.object({
@@ -388,33 +434,70 @@ export namespace Config {
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
options: z.record(z.string(), z.any()).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
.optional()
.describe("Hex color code for the agent (e.g., #FF5733)"),
maxSteps: z
steps: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of agentic iterations before forcing text-only response"),
permission: z
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
})
.optional(),
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
permission: Permission.optional(),
})
.catchall(z.any())
.transform((agent, ctx) => {
const knownKeys = new Set([
"model",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
// Extract unknown properties into options
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
}
// Convert legacy tools config to permissions
const permission: Permission = { ...agent.permission }
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
// write, edit, patch, multiedit all map to edit permission
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
} else {
permission[tool] = action
}
}
// Convert legacy maxSteps to steps
const steps = agent.steps ?? agent.maxSteps
return { ...agent, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: Permission
steps?: number
}
})
.meta({
ref: "AgentConfig",
})
@@ -787,16 +870,7 @@ export namespace Config {
),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: z
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
})
.optional(),
permission: Permission.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({
+2 -1
View File
@@ -158,6 +158,7 @@ export namespace Installation {
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),
})
await $`${process.execPath} --version`.nothrow().quiet().text()
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
@@ -194,7 +195,7 @@ export namespace Installation {
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
+163
View File
@@ -0,0 +1,163 @@
export namespace BashArity {
export function prefix(tokens: string[]) {
for (let len = tokens.length; len > 0; len--) {
const prefix = tokens.slice(0, len).join(" ")
const arity = ARITY[prefix]
if (arity !== undefined) return tokens.slice(0, arity)
}
if (tokens.length === 0) return []
return tokens.slice(0, 1)
}
/* Generated with following prompt:
You are generating a dictionary of command-prefix arities for bash-style commands.
This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string number**, representing how many **tokens** define the command.
2. **Flags NEVER count as tokens**. Only subcommands count.
3. **Longest matching prefix wins**.
4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity.
5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical
6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` `touch` (arity 1, explicitly listed)
* `git checkout main` `git checkout` (because `git` has arity 2)
* `npm install` `npm install` (because `npm` has arity 2)
* `npm run dev` `npm run dev` (because `npm run` has arity 3)
* `python script.py` `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
*/
const ARITY: Record<string, number> = {
cat: 1, // cat file.txt
cd: 1, // cd /path/to/dir
chmod: 1, // chmod 755 script.sh
chown: 1, // chown user:group file.txt
cp: 1, // cp source.txt dest.txt
echo: 1, // echo "hello world"
env: 1, // env
export: 1, // export PATH=/usr/bin
grep: 1, // grep pattern file.txt
kill: 1, // kill 1234
killall: 1, // killall process
ln: 1, // ln -s source target
ls: 1, // ls -la
mkdir: 1, // mkdir new-dir
mv: 1, // mv old.txt new.txt
ps: 1, // ps aux
pwd: 1, // pwd
rm: 1, // rm file.txt
rmdir: 1, // rmdir empty-dir
sleep: 1, // sleep 5
source: 1, // source ~/.bashrc
tail: 1, // tail -f log.txt
touch: 1, // touch file.txt
unset: 1, // unset VAR
which: 1, // which node
aws: 3, // aws s3 ls
az: 3, // az storage blob list
bazel: 2, // bazel build
brew: 2, // brew install node
bun: 2, // bun install
"bun run": 3, // bun run dev
"bun x": 3, // bun x vite
cargo: 2, // cargo build
"cargo add": 3, // cargo add tokio
"cargo run": 3, // cargo run main
cdk: 2, // cdk deploy
cf: 2, // cf push app
cmake: 2, // cmake build
composer: 2, // composer require laravel
consul: 2, // consul members
"consul kv": 3, // consul kv get config/app
crictl: 2, // crictl ps
deno: 2, // deno run server.ts
"deno task": 3, // deno task dev
doctl: 3, // doctl kubernetes cluster list
docker: 2, // docker run nginx
"docker builder": 3, // docker builder prune
"docker compose": 3, // docker compose up
"docker container": 3, // docker container ls
"docker image": 3, // docker image prune
"docker network": 3, // docker network inspect
"docker volume": 3, // docker volume ls
eksctl: 2, // eksctl get clusters
"eksctl create": 3, // eksctl create cluster
firebase: 2, // firebase deploy
flyctl: 2, // flyctl deploy
gcloud: 3, // gcloud compute instances list
gh: 3, // gh pr list
git: 2, // git checkout main
"git config": 3, // git config user.name
"git remote": 3, // git remote add origin
"git stash": 3, // git stash pop
go: 2, // go build
gradle: 2, // gradle build
helm: 2, // helm install mychart
heroku: 2, // heroku logs
hugo: 2, // hugo new site blog
ip: 2, // ip link show
"ip addr": 3, // ip addr show
"ip link": 3, // ip link set eth0 up
"ip netns": 3, // ip netns exec foo bash
"ip route": 3, // ip route add default via 1.1.1.1
kind: 2, // kind delete cluster
"kind create": 3, // kind create cluster
kubectl: 2, // kubectl get pods
"kubectl kustomize": 3, // kubectl kustomize overlays/dev
"kubectl rollout": 3, // kubectl rollout restart deploy/api
kustomize: 2, // kustomize build .
make: 2, // make build
mc: 2, // mc ls myminio
"mc admin": 3, // mc admin info myminio
minikube: 2, // minikube start
mongosh: 2, // mongosh test
mysql: 2, // mysql -u root
mvn: 2, // mvn compile
ng: 2, // ng generate component home
npm: 2, // npm install
"npm exec": 3, // npm exec vite
"npm init": 3, // npm init vue
"npm run": 3, // npm run dev
"npm view": 3, // npm view react version
nvm: 2, // nvm use 18
nx: 2, // nx build
openssl: 2, // openssl genrsa 2048
"openssl req": 3, // openssl req -new -key key.pem
"openssl x509": 3, // openssl x509 -in cert.pem
pip: 2, // pip install numpy
pipenv: 2, // pipenv install flask
pnpm: 2, // pnpm install
"pnpm dlx": 3, // pnpm dlx create-next-app
"pnpm exec": 3, // pnpm exec vite
"pnpm run": 3, // pnpm run dev
poetry: 2, // poetry add requests
podman: 2, // podman run alpine
"podman container": 3, // podman container ls
"podman image": 3, // podman image prune
psql: 2, // psql -d mydb
pulumi: 2, // pulumi up
"pulumi stack": 3, // pulumi stack output
pyenv: 2, // pyenv install 3.11
python: 2, // python -m venv env
rake: 2, // rake db:migrate
rbenv: 2, // rbenv install 3.2.0
"redis-cli": 2, // redis-cli ping
rustup: 2, // rustup update
serverless: 2, // serverless invoke
sfdx: 3, // sfdx force:org:list
skaffold: 2, // skaffold dev
sls: 2, // sls deploy
sst: 2, // sst deploy
swift: 2, // swift build
systemctl: 2, // systemctl restart nginx
terraform: 2, // terraform apply
"terraform workspace": 3, // terraform workspace select prod
tmux: 2, // tmux new -s dev
turbo: 2, // turbo run build
ufw: 2, // ufw allow 22
vault: 2, // vault login
"vault auth": 3, // vault auth list
"vault kv": 3, // vault kv get secret/api
vercel: 2, // vercel deploy
volta: 2, // volta install node
wp: 2, // wp plugin install
yarn: 2, // yarn add react
"yarn dlx": 3, // yarn dlx create-react-app
"yarn run": 3, // yarn run dev
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ export namespace Permission {
sessionID: z.string(),
messageID: z.string(),
callID: z.string().optional(),
title: z.string(),
message: z.string(),
metadata: z.record(z.string(), z.any()),
time: z.object({
created: z.number(),
@@ -99,7 +99,7 @@ export namespace Permission {
export async function ask(input: {
type: Info["type"]
title: Info["title"]
message: Info["message"]
pattern?: Info["pattern"]
callID?: Info["callID"]
sessionID: Info["sessionID"]
@@ -123,7 +123,7 @@ export namespace Permission {
sessionID: input.sessionID,
messageID: input.messageID,
callID: input.callID,
title: input.title,
message: input.message,
metadata: input.metadata,
time: {
created: Date.now(),
+258
View File
@@ -0,0 +1,258 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
import { Storage } from "@/storage/storage"
import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import z from "zod"
export namespace PermissionNext {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({
permission: key,
action: value,
pattern: "*",
})
continue
}
ruleset.push(...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern, action })))
}
return ruleset
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export const Request = z
.object({
id: Identifier.schema("permission"),
sessionID: Identifier.schema("session"),
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: z.string(),
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: z.string(),
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: z.string(),
requestID: z.string(),
reply: Reply,
}),
),
}
const state = Instance.state(async () => {
const projectID = Instance.project.id
const stored = await Storage.read<Ruleset>(["permission", projectID]).catch(() => [] as Ruleset)
const pending: Record<
string,
{
info: Request
resolve: () => void
reject: (e: any) => void
}
> = {}
return {
pending,
approved: stored,
}
})
export const ask = fn(
Request.partial({ id: true }).extend({
ruleset: Ruleset,
}),
async (input) => {
const s = await state()
const { ruleset, ...request } = input
for (const pattern of request.patterns ?? []) {
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny")
throw new AutoRejectedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
...request,
}
s.pending[id] = {
info,
resolve,
reject,
}
Bus.publish(Event.Asked, info)
})
}
if (rule.action === "allow") continue
}
},
)
export const reply = fn(
z.object({
requestID: Identifier.schema("permission"),
reply: Reply,
}),
async (input) => {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) return
delete s.pending[input.requestID]
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
existing.reject(new RejectedError())
// Reject all other pending permissions for this session
const sessionID = existing.info.sessionID
for (const [id, pending] of Object.entries(s.pending)) {
if (pending.info.sessionID === sessionID) {
delete s.pending[id]
Bus.publish(Event.Replied, {
sessionID: pending.info.sessionID,
requestID: pending.info.id,
reply: "reject",
})
pending.reject(new RejectedError())
}
}
return
}
if (input.reply === "once") {
existing.resolve()
return
}
if (input.reply === "always") {
for (const pattern of existing.info.always) {
s.approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
existing.resolve()
const sessionID = existing.info.sessionID
for (const [id, pending] of Object.entries(s.pending)) {
if (pending.info.sessionID !== sessionID) continue
const ok = pending.info.patterns.every(
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
)
if (!ok) continue
delete s.pending[id]
Bus.publish(Event.Replied, {
sessionID: pending.info.sessionID,
requestID: pending.info.id,
reply: "always",
})
pending.resolve()
}
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// await Storage.write(["permission", Instance.project.id], s.approved)
return
}
},
)
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const merged = merge(...rulesets)
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
if (evaluate(permission, "*", ruleset).action === "deny") {
result.add(tool)
}
}
return result
}
export class RejectedError extends Error {
constructor() {
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
}
}
export class AutoRejectedError extends Error {
constructor(public readonly ruleset: Ruleset) {
super(
`The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`,
)
}
}
export async function list() {
return state().then((x) => Object.values(x.pending).map((x) => x.info))
}
}
+9 -3
View File
@@ -11,6 +11,8 @@ import { Flag } from "../flag/flag"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-copilot-auth@0.0.9", "opencode-anthropic-auth@0.0.5"]
const state = Instance.state(async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
@@ -29,8 +31,7 @@ export namespace Plugin {
}
const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push("opencode-copilot-auth@0.0.9")
plugins.push("opencode-anthropic-auth@0.0.5")
plugins.push(...BUILTIN)
}
for (let plugin of plugins) {
log.info("loading plugin", { path: plugin })
@@ -38,7 +39,11 @@ export namespace Plugin {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version)
plugin = await BunProc.install(pkg, version).catch((err) => {
if (BUILTIN.includes(pkg)) return ""
throw err
})
if (!plugin) continue
}
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
@@ -78,6 +83,7 @@ export namespace Plugin {
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
for (const hook of hooks) {
// @ts-expect-error this is because we haven't moved plugin to sdk v2
await hook.config?.(config)
}
Bus.subscribeAll(async (input) => {
+1 -5
View File
@@ -374,7 +374,7 @@ export namespace Provider {
return {
autoload: true,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.chat(modelID)
return sdk.languageModel(modelID)
},
options: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
@@ -1026,10 +1026,6 @@ export namespace Provider {
"gemini-2.5-flash",
"gpt-5-nano",
]
// claude-haiku-4.5 is considered a premium model in github copilot, we shouldn't use premium requests for title gen
if (providerID === "github-copilot") {
priority = priority.filter((m) => m !== "claude-haiku-4.5")
}
if (providerID.startsWith("opencode")) {
priority = ["gpt-5-nano"]
}
+6 -3
View File
@@ -279,7 +279,7 @@ export namespace ProviderTransform {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure
if (id === "o1-mini") return {}
const azureEfforts = ["low", "medium", "high"]
if (id.includes("gpt-5")) {
if (id.includes("gpt-5-") || id === "gpt-5") {
azureEfforts.unshift("minimal")
}
return Object.fromEntries(
@@ -296,8 +296,11 @@ export namespace ProviderTransform {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
if (id === "gpt-5-pro") return {}
const openaiEfforts = iife(() => {
if (model.id.includes("codex")) return WIDELY_SUPPORTED_EFFORTS
const arr = ["minimal", ...WIDELY_SUPPORTED_EFFORTS]
if (id.includes("codex")) return WIDELY_SUPPORTED_EFFORTS
const arr = [...WIDELY_SUPPORTED_EFFORTS]
if (id.includes("gpt-5-") || id === "gpt-5") {
arr.unshift("minimal")
}
if (model.release_date >= "2025-11-13") {
arr.unshift("none")
}
+1 -20
View File
@@ -7,32 +7,12 @@ import { Log } from "../util/log"
import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import {} from "process"
import { Installation } from "@/installation"
import { Shell } from "@/shell/shell"
export namespace Pty {
const log = Log.create({ service: "pty" })
const pty = lazy(async () => {
if (!Installation.isLocal()) {
const path = require(
`bun-pty/rust-pty/target/release/${
process.platform === "win32"
? "rust_pty.dll"
: process.platform === "linux" && process.arch === "x64"
? "librust_pty.so"
: process.platform === "darwin" && process.arch === "x64"
? "librust_pty.dylib"
: process.platform === "darwin" && process.arch === "arm64"
? "librust_pty_arm64.dylib"
: process.platform === "linux" && process.arch === "arm64"
? "librust_pty_arm64.so"
: ""
}`,
)
process.env.BUN_PTY_LIB = path
}
const { spawn } = await import("bun-pty")
return spawn
})
@@ -128,6 +108,7 @@ export namespace Pty {
cwd,
env,
})
const info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
+76 -11
View File
@@ -20,7 +20,6 @@ import { LSP } from "../lsp"
import { Format } from "../format"
import { MessageV2 } from "../session/message-v2"
import { TuiRoute } from "./tui"
import { Permission } from "../permission"
import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent"
@@ -47,6 +46,7 @@ import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun"
import { errors } from "./error"
import { Pty } from "@/pty"
import { PermissionNext } from "@/permission/next"
import { Installation } from "@/installation"
import { MDNS } from "./mdns"
@@ -1524,6 +1524,7 @@ export namespace Server {
"/session/:sessionID/permissions/:permissionID",
describeRoute({
summary: "Respond to permission",
deprecated: true,
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.respond",
responses: {
@@ -1545,15 +1546,47 @@ export namespace Server {
permissionID: z.string(),
}),
),
validator("json", z.object({ response: Permission.Response })),
validator("json", z.object({ response: PermissionNext.Reply })),
async (c) => {
const params = c.req.valid("param")
const sessionID = params.sessionID
const permissionID = params.permissionID
Permission.respond({
sessionID,
permissionID,
response: c.req.valid("json").response,
PermissionNext.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
return c.json(true)
},
)
.post(
"/permission/:requestID/reply",
describeRoute({
summary: "Respond to permission request",
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.reply",
responses: {
200: {
description: "Permission processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", z.object({ reply: PermissionNext.Reply })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await PermissionNext.reply({
requestID: params.requestID,
reply: json.reply,
})
return c.json(true)
},
@@ -1569,14 +1602,14 @@ export namespace Server {
description: "List of pending permissions",
content: {
"application/json": {
schema: resolver(Permission.Info.array()),
schema: resolver(PermissionNext.Request.array()),
},
},
},
},
}),
async (c) => {
const permissions = Permission.list()
const permissions = await PermissionNext.list()
return c.json(permissions)
},
)
@@ -2657,12 +2690,44 @@ export namespace Server {
},
)
.all("/*", async (c) => {
return proxy(`https://app.opencode.ai${c.req.path}`, {
const path = c.req.path
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
host: "app.opencode.ai",
},
})
// Cloudflare doesn't return Content-Type for static assets, so we need to add it
const mimeTypes: Record<string, string> = {
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".json": "application/json",
".wasm": "application/wasm",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
}
for (const [ext, mime] of Object.entries(mimeTypes)) {
if (path.endsWith(ext)) {
const headers = new Headers(response.headers)
headers.set("Content-Type", mime)
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}
}
return response
}),
)

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