mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-02 23:04:07 +08:00
Compare commits
202 Commits
v1.0.210
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b17439f9 | ||
|
|
1261b7d333 | ||
|
|
a3f38e0533 | ||
|
|
681a257df6 | ||
|
|
586207adb4 | ||
|
|
a58dbb3b5c | ||
|
|
131d8e5778 | ||
|
|
0cf0294787 | ||
|
|
3c41e4e8f1 | ||
|
|
66bc046503 | ||
|
|
6e68ea034c | ||
|
|
c51fa7cb24 | ||
|
|
a4c67515c9 | ||
|
|
1d2d710fce | ||
|
|
2fd97377f6 | ||
|
|
47ebb2973f | ||
|
|
49d7ccd1db | ||
|
|
c996f3d847 | ||
|
|
70881b2937 | ||
|
|
d8753cda02 | ||
|
|
2685de2a33 | ||
|
|
ddb1ec294e | ||
|
|
fbd9677932 | ||
|
|
814e513db7 | ||
|
|
c600114db9 | ||
|
|
038cff4a93 | ||
|
|
741cb9c0ef | ||
|
|
38e5adc491 | ||
|
|
389a5fc017 | ||
|
|
d60393835c | ||
|
|
e6ba241045 | ||
|
|
cd2c160cf6 | ||
|
|
0f34634c52 | ||
|
|
a5a569f892 | ||
|
|
afc1825cf5 | ||
|
|
6b4c433e14 | ||
|
|
797d8425e0 | ||
|
|
260eef2d66 | ||
|
|
93f1e1afb8 | ||
|
|
6acd16dde4 | ||
|
|
6647b1e22f | ||
|
|
b8872d9d20 | ||
|
|
78940d5b7e | ||
|
|
b84a1f714b | ||
|
|
07c008fe3d | ||
|
|
dad9c917d2 | ||
|
|
2aaea71eb3 | ||
|
|
db8d83b53d | ||
|
|
963f407062 | ||
|
|
4f1ef93910 | ||
|
|
05eee679a3 | ||
|
|
154c52c4d9 | ||
|
|
680db7b9e4 | ||
|
|
7aa1dbe873 | ||
|
|
76186d19f3 | ||
|
|
7760b33956 | ||
|
|
99794c25b0 | ||
|
|
351ddeed91 | ||
|
|
dccb8875ad | ||
|
|
5f2be55e54 | ||
|
|
8b35d56a48 | ||
|
|
9be944a2d2 | ||
|
|
5138f9250e | ||
|
|
e503654252 | ||
|
|
8ebc601ea2 | ||
|
|
7a3ff5b98f | ||
|
|
3b03324578 | ||
|
|
35fff0ca70 | ||
|
|
dc8586371c | ||
|
|
41f9a58c27 | ||
|
|
01237c5325 | ||
|
|
6e7fc30f94 | ||
|
|
03733b0505 | ||
|
|
d1a4295a32 | ||
|
|
6341ed506c | ||
|
|
ed745df375 | ||
|
|
80db008419 | ||
|
|
4039670a24 | ||
|
|
d59357c89b | ||
|
|
3331b0600a | ||
|
|
c131dd0829 | ||
|
|
1c25f1fae0 | ||
|
|
2da71e0a50 | ||
|
|
87978b1c17 | ||
|
|
63d2b21b8f | ||
|
|
9a1dc1ffe4 | ||
|
|
c93e7621be | ||
|
|
e842205550 | ||
|
|
b2aa387376 | ||
|
|
34aecda47c | ||
|
|
b419b0ec55 | ||
|
|
538ac208e1 | ||
|
|
16957fd107 | ||
|
|
7f3a0b8e5c | ||
|
|
d4a2652eda | ||
|
|
7a4bfbe56d | ||
|
|
31e2c8b5e9 | ||
|
|
eab23738a8 | ||
|
|
93845db462 | ||
|
|
65bc72098b | ||
|
|
b5546dce80 | ||
|
|
3807364e73 | ||
|
|
3a1cfa6c73 | ||
|
|
a2857bba83 | ||
|
|
97a0fd1d54 | ||
|
|
87f9ebd17c | ||
|
|
e7422ee782 | ||
|
|
dc3e731afe | ||
|
|
de50e7f9b9 | ||
|
|
f3db966317 | ||
|
|
decc616c80 | ||
|
|
e0450e74fb | ||
|
|
094af4dc7d | ||
|
|
2dc14718aa | ||
|
|
b97d20f252 | ||
|
|
fcfcdd95e9 | ||
|
|
c42bd492ea | ||
|
|
840fe030ab | ||
|
|
a7c4f83ca2 | ||
|
|
ed70a07201 | ||
|
|
18a5eb205f | ||
|
|
5249f04ea0 | ||
|
|
6e3ead198e | ||
|
|
4309d439f5 | ||
|
|
2ec6a21cc0 | ||
|
|
ebf5ad25c5 | ||
|
|
8aa34ab9f3 | ||
|
|
50ef866a02 | ||
|
|
3650fefe2d | ||
|
|
22091c29f1 | ||
|
|
e7e89dc5a6 | ||
|
|
34e9392bb4 | ||
|
|
05c3bc27ff | ||
|
|
b1a6333d17 | ||
|
|
5c9d619620 | ||
|
|
dfb9caa2a9 | ||
|
|
57a2b5f444 | ||
|
|
977c9a3e2c | ||
|
|
db84ee17f4 | ||
|
|
0b1f6a7d2d | ||
|
|
a6d225558c | ||
|
|
2434965b7f | ||
|
|
d4cf78bceb | ||
|
|
ed4ce67cdc | ||
|
|
94dca309e9 | ||
|
|
52e4dd110b | ||
|
|
1e74560796 | ||
|
|
48f2419d9d | ||
|
|
b9ef09a0f4 | ||
|
|
eb81994a18 | ||
|
|
a3819e088c | ||
|
|
324ae9c471 | ||
|
|
7349626757 | ||
|
|
76c25ef286 | ||
|
|
c8b3b31d27 | ||
|
|
81fef60266 | ||
|
|
3fe5d91372 | ||
|
|
7adb6e495a | ||
|
|
2039c6936f | ||
|
|
a02fefe9dc | ||
|
|
cb0e05db26 | ||
|
|
b9cdcaa9db | ||
|
|
94453eb1bd | ||
|
|
8f629db988 | ||
|
|
585378cba0 | ||
|
|
8cd8393339 | ||
|
|
b184b2fb73 | ||
|
|
c88c2da9be | ||
|
|
9b04081ae0 | ||
|
|
7d2d87fa2c | ||
|
|
787f37b382 | ||
|
|
8fa1af851c | ||
|
|
73bc3e704e | ||
|
|
8d2feed30e | ||
|
|
2d8d4e5dee | ||
|
|
b3784588ae | ||
|
|
104d52bc38 | ||
|
|
dff1fe2d28 | ||
|
|
72ab4260ee | ||
|
|
9e9b4a0555 | ||
|
|
e53192889c | ||
|
|
23bbfb3d15 | ||
|
|
37da005a01 | ||
|
|
8b708242f1 | ||
|
|
339d2dcb98 | ||
|
|
bbc8678164 | ||
|
|
a1d54475fe | ||
|
|
55c601d13a | ||
|
|
9115fac4c4 | ||
|
|
cfcb2c1fd8 | ||
|
|
221fc62135 | ||
|
|
faaef45384 | ||
|
|
2d18d80ac3 | ||
|
|
e0e07c5d48 | ||
|
|
281f9e6236 | ||
|
|
f88903a901 | ||
|
|
ad425a6a6a | ||
|
|
e635d37027 | ||
|
|
97081484d5 | ||
|
|
e451504496 | ||
|
|
53211c5d37 | ||
|
|
98b6817e20 |
2
.github/workflows/opencode.yml
vendored
2
.github/workflows/opencode.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Run opencode
|
||||
uses: sst/opencode/github@latest
|
||||
uses: anomalyco/opencode/github@latest
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_PERMISSION: '{"bash": "deny"}'
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -2,11 +2,9 @@ name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- production
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- production
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
## Debugging
|
||||
|
||||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
[](https://opencode.ai)
|
||||
@@ -30,8 +30,8 @@ scoop bucket add extras; scoop install extras/opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install opencode # macOS and Linux
|
||||
paru -S opencode-bin # Arch Linux
|
||||
mise use -g github:sst/opencode # Any OS
|
||||
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
|
||||
mise use -g opencode # Any OS
|
||||
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 |
|
||||
| --------------------- | ------------------------------------- |
|
||||
|
||||
@@ -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>
|
||||
|
||||
[](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) 下載。
|
||||
|
||||
| 平台 | 下載連結 |
|
||||
| --------------------- | ------------------------------------- |
|
||||
|
||||
4
STATS.md
4
STATS.md
@@ -185,3 +185,7 @@
|
||||
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
|
||||
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
||||
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
|
||||
| 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) |
|
||||
|
||||
63
bun.lock
63
bun.lock
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.210",
|
||||
"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.210",
|
||||
"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.210",
|
||||
"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.210",
|
||||
"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.210",
|
||||
"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.210",
|
||||
"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.210",
|
||||
"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.210",
|
||||
"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.210",
|
||||
"version": "1.0.224",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -271,6 +271,7 @@
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.19",
|
||||
"@ai-sdk/togetherai": "1.0.30",
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.42",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -284,8 +285,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.66",
|
||||
"@opentui/solid": "0.1.66",
|
||||
"@opentui/core": "0.1.67",
|
||||
"@opentui/solid": "0.1.67",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -293,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",
|
||||
@@ -347,7 +348,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -367,7 +368,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -378,7 +379,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -391,7 +392,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -429,7 +430,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -440,7 +441,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -571,6 +572,8 @@
|
||||
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="],
|
||||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.42", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wlwO4yRoZ/d+ca29vN8SDzxus7POdnL7GBTyRdSrt6icUF0hooLesauC8qRUC4aLxtqvMEc1YHtJOU7ZnLWbTQ=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
@@ -1193,21 +1196,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.66", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.66", "@opentui/core-darwin-x64": "0.1.66", "@opentui/core-linux-arm64": "0.1.66", "@opentui/core-linux-x64": "0.1.66", "@opentui/core-win32-arm64": "0.1.66", "@opentui/core-win32-x64": "0.1.66", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-clmYc9Aj4Ec6pqklplPFfhjF0Fs5hFyuNVOtzRe2lUZLM9hZMsBG0PwHun03HRsDLrx/iCk1P1++FO8IrmaZGg=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.67", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.67", "@opentui/core-darwin-x64": "0.1.67", "@opentui/core-linux-arm64": "0.1.67", "@opentui/core-linux-x64": "0.1.67", "@opentui/core-win32-arm64": "0.1.67", "@opentui/core-win32-x64": "0.1.67", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-zmfyA10QUbzT6ohacPoHmGiYzuJrDSCfQWRWrKtao0BrHj9bii73qWy3V/eR4ibVueoRREwxJs5GlBOSvK6IoA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.66", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sf+ywTM9brOKq+p9yXgOYZ6w2RH73y+0gpwi59b6fh+BDFFUVEq+HyAA8wSJBo9x3F4qUO/y5TKCwl1Z/rSWaw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.67", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LtOcTlFD+kO7neItmkiF77H8cnjTYzBOZe8JQGwRSt9aaCke3UzMvLxmQnj4BP/kPC3hi9V6NRnFdptz0sJZIQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.66", "", { "os": "darwin", "cpu": "x64" }, "sha512-3m7QzyWOzTN0jbH+pZIAVKBCfDHqaNHcav1RvVOGkNdqWarUSIRViFOUhe1Y98MCG2Aq4vLAA0TreqaMi30otg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.67", "", { "os": "darwin", "cpu": "x64" }, "sha512-9i+awVWgpEVqZhFLaLq8usNGyCiyT5QxMLy6eH7JmRic79S34u23HfxiniGRtdYh3aqpm9SbLzo60v0nRIUkCA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.66", "", { "os": "linux", "cpu": "arm64" }, "sha512-pBaFBvTb0Zd6yX6LP7T4sno4Tcof5nqv6jhRUOyFr2rtb1i8bK30ttMUzgQ1uOydKZPJ6rBdWZyyiQC1Iqn0aQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.67", "", { "os": "linux", "cpu": "arm64" }, "sha512-WLjnTM3Ig//SRo0FUZYZJ5TITVbR6dKDVg6axU2D+sMoUzJMBP/Xo04q/TvZ3wP764Yca9l7oVMKWDxHlygyjQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.66", "", { "os": "linux", "cpu": "x64" }, "sha512-oLU+yDW4trpve6xEAR7ihgozPU4sDJd7U4+Ndx8TXVkPZMzCv1KeJv3DLq5kUGr8F9LxJWjppROo74LUKzOkIQ=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.67", "", { "os": "linux", "cpu": "x64" }, "sha512-5UbZ/TqWi/DAmHIZL4NvhdpgTwglszRiddkRiQ8cT0IbnE4lutd4XxWUWcLKwsNT1YJv32TtcGWkuthluLiriQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.66", "", { "os": "win32", "cpu": "arm64" }, "sha512-zSPd5HXb5oqt/m6Sp9nHkaCHWYV5mBO7ilWe3K5QLDaVR+rayofzhiDEIwCpmA6IsPvp8FL4d5sv8SlcMWnqdg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.67", "", { "os": "win32", "cpu": "arm64" }, "sha512-KNam5rObhN8/U9+GVVuvtAlGXp3MfdMHnw4W2P6YH7xp8HTsLvABUT91SJEyJ/ktVe9e1itLDG2fDHSoA5NbUg=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.66", "", { "os": "win32", "cpu": "x64" }, "sha512-MhF6g3Sr1aKUmRWGX1TbdukZk9nKRFxgddJnNz1PhIFlGwq10/6FE97ZtnxYhfgXN3trgb4haTZGpdkjFZ9Mfg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.67", "", { "os": "win32", "cpu": "x64" }, "sha512-740lkOw42zLNh9YfahXjCwV2DS/amH2uMDh3tCADDCLckrMhemIhqArXDiMlalDxDqYspoaZCpBsFVsG9dMS6A=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.66", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.66", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-xt+H15210j+HZY5qAdfd5oPKHRA0NW8iK9/WUne4Rj6YKjliqNV2ZgpOD7i3U2UDnrLSUq0RS6CphdXYALi/Cw=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.67", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.67", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-dVNq0+PJIdNb63D0T7vcbyVF/ZvLCihGvivTU50zDOzd0Sk5prbrIfpG8+DjMErFubXfdZQvdy/PqFdtw0rjtQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -2041,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=="],
|
||||
|
||||
@@ -3917,6 +3920,12 @@
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
|
||||
|
||||
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
[install]
|
||||
exact = true
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766996594,
|
||||
"narHash": "sha256-SosfgQSqVmOkqVgNYJnxW5FvoIQX4grOcpIKNrIwz4o=",
|
||||
"lastModified": 1767273430,
|
||||
"narHash": "sha256-kDpoFwQ8GLrPiS3KL+sAwreXrph2KhdXuJzo5+vSLoo=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0744ef1b047f07d31d9962d757ffe38ec14a4d41",
|
||||
"rev": "76eec3925eb9bbe193934987d3285473dbcfad50",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
lib = nixpkgs.lib;
|
||||
inherit (nixpkgs) lib;
|
||||
forEachSystem = lib.genAttrs systems;
|
||||
pkgsFor = system: nixpkgs.legacyPackages.${system};
|
||||
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
|
||||
@@ -70,12 +70,12 @@
|
||||
in
|
||||
{
|
||||
default = mkPackage {
|
||||
version = packageJson.version;
|
||||
inherit (packageJson) version;
|
||||
src = ./.;
|
||||
scripts = ./nix/scripts;
|
||||
target = bunTarget.${system};
|
||||
modelsDev = "${modelsDev.${system}}/dist/_api.json";
|
||||
mkNodeModules = mkNodeModules;
|
||||
inherit mkNodeModules;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:"
|
||||
},
|
||||
|
||||
10
install
10
install
@@ -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,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-kgJKAqccRJOQcJ8+/j+lGe7T6WZuQqYv6UGl3jvI9wQ="
|
||||
"nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw="
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
{ hash, lib, stdenvNoCC, bun, cacert, curl }:
|
||||
{
|
||||
hash,
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
bun,
|
||||
cacert,
|
||||
curl,
|
||||
}:
|
||||
args:
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "opencode-node_modules";
|
||||
version = args.version;
|
||||
src = args.src;
|
||||
inherit (args) version src;
|
||||
|
||||
impureEnvVars =
|
||||
lib.fetchers.proxyImpureEnvVars
|
||||
++ [
|
||||
"GIT_PROXY_COMMAND"
|
||||
"SOCKS_SERVER"
|
||||
];
|
||||
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
|
||||
"GIT_PROXY_COMMAND"
|
||||
"SOCKS_SERVER"
|
||||
];
|
||||
|
||||
nativeBuildInputs = [ bun cacert curl ];
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
cacert
|
||||
curl
|
||||
];
|
||||
|
||||
dontConfigure = true;
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
{ lib, stdenvNoCC, bun, ripgrep, makeBinaryWrapper }:
|
||||
{
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
bun,
|
||||
ripgrep,
|
||||
makeBinaryWrapper,
|
||||
}:
|
||||
args:
|
||||
let
|
||||
scripts = args.scripts;
|
||||
inherit (args) scripts;
|
||||
mkModules =
|
||||
attrs:
|
||||
args.mkNodeModules (
|
||||
@@ -14,13 +20,10 @@ let
|
||||
in
|
||||
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
pname = "opencode";
|
||||
version = args.version;
|
||||
|
||||
src = args.src;
|
||||
inherit (args) version src;
|
||||
|
||||
node_modules = mkModules {
|
||||
version = finalAttrs.version;
|
||||
src = finalAttrs.src;
|
||||
inherit (finalAttrs) version src;
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
@@ -122,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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@/index.css"
|
||||
import { ErrorBoundary, Show } from "solid-js"
|
||||
import { ErrorBoundary, Show, type ParentProps } from "solid-js"
|
||||
import { Router, Route, Navigate } from "@solidjs/router"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
@@ -10,10 +10,13 @@ import { Diff } from "@opencode-ai/ui/diff"
|
||||
import { Code } from "@opencode-ai/ui/code"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
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"
|
||||
@@ -30,7 +33,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const url = iife(() => {
|
||||
const defaultServerUrl = iife(() => {
|
||||
const param = new URLSearchParams(document.location.search).get("url")
|
||||
if (param) return param
|
||||
|
||||
@@ -42,6 +45,15 @@ const url = iife(() => {
|
||||
return window.location.origin
|
||||
})
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
const server = useServer()
|
||||
return (
|
||||
<Show when={server.url} keyed>
|
||||
{props.children}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<MetaProvider>
|
||||
@@ -52,15 +64,21 @@ export function App() {
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>
|
||||
<GlobalSDKProvider url={url}>
|
||||
<GlobalSyncProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<ServerProvider defaultUrl={defaultServerUrl}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={Home} />
|
||||
@@ -71,19 +89,21 @@ export function App() {
|
||||
component={(p) => (
|
||||
<Show when={p.params.id ?? "new"} keyed>
|
||||
<TerminalProvider>
|
||||
<PromptProvider>
|
||||
<Session />
|
||||
</PromptProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<Session />
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
</Router>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
</ServerProvider>
|
||||
</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
</MarkedProvider>
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, createMemo, createSignal, JSX, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
@@ -9,9 +10,12 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogManageModels } from "./dialog-manage-models"
|
||||
|
||||
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||
const ModelList: Component<{
|
||||
provider?: string
|
||||
class?: string
|
||||
onSelect: () => void
|
||||
}> = (props) => {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const models = createMemo(() =>
|
||||
local.model
|
||||
@@ -20,6 +24,70 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||
)
|
||||
|
||||
return (
|
||||
<List
|
||||
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
||||
search={{ placeholder: "Search models", autofocus: true }}
|
||||
emptyMessage="No model results"
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={models}
|
||||
current={local.model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
groupBy={(x) => x.provider.name}
|
||||
sortGroupsBy={(a, b) => {
|
||||
if (a.category === "Recent" && b.category !== "Recent") return -1
|
||||
if (b.category === "Recent" && a.category !== "Recent") return 1
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
props.onSelect()
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||
<span class="truncate">{i.name}</span>
|
||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
||||
<Tag>Free</Tag>
|
||||
</Show>
|
||||
<Show when={i.latest}>
|
||||
<Tag>Latest</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
export const ModelSelectorPopover: Component<{
|
||||
provider?: string
|
||||
children: JSX.Element
|
||||
}> = (props) => {
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
|
||||
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
||||
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Select model"
|
||||
@@ -34,43 +102,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<List
|
||||
search={{ placeholder: "Search models", autofocus: true }}
|
||||
emptyMessage="No model results"
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={models}
|
||||
current={local.model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
groupBy={(x) => x.provider.name}
|
||||
sortGroupsBy={(a, b) => {
|
||||
if (a.category === "Recent" && b.category !== "Recent") return -1
|
||||
if (b.category === "Recent" && a.category !== "Recent") return 1
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
dialog.close()
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-3">
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
||||
<Tag>Free</Tag>
|
||||
</Show>
|
||||
<Show when={i.latest}>
|
||||
<Tag>Latest</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||
|
||||
179
packages/app/src/components/dialog-select-server.tsx
Normal file
179
packages/app/src/components/dialog-select-server.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
|
||||
type ServerStatus = { healthy: boolean; version?: string }
|
||||
|
||||
async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
fetch,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return sdk.global
|
||||
.health()
|
||||
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
|
||||
export function DialogSelectServer() {
|
||||
const navigate = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
url: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
status: {} as Record<string, ServerStatus | undefined>,
|
||||
})
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.url
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((x) => x !== current)]
|
||||
})
|
||||
|
||||
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const list = items()
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerStatus) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(store.status[a]) - rank(store.status[b])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<string, ServerStatus> = {}
|
||||
await Promise.all(
|
||||
items().map(async (url) => {
|
||||
results[url] = await checkHealth(url, platform.fetch)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
items()
|
||||
refreshHealth()
|
||||
const interval = setInterval(refreshHealth, 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
function select(value: string, persist?: boolean) {
|
||||
if (!persist && store.status[value]?.healthy === false) return
|
||||
dialog.close()
|
||||
if (persist) {
|
||||
server.add(value)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
server.setActive(value)
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const value = normalizeServerUrl(store.url)
|
||||
if (!value) return
|
||||
|
||||
setStore("adding", true)
|
||||
setStore("error", "")
|
||||
|
||||
const result = await checkHealth(value, platform.fetch)
|
||||
setStore("adding", false)
|
||||
|
||||
if (!result.healthy) {
|
||||
setStore("error", "Could not connect to server")
|
||||
return
|
||||
}
|
||||
|
||||
setStore("url", "")
|
||||
select(value, true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
|
||||
<div class="flex flex-col gap-4 pb-4">
|
||||
<List
|
||||
search={{ placeholder: "Search servers", autofocus: true }}
|
||||
emptyMessage="No servers yet"
|
||||
items={sortedItems}
|
||||
key={(x) => x}
|
||||
current={current()}
|
||||
onSelect={(x) => {
|
||||
if (x) select(x)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div
|
||||
class="flex items-center gap-2 min-w-0 flex-1"
|
||||
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||
"bg-border-weak-base": store.status[i] === undefined,
|
||||
}}
|
||||
/>
|
||||
<span class="truncate">{serverDisplayName(i)}</span>
|
||||
<span class="text-text-weak">{store.status[i]?.version}</span>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
|
||||
<div class="mt-6 px-3 flex flex-col gap-1.5">
|
||||
<div class="px-3">
|
||||
<h3 class="text-14-regular text-text-weak">Add a server</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1 min-w-0 h-auto">
|
||||
<TextField
|
||||
type="text"
|
||||
label="Server URL"
|
||||
hideLabel
|
||||
placeholder="http://localhost:4096"
|
||||
value={store.url}
|
||||
onChange={(v) => {
|
||||
setStore("url", v)
|
||||
setStore("error", "")
|
||||
}}
|
||||
validationState={store.error ? "invalid" : "valid"}
|
||||
error={store.error}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
|
||||
{store.adding ? "Checking..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, Show } from "solid-js"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
|
||||
export function Header(props: {
|
||||
navigateToProject: (directory: string) => void
|
||||
navigateToSession: (session: Session | undefined) => void
|
||||
onMobileMenuToggle?: () => void
|
||||
}) {
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const command = useCommand()
|
||||
|
||||
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={props.onMobileMenuToggle}
|
||||
>
|
||||
<Icon name="menu" size="small" />
|
||||
</button>
|
||||
<A
|
||||
href="/"
|
||||
classList={{
|
||||
"hidden xl:flex": true,
|
||||
"w-12 shrink-0 px-4 py-3.5": true,
|
||||
"items-center justify-start self-stretch": true,
|
||||
"border-r border-border-weak-base": true,
|
||||
}}
|
||||
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<Mark class="shrink-0" />
|
||||
</A>
|
||||
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
|
||||
<Show when={layout.projects.list().length > 0 && params.dir}>
|
||||
{(directory) => {
|
||||
const currentDirectory = createMemo(() => base64Decode(directory()))
|
||||
const store = createMemo(() => globalSync.child(currentDirectory())[0])
|
||||
const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
|
||||
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
||||
const shareEnabled = createMemo(() => store().config.share !== "disabled")
|
||||
return (
|
||||
<>
|
||||
<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={currentDirectory()}
|
||||
label={(x) => getFilename(x)}
|
||||
onSelect={(x) => (x ? props.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={props.navigateToSession}
|
||||
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
<Show when={currentSession()}>
|
||||
<Tooltip
|
||||
class="hidden xl:block"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>New session</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
|
||||
New session
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<Show when={currentSession()?.summary?.files}>
|
||||
<Tooltip
|
||||
class="hidden md:block shrink-0"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Toggle review</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Tooltip
|
||||
class="hidden md:block shrink-0"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Toggle terminal</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Tooltip>
|
||||
<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: currentDirectory() })
|
||||
.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>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -11,18 +20,19 @@ import { useSync } from "@/context/sync"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { usePermission } from "@/context/permission"
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||
@@ -74,12 +84,14 @@ 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()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef!: HTMLInputElement
|
||||
let scrollRef!: HTMLDivElement
|
||||
@@ -116,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(
|
||||
() =>
|
||||
@@ -126,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
|
||||
@@ -169,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,
|
||||
@@ -292,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)
|
||||
}
|
||||
@@ -319,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[]>(() => {
|
||||
@@ -413,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
|
||||
@@ -436,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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -471,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 ?? ""
|
||||
@@ -484,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
|
||||
@@ -537,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")
|
||||
@@ -558,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
|
||||
@@ -580,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()
|
||||
@@ -832,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)
|
||||
}
|
||||
@@ -1073,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}`
|
||||
@@ -1100,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,
|
||||
@@ -1109,17 +1256,23 @@ 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", [])
|
||||
setStore("mode", "normal")
|
||||
|
||||
const model = {
|
||||
modelID: local.model.current()!.id,
|
||||
providerID: local.model.current()!.provider.id,
|
||||
const currentModel = local.model.current()
|
||||
const currentAgent = local.agent.current()
|
||||
if (!currentModel || !currentAgent) {
|
||||
console.warn("No agent or model available for prompt submission")
|
||||
return
|
||||
}
|
||||
const agent = local.agent.current()!.name
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const variant = local.model.variant.current()
|
||||
|
||||
if (isShellMode) {
|
||||
sdk.client.session
|
||||
@@ -1147,6 +1300,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to send command", e)
|
||||
@@ -1161,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,
|
||||
@@ -1183,6 +1343,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to send prompt", e)
|
||||
@@ -1198,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>
|
||||
@@ -1262,6 +1445,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input": true,
|
||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
||||
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
|
||||
"border-icon-info-active border-dashed": store.dragging,
|
||||
@@ -1276,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}>
|
||||
@@ -1321,8 +1565,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
@@ -1333,12 +1579,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="absolute top-4.5 right-4">
|
||||
<SessionContextUsage />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative p-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-start gap-1">
|
||||
<div class="flex items-center justify-start gap-0.5">
|
||||
<Switch>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<div class="flex items-center gap-2 px-2 h-6">
|
||||
@@ -1348,52 +1591,77 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Cycle agent</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind placement="top" title="Cycle agent" keybind={command.keybind("agent.cycle")}>
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Choose model</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
|
||||
</div>
|
||||
</TooltipKeybind>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
||||
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
dialog.show(() =>
|
||||
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
|
||||
)
|
||||
}
|
||||
<ModelSelectorPopover>
|
||||
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
||||
<Button as="div" variant="ghost">
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</ModelSelectorPopover>
|
||||
</Show>
|
||||
<Show when={local.model.variant.list().length > 0}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
title="Thinking effort"
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
|
||||
{local.model.current()?.provider.name}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-text-base _hidden group-hover/prompt-input:inline-block"
|
||||
onClick={() => local.model.variant.cycle()}
|
||||
>
|
||||
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Show when={permission.permissionsEnabled() && params.id}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
title="Auto-accept edits"
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||
classList={{
|
||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||
"text-text-base": !permission.isAutoAccepting(params.id!),
|
||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-double-right"
|
||||
size="small"
|
||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 absolute right-2 bottom-2">
|
||||
<div class="flex items-center gap-3 absolute right-2 bottom-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -1405,17 +1673,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<Tooltip placement="top" value="Attach image">
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="photo"
|
||||
variant="ghost"
|
||||
class="h-10 w-8"
|
||||
onClick={() => fileInputRef.click()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage />
|
||||
<Show when={store.mode === "normal"}>
|
||||
<Tooltip placement="top" value="Attach image">
|
||||
<Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
|
||||
<Icon name="photo" class="size-4.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
inactive={!prompt.dirty() && !working()}
|
||||
@@ -1441,7 +1708,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="h-10 w-8"
|
||||
class="h-6 w-4.5"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -1499,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) {
|
||||
@@ -1512,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,28 +46,57 @@ export function SessionContextUsage() {
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={context?.()}>
|
||||
{(ctx) => (
|
||||
<Tooltip
|
||||
value={
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<span class="opacity-70 text-right">Tokens</span>
|
||||
<span class="text-left">{ctx().tokens}</span>
|
||||
<span class="opacity-70 text-right">Usage</span>
|
||||
<span class="text-left">{ctx().percentage ?? 0}%</span>
|
||||
<span class="opacity-70 text-right">Cost</span>
|
||||
<span class="text-left">{cost()}</span>
|
||||
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="flex items-center gap-1.5">
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
|
||||
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
|
||||
@@ -24,12 +23,11 @@ export function SessionLspIndicator() {
|
||||
<Show when={lspStats().total > 0}>
|
||||
<Tooltip placement="top" value={tooltipContent()}>
|
||||
<div class="flex items-center gap-1 px-2 cursor-default select-none">
|
||||
<Icon
|
||||
name="code"
|
||||
size="small"
|
||||
<div
|
||||
classList={{
|
||||
"text-icon-critical-base": lspStats().hasError,
|
||||
"text-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-critical-base": lspStats().hasError,
|
||||
"bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
@@ -21,12 +20,11 @@ export function SessionMcpIndicator() {
|
||||
return (
|
||||
<Show when={mcpStats().total > 0}>
|
||||
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
|
||||
<Icon
|
||||
name="mcp"
|
||||
size="small"
|
||||
<div
|
||||
classList={{
|
||||
"text-icon-critical-base": mcpStats().failed,
|
||||
"text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-critical-base": mcpStats().failed,
|
||||
"bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
|
||||
|
||||
5
packages/app/src/components/session/index.ts
Normal file
5
packages/app/src/components/session/index.ts
Normal file
@@ -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"
|
||||
419
packages/app/src/components/session/session-context-tab.tsx
Normal file
419
packages/app/src/components/session/session-context-tab.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
212
packages/app/src/components/session/session-header.tsx
Normal file
212
packages/app/src/components/session/session-header.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
35
packages/app/src/components/session/session-new-view.tsx
Normal file
35
packages/app/src/components/session/session-new-view.tsx
Normal file
@@ -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
|
||||
<span class="text-text-strong">
|
||||
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
packages/app/src/components/session/session-sortable-tab.tsx
Normal file
48
packages/app/src/components/session/session-sortable-tab.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
|
||||
export function StatusBar(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
|
||||
const directoryDisplay = createMemo(() => {
|
||||
const directory = sync.data.path.directory || ""
|
||||
const home = globalSync.data.path.home || ""
|
||||
const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
|
||||
const branch = sync.data.vcs?.branch
|
||||
return branch ? `${short}:${branch}` : short
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
|
||||
<div class="flex items-center gap-3">
|
||||
<Show when={platform.version}>
|
||||
<span class="text-12-regular text-text-weak">v{platform.version}</span>
|
||||
</Show>
|
||||
<Show when={directoryDisplay()}>
|
||||
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -233,6 +233,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
"select-text": true,
|
||||
"size-full px-6 py-3 font-mono": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
|
||||
282
packages/app/src/context/file.tsx
Normal file
282
packages/app/src/context/file.tsx
Normal 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,34 +1,40 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||
name: "GlobalSDK",
|
||||
init: (props: { url: string }) => {
|
||||
init: () => {
|
||||
const server = useServer()
|
||||
const abort = new AbortController()
|
||||
|
||||
const eventSdk = createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
// signal: AbortSignal.timeout(1000 * 60 * 10),
|
||||
baseUrl: server.url,
|
||||
signal: abort.signal,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
eventSdk.global.event().then(async (events) => {
|
||||
void (async () => {
|
||||
const events = await eventSdk.global.event()
|
||||
for await (const event of events.stream) {
|
||||
// console.log("event", event)
|
||||
emitter.emit(event.directory ?? "global", event.payload)
|
||||
}
|
||||
})
|
||||
})().catch(() => undefined)
|
||||
|
||||
onCleanup(() => abort.abort())
|
||||
|
||||
const platform = usePlatform()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: AbortSignal.timeout(1000 * 60 * 10),
|
||||
baseUrl: server.url,
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return { url: props.url, client: sdk, event: emitter }
|
||||
return { url: server.url, client: sdk, event: emitter }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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: [],
|
||||
@@ -115,13 +115,14 @@ function createGlobalSync() {
|
||||
.then((x) => {
|
||||
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.slice()
|
||||
.filter((s) => !s.time.archived)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
// Include up to the limit, plus any updated in the last 4 hours
|
||||
const sessions = nonArchived.filter((s, i) => {
|
||||
if (i < store.limit) return true
|
||||
const updated = new Date(s.time.updated).getTime()
|
||||
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
|
||||
return updated > fourHoursAgo
|
||||
})
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
@@ -141,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) => {
|
||||
@@ -156,47 +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 ?? []) {
|
||||
const existing = grouped[perm.sessionID]
|
||||
if (existing) {
|
||||
existing.push(perm)
|
||||
continue
|
||||
}
|
||||
grouped[perm.sessionID] = [perm]
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||
.then(() => setStore("ready", true))
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -344,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) {
|
||||
@@ -370,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",
|
||||
@@ -414,10 +426,12 @@ function createGlobalSync() {
|
||||
),
|
||||
retry(() =>
|
||||
globalSDK.client.project.list().then(async (x) => {
|
||||
setGlobalStore(
|
||||
"project",
|
||||
x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { batch, createMemo, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useServer } from "./server"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
@@ -22,22 +23,41 @@ 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"
|
||||
|
||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||
name: "Layout",
|
||||
init: () => {
|
||||
const globalSdk = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const server = useServer()
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"layout.v3",
|
||||
"layout.v6",
|
||||
createStore({
|
||||
projects: [] as { worktree: string; expanded: boolean }[],
|
||||
sidebar: {
|
||||
opened: false,
|
||||
width: 280,
|
||||
@@ -48,11 +68,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
review: {
|
||||
opened: true,
|
||||
diffStyle: "split" as ReviewDiffStyle,
|
||||
},
|
||||
session: {
|
||||
width: 600,
|
||||
},
|
||||
mobileSidebar: {
|
||||
opened: false,
|
||||
},
|
||||
sessionTabs: {} as Record<string, SessionTabs>,
|
||||
sessionView: {} as Record<string, SessionView>,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -86,12 +111,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
return project
|
||||
}
|
||||
|
||||
const enriched = createMemo(() => store.projects.flatMap(enrich))
|
||||
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
|
||||
const list = createMemo(() => enriched().flatMap(colorize))
|
||||
|
||||
onMount(() => {
|
||||
Promise.all(
|
||||
store.projects.map((project) => {
|
||||
server.projects.list().map((project) => {
|
||||
return globalSync.project.loadSessions(project.worktree)
|
||||
}),
|
||||
)
|
||||
@@ -102,32 +127,23 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
projects: {
|
||||
list,
|
||||
open(directory: string) {
|
||||
if (store.projects.find((x) => x.worktree === directory)) {
|
||||
if (server.projects.list().find((x) => x.worktree === directory)) {
|
||||
return
|
||||
}
|
||||
globalSync.project.loadSessions(directory)
|
||||
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
|
||||
server.projects.open(directory)
|
||||
},
|
||||
close(directory: string) {
|
||||
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
|
||||
server.projects.close(directory)
|
||||
},
|
||||
expand(directory: string) {
|
||||
const index = store.projects.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", index, "expanded", true)
|
||||
server.projects.expand(directory)
|
||||
},
|
||||
collapse(directory: string) {
|
||||
const index = store.projects.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", index, "expanded", false)
|
||||
server.projects.collapse(directory)
|
||||
},
|
||||
move(directory: string, toIndex: number) {
|
||||
setStore("projects", (projects) => {
|
||||
const fromIndex = projects.findIndex((x) => x.worktree === directory)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return projects
|
||||
const result = [...projects]
|
||||
const [item] = result.splice(fromIndex, 1)
|
||||
result.splice(toIndex, 0, item)
|
||||
return result
|
||||
})
|
||||
server.projects.move(directory, toIndex)
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
@@ -164,6 +180,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
review: {
|
||||
opened: createMemo(() => store.review?.opened ?? true),
|
||||
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||
if (!store.review) {
|
||||
setStore("review", { opened: true, diffStyle })
|
||||
return
|
||||
}
|
||||
setStore("review", "diffStyle", diffStyle)
|
||||
},
|
||||
open() {
|
||||
setStore("review", "opened", true)
|
||||
},
|
||||
@@ -179,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 {
|
||||
@@ -206,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) {
|
||||
|
||||
@@ -65,23 +65,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
current?: string
|
||||
}>({
|
||||
current: list()[0].name,
|
||||
current: list()[0]?.name,
|
||||
})
|
||||
return {
|
||||
list,
|
||||
current() {
|
||||
return list().find((x) => x.name === store.current)!
|
||||
const available = list()
|
||||
if (available.length === 0) return undefined
|
||||
return available.find((x) => x.name === store.current) ?? available[0]
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
setStore("current", name ?? list()[0].name)
|
||||
const available = list()
|
||||
if (available.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
if (name && available.some((x) => x.name === name)) {
|
||||
setStore("current", name)
|
||||
return
|
||||
}
|
||||
setStore("current", available[0].name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
let next = list().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = list().length - 1
|
||||
if (next >= list().length) next = 0
|
||||
const value = list()[next]
|
||||
const available = list()
|
||||
if (available.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
let next = available.findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = available.length - 1
|
||||
if (next >= available.length) next = 0
|
||||
const value = available[next]
|
||||
if (!value) return
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
@@ -98,9 +115,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
createStore<{
|
||||
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
||||
recent: ModelKey[]
|
||||
variant?: Record<string, string | undefined>
|
||||
}>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -182,11 +201,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
if (!a) return undefined
|
||||
const key = getFirstValidModel(
|
||||
() => ephemeral.model[a.name],
|
||||
() => a.model,
|
||||
fallbackModel,
|
||||
)!
|
||||
)
|
||||
if (!key) return undefined
|
||||
return find(key)
|
||||
})
|
||||
|
||||
@@ -232,7 +253,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
cycle,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setEphemeral("model", agent.current().name, model ?? fallbackModel())
|
||||
const currentAgent = agent.current()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
|
||||
if (model) updateVisibility(model, "show")
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
@@ -252,6 +274,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setVisibility(model: ModelKey, visible: boolean) {
|
||||
updateVisibility(model, visible ? "show" : "hide")
|
||||
},
|
||||
variant: {
|
||||
current() {
|
||||
const m = current()
|
||||
if (!m) return undefined
|
||||
const key = `${m.provider.id}/${m.id}`
|
||||
return store.variant?.[key]
|
||||
},
|
||||
list() {
|
||||
const m = current()
|
||||
if (!m) return []
|
||||
if (!m.variants) return []
|
||||
return Object.keys(m.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = current()
|
||||
if (!m) return
|
||||
const key = `${m.provider.id}/${m.id}`
|
||||
if (!store.variant) {
|
||||
setStore("variant", { [key]: value })
|
||||
} else {
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
const currentVariant = this.current()
|
||||
if (!currentVariant) {
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
const index = variants.indexOf(currentVariant)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
this.set(undefined)
|
||||
return
|
||||
}
|
||||
this.set(variants[index + 1])
|
||||
},
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -369,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
|
||||
@@ -477,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
|
||||
},
|
||||
|
||||
@@ -1,130 +1,122 @@
|
||||
import { createEffect, createRoot, onCleanup } from "solid-js"
|
||||
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"
|
||||
|
||||
type PermissionsBySession = {
|
||||
[sessionID: string]: Permission[]
|
||||
}
|
||||
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
|
||||
permissionID: string
|
||||
response: "once" | "always" | "reject"
|
||||
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({
|
||||
name: "Permission",
|
||||
init: (props: { permissions: PermissionsBySession; onRespond: PermissionRespondFn }) => {
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
if (!params.dir || !base64Decode(params.dir)) return false
|
||||
const [store] = globalSync.child(base64Decode(params.dir))
|
||||
return store.config.permission !== undefined
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"permission.v1",
|
||||
"permission.v3",
|
||||
createStore({
|
||||
autoAcceptEdits: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
const responded = new Set<string>()
|
||||
const watches = new Map<string, () => void>()
|
||||
|
||||
function respond(perm: Permission) {
|
||||
if (responded.has(perm.id)) return
|
||||
responded.add(perm.id)
|
||||
props.onRespond({
|
||||
sessionID: perm.sessionID,
|
||||
permissionID: perm.id,
|
||||
response: "once",
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
globalSDK.client.permission.respond(input).catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function watch(sessionID: string) {
|
||||
if (watches.has(sessionID)) return
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
if (responded.has(permission.id)) return
|
||||
responded.add(permission.id)
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
const dispose = createRoot((dispose) => {
|
||||
createEffect(() => {
|
||||
if (!store.autoAcceptEdits[sessionID]) return
|
||||
function isAutoAccepting(sessionID: string) {
|
||||
return store.autoAcceptEdits[sessionID] ?? false
|
||||
}
|
||||
|
||||
const permissions = props.permissions[sessionID] ?? []
|
||||
permissions.length
|
||||
const unsubscribe = globalSDK.event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
for (const perm of permissions) {
|
||||
const perm = event.properties
|
||||
if (!isAutoAccepting(perm.sessionID)) return
|
||||
if (!shouldAutoAccept(perm)) return
|
||||
|
||||
respondOnce(perm, e.name)
|
||||
})
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
setStore("autoAcceptEdits", sessionID, true)
|
||||
|
||||
globalSDK.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (perm.sessionID !== sessionID) continue
|
||||
if (!shouldAutoAccept(perm)) continue
|
||||
respond(perm)
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
|
||||
return dispose
|
||||
})
|
||||
|
||||
watches.set(sessionID, dispose)
|
||||
}
|
||||
|
||||
function unwatch(sessionID: string) {
|
||||
const dispose = watches.get(sessionID)
|
||||
if (!dispose) return
|
||||
dispose()
|
||||
watches.delete(sessionID)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
|
||||
for (const sessionID in store.autoAcceptEdits) {
|
||||
if (!store.autoAcceptEdits[sessionID]) continue
|
||||
watch(sessionID)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
for (const dispose of watches.values()) dispose()
|
||||
watches.clear()
|
||||
})
|
||||
|
||||
function enable(sessionID: string) {
|
||||
setStore("autoAcceptEdits", sessionID, true)
|
||||
watch(sessionID)
|
||||
|
||||
const permissions = props.permissions[sessionID] ?? []
|
||||
for (const perm of permissions) {
|
||||
if (!shouldAutoAccept(perm)) continue
|
||||
respond(perm)
|
||||
}
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string) {
|
||||
setStore("autoAcceptEdits", sessionID, false)
|
||||
unwatch(sessionID)
|
||||
}
|
||||
|
||||
return {
|
||||
get permissions() {
|
||||
return props.permissions
|
||||
ready,
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest) {
|
||||
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
|
||||
},
|
||||
respond: props.onRespond,
|
||||
isAutoAccepting(sessionID: string) {
|
||||
return store.autoAcceptEdits[sessionID] ?? false
|
||||
},
|
||||
toggleAutoAccept(sessionID: string) {
|
||||
if (store.autoAcceptEdits[sessionID]) {
|
||||
isAutoAccepting,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID)) {
|
||||
disable(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID)
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string) {
|
||||
if (store.autoAcceptEdits[sessionID]) return
|
||||
enable(sessionID)
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID)) return
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string) {
|
||||
disable(sessionID)
|
||||
},
|
||||
permissionsEnabled,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
185
packages/app/src/context/server.tsx
Normal file
185
packages/app/src/context/server.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return
|
||||
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
|
||||
const cleaned = withProtocol.replace(/\/+$/, "")
|
||||
return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
|
||||
}
|
||||
|
||||
export function serverDisplayName(url: string) {
|
||||
if (!url) return ""
|
||||
return url
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/+$/, "")
|
||||
.split("/")[0]
|
||||
}
|
||||
|
||||
function projectsKey(url: string) {
|
||||
if (!url) return ""
|
||||
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||
return url
|
||||
}
|
||||
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
name: "Server",
|
||||
init: (props: { defaultUrl: string }) => {
|
||||
const platform = usePlatform()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"server.v3",
|
||||
createStore({
|
||||
list: [] as string[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
}),
|
||||
)
|
||||
|
||||
const [active, setActiveRaw] = createSignal("")
|
||||
|
||||
function setActive(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
setActiveRaw(url)
|
||||
}
|
||||
|
||||
function add(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
|
||||
const fallback = normalizeServerUrl(props.defaultUrl)
|
||||
if (fallback && url === fallback) {
|
||||
setActiveRaw(url)
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
if (!store.list.includes(url)) {
|
||||
setStore("list", store.list.length, url)
|
||||
}
|
||||
setActiveRaw(url)
|
||||
})
|
||||
}
|
||||
|
||||
function remove(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
|
||||
const list = store.list.filter((x) => x !== url)
|
||||
const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
|
||||
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setActiveRaw(next)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (active()) return
|
||||
const url = normalizeServerUrl(props.defaultUrl)
|
||||
if (!url) return
|
||||
setActiveRaw(url)
|
||||
})
|
||||
|
||||
const isReady = createMemo(() => ready() && !!active())
|
||||
|
||||
const [healthy, { refetch }] = createResource(
|
||||
() => active() || undefined,
|
||||
async (url) => {
|
||||
if (!url) return
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
fetch: platform.fetch,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return sdk.global
|
||||
.health()
|
||||
.then((x) => x.data?.healthy === true)
|
||||
.catch(() => false)
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!active()) return
|
||||
const interval = setInterval(() => refetch(), 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const origin = createMemo(() => projectsKey(active()))
|
||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||
const isLocal = createMemo(() => origin() === "local")
|
||||
|
||||
return {
|
||||
ready: isReady,
|
||||
healthy,
|
||||
isLocal,
|
||||
get url() {
|
||||
return active()
|
||||
},
|
||||
get name() {
|
||||
return serverDisplayName(active())
|
||||
},
|
||||
get list() {
|
||||
return store.list
|
||||
},
|
||||
setActive,
|
||||
add,
|
||||
remove,
|
||||
projects: {
|
||||
list: projectsList,
|
||||
open(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
if (current.find((x) => x.worktree === directory)) return
|
||||
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
|
||||
},
|
||||
close(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
setStore(
|
||||
"projects",
|
||||
key,
|
||||
current.filter((x) => x.worktree !== directory),
|
||||
)
|
||||
},
|
||||
expand(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
const index = current.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", key, index, "expanded", true)
|
||||
},
|
||||
collapse(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
const index = current.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", key, index, "expanded", false)
|
||||
},
|
||||
move(directory: string, toIndex: number) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
const fromIndex = current.findIndex((x) => x.worktree === directory)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return
|
||||
const result = [...current]
|
||||
const [item] = result.splice(fromIndex, 1)
|
||||
result.splice(toIndex, 0, item)
|
||||
setStore("projects", key, result)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
@@ -56,14 +59,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, message)
|
||||
}
|
||||
draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
draft.part[input.messageID] = input.parts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}),
|
||||
)
|
||||
},
|
||||
async sync(sessionID: string, _isRetry = false) {
|
||||
const [session, messages, todo, diff] = await Promise.all([
|
||||
retry(() => sdk.client.session.get({ sessionID })),
|
||||
retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
|
||||
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
|
||||
retry(() => sdk.client.session.todo({ sessionID })),
|
||||
retry(() => sdk.client.session.diff({ sessionID })),
|
||||
])
|
||||
@@ -88,6 +94,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
reconcile(
|
||||
(messages.data ?? [])
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
@@ -95,11 +102,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
)
|
||||
|
||||
for (const message of messages.data ?? []) {
|
||||
if (!message?.info?.id) continue
|
||||
setStore(
|
||||
"part",
|
||||
message.info.id,
|
||||
reconcile(
|
||||
message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
message.parts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
@@ -112,6 +123,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
setStore("limit", (x) => x + count)
|
||||
await sdk.client.session.list().then((x) => {
|
||||
const sessions = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.slice(0, store.limit)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams } from "@solidjs/router"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
@@ -27,11 +27,9 @@ export default function Layout(props: ParentProps) {
|
||||
}) => sdk.client.permission.respond(input)
|
||||
|
||||
return (
|
||||
<PermissionProvider permissions={sync.data.permission} onRespond={respond}>
|
||||
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)
|
||||
})}
|
||||
</SyncProvider>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
||||
@@ -181,6 +182,25 @@ interface ErrorPageProps {
|
||||
|
||||
export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
version: undefined as string | undefined,
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
const result = await platform.checkUpdate()
|
||||
setStore("checking", false)
|
||||
if (result.updateAvailable && result.version) setStore("version", result.version)
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
if (!platform.update || !platform.restart) return
|
||||
await platform.update()
|
||||
await platform.restart()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
|
||||
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||
@@ -198,9 +218,25 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
label="Error Details"
|
||||
hideLabel
|
||||
/>
|
||||
<Button size="large" onClick={platform.restart}>
|
||||
Restart
|
||||
</Button>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button size="large" onClick={platform.restart}>
|
||||
Restart
|
||||
</Button>
|
||||
<Show when={platform.checkUpdate}>
|
||||
<Show
|
||||
when={store.version}
|
||||
fallback={
|
||||
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
|
||||
{store.checking ? "Checking..." : "Check for updates"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button size="large" onClick={installUpdate}>
|
||||
Update to {store.version}
|
||||
</Button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Please report this error to the OpenCode team
|
||||
|
||||
@@ -10,6 +10,8 @@ import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useServer } from "@/context/server"
|
||||
|
||||
export default function Home() {
|
||||
const sync = useGlobalSync()
|
||||
@@ -17,6 +19,7 @@ export default function Home() {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
|
||||
function openProject(directory: string) {
|
||||
@@ -35,7 +38,7 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog) {
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: "Open project",
|
||||
multiple: true,
|
||||
@@ -52,6 +55,22 @@ export default function Home() {
|
||||
return (
|
||||
<div class="mx-auto mt-55">
|
||||
<Logo class="w-xl opacity-12" />
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-2 rounded-full": true,
|
||||
"bg-icon-success-base": server.healthy() === true,
|
||||
"bg-icon-critical-base": server.healthy() === false,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
{server.name}
|
||||
</Button>
|
||||
<Switch>
|
||||
<Match when={sync.data.project.length > 0}>
|
||||
<div class="mt-20 w-full flex flex-col gap-4">
|
||||
|
||||
@@ -22,10 +22,11 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -44,31 +45,26 @@ import { useProviders } from "@/hooks/use-providers"
|
||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { Header } from "@/components/header"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { useServer } from "@/context/server"
|
||||
|
||||
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),
|
||||
@@ -87,7 +83,9 @@ export default function Layout(props: ParentProps) {
|
||||
const globalSync = useGlobalSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const server = useServer()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const navigate = useNavigate()
|
||||
const providers = useProviders()
|
||||
const dialog = useDialog()
|
||||
@@ -128,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",
|
||||
@@ -153,31 +155,48 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pollUpdate()
|
||||
const interval = setInterval(pollUpdate, 10 * 60 * 1000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const seenSessions = new Set<string>()
|
||||
const toastBySession = new Map<string, number>()
|
||||
const alertedAtBySession = new Map<string, number>()
|
||||
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 permission = e.details.properties
|
||||
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
||||
const currentSession = params.id
|
||||
const perm = e.details.properties
|
||||
if (permission.autoResponds(perm)) return
|
||||
|
||||
const sessionKey = `${directory}:${perm.sessionID}`
|
||||
const [store] = globalSync.child(directory)
|
||||
const session = store.session.find((s) => s.id === permission.sessionID)
|
||||
const session = store.session.find((s) => s.id === perm.sessionID)
|
||||
|
||||
const sessionTitle = session?.title ?? "New session"
|
||||
const projectName = getFilename(directory)
|
||||
const description = `${sessionTitle} in ${projectName} needs permission`
|
||||
const href = `/${base64Encode(directory)}/session/${permission.sessionID}`
|
||||
const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
|
||||
|
||||
const now = Date.now()
|
||||
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
|
||||
if (now - lastAlerted < permissionAlertCooldownMs) return
|
||||
alertedAtBySession.set(sessionKey, now)
|
||||
|
||||
void platform.notify("Permission required", description, href)
|
||||
|
||||
if (directory === currentDir && permission.sessionID === currentSession) return
|
||||
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
||||
const currentSession = params.id
|
||||
if (directory === currentDir && perm.sessionID === currentSession) return
|
||||
if (directory === currentDir && session?.parentID === currentSession) return
|
||||
|
||||
const sessionKey = `${directory}:${permission.sessionID}`
|
||||
if (seenSessions.has(sessionKey)) return
|
||||
seenSessions.add(sessionKey)
|
||||
const existingToastId = toastBySession.get(sessionKey)
|
||||
if (existingToastId !== undefined) {
|
||||
toaster.dismiss(existingToastId)
|
||||
}
|
||||
|
||||
const toastId = showToast({
|
||||
persistent: true,
|
||||
@@ -210,7 +229,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (toastId !== undefined) {
|
||||
toaster.dismiss(toastId)
|
||||
toastBySession.delete(sessionKey)
|
||||
seenSessions.delete(sessionKey)
|
||||
alertedAtBySession.delete(sessionKey)
|
||||
}
|
||||
const [store] = globalSync.child(currentDir)
|
||||
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
||||
@@ -220,7 +239,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (childToastId !== undefined) {
|
||||
toaster.dismiss(childToastId)
|
||||
toastBySession.delete(childKey)
|
||||
seenSessions.delete(childKey)
|
||||
alertedAtBySession.delete(childKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -352,6 +371,12 @@ export default function Layout(props: ParentProps) {
|
||||
category: "Provider",
|
||||
onSelect: () => connectProvider(),
|
||||
},
|
||||
{
|
||||
id: "server.switch",
|
||||
title: "Switch server",
|
||||
category: "Server",
|
||||
onSelect: () => openServer(),
|
||||
},
|
||||
{
|
||||
id: "session.previous",
|
||||
title: "Previous session",
|
||||
@@ -427,17 +452,21 @@ export default function Layout(props: ParentProps) {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
|
||||
function openServer() {
|
||||
dialog.show(() => <DialogSelectServer />)
|
||||
}
|
||||
|
||||
function navigateToProject(directory: string | undefined) {
|
||||
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) {
|
||||
@@ -465,7 +494,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog) {
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: "Open project",
|
||||
multiple: true,
|
||||
@@ -695,17 +724,13 @@ export default function Layout(props: ParentProps) {
|
||||
</A>
|
||||
</Tooltip>
|
||||
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
||||
<Tooltip
|
||||
<TooltipKeybind
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Archive session</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
|
||||
</div>
|
||||
}
|
||||
title="Archive session"
|
||||
keybind={command.keybind("session.archive")}
|
||||
>
|
||||
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -773,17 +798,9 @@ export default function Layout(props: ParentProps) {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>New session</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
|
||||
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Button>
|
||||
<Collapsible.Content>
|
||||
@@ -861,15 +878,16 @@ export default function Layout(props: ParentProps) {
|
||||
<>
|
||||
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
||||
<Show when={!sidebarProps.mobile}>
|
||||
<Tooltip
|
||||
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region>
|
||||
<Mark class="shrink-0" />
|
||||
</A>
|
||||
</Show>
|
||||
<Show when={!sidebarProps.mobile}>
|
||||
<TooltipKeybind
|
||||
class="shrink-0"
|
||||
placement="right"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Toggle sidebar</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
|
||||
</div>
|
||||
}
|
||||
title="Toggle sidebar"
|
||||
keybind={command.keybind("sidebar.toggle")}
|
||||
inactive={expanded()}
|
||||
>
|
||||
<Button
|
||||
@@ -901,7 +919,7 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
</Show>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
@@ -1004,22 +1022,24 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative flex-1 min-h-0 flex flex-col">
|
||||
<Header
|
||||
navigateToProject={navigateToProject}
|
||||
navigateToSession={navigateToSession}
|
||||
onMobileMenuToggle={mobileSidebar.toggle}
|
||||
/>
|
||||
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<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"
|
||||
@@ -1031,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,7 +1,8 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, Show } from "solid-js"
|
||||
import { createEffect, Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
@@ -68,6 +68,12 @@ export function ReloadSection() {
|
||||
reloadTrigger: "",
|
||||
})
|
||||
|
||||
const processingFee = createMemo(() => {
|
||||
const reloadAmount = billingInfo()?.reloadAmount
|
||||
if (!reloadAmount) return "0.00"
|
||||
return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
|
||||
setStore("show", false)
|
||||
@@ -104,8 +110,8 @@ export function ReloadSection() {
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee)
|
||||
when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
|
||||
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
|
||||
processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
|
||||
</p>
|
||||
</Show>
|
||||
<button data-color="primary" type="button" onClick={() => show()}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -17,5 +17,6 @@
|
||||
"scripts": {
|
||||
"dev": "email preview emails/templates"
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-resource",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "catalog:"
|
||||
},
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
"predev": "bun ./scripts/predev.ts",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,19 +57,71 @@ const platform: Platform = {
|
||||
},
|
||||
|
||||
openLink(url: string) {
|
||||
shellOpen(url)
|
||||
void shellOpen(url).catch(() => undefined)
|
||||
},
|
||||
|
||||
storage: (name = "default.dat") => {
|
||||
const api: AsyncStorage = {
|
||||
type StoreLike = {
|
||||
get(key: string): Promise<string | null | undefined>
|
||||
set(key: string, value: string): Promise<unknown>
|
||||
delete(key: string): Promise<unknown>
|
||||
clear(): Promise<unknown>
|
||||
keys(): Promise<string[]>
|
||||
length(): Promise<number>
|
||||
}
|
||||
|
||||
const memory = () => {
|
||||
const data = new Map<string, string>()
|
||||
const store: StoreLike = {
|
||||
get: async (key) => data.get(key),
|
||||
set: async (key, value) => {
|
||||
data.set(key, value)
|
||||
},
|
||||
delete: async (key) => {
|
||||
data.delete(key)
|
||||
},
|
||||
clear: async () => {
|
||||
data.clear()
|
||||
},
|
||||
keys: async () => Array.from(data.keys()),
|
||||
length: async () => data.size,
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
const api: AsyncStorage & { _store: Promise<StoreLike> | null; _getStore: () => Promise<StoreLike> } = {
|
||||
_store: null,
|
||||
_getStore: async () => api._store || (api._store = Store.load(name)),
|
||||
getItem: async (key: string) => (await (await api._getStore()).get(key)) ?? null,
|
||||
setItem: async (key: string, value: string) => await (await api._getStore()).set(key, value),
|
||||
removeItem: async (key: string) => await (await api._getStore()).delete(key),
|
||||
clear: async () => await (await api._getStore()).clear(),
|
||||
key: async (index: number) => (await (await api._getStore()).keys())[index],
|
||||
getLength: async () => (await api._getStore()).length(),
|
||||
_getStore: async () => {
|
||||
if (api._store) return api._store
|
||||
api._store = Store.load(name).catch(() => memory())
|
||||
return api._store
|
||||
},
|
||||
getItem: async (key: string) => {
|
||||
const store = await api._getStore()
|
||||
const value = await store.get(key).catch(() => null)
|
||||
if (value === undefined) return null
|
||||
return value
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
const store = await api._getStore()
|
||||
await store.set(key, value).catch(() => undefined)
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
const store = await api._getStore()
|
||||
await store.delete(key).catch(() => undefined)
|
||||
},
|
||||
clear: async () => {
|
||||
const store = await api._getStore()
|
||||
await store.clear().catch(() => undefined)
|
||||
},
|
||||
key: async (index: number) => {
|
||||
const store = await api._getStore()
|
||||
return (await store.keys().catch(() => []))[index]
|
||||
},
|
||||
getLength: async () => {
|
||||
const store = await api._getStore()
|
||||
return await store.length().catch(() => 0)
|
||||
},
|
||||
get length() {
|
||||
return api.getLength()
|
||||
},
|
||||
@@ -79,20 +131,25 @@ const platform: Platform = {
|
||||
|
||||
checkUpdate: async () => {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
update = await check()
|
||||
if (!update) return { updateAvailable: false }
|
||||
await update.download()
|
||||
return { updateAvailable: true, version: update.version }
|
||||
const next = await check().catch(() => null)
|
||||
if (!next) return { updateAvailable: false }
|
||||
const ok = await next
|
||||
.download()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!ok) return { updateAvailable: false }
|
||||
update = next
|
||||
return { updateAvailable: true, version: next.version }
|
||||
},
|
||||
|
||||
update: async () => {
|
||||
if (!UPDATER_ENABLED || !update) return
|
||||
if (ostype() === "windows") await invoke("kill_sidecar")
|
||||
await update.install()
|
||||
if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
|
||||
await update.install().catch(() => undefined)
|
||||
},
|
||||
|
||||
restart: async () => {
|
||||
await invoke("kill_sidecar")
|
||||
await invoke("kill_sidecar").catch(() => undefined)
|
||||
await relaunch()
|
||||
},
|
||||
|
||||
@@ -107,7 +164,10 @@ const platform: Platform = {
|
||||
|
||||
await Promise.resolve()
|
||||
.then(() => {
|
||||
const notification = new Notification(title, { body: description ?? "" })
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
const win = getCurrentWindow()
|
||||
void win.show().catch(() => undefined)
|
||||
@@ -138,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,8 +1,9 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileRoutes } from "@solidjs/start/router"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { Suspense } from "solid-js"
|
||||
import "./app.css"
|
||||
import { Favicon } from "@opencode-ai/ui/favicon"
|
||||
@@ -12,11 +13,13 @@ export default function App() {
|
||||
<Router
|
||||
root={(props) => (
|
||||
<MetaProvider>
|
||||
<MarkedProvider>
|
||||
<Favicon />
|
||||
<Font />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MarkedProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<Favicon />
|
||||
<Font />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</MetaProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,7 @@ const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m)
|
||||
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
|
||||
import("@opencode-ai/ui/pierre/worker").then((m) => ({
|
||||
default: (props: { children: any }) => (
|
||||
<WorkerPoolProvider pool={m.workerPool}>{props.children}</WorkerPoolProvider>
|
||||
<WorkerPoolProvider pools={m.getWorkerPools()}>{props.children}</WorkerPoolProvider>
|
||||
),
|
||||
})),
|
||||
)
|
||||
@@ -162,11 +162,20 @@ export default function () {
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(e) => {
|
||||
fallback={(error) => {
|
||||
if (SessionDataMissingError.isInstance(error)) {
|
||||
return <NotFound />
|
||||
}
|
||||
console.error(error)
|
||||
const details = error instanceof Error ? (error.stack ?? error.message) : String(error)
|
||||
return (
|
||||
<Show when={e.message === "SessionDataMissingError"}>
|
||||
<NotFound />
|
||||
</Show>
|
||||
<div class="min-h-screen w-full bg-background-base text-text-base flex flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<p class="text-16-medium">Unable to render this share.</p>
|
||||
<p class="text-14-regular text-text-weaker">Check the console for more details.</p>
|
||||
<pre class="text-12-mono text-left whitespace-pre-wrap break-words w-full max-w-200 bg-background-stronger rounded-md p-4">
|
||||
{details}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
>
|
||||
@@ -319,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"
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.0.210"
|
||||
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.210/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.210/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.210/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.210/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.210/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,9 +1,10 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.210",
|
||||
"version": "1.0.224",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -66,6 +67,7 @@
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.19",
|
||||
"@ai-sdk/togetherai": "1.0.30",
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.42",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -79,8 +81,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.66",
|
||||
"@opentui/solid": "0.1.66",
|
||||
"@opentui/core": "0.1.67",
|
||||
"@opentui/solid": "0.1.67",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -88,7 +90,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",
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
@@ -349,6 +349,12 @@ export const AuthLoginCommand = cmd({
|
||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
}
|
||||
|
||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||
prompts.log.info(
|
||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||
)
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
|
||||
28
packages/opencode/src/cli/cmd/debug/agent.ts
Normal file
28
packages/opencode/src/cli/cmd/debug/agent.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { EOL } from "os"
|
||||
import { basename } from "path"
|
||||
import { Agent } from "../../../agent/agent"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
command: "agent <name>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("name", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Agent name",
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const agentName = args.name as string
|
||||
const agent = await Agent.get(agentName)
|
||||
if (!agent) {
|
||||
process.stderr.write(
|
||||
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { RipgrepCommand } from "./ripgrep"
|
||||
import { ScrapCommand } from "./scrap"
|
||||
import { SkillCommand } from "./skill"
|
||||
import { SnapshotCommand } from "./snapshot"
|
||||
import { AgentCommand } from "./agent"
|
||||
|
||||
export const DebugCommand = cmd({
|
||||
command: "debug",
|
||||
@@ -20,6 +21,7 @@ export const DebugCommand = cmd({
|
||||
.command(ScrapCommand)
|
||||
.command(SkillCommand)
|
||||
.command(SnapshotCommand)
|
||||
.command(AgentCommand)
|
||||
.command(PathsCommand)
|
||||
.command({
|
||||
command: "wait",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,6 +20,17 @@ interface SessionStats {
|
||||
}
|
||||
}
|
||||
toolUsage: Record<string, number>
|
||||
modelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
}
|
||||
cost: number
|
||||
}
|
||||
>
|
||||
dateRange: {
|
||||
earliest: number
|
||||
latest: number
|
||||
@@ -43,6 +54,9 @@ export const StatsCommand = cmd({
|
||||
describe: "number of tools to show (default: all)",
|
||||
type: "number",
|
||||
})
|
||||
.option("models", {
|
||||
describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
|
||||
})
|
||||
.option("project", {
|
||||
describe: "filter by project (default: all projects, empty string: current project)",
|
||||
type: "string",
|
||||
@@ -51,7 +65,15 @@ export const StatsCommand = cmd({
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const stats = await aggregateSessionStats(args.days, args.project)
|
||||
displayStats(stats, args.tools)
|
||||
|
||||
let modelLimit: number | undefined
|
||||
if (args.models === true) {
|
||||
modelLimit = Infinity
|
||||
} else if (typeof args.models === "number") {
|
||||
modelLimit = args.models
|
||||
}
|
||||
|
||||
displayStats(stats, args.tools, modelLimit)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -96,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) {
|
||||
@@ -121,6 +149,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
},
|
||||
},
|
||||
toolUsage: {},
|
||||
modelUsage: {},
|
||||
dateRange: {
|
||||
earliest: Date.now(),
|
||||
latest: Date.now(),
|
||||
@@ -136,6 +165,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
}
|
||||
|
||||
if (filteredSessions.length === 0) {
|
||||
stats.days = windowDays ?? 0
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -154,17 +184,43 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
let sessionToolUsage: Record<string, number> = {}
|
||||
let sessionModelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
}
|
||||
cost: number
|
||||
}
|
||||
> = {}
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "assistant") {
|
||||
sessionCost += message.info.cost || 0
|
||||
|
||||
const modelKey = `${message.info.providerID}/${message.info.modelID}`
|
||||
if (!sessionModelUsage[modelKey]) {
|
||||
sessionModelUsage[modelKey] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0 },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
sessionModelUsage[modelKey].messages++
|
||||
sessionModelUsage[modelKey].cost += message.info.cost || 0
|
||||
|
||||
if (message.info.tokens) {
|
||||
sessionTokens.input += message.info.tokens.input || 0
|
||||
sessionTokens.output += message.info.tokens.output || 0
|
||||
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
||||
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
|
||||
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
|
||||
sessionModelUsage[modelKey].tokens.output +=
|
||||
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +237,8 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
sessionTokens,
|
||||
sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
|
||||
sessionToolUsage,
|
||||
earliestTime: session.time.created,
|
||||
sessionModelUsage,
|
||||
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
|
||||
latestTime: session.time.updated,
|
||||
}
|
||||
})
|
||||
@@ -204,16 +261,31 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
||||
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
||||
}
|
||||
|
||||
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
|
||||
if (!stats.modelUsage[model]) {
|
||||
stats.modelUsage[model] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0 },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
stats.modelUsage[model].messages += usage.messages
|
||||
stats.modelUsage[model].tokens.input += usage.tokens.input
|
||||
stats.modelUsage[model].tokens.output += usage.tokens.output
|
||||
stats.modelUsage[model].cost += usage.cost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -228,7 +300,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
return stats
|
||||
}
|
||||
|
||||
export function displayStats(stats: SessionStats, toolLimit?: number) {
|
||||
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
|
||||
const width = 56
|
||||
|
||||
function renderRow(label: string, value: string): string {
|
||||
@@ -267,6 +339,29 @@ export function displayStats(stats: SessionStats, toolLimit?: number) {
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
console.log()
|
||||
|
||||
// Model Usage section
|
||||
if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
|
||||
const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
|
||||
const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
|
||||
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ MODEL USAGE │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
|
||||
for (const [model, usage] of modelsToDisplay) {
|
||||
console.log(`│ ${model.padEnd(54)} │`)
|
||||
console.log(renderRow(" Messages", usage.messages.toLocaleString()))
|
||||
console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
|
||||
console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
|
||||
console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
}
|
||||
// Remove last separator and add bottom border
|
||||
process.stdout.write("\x1B[1A") // Move up one line
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
}
|
||||
console.log()
|
||||
|
||||
// Tool Usage section
|
||||
if (Object.keys(stats.toolUsage).length > 0) {
|
||||
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
|
||||
|
||||
@@ -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"> {
|
||||
@@ -372,6 +372,15 @@ function App() {
|
||||
local.agent.move(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Variant cycle",
|
||||
value: "variant.cycle",
|
||||
keybind: "variant_cycle",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Agent cycle reverse",
|
||||
value: "agent.cycle.reverse",
|
||||
@@ -467,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",
|
||||
@@ -526,6 +549,13 @@ function App() {
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: evt.properties.sessionID,
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
@@ -625,7 +655,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"
|
||||
|
||||
@@ -259,7 +259,7 @@ export function Autocomplete(props: {
|
||||
const s = session()
|
||||
for (const command of sync.data.command) {
|
||||
results.push({
|
||||
display: "/" + command.name,
|
||||
display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
|
||||
description: command.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + command.name + " "
|
||||
|
||||
@@ -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()}
|
||||
@@ -877,12 +883,6 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (keybind.match("variant_cycle", e)) {
|
||||
e.preventDefault()
|
||||
if (local.model.variant.list().length === 0) return
|
||||
local.model.variant.cycle()
|
||||
return
|
||||
}
|
||||
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
||||
if (!autocomplete.visible) {
|
||||
if (
|
||||
|
||||
@@ -28,7 +28,7 @@ export const TIPS = [
|
||||
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
|
||||
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
|
||||
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
|
||||
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
|
||||
"Use {highlight}@<agent-name>{/highlight} in prompts to invoke specialized subagents.",
|
||||
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
|
||||
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
|
||||
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
|
||||
@@ -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(() => [
|
||||
@@ -253,7 +253,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const next = favorites[index]
|
||||
if (!next) return
|
||||
setModelStore("model", agent.current().name, { ...next })
|
||||
const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID)
|
||||
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
||||
if (uniq.length > 10) uniq.pop()
|
||||
setModelStore(
|
||||
"recent",
|
||||
@@ -273,7 +273,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
setModelStore("model", agent.current().name, model)
|
||||
if (options?.recent) {
|
||||
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
|
||||
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
||||
if (uniq.length > 10) uniq.pop()
|
||||
setModelStore(
|
||||
"recent",
|
||||
@@ -319,9 +319,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const provider = sync.data.provider.find((x) => x.id === m.providerID)
|
||||
const info = provider?.models[m.modelID]
|
||||
if (!info?.variants) return []
|
||||
return Object.entries(info.variants)
|
||||
.filter(([_, v]) => !v.disabled)
|
||||
.map(([name]) => name)
|
||||
return Object.keys(info.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = currentModel()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user