Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f54d5377a4 | ||
|
|
a576fdb5e4 | ||
|
|
ae53f876f1 | ||
|
|
a7beba5aa9 | ||
|
|
e9ef72c20f | ||
|
|
fa1ac7bc95 | ||
|
|
c82ab649e2 | ||
|
|
abc7eed92b | ||
|
|
1670d220da | ||
|
|
ddc4e34731 | ||
|
|
af99d83709 | ||
|
|
ed0c0d90be | ||
|
|
e1dd9c4ccb | ||
|
|
4657fa823f | ||
|
|
1d589c7ac7 | ||
|
|
6b5a0fb261 | ||
|
|
6d93a7bf55 | ||
|
|
4ca7ab6be8 | ||
|
|
713d996b9f | ||
|
|
aa16610021 | ||
|
|
d98568fe7e | ||
|
|
1da3550c4d | ||
|
|
0c48e6a116 | ||
|
|
ef266b2c74 | ||
|
|
0a1cdc7a58 | ||
|
|
2dec956a17 | ||
|
|
ef8388f0ee | ||
|
|
e5c5b5e872 | ||
|
|
a1c9a1b8c5 | ||
|
|
76b012139a | ||
|
|
02e5a19242 | ||
|
|
af967648cb | ||
|
|
504a668a26 | ||
|
|
5efb1c7b2d | ||
|
|
fd973d242e | ||
|
|
c3d8672753 | ||
|
|
fe8ef041f6 | ||
|
|
c841de947e | ||
|
|
825dfd48b1 | ||
|
|
923d114ffa | ||
|
|
b157fd10a7 | ||
|
|
67ebe68160 | ||
|
|
7b63c14154 | ||
|
|
cdc11cde2e | ||
|
|
9721223b7e | ||
|
|
35a626e711 | ||
|
|
bb7b0ff221 | ||
|
|
68b4038196 | ||
|
|
3109214900 | ||
|
|
86ccc3409b | ||
|
|
a89089c88f | ||
|
|
e617c5d689 | ||
|
|
31983ca5ff | ||
|
|
59e3b7409f | ||
|
|
b7ce46f7a1 | ||
|
|
82b8d8fa5d | ||
|
|
77c837eb1a | ||
|
|
db77cc9845 | ||
|
|
68043edae6 | ||
|
|
337681dbbf | ||
|
|
66afc034d1 | ||
|
|
11ab8de59f | ||
|
|
5f074edc3a | ||
|
|
56b5cdf883 | ||
|
|
fb0e1e4d8d | ||
|
|
b745b1593f | ||
|
|
7376c3f8e7 | ||
|
|
831e9bce51 | ||
|
|
5de73abd82 | ||
|
|
3adbbc1b23 | ||
|
|
c6c29b3dcf | ||
|
|
a687d7c15f | ||
|
|
0c6da69f39 | ||
|
|
c4930eb6b2 | ||
|
|
a24549fce7 | ||
|
|
c0f9b13630 | ||
|
|
98fd53fd5f | ||
|
|
5b02a3029e | ||
|
|
94e851c2a2 | ||
|
|
1658a3ff59 | ||
|
|
9c8bc64138 | ||
|
|
80f704ebbf | ||
|
|
4dae6d1fcf | ||
|
|
5d2cab39da | ||
|
|
6963f96d4b | ||
|
|
05a9e7ce7a | ||
|
|
896d18ab3f | ||
|
|
893888536a | ||
|
|
c6221fc8b3 | ||
|
|
ae67f43ff0 | ||
|
|
76880dce0d | ||
|
|
aafffb5b4b | ||
|
|
a71c9e3f2e | ||
|
|
0156f03e0e | ||
|
|
e0bb96a9f9 | ||
|
|
82e5d6d458 | ||
|
|
a4411c21b6 | ||
|
|
9d61370ac4 | ||
|
|
f3febd6e39 | ||
|
|
f12d55bf1e | ||
|
|
0c19b71f42 | ||
|
|
70fa66397e | ||
|
|
6e8cd3174c | ||
|
|
5bfffbe083 | ||
|
|
29d8557d41 | ||
|
|
ffd20b4477 | ||
|
|
2abaa46e23 | ||
|
|
0cbbb20d22 | ||
|
|
81c5e7b9ed | ||
|
|
ddf4897eaa | ||
|
|
040939fb72 | ||
|
|
f89b83a6d7 | ||
|
|
82a876da4d | ||
|
|
69a15ae9c1 | ||
|
|
18c8e5f451 | ||
|
|
ba3a1cfa0b | ||
|
|
d8563160f7 | ||
|
|
4a9ff9412e | ||
|
|
d6db6ff198 | ||
|
|
79c263494f | ||
|
|
1b5bf32ce5 | ||
|
|
2e972b3fdc | ||
|
|
d70e9fb01e | ||
|
|
fc082a0f14 | ||
|
|
953e4e9446 | ||
|
|
7ea0d37ee3 | ||
|
|
e35d97f9d7 | ||
|
|
2c0d9a46cb | ||
|
|
2fe7a7f2d3 | ||
|
|
8a2f4ddf70 | ||
|
|
7a94d7a2c5 | ||
|
|
de28fafb47 | ||
|
|
9d485dd307 | ||
|
|
613813ac12 | ||
|
|
7617f59441 | ||
|
|
7aecb43e84 | ||
|
|
21eba5f987 | ||
|
|
c523ca4127 | ||
|
|
685f3ea324 | ||
|
|
4667d57e3c | ||
|
|
e6b9988fa4 | ||
|
|
3c02d5d338 | ||
|
|
bfb9787361 | ||
|
|
1bcc72c477 | ||
|
|
4385fa4dd7 | ||
|
|
2b054bec95 | ||
|
|
2cdc88d295 | ||
|
|
f8fb08b3b4 | ||
|
|
ed06de5e30 | ||
|
|
52b99622ad | ||
|
|
a15397cd89 | ||
|
|
da394439a1 | ||
|
|
390b0a79b3 | ||
|
|
b2f45d574f | ||
|
|
1e2ef07c97 | ||
|
|
664e6bf2d0 | ||
|
|
160c8ab7cc | ||
|
|
1626341a4a | ||
|
|
61ddd1716d | ||
|
|
053a10e515 | ||
|
|
e1c1b1340b | ||
|
|
7a5fbdf67c | ||
|
|
9afc451020 | ||
|
|
f4fdf0eb03 | ||
|
|
505068d5a6 | ||
|
|
2e10ffac6b | ||
|
|
4abaa052db | ||
|
|
1bcf8d8806 | ||
|
|
25c68c8061 | ||
|
|
b0e4408ecf | ||
|
|
8416db03ef | ||
|
|
d5b47d9128 | ||
|
|
634559760a | ||
|
|
155ba794cf | ||
|
|
f1ab427f0e | ||
|
|
2333af6ed3 | ||
|
|
54588b4570 | ||
|
|
26e7043718 | ||
|
|
dd569c927a | ||
|
|
cf38884778 | ||
|
|
2946a6d9a7 | ||
|
|
3522c460e3 | ||
|
|
b6a264819e | ||
|
|
46c7a41d5f | ||
|
|
7cc4b24ac2 | ||
|
|
281ce4c0c3 | ||
|
|
f59d274d0f | ||
|
|
8886c78dce | ||
|
|
d9f0f58277 |
@@ -5,6 +5,9 @@ on:
|
||||
- cron: "0 */12 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
LOOKBACK_HOURS: 4
|
||||
|
||||
jobs:
|
||||
update-docs:
|
||||
if: github.repository == 'sst/opencode'
|
||||
@@ -25,9 +28,9 @@ jobs:
|
||||
- name: Get recent commits
|
||||
id: commits
|
||||
run: |
|
||||
COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
|
||||
COMMITS=$(git log --since="${{ env.LOOKBACK_HOURS }} hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No commits in the last 4 hours"
|
||||
echo "No commits in the last ${{ env.LOOKBACK_HOURS }} hours"
|
||||
echo "has_commits=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_commits=true" >> $GITHUB_OUTPUT
|
||||
@@ -47,7 +50,7 @@ jobs:
|
||||
model: opencode/gpt-5.2
|
||||
agent: docs
|
||||
prompt: |
|
||||
Review the following commits from the last 4 hours and identify any new features that may need documentation.
|
||||
Review the following commits from the last ${{ env.LOOKBACK_HOURS }} hours and identify any new features that may need documentation.
|
||||
|
||||
<recent_commits>
|
||||
${{ steps.commits.outputs.list }}
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
|
||||
|
||||
When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage.
|
||||
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
|
||||
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
|
||||
|
||||
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
||||
If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: "Auto-close stale issues"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *" # Daily at 1:30 AM
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DAYS_BEFORE_STALE: 90
|
||||
DAYS_BEFORE_CLOSE: 7
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
|
||||
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
|
||||
stale-issue-label: "stale"
|
||||
close-issue-message: |
|
||||
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
|
||||
|
||||
Feel free to reopen if you still need this!
|
||||
stale-issue-message: |
|
||||
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
|
||||
|
||||
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
|
||||
remove-stale-when-updated: true
|
||||
exempt-issue-labels: "pinned,security,feature-request,on-hold"
|
||||
start-date: "2025-12-27"
|
||||
@@ -2,8 +2,8 @@ name: "sync-zed-extension"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# release:
|
||||
# types: [published]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
zed:
|
||||
@@ -31,4 +31,5 @@ jobs:
|
||||
run: |
|
||||
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
|
||||
ZED_PR_PAT: ${{ secrets.ZED_PR_PAT }}
|
||||
|
||||
@@ -20,3 +20,7 @@ opencode.json
|
||||
a.out
|
||||
target
|
||||
.scripts
|
||||
|
||||
# Local dev files
|
||||
opencode-dev
|
||||
logs/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
description: ALWAYS use this when writing docs
|
||||
color: "#38A3EE"
|
||||
---
|
||||
|
||||
You are an expert technical documentation writer
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode/claude-haiku-4-5
|
||||
color: "#44BA81"
|
||||
tools:
|
||||
"*": false
|
||||
"github-triage": true
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||
|
||||
## SDK
|
||||
|
||||
To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
|
||||
|
||||
## Tool Calling
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
|
||||
@@ -34,6 +34,36 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Running against a different directory
|
||||
|
||||
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
|
||||
|
||||
```bash
|
||||
bun dev <directory>
|
||||
```
|
||||
|
||||
To run OpenCode in the root of the opencode repo itself:
|
||||
|
||||
```bash
|
||||
bun dev .
|
||||
```
|
||||
|
||||
### Building a "localcode"
|
||||
|
||||
To compile a standalone executable:
|
||||
|
||||
```bash
|
||||
./packages/opencode/script/build.ts --single
|
||||
```
|
||||
|
||||
Then run it with:
|
||||
|
||||
```bash
|
||||
./packages/opencode/dist/opencode-<platform>/bin/opencode
|
||||
```
|
||||
|
||||
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
|
||||
|
||||
- Core pieces:
|
||||
- `packages/opencode`: OpenCode core business logic & server.
|
||||
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
|
||||
|
||||
@@ -30,7 +30,7 @@ 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
|
||||
mise use -g opencode # Any OS
|
||||
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
|
||||
```
|
||||
|
||||
@@ -79,7 +79,7 @@ you can switch between these using the `Tab` key.
|
||||
- Asks permission before running bash commands
|
||||
- Ideal for exploring unfamiliar codebases or planning changes
|
||||
|
||||
Also, included is a **general** subagent for complex searches and multi-step tasks.
|
||||
Also, included is a **general** subagent for complex searches and multistep tasks.
|
||||
This is used internally and can be invoked using `@general` in messages.
|
||||
|
||||
Learn more about [agents](https://opencode.ai/docs/agents).
|
||||
@@ -98,7 +98,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
||||
|
||||
### FAQ
|
||||
|
||||
#### How is this different than Claude Code?
|
||||
#### How is this different from Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
|
||||
@@ -181,3 +181,9 @@
|
||||
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
|
||||
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
|
||||
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
|
||||
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
|
||||
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
|
||||
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
||||
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
|
||||
| 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) |
|
||||
|
||||
@@ -5,13 +5,6 @@
|
||||
"": {
|
||||
"name": "opencode",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cerebras": "1.0.33",
|
||||
"@ai-sdk/cohere": "2.0.21",
|
||||
"@ai-sdk/deepinfra": "1.0.30",
|
||||
"@ai-sdk/gateway": "2.0.23",
|
||||
"@ai-sdk/groq": "2.0.33",
|
||||
"@ai-sdk/perplexity": "2.0.22",
|
||||
"@ai-sdk/togetherai": "1.0.30",
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
@@ -29,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -77,7 +70,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -105,7 +98,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -132,7 +125,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -156,7 +149,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -180,13 +173,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "~2",
|
||||
"@tauri-apps/plugin-process": "~2",
|
||||
@@ -207,7 +201,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -236,7 +230,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -252,7 +246,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -261,16 +255,23 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.5.1",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.57",
|
||||
"@ai-sdk/anthropic": "2.0.50",
|
||||
"@ai-sdk/azure": "2.0.73",
|
||||
"@ai-sdk/google": "2.0.44",
|
||||
"@ai-sdk/anthropic": "2.0.56",
|
||||
"@ai-sdk/azure": "2.0.82",
|
||||
"@ai-sdk/cerebras": "1.0.33",
|
||||
"@ai-sdk/cohere": "2.0.21",
|
||||
"@ai-sdk/deepinfra": "1.0.30",
|
||||
"@ai-sdk/gateway": "2.0.23",
|
||||
"@ai-sdk/google": "2.0.49",
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@ai-sdk/mcp": "0.0.8",
|
||||
"@ai-sdk/groq": "2.0.33",
|
||||
"@ai-sdk/mistral": "2.0.26",
|
||||
"@ai-sdk/openai": "2.0.71",
|
||||
"@ai-sdk/openai-compatible": "1.0.27",
|
||||
"@ai-sdk/openai-compatible": "1.0.29",
|
||||
"@ai-sdk/perplexity": "2.0.22",
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.18",
|
||||
"@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,15 +285,16 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.63",
|
||||
"@opentui/solid": "0.1.63",
|
||||
"@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",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"bun-pty": "0.4.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.4",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
@@ -346,7 +348,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -366,7 +368,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -377,7 +379,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -390,7 +392,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -402,8 +404,10 @@
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
@@ -415,6 +419,7 @@
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/katex": "0.16.7",
|
||||
"@types/luxon": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -425,7 +430,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -436,7 +441,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -537,7 +542,7 @@
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
|
||||
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.73", "", { "dependencies": { "@ai-sdk/openai": "2.0.71", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LpAg3Ak/V3WOemBu35Qbx9jfQfApsHNXX9p3bXVsnRu3XXi1QQUt5gMOCIb4znPonz+XnHenIDZMBwdsb1TfRQ=="],
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.82", "", { "dependencies": { "@ai-sdk/openai": "2.0.80", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Bpab51ETBB4adZC1xGMYsryL/CB8j1sA+t5aDqhRv3t3WRLTxhaBDcFKtQTIuxiEQTFosz9Q2xQqdfBvQm5jHw=="],
|
||||
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.33", "", { "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-2gSSS/7kunIwMdC4td5oWsUAzoLw84ccGpz6wQbxVnrb1iWnrEnKa5tRBduaP6IXpzLWsu8wME3+dQhZy+gT7w=="],
|
||||
|
||||
@@ -547,14 +552,12 @@
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@2.0.44", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-c5dck36FjqiVoeeMJQLTEmUheoURcGTU/nBT6iJu8/nZiKFT/y8pD85KMDRB7RerRYaaQOtslR2d6/5PditiRw=="],
|
||||
"@ai-sdk/google": ["@ai-sdk/google@2.0.49", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-efwKk4mOV0SpumUaQskeYABk37FJPmEYwoDJQEjyLRmGSjtHRe9P5Cwof5ffLvaFav2IaJpBGEz98pyTs7oNWA=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.81", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/google": "2.0.44", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yrl5Ug0Mqwo9ya45oxczgy2RWgpEA/XQQCSFYP+3NZMQ4yA3Iim1vkOjVCsGaZZ8rjVk395abi1ZMZV0/6rqVA=="],
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FWGl7xNr88NBveao3y9EcVWYUt9ABPrwLFY7pIutSNgaTf32vgvyhREobaMrLU4Scr5G/2tlNqOPZ5wkYMaZig=="],
|
||||
|
||||
"@ai-sdk/mcp": ["@ai-sdk/mcp@0.0.8", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9y9GuGcZ9/+pMIHfpOCJgZVp+AZMv6TkjX2NVT17SQZvTF2N8LXuCXyoUPyi1PxIxzxl0n463LxxaB2O6olC+Q=="],
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.26", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jxDB++4WI1wEx5ONNBI+VbkmYJOYIuS8UQY13/83UGRaiW7oB/WHiH4ETe6KzbKpQPB3XruwTJQjUMsMfKyTXA=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="],
|
||||
@@ -565,10 +568,12 @@
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -1081,6 +1086,8 @@
|
||||
|
||||
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||
|
||||
"@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
|
||||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
@@ -1189,21 +1196,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.63", "", { "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.63", "@opentui/core-darwin-x64": "0.1.63", "@opentui/core-linux-arm64": "0.1.63", "@opentui/core-linux-x64": "0.1.63", "@opentui/core-win32-arm64": "0.1.63", "@opentui/core-win32-x64": "0.1.63", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-m4xZQTNCnHXWUWCnGvacJ3Gts1H2aMwP5V/puAG77SDb51jm4W/QOyqAAdgeSakkb9II+8FfUpApX7sfwRXPUg=="],
|
||||
"@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.63", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jKCThZGiiublKkP/hMtDtl1MLCw5NU0hMNJdEYvz1WLT9bzliWf6Kb7MIDAmk32XlbQW8/RHdp+hGyGDXK62OQ=="],
|
||||
"@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.63", "", { "os": "darwin", "cpu": "x64" }, "sha512-rfNxynHzJpxN9i+SAMnn1NToEc8rYj64BsOxY78JNsm4Gg1Js1uyMaawwh2WbdGknFy4cDXS9QwkUMdMcfnjiw=="],
|
||||
"@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.63", "", { "os": "linux", "cpu": "arm64" }, "sha512-wG9d6mHWWKZGrzxYS4c+BrcEGXBv/MYBUPSyjP/lD0CxT+X3h6CYhI317JkRyMNfh3vI9CpAKGFTOFvrTTHimQ=="],
|
||||
"@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.63", "", { "os": "linux", "cpu": "x64" }, "sha512-TKSzFv4BgWW3RB/iZmq5qxTR4/tRaXo8IZNnVR+LFzShbPOqhUi466AByy9SUmCxD8uYjmMDFYfKtkCy0AnAwA=="],
|
||||
"@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.63", "", { "os": "win32", "cpu": "arm64" }, "sha512-CBWPyPognERP0Mq4eC1q01Ado2C2WU+BLTgMdhyt+E2P4w8rPhJ2kCt2MNxO66vQUiynspmZkgjQr0II/VjxWA=="],
|
||||
"@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.63", "", { "os": "win32", "cpu": "x64" }, "sha512-qEp6h//FrT+TQiiHm87wZWUwqTPTqIy1ZD+8R+VCUK+usoQiOAD2SqrYnM7W8JkCMGn5/TKm/GaKLyx/qlK4VA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.67", "", { "os": "win32", "cpu": "x64" }, "sha512-740lkOw42zLNh9YfahXjCwV2DS/amH2uMDh3tCADDCLckrMhemIhqArXDiMlalDxDqYspoaZCpBsFVsG9dMS6A=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.63", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.63", "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-Gccln4qRucAoaoQEZ4NPAHvGmVYzU/8aKCLG8EPgwCKTcpUzlqYt4357cDHq4cnCNOcXOC06hTz/0pK9r0dqXA=="],
|
||||
"@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=="],
|
||||
|
||||
@@ -1703,6 +1710,8 @@
|
||||
|
||||
"@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg=="],
|
||||
|
||||
"@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="],
|
||||
|
||||
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
|
||||
@@ -1771,6 +1780,8 @@
|
||||
|
||||
"@types/jsonwebtoken": ["@types/jsonwebtoken@8.5.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg=="],
|
||||
|
||||
"@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="],
|
||||
|
||||
"@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
@@ -2003,6 +2014,8 @@
|
||||
|
||||
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
|
||||
|
||||
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
|
||||
@@ -2031,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=="],
|
||||
|
||||
@@ -2247,6 +2260,8 @@
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
@@ -2779,6 +2794,8 @@
|
||||
|
||||
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
|
||||
|
||||
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
@@ -2869,6 +2886,8 @@
|
||||
|
||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
||||
|
||||
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -3023,6 +3042,8 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
|
||||
|
||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||
|
||||
"mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="],
|
||||
@@ -3595,6 +3616,8 @@
|
||||
|
||||
"three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="],
|
||||
|
||||
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
@@ -3881,44 +3904,30 @@
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.80", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tNHuraF11db+8xJEDBoU9E3vMcpnHFKRhnLQ3DQX2LnEzfPB9DksZ8rE+yVuDN1WRW9cm2OWAhgHFgVKs7ICuw=="],
|
||||
|
||||
"@ai-sdk/cerebras/@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/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@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/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@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/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
"@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=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
@@ -4273,6 +4282,8 @@
|
||||
|
||||
"jsonwebtoken/jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="],
|
||||
|
||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -4297,11 +4308,11 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bpYruxVLhrTbVH6CCq48zMJNeHu6FmHtEedl9FXckEgcIEAi036idFhJlcRwC1jNCwlacbzb8dPD7OAH1EKJaQ=="],
|
||||
"opencode/@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=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||
|
||||
@@ -4905,8 +4916,6 @@
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766532406,
|
||||
"narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=",
|
||||
"lastModified": 1767026758,
|
||||
"narHash": "sha256-7fsac/f7nh/VaKJ/qm3I338+wAJa/3J57cOGpXi0Sbg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8142186f001295e5a3239f485c8a49bf2de2695a",
|
||||
"rev": "346dd96ad74dc4457a9db9de4f4f57dab2e5731d",
|
||||
"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;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -155,8 +155,18 @@ if [ -z "$requested_version" ]; then
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Strip leading 'v' if present
|
||||
requested_version="${requested_version#v}"
|
||||
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
|
||||
specific_version=$requested_version
|
||||
|
||||
# Verify the release exists before downloading
|
||||
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/sst/opencode/releases/tag/v${requested_version}")
|
||||
if [ "$http_status" = "404" ]; then
|
||||
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
|
||||
echo -e "${MUTED}Available releases: https://github.com/sst/opencode/releases${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
print_message() {
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-hotsyeWJA6/dP6DvZTN1Ak2RSKcsyvXlXPI/jexBHME="
|
||||
"nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
|
||||
}
|
||||
|
||||
+18
-10
@@ -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;
|
||||
|
||||
|
||||
+10
-7
@@ -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 = [
|
||||
|
||||
@@ -31,9 +31,13 @@ for (const [name, wasmPath] of byName) {
|
||||
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
|
||||
|
||||
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
|
||||
const nixStorePrefix = process.env.NIX_STORE || "/nix/store"
|
||||
next = next.replace(/(\.\/)+/g, "./")
|
||||
next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2")
|
||||
next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3")
|
||||
next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3")
|
||||
next = next.replace(
|
||||
new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"),
|
||||
"/$2",
|
||||
)
|
||||
next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
|
||||
next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
|
||||
|
||||
if (next !== content) fs.writeFileSync(file, next)
|
||||
|
||||
@@ -67,13 +67,6 @@
|
||||
"turbo": "2.5.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/cerebras": "1.0.33",
|
||||
"@ai-sdk/cohere": "2.0.21",
|
||||
"@ai-sdk/deepinfra": "1.0.30",
|
||||
"@ai-sdk/gateway": "2.0.23",
|
||||
"@ai-sdk/groq": "2.0.33",
|
||||
"@ai-sdk/perplexity": "2.0.22",
|
||||
"@ai-sdk/togetherai": "1.0.30",
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
||||
+31
-6
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" style="background-color: var(--background-base)">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -13,14 +13,39 @@
|
||||
<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" />
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<script>
|
||||
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
||||
<script id="oc-theme-preload-script">
|
||||
;(function () {
|
||||
const savedTheme = localStorage.getItem("theme") || "oc-1"
|
||||
document.documentElement.setAttribute("data-theme", savedTheme)
|
||||
var themeId = localStorage.getItem("opencode-theme-id")
|
||||
if (!themeId) return
|
||||
|
||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
var mode = isDark ? "dark" : "light"
|
||||
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
|
||||
if (themeId === "oc-1") return
|
||||
|
||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||
if (css) {
|
||||
var style = document.createElement("style")
|
||||
style.id = "oc-theme-preload"
|
||||
style.textContent =
|
||||
":root{color-scheme:" +
|
||||
mode +
|
||||
";--text-mix-blend-mode:" +
|
||||
(isDark ? "plus-lighter" : "multiply") +
|
||||
";" +
|
||||
css +
|
||||
"}"
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-screen"></div>
|
||||
<script src="/src/entry.tsx" type="module"></script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
+65
-45
@@ -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"
|
||||
@@ -8,9 +8,12 @@ import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||
import { Diff } from "@opencode-ai/ui/diff"
|
||||
import { Code } from "@opencode-ai/ui/code"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { 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 { NotificationProvider } from "@/context/notification"
|
||||
@@ -29,7 +32,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const url = iife(() => {
|
||||
const defaultServerUrl = iife(() => {
|
||||
const param = new URLSearchParams(document.location.search).get("url")
|
||||
if (param) return param
|
||||
|
||||
@@ -38,55 +41,72 @@ const url = iife(() => {
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
|
||||
return "http://localhost:4096"
|
||||
return window.location.origin
|
||||
})
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
const server = useServer()
|
||||
return (
|
||||
<Show when={server.url} keyed>
|
||||
{props.children}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>
|
||||
<GlobalSDKProvider url={url}>
|
||||
<GlobalSyncProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id || true} keyed>
|
||||
<TerminalProvider>
|
||||
<PromptProvider>
|
||||
<Session />
|
||||
</PromptProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
</Router>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>
|
||||
<ServerProvider defaultUrl={defaultServerUrl}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id ?? "new"} keyed>
|
||||
<TerminalProvider>
|
||||
<PromptProvider>
|
||||
<Session />
|
||||
</PromptProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
</Router>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
</ServerProvider>
|
||||
</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</MetaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
|
||||
function getFilename(input: string) {
|
||||
const parts = input.split("/")
|
||||
return parts[parts.length - 1] || input
|
||||
}
|
||||
|
||||
export function DialogEditProject(props: { project: LocalProject }) {
|
||||
const dialog = useDialog()
|
||||
const globalSDK = useGlobalSDK()
|
||||
|
||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||
const defaultName = createMemo(() => props.project.name || folderName())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
name: defaultName(),
|
||||
color: props.project.icon?.color || "pink",
|
||||
iconUrl: props.project.icon?.url || "",
|
||||
saving: false,
|
||||
})
|
||||
|
||||
const [dragOver, setDragOver] = createSignal(false)
|
||||
|
||||
function handleFileSelect(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setDragOver(false)
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}
|
||||
|
||||
function clearIcon() {
|
||||
setStore("iconUrl", "")
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
if (!props.project.id) return
|
||||
|
||||
setStore("saving", true)
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
name,
|
||||
icon: { color: store.color, url: store.iconUrl },
|
||||
})
|
||||
setStore("saving", false)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Edit project">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label="Name"
|
||||
placeholder={folderName()}
|
||||
value={store.name}
|
||||
onChange={(v) => setStore("name", v)}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">Icon</label>
|
||||
<div class="flex gap-3 items-start">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
|
||||
classList={{
|
||||
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
|
||||
"border-border-base hover:border-border-strong": !dragOver(),
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => document.getElementById("icon-upload")?.click()}
|
||||
>
|
||||
<Show
|
||||
when={store.iconUrl}
|
||||
fallback={
|
||||
<div class="size-full flex items-center justify-center">
|
||||
<Avatar
|
||||
fallback={store.name || defaultName()}
|
||||
{...getAvatarColors(store.color)}
|
||||
class="size-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={store.iconUrl}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
|
||||
onClick={clearIcon}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-icon-base" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
|
||||
<span>Click or drag an image</span>
|
||||
<span>Recommended: 128x128px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!store.iconUrl}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">Color</label>
|
||||
<div class="flex gap-2">
|
||||
<For each={AVATAR_COLOR_KEYS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
class="relative size-8 rounded-md transition-all"
|
||||
classList={{
|
||||
"ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
|
||||
store.color === color,
|
||||
}}
|
||||
style={{ background: getAvatarColors(color).background }}
|
||||
onClick={() => setStore("color", color)}
|
||||
>
|
||||
<Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
||||
{store.saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
}
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const sync = useGlobalSync()
|
||||
const sdk = useGlobalSDK()
|
||||
const dialog = useDialog()
|
||||
|
||||
const home = createMemo(() => sync.data.path.home)
|
||||
const root = createMemo(() => sync.data.path.home || sync.data.path.directory)
|
||||
|
||||
function join(base: string | undefined, rel: string) {
|
||||
const b = (base ?? "").replace(/[\\/]+$/, "")
|
||||
const r = rel.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "")
|
||||
if (!b) return r
|
||||
if (!r) return b
|
||||
return b + "/" + r
|
||||
}
|
||||
|
||||
function display(rel: string) {
|
||||
const full = join(root(), rel)
|
||||
const h = home()
|
||||
if (!h) return full
|
||||
if (full === h) return "~"
|
||||
if (full.startsWith(h + "/") || full.startsWith(h + "\\")) {
|
||||
return "~" + full.slice(h.length)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string) {
|
||||
const h = home()
|
||||
|
||||
if (!query) return query
|
||||
if (query.startsWith("~/")) return query.slice(2)
|
||||
|
||||
if (h) {
|
||||
const lc = query.toLowerCase()
|
||||
const hc = h.toLowerCase()
|
||||
if (lc === hc || lc.startsWith(hc + "/") || lc.startsWith(hc + "\\")) {
|
||||
return query.slice(h.length).replace(/^[\\/]+/, "")
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
async function fetchDirs(query: string) {
|
||||
const directory = root()
|
||||
if (!directory) return [] as string[]
|
||||
|
||||
const results = await sdk.client.find
|
||||
.files({ directory, query, type: "directory", limit: 50 })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
return results.map((x) => x.replace(/[\\/]+$/, ""))
|
||||
}
|
||||
|
||||
const directories = async (filter: string) => {
|
||||
const query = normalizeQuery(filter.trim())
|
||||
return fetchDirs(query)
|
||||
}
|
||||
|
||||
function resolve(rel: string) {
|
||||
const absolute = join(root(), rel)
|
||||
props.onSelect(props.multiple ? [absolute] : absolute)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={props.title ?? "Open project"}>
|
||||
<List
|
||||
search={{ placeholder: "Search folders", autofocus: true }}
|
||||
emptyMessage="No folders found"
|
||||
items={directories}
|
||||
key={(x) => x}
|
||||
onSelect={(path) => {
|
||||
if (!path) return
|
||||
resolve(path)
|
||||
}}
|
||||
>
|
||||
{(rel) => {
|
||||
const path = display(rel)
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: rel, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, createMemo, createSignal, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
|
||||
export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
} else {
|
||||
await sdk.client.mcp.connect({ name })
|
||||
}
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
setLoading(null)
|
||||
}
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
const totalCount = createMemo(() => items().length)
|
||||
|
||||
return (
|
||||
<Dialog title="MCPs" description={`${enabledCount()} of ${totalCount()} enabled`}>
|
||||
<List
|
||||
search={{ placeholder: "Search", autofocus: true }}
|
||||
emptyMessage="No MCPs configured"
|
||||
key={(x) => x?.name ?? ""}
|
||||
items={items}
|
||||
filterKeys={["name", "status"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
onSelect={(x) => {
|
||||
if (x) toggle(x.name)
|
||||
}}
|
||||
>
|
||||
{(i) => {
|
||||
const mcpStatus = () => sync.data.mcp[i.name]
|
||||
const status = () => mcpStatus()?.status
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
return s?.status === "failed" ? s.error : undefined
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between gap-x-3">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate">{i.name}</span>
|
||||
<Show when={status() === "connected"}>
|
||||
<span class="text-11-regular text-text-weaker">connected</span>
|
||||
</Show>
|
||||
<Show when={status() === "failed"}>
|
||||
<span class="text-11-regular text-text-weaker">failed</span>
|
||||
</Show>
|
||||
<Show when={status() === "needs_auth"}>
|
||||
<span class="text-11-regular text-text-weaker">needs auth</span>
|
||||
</Show>
|
||||
<Show when={status() === "disabled"}>
|
||||
<span class="text-11-regular text-text-weaker">disabled</span>
|
||||
</Show>
|
||||
<Show when={loading() === i.name}>
|
||||
<span class="text-11-regular text-text-weak">...</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={error()}>
|
||||
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,211 +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)
|
||||
}
|
||||
return shareURL
|
||||
},
|
||||
)
|
||||
return (
|
||||
<Show when={url()}>
|
||||
{(url) => <TextField value={url()} readOnly copyable class="w-72" />}
|
||||
</Show>
|
||||
)
|
||||
})}
|
||||
</Popover>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export function SessionContextUsage() {
|
||||
const sync = useSync()
|
||||
@@ -35,26 +35,25 @@ export function SessionContextUsage() {
|
||||
{(ctx) => (
|
||||
<Tooltip
|
||||
value={
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-3">
|
||||
<span class="opacity-70 text-right flex-1">Tokens</span>
|
||||
<span class="text-left flex-1">{ctx().tokens}</span>
|
||||
<div class="">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().tokens}</span>
|
||||
<span class="text-text-invert-base">Tokens</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="opacity-70 text-right flex-1">Usage</span>
|
||||
<span class="text-left flex-1">{ctx().percentage ?? 0}%</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
|
||||
<span class="text-text-invert-base">Usage</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="opacity-70 text-right flex-1">Cost</span>
|
||||
<span class="text-left flex-1">{cost()}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{cost()}</span>
|
||||
<span class="text-text-invert-base">Cost</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="p-1">
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
|
||||
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
|
||||
export function SessionLspIndicator() {
|
||||
const sync = useSync()
|
||||
|
||||
const lspStats = createMemo(() => {
|
||||
const lsp = sync.data.lsp ?? []
|
||||
const connected = lsp.filter((s) => s.status === "connected").length
|
||||
const hasError = lsp.some((s) => s.status === "error")
|
||||
const total = lsp.length
|
||||
return { connected, hasError, total }
|
||||
})
|
||||
|
||||
const tooltipContent = createMemo(() => {
|
||||
const lsp = sync.data.lsp ?? []
|
||||
if (lsp.length === 0) return "No LSP servers"
|
||||
return lsp.map((s) => s.name).join(", ")
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={lspStats().total > 0}>
|
||||
<Tooltip placement="top" value={tooltipContent()}>
|
||||
<div class="flex items-center gap-1 px-2 cursor-default select-none">
|
||||
<div
|
||||
classList={{
|
||||
"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>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
|
||||
export function SessionMcpIndicator() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcpStats = createMemo(() => {
|
||||
const mcp = sync.data.mcp ?? {}
|
||||
const entries = Object.entries(mcp)
|
||||
const enabled = entries.filter(([, status]) => status.status === "connected").length
|
||||
const failed = entries.some(([, status]) => status.status === "failed")
|
||||
const total = entries.length
|
||||
return { enabled, failed, total }
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={mcpStats().total > 0}>
|
||||
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
|
||||
<div
|
||||
classList={{
|
||||
"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>
|
||||
</Button>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { LocalPTY } from "@/context/terminal"
|
||||
import { usePrefersDark } from "@solid-primitives/media"
|
||||
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
|
||||
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
@@ -12,8 +12,28 @@ export interface TerminalProps extends ComponentProps<"div"> {
|
||||
onConnectError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
type TerminalColors = {
|
||||
background: string
|
||||
foreground: string
|
||||
cursor: string
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||
light: {
|
||||
background: "#fcfcfc",
|
||||
foreground: "#211e1e",
|
||||
cursor: "#211e1e",
|
||||
},
|
||||
dark: {
|
||||
background: "#191515",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
},
|
||||
}
|
||||
|
||||
export const Terminal = (props: TerminalProps) => {
|
||||
const sdk = useSDK()
|
||||
const theme = useTheme()
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
||||
let ws: WebSocket
|
||||
@@ -22,7 +42,64 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let serializeAddon: SerializeAddon
|
||||
let fitAddon: FitAddon
|
||||
let handleResize: () => void
|
||||
const prefersDark = usePrefersDark()
|
||||
|
||||
const getTerminalColors = (): TerminalColors => {
|
||||
const mode = theme.mode()
|
||||
const fallback = DEFAULT_TERMINAL_COLORS[mode]
|
||||
const currentTheme = theme.themes()[theme.themeId()]
|
||||
if (!currentTheme) return fallback
|
||||
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
||||
if (!variant?.seeds) return fallback
|
||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||
const text = resolved["text-base"] ?? fallback.foreground
|
||||
const background = resolved["background-stronger"] ?? fallback.background
|
||||
return {
|
||||
background,
|
||||
foreground: text,
|
||||
cursor: text,
|
||||
}
|
||||
}
|
||||
|
||||
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
|
||||
|
||||
createEffect(() => {
|
||||
const colors = getTerminalColors()
|
||||
setTerminalColors(colors)
|
||||
if (!term) return
|
||||
const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
|
||||
if (!setOption) return
|
||||
setOption("theme", colors)
|
||||
})
|
||||
|
||||
const focusTerminal = () => term?.focus()
|
||||
const copySelection = () => {
|
||||
if (!term || !term.hasSelection()) return false
|
||||
const selection = term.getSelection()
|
||||
if (!selection) return false
|
||||
const clipboard = navigator.clipboard
|
||||
if (clipboard?.writeText) {
|
||||
clipboard.writeText(selection).catch(() => {})
|
||||
return true
|
||||
}
|
||||
if (!document.body) return false
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = selection
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
return copied
|
||||
}
|
||||
const handlePointerDown = () => {
|
||||
const activeElement = document.activeElement
|
||||
if (activeElement instanceof HTMLElement && activeElement !== container) {
|
||||
activeElement.blur()
|
||||
}
|
||||
focusTerminal()
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
ghostty = await Ghostty.load()
|
||||
@@ -33,23 +110,22 @@ export const Terminal = (props: TerminalProps) => {
|
||||
fontSize: 14,
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
allowTransparency: true,
|
||||
theme: prefersDark()
|
||||
? {
|
||||
background: "#191515",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
}
|
||||
: {
|
||||
background: "#fcfcfc",
|
||||
foreground: "#211e1e",
|
||||
cursor: "#211e1e",
|
||||
},
|
||||
theme: terminalColors(),
|
||||
scrollback: 10_000,
|
||||
ghostty,
|
||||
})
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === "c") {
|
||||
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
|
||||
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
|
||||
if ((macCopy || linuxCopy) && copySelection()) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
}
|
||||
// allow for ctrl-` to toggle terminal in parent
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "`") {
|
||||
if (event.ctrlKey && key === "`") {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
@@ -62,6 +138,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
term.loadAddon(fitAddon)
|
||||
|
||||
term.open(container)
|
||||
container.addEventListener("pointerdown", handlePointerDown)
|
||||
focusTerminal()
|
||||
|
||||
if (local.pty.buffer) {
|
||||
if (local.pty.rows && local.pty.cols) {
|
||||
@@ -75,20 +153,20 @@ export const Terminal = (props: TerminalProps) => {
|
||||
fitAddon.fit()
|
||||
}
|
||||
|
||||
container.focus()
|
||||
|
||||
fitAddon.observeResize()
|
||||
handleResize = () => fitAddon.fit()
|
||||
window.addEventListener("resize", handleResize)
|
||||
term.onResize(async (size) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
await sdk.client.pty.update({
|
||||
ptyID: local.pty.id,
|
||||
size: {
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
},
|
||||
})
|
||||
await sdk.client.pty
|
||||
.update({
|
||||
ptyID: local.pty.id,
|
||||
size: {
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
term.onData((data) => {
|
||||
@@ -106,13 +184,15 @@ export const Terminal = (props: TerminalProps) => {
|
||||
// })
|
||||
ws.addEventListener("open", () => {
|
||||
console.log("WebSocket connected")
|
||||
sdk.client.pty.update({
|
||||
ptyID: local.pty.id,
|
||||
size: {
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
},
|
||||
})
|
||||
sdk.client.pty
|
||||
.update({
|
||||
ptyID: local.pty.id,
|
||||
size: {
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
ws.addEventListener("message", (event) => {
|
||||
term.write(event.data)
|
||||
@@ -130,6 +210,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (handleResize) {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
}
|
||||
container.removeEventListener("pointerdown", handlePointerDown)
|
||||
if (serializeAddon && props.onCleanup) {
|
||||
const buffer = serializeAddon.serialize()
|
||||
props.onCleanup({
|
||||
@@ -149,8 +230,10 @@ export const Terminal = (props: TerminalProps) => {
|
||||
ref={container}
|
||||
data-component="terminal"
|
||||
data-prevent-autofocus
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
"select-text": true,
|
||||
"size-full px-6 py-3 font-mono": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface CommandOption {
|
||||
suggested?: boolean
|
||||
disabled?: boolean
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
|
||||
export function parseKeybind(config: string): Keybind[] {
|
||||
@@ -115,6 +116,28 @@ export function formatKeybind(config: string): string {
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[] }) {
|
||||
const dialog = useDialog()
|
||||
let cleanup: (() => void) | void
|
||||
let committed = false
|
||||
|
||||
const handleMove = (option: CommandOption | undefined) => {
|
||||
cleanup?.()
|
||||
cleanup = option?.onHighlight?.()
|
||||
}
|
||||
|
||||
const handleSelect = (option: CommandOption | undefined) => {
|
||||
if (option) {
|
||||
committed = true
|
||||
cleanup = undefined
|
||||
dialog.close()
|
||||
option.onSelect?.("palette")
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (!committed) {
|
||||
cleanup?.()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog title="Commands">
|
||||
@@ -125,12 +148,8 @@ function DialogCommand(props: { options: CommandOption[] }) {
|
||||
key={(x) => x?.id}
|
||||
filterKeys={["title", "description", "category"]}
|
||||
groupBy={(x) => x.category ?? ""}
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
dialog.close()
|
||||
option.onSelect?.("palette")
|
||||
}
|
||||
}}
|
||||
onMove={handleMove}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{(option) => (
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
|
||||
@@ -1,34 +1,41 @@
|
||||
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,
|
||||
baseUrl: server.url,
|
||||
signal: AbortSignal.timeout(1000 * 60 * 10),
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return { url: props.url, client: sdk, event: emitter }
|
||||
return { url: server.url, client: sdk, event: emitter }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
type ProviderListResponse,
|
||||
type ProviderAuthResponse,
|
||||
type Command,
|
||||
type McpStatus,
|
||||
type LspStatus,
|
||||
type VcsInfo,
|
||||
type Permission,
|
||||
createOpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -19,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { ErrorPage, type InitError } from "../pages/error"
|
||||
import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
@@ -41,6 +45,14 @@ type State = {
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
permission: {
|
||||
[sessionID: string]: Permission[]
|
||||
}
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
}
|
||||
lsp: LspStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
limit: number
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
@@ -59,21 +71,19 @@ function createGlobalSync() {
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
children: Record<string, State>
|
||||
}>({
|
||||
ready: false,
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
project: [],
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
provider_auth: {},
|
||||
children: {},
|
||||
})
|
||||
|
||||
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
|
||||
function child(directory: string) {
|
||||
if (!directory) console.error("No directory provided")
|
||||
if (!children[directory]) {
|
||||
setGlobalStore("children", directory, {
|
||||
children[directory] = createStore<State>({
|
||||
project: "",
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
@@ -85,11 +95,14 @@ function createGlobalSync() {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
children[directory] = createStore(globalStore.children[directory])
|
||||
bootstrapInstance(directory)
|
||||
}
|
||||
return children[directory]
|
||||
@@ -102,16 +115,17 @@ 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", sessions)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
@@ -122,7 +136,7 @@ function createGlobalSync() {
|
||||
|
||||
async function bootstrapInstance(directory: string) {
|
||||
if (!directory) return
|
||||
const [, setStore] = child(directory)
|
||||
const [store, setStore] = child(directory)
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
directory,
|
||||
@@ -149,6 +163,42 @@ function createGlobalSync() {
|
||||
session: () => loadSessions(directory),
|
||||
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
|
||||
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
|
||||
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
|
||||
permission: () =>
|
||||
sdk.permission.list().then((x) => {
|
||||
const grouped: Record<string, Permission[]> = {}
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id || !perm.sessionID) continue
|
||||
const existing = grouped[perm.sessionID]
|
||||
if (existing) {
|
||||
existing.push(perm)
|
||||
continue
|
||||
}
|
||||
grouped[perm.sessionID] = [perm]
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||
.then(() => setStore("ready", true))
|
||||
@@ -218,7 +268,7 @@ function createGlobalSync() {
|
||||
setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
|
||||
break
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos))
|
||||
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
|
||||
break
|
||||
case "session.status": {
|
||||
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
|
||||
@@ -295,6 +345,56 @@ function createGlobalSync() {
|
||||
}
|
||||
break
|
||||
}
|
||||
case "vcs.branch.updated": {
|
||||
setStore("vcs", { branch: event.properties.branch })
|
||||
break
|
||||
}
|
||||
case "permission.updated": {
|
||||
const sessionID = event.properties.sessionID
|
||||
const permissions = store.permission[sessionID]
|
||||
if (!permissions) {
|
||||
setStore("permission", sessionID, [event.properties])
|
||||
break
|
||||
}
|
||||
|
||||
const result = Binary.search(permissions, event.properties.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("permission", sessionID, result.index, reconcile(event.properties))
|
||||
break
|
||||
}
|
||||
|
||||
setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "permission.replied": {
|
||||
const permissions = store.permission[event.properties.sessionID]
|
||||
if (!permissions) break
|
||||
const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
|
||||
if (!result.found) break
|
||||
setStore(
|
||||
"permission",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "lsp.updated": {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -319,10 +419,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"
|
||||
|
||||
@@ -29,15 +30,17 @@ type SessionTabs = {
|
||||
|
||||
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.v4",
|
||||
createStore({
|
||||
projects: [] as { worktree: string; expanded: boolean }[],
|
||||
sidebar: {
|
||||
opened: false,
|
||||
width: 280,
|
||||
@@ -48,6 +51,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
review: {
|
||||
opened: true,
|
||||
diffStyle: "split" as ReviewDiffStyle,
|
||||
},
|
||||
session: {
|
||||
width: 600,
|
||||
@@ -70,6 +74,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
{
|
||||
...project,
|
||||
...(metadata ?? {}),
|
||||
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -85,12 +90,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)
|
||||
}),
|
||||
)
|
||||
@@ -101,32 +106,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: {
|
||||
@@ -163,6 +159,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)
|
||||
},
|
||||
|
||||
@@ -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])
|
||||
},
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -377,17 +438,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const list = async (path: string) => {
|
||||
return sdk.client.file.list({ path: path + "/" }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
produce((draft) => {
|
||||
x.data!.forEach((node) => {
|
||||
if (node.path in draft) return
|
||||
draft[node.path] = node
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
return sdk.client.file
|
||||
.list({ path: path + "/" })
|
||||
.then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
produce((draft) => {
|
||||
x.data!.forEach((node) => {
|
||||
if (node.path in draft) return
|
||||
draft[node.path] = node
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
|
||||
|
||||
@@ -2,7 +2,9 @@ import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { makeAudioPlayer } from "@solid-primitives/audio"
|
||||
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||
@@ -43,6 +45,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const platform = usePlatform()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"notification.v1",
|
||||
@@ -64,8 +67,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const sessionID = event.properties.sessionID
|
||||
const [syncStore] = globalSync.child(directory)
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
const isChild = match.found && syncStore.session[match.index].parentID
|
||||
if (isChild) break
|
||||
const session = match.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
try {
|
||||
idlePlayer?.play()
|
||||
} catch {}
|
||||
@@ -74,25 +77,29 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
void platform.notify("Response ready", session?.title ?? sessionID, href)
|
||||
break
|
||||
}
|
||||
case "session.error": {
|
||||
const sessionID = event.properties.sessionID
|
||||
if (sessionID) {
|
||||
const [syncStore] = globalSync.child(directory)
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
const isChild = match.found && syncStore.session[match.index].parentID
|
||||
if (isChild) break
|
||||
}
|
||||
const [syncStore] = globalSync.child(directory)
|
||||
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
|
||||
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
try {
|
||||
errorPlayer?.play()
|
||||
} catch {}
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
setStore("list", store.list.length, {
|
||||
...base,
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error: "error" in event.properties ? event.properties.error : undefined,
|
||||
error,
|
||||
})
|
||||
const description = session?.title ?? (typeof error === "string" ? error : "An error occurred")
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
void platform.notify("Session error", description, href)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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 { persisted } from "@/utils/persist"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
name: "Permission",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
if (!params.dir) return false
|
||||
const [store] = globalSync.child(params.dir)
|
||||
return store.config.permission !== undefined
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"permission.v3",
|
||||
createStore({
|
||||
autoAcceptEdits: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
const responded = new Set<string>()
|
||||
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
globalSDK.client.permission.respond(input).catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: Permission, directory?: string) {
|
||||
if (responded.has(permission.id)) return
|
||||
responded.add(permission.id)
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string) {
|
||||
return store.autoAcceptEdits[sessionID] ?? false
|
||||
}
|
||||
|
||||
const unsubscribe = globalSDK.event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.updated") return
|
||||
|
||||
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
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string) {
|
||||
setStore("autoAcceptEdits", sessionID, false)
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
respond,
|
||||
autoResponds(permission: Permission) {
|
||||
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
|
||||
},
|
||||
isAutoAccepting,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID)) {
|
||||
disable(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID)) return
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string) {
|
||||
disable(sessionID)
|
||||
},
|
||||
permissionsEnabled,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -14,7 +14,10 @@ export type Platform = {
|
||||
/** Restart the app */
|
||||
restart(): Promise<void>
|
||||
|
||||
/** Open native directory picker dialog (Tauri only) */
|
||||
/** Send a system notification (optional deep link) */
|
||||
notify(title: string, description?: string, href?: string): Promise<void>
|
||||
|
||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
||||
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
||||
|
||||
/** Open native file picker dialog (Tauri only) */
|
||||
|
||||
@@ -21,6 +21,11 @@ export interface FileAttachmentPart extends PartBase {
|
||||
selection?: TextSelection
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
@@ -29,7 +34,7 @@ export interface ImageAttachmentPart {
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
@@ -46,6 +51,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
|
||||
return false
|
||||
}
|
||||
@@ -61,6 +69,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),
|
||||
|
||||
@@ -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(2000),
|
||||
})
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { produce } from "solid-js/store"
|
||||
import { createMemo } from "solid-js"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { produce, reconcile } from "solid-js/store"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
@@ -56,42 +56,75 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, message)
|
||||
}
|
||||
draft.part[input.messageID] = input.parts.slice()
|
||||
draft.part[input.messageID] = input.parts
|
||||
.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 })),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
if (match.found) draft.session[match.index] = session.data!
|
||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||
draft.todo[sessionID] = todo.data ?? []
|
||||
draft.message[sessionID] = messages
|
||||
.data!.map((x) => x.info)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
draft.session_diff[sessionID] = diff.data ?? []
|
||||
}),
|
||||
)
|
||||
|
||||
batch(() => {
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = session.data!
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, session.data!)
|
||||
}),
|
||||
)
|
||||
|
||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||
setStore(
|
||||
"message",
|
||||
sessionID,
|
||||
reconcile(
|
||||
(messages.data ?? [])
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
|
||||
for (const message of messages.data ?? []) {
|
||||
if (!message?.info?.id) continue
|
||||
setStore(
|
||||
"part",
|
||||
message.info.id,
|
||||
reconcile(
|
||||
message.parts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||
})
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
setStore("limit", (x) => x + count)
|
||||
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)
|
||||
setStore("session", sessions)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
},
|
||||
more: createMemo(() => store.session.length >= store.limit),
|
||||
|
||||
@@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
all: createMemo(() => Object.values(store.all)),
|
||||
active: createMemo(() => store.active),
|
||||
new() {
|
||||
sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
setStore("all", [
|
||||
...store.all,
|
||||
{
|
||||
id,
|
||||
title: pty.data?.title ?? "Terminal",
|
||||
},
|
||||
])
|
||||
setStore("active", id)
|
||||
})
|
||||
sdk.client.pty
|
||||
.create({ title: `Terminal ${store.all.length + 1}` })
|
||||
.then((pty) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
setStore("all", [
|
||||
...store.all,
|
||||
{
|
||||
id,
|
||||
title: pty.data?.title ?? "Terminal",
|
||||
},
|
||||
])
|
||||
setStore("active", id)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to create terminal", e)
|
||||
})
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
||||
sdk.client.pty.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
sdk.client.pty
|
||||
.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to update terminal", e)
|
||||
})
|
||||
},
|
||||
async clone(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const clone = await sdk.client.pty.create({
|
||||
title: pty.title,
|
||||
})
|
||||
if (!clone.data) return
|
||||
const clone = await sdk.client.pty
|
||||
.create({
|
||||
title: pty.title,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to clone terminal", e)
|
||||
return undefined
|
||||
})
|
||||
if (!clone?.data) return
|
||||
setStore("all", index, {
|
||||
...pty,
|
||||
...clone.data,
|
||||
@@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
setStore("active", previous?.id)
|
||||
}
|
||||
})
|
||||
await sdk.client.pty.remove({ ptyID: id })
|
||||
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
||||
console.error("Failed to close terminal", e)
|
||||
})
|
||||
},
|
||||
move(id: string, to: number) {
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
|
||||
@@ -20,6 +20,36 @@ const platform: Platform = {
|
||||
restart: async () => {
|
||||
window.location.reload()
|
||||
},
|
||||
notify: async (title, description, href) => {
|
||||
if (!("Notification" in window)) return
|
||||
|
||||
const permission =
|
||||
Notification.permission === "default"
|
||||
? await Notification.requestPermission().catch(() => "denied")
|
||||
: Notification.permission
|
||||
|
||||
if (permission !== "granted") return
|
||||
|
||||
const inView = document.visibilityState === "visible" && document.hasFocus()
|
||||
if (inView) return
|
||||
|
||||
await Promise.resolve()
|
||||
.then(() => {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
if (href) {
|
||||
window.history.pushState(null, "", href)
|
||||
window.dispatchEvent(new PopStateEvent("popstate"))
|
||||
}
|
||||
notification.close()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
@@ -18,8 +19,15 @@ export default function Layout(props: ParentProps) {
|
||||
<SyncProvider>
|
||||
{iife(() => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const respond = (input: {
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
response: "once" | "always" | "reject"
|
||||
}) => sdk.client.permission.respond(input)
|
||||
|
||||
return (
|
||||
<DataProvider data={sync.data} directory={directory()}>
|
||||
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -20,11 +21,51 @@ function isInitError(error: unknown): error is InitError {
|
||||
)
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
const seen = new WeakSet<object>()
|
||||
const json = JSON.stringify(
|
||||
value,
|
||||
(_key, val) => {
|
||||
if (typeof val === "bigint") return val.toString()
|
||||
if (typeof val === "object" && val) {
|
||||
if (seen.has(val)) return "[Circular]"
|
||||
seen.add(val)
|
||||
}
|
||||
return val
|
||||
},
|
||||
2,
|
||||
)
|
||||
return json ?? String(value)
|
||||
}
|
||||
|
||||
function formatInitError(error: InitError): string {
|
||||
const data = error.data
|
||||
switch (error.name) {
|
||||
case "MCPFailed":
|
||||
return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
case "ProviderAuthError": {
|
||||
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
|
||||
const message = typeof data.message === "string" ? data.message : safeJson(data.message)
|
||||
return `Provider authentication failed (${providerID}): ${message}`
|
||||
}
|
||||
case "APIError": {
|
||||
const message = typeof data.message === "string" ? data.message : "API error"
|
||||
const lines: string[] = [message]
|
||||
|
||||
if (typeof data.statusCode === "number") {
|
||||
lines.push(`Status: ${data.statusCode}`)
|
||||
}
|
||||
|
||||
if (typeof data.isRetryable === "boolean") {
|
||||
lines.push(`Retryable: ${data.isRetryable}`)
|
||||
}
|
||||
|
||||
if (typeof data.responseBody === "string" && data.responseBody) {
|
||||
lines.push(`Response body:\n${data.responseBody}`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
case "ProviderModelNotFoundError": {
|
||||
const { providerID, modelID, suggestions } = data as {
|
||||
providerID: string
|
||||
@@ -37,10 +78,14 @@ function formatInitError(error: InitError): string {
|
||||
`Check your config (opencode.json) provider/model names`,
|
||||
].join("\n")
|
||||
}
|
||||
case "ProviderInitError":
|
||||
return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.`
|
||||
case "ConfigJsonError":
|
||||
return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "")
|
||||
case "ProviderInitError": {
|
||||
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
|
||||
return `Failed to initialize provider "${providerID}". Check credentials and configuration.`
|
||||
}
|
||||
case "ConfigJsonError": {
|
||||
const message = typeof data.message === "string" ? data.message : ""
|
||||
return `Config file at ${data.path} is not valid JSON(C)` + (message ? `: ${message}` : "")
|
||||
}
|
||||
case "ConfigDirectoryTypoError":
|
||||
return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.`
|
||||
case "ConfigFrontmatterError":
|
||||
@@ -51,14 +96,14 @@ function formatInitError(error: InitError): string {
|
||||
(issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
|
||||
)
|
||||
: []
|
||||
return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join(
|
||||
"\n",
|
||||
)
|
||||
const message = typeof data.message === "string" ? data.message : ""
|
||||
return [`Config file at ${data.path} is invalid` + (message ? `: ${message}` : ""), ...issues].join("\n")
|
||||
}
|
||||
case "UnknownError":
|
||||
return String(data.message)
|
||||
return typeof data.message === "string" ? data.message : safeJson(data)
|
||||
default:
|
||||
return data.message ? String(data.message) : JSON.stringify(data, null, 2)
|
||||
if (typeof data.message === "string") return data.message
|
||||
return safeJson(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +114,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
|
||||
const message = formatInitError(error)
|
||||
if (depth > 0 && parentMessage === message) return ""
|
||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||
return indent + message
|
||||
return indent + `${error.name}\n${message}`
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
@@ -77,15 +122,34 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
|
||||
const parts: string[] = []
|
||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||
|
||||
if (!isDuplicate) {
|
||||
// Stack already includes error name and message, so prefer it
|
||||
parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
|
||||
} else if (error.stack) {
|
||||
// Duplicate message - only show the stack trace lines (skip message)
|
||||
const trace = error.stack.split("\n").slice(1).join("\n").trim()
|
||||
if (trace) {
|
||||
parts.push(trace)
|
||||
const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
|
||||
const stack = error.stack?.trim()
|
||||
|
||||
if (stack) {
|
||||
const startsWithHeader = stack.startsWith(header)
|
||||
|
||||
if (isDuplicate && startsWithHeader) {
|
||||
const trace = stack.split("\n").slice(1).join("\n").trim()
|
||||
if (trace) {
|
||||
parts.push(indent + trace)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDuplicate && !startsWithHeader) {
|
||||
parts.push(indent + stack)
|
||||
}
|
||||
|
||||
if (!isDuplicate && startsWithHeader) {
|
||||
parts.push(indent + stack)
|
||||
}
|
||||
|
||||
if (!isDuplicate && !startsWithHeader) {
|
||||
parts.push(indent + `${header}\n${stack}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!stack && !isDuplicate) {
|
||||
parts.push(indent + header)
|
||||
}
|
||||
|
||||
if (error.cause) {
|
||||
@@ -105,7 +169,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
|
||||
}
|
||||
|
||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||
return indent + JSON.stringify(error, null, 2)
|
||||
return indent + safeJson(error)
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
@@ -118,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">
|
||||
@@ -131,13 +214,29 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
readOnly
|
||||
copyable
|
||||
multiline
|
||||
class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre"
|
||||
class="max-h-96 w-full font-mono text-xs no-scrollbar"
|
||||
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
|
||||
|
||||
@@ -8,12 +8,18 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
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()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
|
||||
function openProject(directory: string) {
|
||||
@@ -22,32 +28,57 @@ export default function Home() {
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: "Open project",
|
||||
multiple: true,
|
||||
})
|
||||
if (Array.isArray(result)) {
|
||||
for (const directory of result) {
|
||||
openProject(directory)
|
||||
function resolve(result: string | string[] | null) {
|
||||
if (Array.isArray(result)) {
|
||||
for (const directory of result) {
|
||||
openProject(directory)
|
||||
}
|
||||
} else if (result) {
|
||||
openProject(result)
|
||||
}
|
||||
} else if (result) {
|
||||
openProject(result)
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: "Open project",
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div class="flex gap-2 items-center justify-between pl-3">
|
||||
<div class="text-14-medium text-text-strong">Recent projects</div>
|
||||
<Show when={platform.openDirectoryPickerDialog}>
|
||||
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
|
||||
Open project
|
||||
</Button>
|
||||
</Show>
|
||||
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
|
||||
Open project
|
||||
</Button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<For
|
||||
@@ -80,11 +111,9 @@ export default function Home() {
|
||||
<div class="text-12-regular text-text-weak">Get started by opening a local project</div>
|
||||
</div>
|
||||
<div />
|
||||
<Show when={platform.openDirectoryPickerDialog}>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
Open project
|
||||
</Button>
|
||||
</Show>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
Open project
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
+330
-130
@@ -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"
|
||||
@@ -41,15 +42,21 @@ import {
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { showToast, Toast } from "@opencode-ai/ui/toast"
|
||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { 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 { useCommand } from "@/context/command"
|
||||
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({
|
||||
@@ -84,17 +91,58 @@ 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()
|
||||
const command = useCommand()
|
||||
const theme = useTheme()
|
||||
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
||||
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||
const colorSchemeLabel: Record<ColorScheme, string> = {
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (platform.checkUpdate && platform.update && platform.restart) {
|
||||
const { updateAvailable, version } = await platform.checkUpdate()
|
||||
if (updateAvailable) {
|
||||
showToast({
|
||||
function cycleTheme(direction = 1) {
|
||||
const ids = availableThemeEntries().map(([id]) => id)
|
||||
if (ids.length === 0) return
|
||||
const currentIndex = ids.indexOf(theme.themeId())
|
||||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
|
||||
const nextThemeId = ids[nextIndex]
|
||||
theme.setTheme(nextThemeId)
|
||||
const nextTheme = theme.themes()[nextThemeId]
|
||||
showToast({
|
||||
title: "Theme switched",
|
||||
description: nextTheme?.name ?? nextThemeId,
|
||||
})
|
||||
}
|
||||
|
||||
function cycleColorScheme(direction = 1) {
|
||||
const current = theme.colorScheme()
|
||||
const currentIndex = colorSchemeOrder.indexOf(current)
|
||||
const nextIndex =
|
||||
currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
|
||||
const next = colorSchemeOrder[nextIndex]
|
||||
theme.setColorScheme(next)
|
||||
showToast({
|
||||
title: "Color scheme",
|
||||
description: colorSchemeLabel[next],
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
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",
|
||||
@@ -115,6 +163,94 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pollUpdate()
|
||||
const interval = setInterval(pollUpdate, 10 * 60 * 1000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
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
|
||||
const directory = e.name
|
||||
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 === perm.sessionID)
|
||||
|
||||
const sessionTitle = session?.title ?? "New session"
|
||||
const projectName = getFilename(directory)
|
||||
const description = `${sessionTitle} in ${projectName} needs permission`
|
||||
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)
|
||||
|
||||
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 existingToastId = toastBySession.get(sessionKey)
|
||||
if (existingToastId !== undefined) {
|
||||
toaster.dismiss(existingToastId)
|
||||
}
|
||||
|
||||
const toastId = showToast({
|
||||
persistent: true,
|
||||
icon: "checklist",
|
||||
title: "Permission required",
|
||||
description,
|
||||
actions: [
|
||||
{
|
||||
label: "Go to session",
|
||||
onClick: () => {
|
||||
navigate(href)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Dismiss",
|
||||
onClick: "dismiss",
|
||||
},
|
||||
],
|
||||
})
|
||||
toastBySession.set(sessionKey, toastId)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
createEffect(() => {
|
||||
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
||||
const currentSession = params.id
|
||||
if (!currentDir || !currentSession) return
|
||||
const sessionKey = `${currentDir}:${currentSession}`
|
||||
const toastId = toastBySession.get(sessionKey)
|
||||
if (toastId !== undefined) {
|
||||
toaster.dismiss(toastId)
|
||||
toastBySession.delete(sessionKey)
|
||||
alertedAtBySession.delete(sessionKey)
|
||||
}
|
||||
const [store] = globalSync.child(currentDir)
|
||||
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
||||
for (const child of childSessions) {
|
||||
const childKey = `${currentDir}:${child.id}`
|
||||
const childToastId = toastBySession.get(childKey)
|
||||
if (childToastId !== undefined) {
|
||||
toaster.dismiss(childToastId)
|
||||
toastBySession.delete(childKey)
|
||||
alertedAtBySession.delete(childKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function sortSessions(a: Session, b: Session) {
|
||||
@@ -221,62 +357,113 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
id: "sidebar.toggle",
|
||||
title: "Toggle sidebar",
|
||||
category: "View",
|
||||
keybind: "mod+b",
|
||||
onSelect: () => layout.sidebar.toggle(),
|
||||
},
|
||||
...(platform.openDirectoryPickerDialog
|
||||
? [
|
||||
{
|
||||
id: "project.open",
|
||||
title: "Open project",
|
||||
category: "Project",
|
||||
keybind: "mod+o",
|
||||
onSelect: () => chooseProject(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "provider.connect",
|
||||
title: "Connect provider",
|
||||
category: "Provider",
|
||||
onSelect: () => connectProvider(),
|
||||
},
|
||||
{
|
||||
id: "session.previous",
|
||||
title: "Previous session",
|
||||
category: "Session",
|
||||
keybind: "alt+arrowup",
|
||||
onSelect: () => navigateSessionByOffset(-1),
|
||||
},
|
||||
{
|
||||
id: "session.next",
|
||||
title: "Next session",
|
||||
category: "Session",
|
||||
keybind: "alt+arrowdown",
|
||||
onSelect: () => navigateSessionByOffset(1),
|
||||
},
|
||||
{
|
||||
id: "session.archive",
|
||||
title: "Archive session",
|
||||
category: "Session",
|
||||
keybind: "mod+shift+backspace",
|
||||
disabled: !params.dir || !params.id,
|
||||
onSelect: () => {
|
||||
const session = currentSessions().find((s) => s.id === params.id)
|
||||
if (session) archiveSession(session)
|
||||
command.register(() => {
|
||||
const commands: CommandOption[] = [
|
||||
{
|
||||
id: "sidebar.toggle",
|
||||
title: "Toggle sidebar",
|
||||
category: "View",
|
||||
keybind: "mod+b",
|
||||
onSelect: () => layout.sidebar.toggle(),
|
||||
},
|
||||
},
|
||||
])
|
||||
{
|
||||
id: "project.open",
|
||||
title: "Open project",
|
||||
category: "Project",
|
||||
keybind: "mod+o",
|
||||
onSelect: () => chooseProject(),
|
||||
},
|
||||
{
|
||||
id: "provider.connect",
|
||||
title: "Connect provider",
|
||||
category: "Provider",
|
||||
onSelect: () => connectProvider(),
|
||||
},
|
||||
{
|
||||
id: "server.switch",
|
||||
title: "Switch server",
|
||||
category: "Server",
|
||||
onSelect: () => openServer(),
|
||||
},
|
||||
{
|
||||
id: "session.previous",
|
||||
title: "Previous session",
|
||||
category: "Session",
|
||||
keybind: "alt+arrowup",
|
||||
onSelect: () => navigateSessionByOffset(-1),
|
||||
},
|
||||
{
|
||||
id: "session.next",
|
||||
title: "Next session",
|
||||
category: "Session",
|
||||
keybind: "alt+arrowdown",
|
||||
onSelect: () => navigateSessionByOffset(1),
|
||||
},
|
||||
{
|
||||
id: "session.archive",
|
||||
title: "Archive session",
|
||||
category: "Session",
|
||||
keybind: "mod+shift+backspace",
|
||||
disabled: !params.dir || !params.id,
|
||||
onSelect: () => {
|
||||
const session = currentSessions().find((s) => s.id === params.id)
|
||||
if (session) archiveSession(session)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "theme.cycle",
|
||||
title: "Cycle theme",
|
||||
category: "Theme",
|
||||
keybind: "mod+shift+t",
|
||||
onSelect: () => cycleTheme(1),
|
||||
},
|
||||
]
|
||||
|
||||
for (const [id, definition] of availableThemeEntries()) {
|
||||
commands.push({
|
||||
id: `theme.set.${id}`,
|
||||
title: `Use theme: ${definition.name ?? id}`,
|
||||
category: "Theme",
|
||||
onSelect: () => theme.commitPreview(),
|
||||
onHighlight: () => {
|
||||
theme.previewTheme(id)
|
||||
return () => theme.cancelPreview()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
commands.push({
|
||||
id: "theme.scheme.cycle",
|
||||
title: "Cycle color scheme",
|
||||
category: "Theme",
|
||||
keybind: "mod+shift+s",
|
||||
onSelect: () => cycleColorScheme(1),
|
||||
})
|
||||
|
||||
for (const scheme of colorSchemeOrder) {
|
||||
commands.push({
|
||||
id: `theme.scheme.${scheme}`,
|
||||
title: `Use color scheme: ${colorSchemeLabel[scheme]}`,
|
||||
category: "Theme",
|
||||
onSelect: () => theme.commitPreview(),
|
||||
onHighlight: () => {
|
||||
theme.previewColorScheme(scheme)
|
||||
return () => theme.cancelPreview()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return commands
|
||||
})
|
||||
|
||||
function connectProvider() {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
|
||||
function openServer() {
|
||||
dialog.show(() => <DialogSelectServer />)
|
||||
}
|
||||
|
||||
function navigateToProject(directory: string | undefined) {
|
||||
if (!directory) return
|
||||
const lastSession = store.lastSession[directory]
|
||||
@@ -304,17 +491,28 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: "Open project",
|
||||
multiple: true,
|
||||
})
|
||||
if (Array.isArray(result)) {
|
||||
for (const directory of result) {
|
||||
openProject(directory, false)
|
||||
function resolve(result: string | string[] | null) {
|
||||
if (Array.isArray(result)) {
|
||||
for (const directory of result) {
|
||||
openProject(directory, false)
|
||||
}
|
||||
navigateToProject(result[0])
|
||||
} else if (result) {
|
||||
openProject(result)
|
||||
}
|
||||
navigateToProject(result[0])
|
||||
} else if (result) {
|
||||
openProject(result)
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: "Open project",
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +574,7 @@ export default function Layout(props: ParentProps) {
|
||||
const notification = useNotification()
|
||||
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
|
||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
|
||||
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
@@ -412,7 +610,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
const current = createMemo(() => base64Decode(params.dir ?? ""))
|
||||
return (
|
||||
<Switch>
|
||||
@@ -454,8 +652,20 @@ export default function Layout(props: ParentProps) {
|
||||
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
||||
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||
const hasPermissions = createMemo(() => {
|
||||
const store = globalSync.child(props.project.worktree)[0]
|
||||
const permissions = store.permission?.[props.session.id] ?? []
|
||||
if (permissions.length > 0) return true
|
||||
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
|
||||
for (const child of childSessions) {
|
||||
const childPermissions = store.permission?.[child.id] ?? []
|
||||
if (childPermissions.length > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
const isWorking = createMemo(() => {
|
||||
if (props.session.id === params.id) return false
|
||||
if (hasPermissions()) return false
|
||||
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
|
||||
return status?.type === "busy" || status?.type === "retry"
|
||||
})
|
||||
@@ -486,6 +696,9 @@ export default function Layout(props: ParentProps) {
|
||||
<Match when={isWorking()}>
|
||||
<Spinner class="size-2.5 mr-0.5" />
|
||||
</Match>
|
||||
<Match when={hasPermissions()}>
|
||||
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={hasError()}>
|
||||
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
@@ -519,17 +732,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>
|
||||
</>
|
||||
@@ -540,7 +749,7 @@ export default function Layout(props: ParentProps) {
|
||||
const sortable = createSortable(props.project.worktree)
|
||||
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
|
||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
||||
const sessions = createMemo(() => store.session.toSorted(sortSessions))
|
||||
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
||||
@@ -586,23 +795,20 @@ export default function Layout(props: ParentProps) {
|
||||
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
|
||||
<DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</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>
|
||||
@@ -680,15 +886,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
|
||||
@@ -720,7 +927,7 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
</Show>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
@@ -749,7 +956,7 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
||||
<Switch>
|
||||
<Match when={!providers.paid().length && expanded()}>
|
||||
<Match when={providers.all().length > 0 && !providers.paid().length && expanded()}>
|
||||
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<div class="text-12-medium text-text-strong">Getting started</div>
|
||||
@@ -768,7 +975,7 @@ export default function Layout(props: ParentProps) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Match when={providers.all().length > 0}>
|
||||
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||
@@ -782,30 +989,28 @@ export default function Layout(props: ParentProps) {
|
||||
</Tooltip>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={platform.openDirectoryPickerDialog}>
|
||||
<Tooltip
|
||||
placement="right"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Open project</span>
|
||||
<Show when={!sidebarProps.mobile}>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
inactive={expanded()}
|
||||
<Tooltip
|
||||
placement="right"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Open project</span>
|
||||
<Show when={!sidebarProps.mobile}>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
inactive={expanded()}
|
||||
>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
icon="folder-add-left"
|
||||
onClick={chooseProject}
|
||||
>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
icon="folder-add-left"
|
||||
onClick={chooseProject}
|
||||
>
|
||||
<Show when={expanded()}>Open project</Show>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={expanded()}>Open project</Show>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip placement="right" value="Share feedback" inactive={expanded()}>
|
||||
<Button
|
||||
as={"a"}
|
||||
@@ -825,12 +1030,7 @@ 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={{
|
||||
|
||||
@@ -21,7 +21,7 @@ import { DateTime } from "luxon"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
@@ -49,13 +49,224 @@ import { checksum } from "@opencode-ai/util/encode"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useServer } from "@/context/server"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
||||
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
function same<T>(a: readonly T[], b: readonly T[]) {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
|
||||
function Header(props: { onMobileMenuToggle?: () => void }) {
|
||||
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={props.onMobileMenuToggle}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
@@ -70,22 +281,40 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const permission = usePermission()
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => lastUserMessage()?.id,
|
||||
() => {
|
||||
const msg = lastUserMessage()
|
||||
if (!msg) return
|
||||
if (msg.agent) local.agent.set(msg.agent)
|
||||
if (msg.model) local.model.set(msg.model)
|
||||
},
|
||||
),
|
||||
)
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
})
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
@@ -155,16 +384,37 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
params.id
|
||||
const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
|
||||
batch(() => {
|
||||
setStore("userInteracted", false)
|
||||
setStore("stepsExpanded", status.type !== "idle")
|
||||
})
|
||||
})
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
(id) => {
|
||||
const status = sync.data.session_status[id ?? ""] ?? idle
|
||||
batch(() => {
|
||||
setStore("userInteracted", false)
|
||||
setStore("stepsExpanded", status.type !== "idle")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => status().type,
|
||||
(type) => {
|
||||
if (type !== "idle") return
|
||||
batch(() => {
|
||||
setStore("userInteracted", false)
|
||||
setStore("stepsExpanded", false)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
|
||||
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
|
||||
|
||||
createRenderEffect((prev) => {
|
||||
@@ -197,21 +447,6 @@ export default function Page() {
|
||||
slash: "open",
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile />),
|
||||
},
|
||||
// {
|
||||
// id: "theme.toggle",
|
||||
// title: "Toggle theme",
|
||||
// description: "Switch between themes",
|
||||
// category: "View",
|
||||
// keybind: "ctrl+t",
|
||||
// slash: "theme",
|
||||
// onSelect: () => {
|
||||
// const currentTheme = localStorage.getItem("theme") ?? "oc-1"
|
||||
// const themes = ["oc-1", "oc-2-paper"]
|
||||
// const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
|
||||
// localStorage.setItem("theme", nextTheme)
|
||||
// document.documentElement.setAttribute("data-theme", nextTheme)
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: "terminal.toggle",
|
||||
title: "Toggle terminal",
|
||||
@@ -274,6 +509,15 @@ export default function Page() {
|
||||
slash: "model",
|
||||
onSelect: () => dialog.show(() => <DialogSelectModel />),
|
||||
},
|
||||
{
|
||||
id: "mcp.toggle",
|
||||
title: "Toggle MCPs",
|
||||
description: "Toggle MCPs",
|
||||
category: "MCP",
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => dialog.show(() => <DialogSelectMcp />),
|
||||
},
|
||||
{
|
||||
id: "agent.cycle",
|
||||
title: "Cycle agent",
|
||||
@@ -291,6 +535,38 @@ export default function Page() {
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => local.agent.move(-1),
|
||||
},
|
||||
{
|
||||
id: "model.variant.cycle",
|
||||
title: "Cycle thinking effort",
|
||||
description: "Switch to the next effort level",
|
||||
category: "Model",
|
||||
keybind: "shift+mod+t",
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
showToast({
|
||||
title: "Thinking effort changed",
|
||||
description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "permissions.autoaccept",
|
||||
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
|
||||
category: "Permissions",
|
||||
keybind: "mod+shift+a",
|
||||
disabled: !params.id || !permission.permissionsEnabled(),
|
||||
onSelect: () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
showToast({
|
||||
title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
|
||||
description: permission.isAutoAccepting(sessionID)
|
||||
? "Edit and write permissions will be automatically approved"
|
||||
: "Edit and write permissions will require approval",
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.undo",
|
||||
title: "Undo",
|
||||
@@ -562,6 +838,7 @@ export default function Page() {
|
||||
<SessionTurn
|
||||
sessionID={params.id!}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
|
||||
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
|
||||
onUserInteracted={() => setStore("userInteracted", true)}
|
||||
@@ -618,6 +895,7 @@ export default function Page() {
|
||||
<SessionTurn
|
||||
sessionID={params.id!}
|
||||
messageID={activeMessage()!.id}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
stepsExpanded={store.stepsExpanded}
|
||||
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
|
||||
onUserInteracted={() => setStore("userInteracted", true)}
|
||||
@@ -640,6 +918,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||
<Header />
|
||||
<div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
|
||||
<Switch>
|
||||
<Match when={!params.id}>
|
||||
@@ -664,6 +943,8 @@ export default function Page() {
|
||||
<div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
classes={{
|
||||
root: "pb-32",
|
||||
header: "px-4",
|
||||
@@ -760,13 +1041,9 @@ export default function Page() {
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||
<Tooltip
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Open file</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("file.open")}</span>
|
||||
</div>
|
||||
}
|
||||
<TooltipKeybind
|
||||
title="Open file"
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
@@ -775,12 +1052,12 @@ export default function Page() {
|
||||
iconSize="large"
|
||||
onClick={() => dialog.show(() => <DialogSelectFile />)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<Show when={diffs().length}>
|
||||
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionReview
|
||||
classes={{
|
||||
@@ -789,7 +1066,8 @@ export default function Page() {
|
||||
container: "px-6",
|
||||
}}
|
||||
diffs={diffs()}
|
||||
split
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
@@ -806,7 +1084,7 @@ export default function Page() {
|
||||
},
|
||||
)
|
||||
return (
|
||||
<Tabs.Content value={tab} class="select-text mt-3">
|
||||
<Tabs.Content value={tab} class="mt-3">
|
||||
<Switch>
|
||||
<Match when={file()}>
|
||||
{(f) => (
|
||||
@@ -818,7 +1096,7 @@ export default function Page() {
|
||||
cacheKey: checksum(f().content?.content ?? ""),
|
||||
}}
|
||||
overflow="scroll"
|
||||
class="pb-40"
|
||||
class="select-text pb-40"
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
@@ -881,17 +1159,13 @@ export default function Page() {
|
||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>New terminal</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("terminal.new")}</span>
|
||||
</div>
|
||||
}
|
||||
<TooltipKeybind
|
||||
title="New terminal"
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<For each={terminal.all()}>
|
||||
|
||||
@@ -10,6 +10,6 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
sourcemap: true,
|
||||
// sourcemap: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const url = new URL(input.request.url)
|
||||
const code = url.searchParams.get("code")
|
||||
if (!code) throw new Error("No code found")
|
||||
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
|
||||
if (result.err) {
|
||||
throw new Error(result.err.message)
|
||||
}
|
||||
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
||||
if (decoded.err) throw new Error(decoded.err.message)
|
||||
const session = await useAuthSession()
|
||||
const id = decoded.subject.properties.accountID
|
||||
await session.update((value) => {
|
||||
return {
|
||||
...value,
|
||||
account: {
|
||||
...value.account,
|
||||
[id]: {
|
||||
id,
|
||||
email: decoded.subject.properties.email,
|
||||
try {
|
||||
const code = url.searchParams.get("code")
|
||||
if (!code) throw new Error("No code found")
|
||||
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
|
||||
if (result.err) throw new Error(result.err.message)
|
||||
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
||||
if (decoded.err) throw new Error(decoded.err.message)
|
||||
const session = await useAuthSession()
|
||||
const id = decoded.subject.properties.accountID
|
||||
await session.update((value) => {
|
||||
return {
|
||||
...value,
|
||||
account: {
|
||||
...value.account,
|
||||
[id]: {
|
||||
id,
|
||||
email: decoded.subject.properties.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
current: id,
|
||||
}
|
||||
})
|
||||
return redirect("/auth")
|
||||
current: id,
|
||||
}
|
||||
})
|
||||
return redirect("/auth")
|
||||
} catch (e: any) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: e.message,
|
||||
cause: Object.fromEntries(url.searchParams.entries()),
|
||||
}),
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { Title } from "@solidjs/meta"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||
|
||||
interface TaskSource {
|
||||
repo: string
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
interface Judge {
|
||||
score: number
|
||||
rationale: string
|
||||
judge: string
|
||||
}
|
||||
|
||||
interface ScoreDetail {
|
||||
criterion: string
|
||||
weight: number
|
||||
average: number
|
||||
variance?: number
|
||||
judges?: Judge[]
|
||||
}
|
||||
|
||||
interface RunUsage {
|
||||
input: number
|
||||
output: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
interface Run {
|
||||
task: string
|
||||
model: string
|
||||
agent: string
|
||||
score: {
|
||||
final: number
|
||||
base: number
|
||||
penalty: number
|
||||
}
|
||||
scoreDetails: ScoreDetail[]
|
||||
usage?: RunUsage
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface Prompt {
|
||||
commit: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
interface AverageUsage {
|
||||
input: number
|
||||
output: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
interface Task {
|
||||
averageScore: number
|
||||
averageDuration?: number
|
||||
averageUsage?: AverageUsage
|
||||
model?: string
|
||||
agent?: string
|
||||
summary?: string
|
||||
runs?: Run[]
|
||||
task: {
|
||||
id: string
|
||||
source: TaskSource
|
||||
prompts?: Prompt[]
|
||||
}
|
||||
}
|
||||
|
||||
interface BenchmarkResult {
|
||||
averageScore: number
|
||||
tasks: Task[]
|
||||
}
|
||||
|
||||
async function getTaskDetail(benchmarkId: string, taskId: string) {
|
||||
"use server"
|
||||
const rows = await Database.use((tx) =>
|
||||
tx.select().from(BenchmarkTable).where(eq(BenchmarkTable.id, benchmarkId)).limit(1),
|
||||
)
|
||||
if (!rows[0]) return null
|
||||
const parsed = JSON.parse(rows[0].result) as BenchmarkResult
|
||||
const task = parsed.tasks.find((t) => t.task.id === taskId)
|
||||
return task ?? null
|
||||
}
|
||||
|
||||
const queryTaskDetail = query(getTaskDetail, "benchmark.task.detail")
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${remainingSeconds}s`
|
||||
}
|
||||
return `${remainingSeconds}s`
|
||||
}
|
||||
|
||||
export default function BenchDetail() {
|
||||
const params = useParams()
|
||||
const [benchmarkId, taskId] = (params.id ?? "").split(":")
|
||||
const task = createAsync(() => queryTaskDetail(benchmarkId, taskId))
|
||||
|
||||
return (
|
||||
<main data-page="bench-detail">
|
||||
<Title>Benchmark - {taskId}</Title>
|
||||
<div style={{ padding: "1rem" }}>
|
||||
<Show when={task()} fallback={<p>Task not found</p>}>
|
||||
<div style={{ "margin-bottom": "1rem" }}>
|
||||
<div>
|
||||
<strong>Agent: </strong>
|
||||
{task()?.agent ?? "N/A"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Model: </strong>
|
||||
{task()?.model ?? "N/A"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Task: </strong>
|
||||
{task()!.task.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ "margin-bottom": "1rem" }}>
|
||||
<div>
|
||||
<strong>Repo: </strong>
|
||||
<a
|
||||
href={`https://github.com/${task()!.task.source.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "#0066cc" }}
|
||||
>
|
||||
{task()!.task.source.repo}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<strong>From: </strong>
|
||||
<a
|
||||
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.from}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "#0066cc" }}
|
||||
>
|
||||
{task()!.task.source.from.slice(0, 7)}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<strong>To: </strong>
|
||||
<a
|
||||
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.to}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "#0066cc" }}
|
||||
>
|
||||
{task()!.task.source.to.slice(0, 7)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={task()?.task.prompts && task()!.task.prompts!.length > 0}>
|
||||
<div style={{ "margin-bottom": "1rem" }}>
|
||||
<strong>Prompt:</strong>
|
||||
<For each={task()!.task.prompts}>
|
||||
{(p) => (
|
||||
<div style={{ "margin-top": "0.5rem" }}>
|
||||
<div style={{ "font-size": "0.875rem", color: "#666" }}>Commit: {p.commit.slice(0, 7)}</div>
|
||||
<p style={{ "margin-top": "0.25rem", "white-space": "pre-wrap" }}>{p.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<hr style={{ margin: "1rem 0", border: "none", "border-top": "1px solid #ccc" }} />
|
||||
|
||||
<div style={{ "margin-bottom": "1rem" }}>
|
||||
<div>
|
||||
<strong>Average Duration: </strong>
|
||||
{task()?.averageDuration ? formatDuration(task()!.averageDuration!) : "N/A"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Average Score: </strong>
|
||||
{task()?.averageScore?.toFixed(3) ?? "N/A"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Average Cost: </strong>
|
||||
{task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={task()?.summary}>
|
||||
<div style={{ "margin-bottom": "1rem" }}>
|
||||
<strong>Summary:</strong>
|
||||
<p style={{ "margin-top": "0.5rem", "white-space": "pre-wrap" }}>{task()!.summary}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={task()?.runs && task()!.runs!.length > 0}>
|
||||
<div style={{ "margin-bottom": "1rem" }}>
|
||||
<strong>Runs:</strong>
|
||||
<table style={{ "margin-top": "0.5rem", "border-collapse": "collapse", width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Run</th>
|
||||
<th
|
||||
style={{
|
||||
border: "1px solid #ccc",
|
||||
padding: "0.5rem",
|
||||
"text-align": "left",
|
||||
"white-space": "nowrap",
|
||||
}}
|
||||
>
|
||||
Score (Base - Penalty)
|
||||
</th>
|
||||
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Cost</th>
|
||||
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Duration</th>
|
||||
<For each={task()!.runs![0]?.scoreDetails}>
|
||||
{(detail) => (
|
||||
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
|
||||
{detail.criterion} ({detail.weight})
|
||||
</th>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={task()!.runs}>
|
||||
{(run, index) => (
|
||||
<tr>
|
||||
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>{index() + 1}</td>
|
||||
<td style={{ border: "1px solid #ccc", padding: "0.5rem", "white-space": "nowrap" }}>
|
||||
{run.score.final.toFixed(3)} ({run.score.base.toFixed(3)} - {run.score.penalty.toFixed(3)})
|
||||
</td>
|
||||
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
|
||||
{run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : "N/A"}
|
||||
</td>
|
||||
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
|
||||
{run.duration ? formatDuration(run.duration) : "N/A"}
|
||||
</td>
|
||||
<For each={run.scoreDetails}>
|
||||
{(detail) => (
|
||||
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
|
||||
<For each={detail.judges}>
|
||||
{(judge) => (
|
||||
<span
|
||||
style={{
|
||||
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
|
||||
"margin-right": "0.25rem",
|
||||
}}
|
||||
>
|
||||
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<For each={task()!.runs}>
|
||||
{(run, index) => (
|
||||
<div style={{ "margin-top": "1rem" }}>
|
||||
<h3 style={{ margin: "0 0 0.5rem 0" }}>Run {index() + 1}</h3>
|
||||
<div>
|
||||
<strong>Score: </strong>
|
||||
{run.score.final.toFixed(3)} (Base: {run.score.base.toFixed(3)} - Penalty:{" "}
|
||||
{run.score.penalty.toFixed(3)})
|
||||
</div>
|
||||
<For each={run.scoreDetails}>
|
||||
{(detail) => (
|
||||
<div style={{ "margin-top": "1rem", "padding-left": "1rem", "border-left": "2px solid #ccc" }}>
|
||||
<div>
|
||||
{detail.criterion} (weight: {detail.weight}){" "}
|
||||
<For each={detail.judges}>
|
||||
{(judge) => (
|
||||
<span
|
||||
style={{
|
||||
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
|
||||
"margin-right": "0.25rem",
|
||||
}}
|
||||
>
|
||||
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={detail.judges && detail.judges.length > 0}>
|
||||
<For each={detail.judges}>
|
||||
{(judge) => {
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
return (
|
||||
<div style={{ "margin-top": "0.5rem", "padding-left": "1rem" }}>
|
||||
<div
|
||||
style={{ "font-size": "0.875rem", cursor: "pointer" }}
|
||||
onClick={() => setExpanded(!expanded())}
|
||||
>
|
||||
<span style={{ "margin-right": "0.5rem" }}>{expanded() ? "▼" : "▶"}</span>
|
||||
<span
|
||||
style={{
|
||||
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
|
||||
}}
|
||||
>
|
||||
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
|
||||
</span>{" "}
|
||||
{judge.judge}
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 0 0",
|
||||
"white-space": "pre-wrap",
|
||||
"font-size": "0.875rem",
|
||||
}}
|
||||
>
|
||||
{judge.rationale}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{(() => {
|
||||
const [jsonExpanded, setJsonExpanded] = createSignal(false)
|
||||
return (
|
||||
<div style={{ "margin-top": "1rem" }}>
|
||||
<button
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
padding: "0.75rem 1.5rem",
|
||||
"font-size": "1rem",
|
||||
background: "#f0f0f0",
|
||||
border: "1px solid #ccc",
|
||||
"border-radius": "4px",
|
||||
}}
|
||||
onClick={() => setJsonExpanded(!jsonExpanded())}
|
||||
>
|
||||
<span style={{ "margin-right": "0.5rem" }}>{jsonExpanded() ? "▼" : "▶"}</span>
|
||||
Raw JSON
|
||||
</button>
|
||||
<Show when={jsonExpanded()}>
|
||||
<pre>{JSON.stringify(task(), null, 2)}</pre>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Title } from "@solidjs/meta"
|
||||
import { A, createAsync, query } from "@solidjs/router"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { Database, desc } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||
|
||||
interface BenchmarkResult {
|
||||
averageScore: number
|
||||
tasks: { averageScore: number; task: { id: string } }[]
|
||||
}
|
||||
|
||||
async function getBenchmarks() {
|
||||
"use server"
|
||||
const rows = await Database.use((tx) =>
|
||||
tx.select().from(BenchmarkTable).orderBy(desc(BenchmarkTable.timeCreated)).limit(100),
|
||||
)
|
||||
return rows.map((row) => {
|
||||
const parsed = JSON.parse(row.result) as BenchmarkResult
|
||||
const taskScores: Record<string, number> = {}
|
||||
for (const t of parsed.tasks) {
|
||||
taskScores[t.task.id] = t.averageScore
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
agent: row.agent,
|
||||
model: row.model,
|
||||
averageScore: parsed.averageScore,
|
||||
taskScores,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const queryBenchmarks = query(getBenchmarks, "benchmarks.list")
|
||||
|
||||
export default function Bench() {
|
||||
const benchmarks = createAsync(() => queryBenchmarks())
|
||||
|
||||
const taskIds = createMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const row of benchmarks() ?? []) {
|
||||
for (const id of Object.keys(row.taskScores)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return [...ids].sort()
|
||||
})
|
||||
|
||||
return (
|
||||
<main data-page="bench" style={{ padding: "2rem" }}>
|
||||
<Title>Benchmark</Title>
|
||||
<h1 style={{ "margin-bottom": "1.5rem" }}>Benchmarks</h1>
|
||||
<table style={{ "border-collapse": "collapse", width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ "text-align": "left", padding: "0.75rem" }}>Agent</th>
|
||||
<th style={{ "text-align": "left", padding: "0.75rem" }}>Model</th>
|
||||
<th style={{ "text-align": "left", padding: "0.75rem" }}>Score</th>
|
||||
<For each={taskIds()}>{(id) => <th style={{ "text-align": "left", padding: "0.75rem" }}>{id}</th>}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={benchmarks()}>
|
||||
{(row) => (
|
||||
<tr>
|
||||
<td style={{ padding: "0.75rem" }}>{row.agent}</td>
|
||||
<td style={{ padding: "0.75rem" }}>{row.model}</td>
|
||||
<td style={{ padding: "0.75rem" }}>{row.averageScore.toFixed(3)}</td>
|
||||
<For each={taskIds()}>
|
||||
{(id) => (
|
||||
<td style={{ padding: "0.75rem" }}>
|
||||
<Show when={row.taskScores[id] !== undefined} fallback="">
|
||||
<A href={`/bench/${row.id}:${id}`} style={{ color: "#0066cc" }}>
|
||||
{row.taskScores[id]?.toFixed(3)}
|
||||
</A>
|
||||
</Show>
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
|
||||
interface SubmissionBody {
|
||||
model: string
|
||||
agent: string
|
||||
result: string
|
||||
}
|
||||
|
||||
export async function POST(event: APIEvent) {
|
||||
const body = (await event.request.json()) as SubmissionBody
|
||||
|
||||
if (!body.model || !body.agent || !body.result) {
|
||||
return Response.json({ error: "All fields are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx.insert(BenchmarkTable).values({
|
||||
id: Identifier.create("benchmark"),
|
||||
model: body.model,
|
||||
agent: body.agent,
|
||||
result: body.result,
|
||||
}),
|
||||
)
|
||||
|
||||
return Response.json({ success: true }, { status: 200 })
|
||||
}
|
||||
@@ -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()}>
|
||||
|
||||
@@ -124,6 +124,8 @@ export async function handler(
|
||||
res.status !== 200 &&
|
||||
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
|
||||
res.status !== 404 &&
|
||||
// ie. cannot change codex model providers mid-session
|
||||
!modelInfo.stickyProvider &&
|
||||
modelInfo.fallbackProvider &&
|
||||
providerInfo.id !== modelInfo.fallbackProvider
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE `benchmark` (
|
||||
`id` varchar(30) NOT NULL,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`model` varchar(64) NOT NULL,
|
||||
`agent` varchar(64) NOT NULL,
|
||||
`result` mediumtext NOT NULL,
|
||||
CONSTRAINT `benchmark_id_pk` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `time_created` ON `benchmark` (`time_created`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -274,6 +274,13 @@
|
||||
"when": 1764110043942,
|
||||
"tag": "0038_famous_magik",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 39,
|
||||
"version": "5",
|
||||
"when": 1766946179892,
|
||||
"tag": "0039_striped_forge",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,6 +5,7 @@ export namespace Identifier {
|
||||
const prefixes = {
|
||||
account: "acc",
|
||||
auth: "aut",
|
||||
benchmark: "ben",
|
||||
billing: "bil",
|
||||
key: "key",
|
||||
model: "mod",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { index, mediumtext, mysqlTable, primaryKey, varchar } from "drizzle-orm/mysql-core"
|
||||
import { id, timestamps } from "../drizzle/types"
|
||||
|
||||
export const BenchmarkTable = mysqlTable(
|
||||
"benchmark",
|
||||
{
|
||||
id: id(),
|
||||
...timestamps,
|
||||
model: varchar("model", { length: 64 }).notNull(),
|
||||
agent: varchar("agent", { length: 64 }).notNull(),
|
||||
result: mediumtext("result").notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.id] }), index("time_created").on(table.timeCreated)],
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -123,7 +123,11 @@ export default {
|
||||
},
|
||||
}).then((x) => x.json())) as any
|
||||
subject = user.id.toString()
|
||||
email = emails.find((x: any) => x.primary && x.verified)?.email
|
||||
|
||||
const primaryEmail = emails.find((x: any) => x.primary)
|
||||
if (!primaryEmail) throw new Error("No primary email found for GitHub user")
|
||||
if (!primaryEmail.verified) throw new Error("Primary email for GitHub user not verified")
|
||||
email = primaryEmail.email
|
||||
} else if (response.provider === "google") {
|
||||
if (!response.id.email_verified) throw new Error("Google email not verified")
|
||||
subject = response.id.sub as string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" style="background-color: var(--background-base)">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -13,14 +13,39 @@
|
||||
<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" />
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<script>
|
||||
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
||||
<script id="oc-theme-preload-script">
|
||||
;(function () {
|
||||
const savedTheme = localStorage.getItem("theme") || "oc-1"
|
||||
document.documentElement.setAttribute("data-theme", savedTheme)
|
||||
var themeId = localStorage.getItem("opencode-theme-id")
|
||||
if (!themeId) return
|
||||
|
||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
var mode = isDark ? "dark" : "light"
|
||||
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
|
||||
if (themeId === "oc-1") return
|
||||
|
||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||
if (css) {
|
||||
var style = document.createElement("style")
|
||||
style.id = "oc-theme-preload"
|
||||
style.textContent =
|
||||
":root{color-scheme:" +
|
||||
mode +
|
||||
";--text-mix-blend-mode:" +
|
||||
(isDark ? "plus-lighter" : "multiply") +
|
||||
";" +
|
||||
css +
|
||||
"}"
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-screen"></div>
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "~2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tauri-apps/plugin-process": "~2",
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Generated
+58
@@ -2210,6 +2210,18 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"objc2 0.6.3",
|
||||
"objc2-foundation 0.3.2",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
@@ -2384,6 +2396,20 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.11.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
@@ -2758,6 +2784,7 @@ dependencies = [
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-process",
|
||||
@@ -4519,6 +4546,25 @@ dependencies = [
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.2"
|
||||
@@ -4754,6 +4800,18 @@ dependencies = [
|
||||
"toml 0.9.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||
dependencies = [
|
||||
"quick-xml 0.37.5",
|
||||
"thiserror 2.0.17",
|
||||
"windows",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.23.0"
|
||||
|
||||
@@ -28,6 +28,7 @@ tauri-plugin-store = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"opener:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:allow-set-webview-zoom",
|
||||
"core:window:allow-is-focused",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-set-focus",
|
||||
"shell:default",
|
||||
"updater:default",
|
||||
"dialog:default",
|
||||
@@ -15,6 +19,7 @@
|
||||
"store:default",
|
||||
"window-state:default",
|
||||
"os:default",
|
||||
"notification:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }]
|
||||
|
||||
@@ -198,6 +198,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(PinchZoomDisablePlugin)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
kill_sidecar,
|
||||
|
||||
+105
-16
@@ -12,6 +12,8 @@ import { UPDATER_ENABLED } from "./updater"
|
||||
import { createMenu } from "./menu"
|
||||
import { check, Update } from "@tauri-apps/plugin-updater"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import pkg from "../package.json"
|
||||
|
||||
@@ -55,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()
|
||||
},
|
||||
@@ -77,23 +131,58 @@ 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()
|
||||
},
|
||||
|
||||
notify: async (title, description, href) => {
|
||||
const granted = await isPermissionGranted().catch(() => false)
|
||||
const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
|
||||
if (permission !== "granted") return
|
||||
|
||||
const win = getCurrentWindow()
|
||||
const focused = await win.isFocused().catch(() => document.hasFocus())
|
||||
if (focused) return
|
||||
|
||||
await Promise.resolve()
|
||||
.then(() => {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
const win = getCurrentWindow()
|
||||
void win.show().catch(() => undefined)
|
||||
void win.unminimize().catch(() => undefined)
|
||||
void win.setFocus().catch(() => undefined)
|
||||
if (href) {
|
||||
window.history.pushState(null, "", href)
|
||||
window.dispatchEvent(new PopStateEvent("popstate"))
|
||||
}
|
||||
notification.close()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
|
||||
// @ts-expect-error
|
||||
fetch: tauriFetch,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ export default defineConfig({
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
esbuild: {
|
||||
// Improves production stack traces
|
||||
keepNames: true,
|
||||
},
|
||||
// build: {
|
||||
// sourcemap: true,
|
||||
// },
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.0.202"
|
||||
version = "1.0.222"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/sst/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/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.202/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/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.202/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/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.202/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -2,3 +2,6 @@ preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
timeout = 10000 # 10 seconds (default is 5000ms)
|
||||
# Enable code coverage
|
||||
coverage = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.202",
|
||||
"version": "1.0.222",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -50,16 +50,23 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.5.1",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.57",
|
||||
"@ai-sdk/anthropic": "2.0.50",
|
||||
"@ai-sdk/azure": "2.0.73",
|
||||
"@ai-sdk/google": "2.0.44",
|
||||
"@ai-sdk/anthropic": "2.0.56",
|
||||
"@ai-sdk/azure": "2.0.82",
|
||||
"@ai-sdk/cerebras": "1.0.33",
|
||||
"@ai-sdk/cohere": "2.0.21",
|
||||
"@ai-sdk/deepinfra": "1.0.30",
|
||||
"@ai-sdk/gateway": "2.0.23",
|
||||
"@ai-sdk/google": "2.0.49",
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@ai-sdk/mcp": "0.0.8",
|
||||
"@ai-sdk/groq": "2.0.33",
|
||||
"@ai-sdk/mistral": "2.0.26",
|
||||
"@ai-sdk/openai": "2.0.71",
|
||||
"@ai-sdk/openai-compatible": "1.0.27",
|
||||
"@ai-sdk/openai-compatible": "1.0.29",
|
||||
"@ai-sdk/perplexity": "2.0.22",
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.18",
|
||||
"@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",
|
||||
@@ -73,15 +80,16 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.63",
|
||||
"@opentui/solid": "0.1.63",
|
||||
"@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",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"bun-pty": "0.4.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.4",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
|
||||
@@ -235,5 +235,19 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "nix",
|
||||
// TODO: Replace with official tree-sitter-nix WASM when published
|
||||
// See: https://github.com/nix-community/tree-sitter-nix/issues/66
|
||||
wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ Rules:
|
||||
- Do not explain what the user asked for
|
||||
- Write in first person (I added..., I fixed...)
|
||||
- Never ask questions or add new questions
|
||||
- Only exception: if the conversation ends with an unanswered question to the user, preserve that exact question
|
||||
- If the conversation ends with an unanswered question to the user, preserve that exact question
|
||||
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary
|
||||
|
||||
@@ -22,7 +22,7 @@ Your output must be:
|
||||
- The title should NEVER include "summarizing" or "generating" when generating a title
|
||||
- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
|
||||
- Always output something meaningful, even if the input is minimal.
|
||||
- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"):
|
||||
- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"):
|
||||
→ create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
|
||||
</rules>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ACP } from "@/acp/agent"
|
||||
import { Server } from "@/server/server"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
|
||||
const log = Log.create({ service: "acp-command" })
|
||||
|
||||
@@ -19,29 +20,16 @@ export const AcpCommand = cmd({
|
||||
command: "acp",
|
||||
describe: "start ACP (Agent Client Protocol) server",
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option("cwd", {
|
||||
describe: "working directory",
|
||||
type: "string",
|
||||
default: process.cwd(),
|
||||
})
|
||||
.option("port", {
|
||||
type: "number",
|
||||
describe: "port to listen on",
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
})
|
||||
return withNetworkOptions(yargs).option("cwd", {
|
||||
describe: "working directory",
|
||||
type: "string",
|
||||
default: process.cwd(),
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const server = Server.listen({
|
||||
port: args.port,
|
||||
hostname: args.hostname,
|
||||
})
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EOL } from "os"
|
||||
import { basename } from "path"
|
||||
import { Agent } from "../../../agent/agent"
|
||||
import { Provider } from "../../../provider/provider"
|
||||
import { ToolRegistry } from "../../../tool/registry"
|
||||
import { Wildcard } from "../../../util/wildcard"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
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)
|
||||
}
|
||||
const resolvedTools = await resolveTools(agent)
|
||||
const output = {
|
||||
...agent,
|
||||
tools: resolvedTools,
|
||||
toolOverrides: agent.tools,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function resolveTools(agent: Agent.Info) {
|
||||
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
|
||||
const toolOverrides = {
|
||||
...agent.tools,
|
||||
...(await ToolRegistry.enabled(agent)),
|
||||
}
|
||||
const availableTools = await ToolRegistry.tools(providerID, agent)
|
||||
const resolved: Record<string, boolean> = {}
|
||||
for (const tool of availableTools) {
|
||||
resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -9,7 +9,9 @@ import * as github from "@actions/github"
|
||||
import type { Context } from "@actions/github/lib/context"
|
||||
import type {
|
||||
IssueCommentEvent,
|
||||
IssuesEvent,
|
||||
PullRequestReviewCommentEvent,
|
||||
WorkflowDispatchEvent,
|
||||
WorkflowRunEvent,
|
||||
PullRequestEvent,
|
||||
} from "@octokit/webhooks-types"
|
||||
@@ -132,7 +134,16 @@ type IssueQueryResponse = {
|
||||
const AGENT_USERNAME = "opencode-agent[bot]"
|
||||
const AGENT_REACTION = "eyes"
|
||||
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
|
||||
const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule", "pull_request"] as const
|
||||
|
||||
// Event categories for routing
|
||||
// USER_EVENTS: triggered by user actions, have actor/issueId, support reactions/comments
|
||||
// REPO_EVENTS: triggered by automation, no actor/issueId, output to logs/PR only
|
||||
const USER_EVENTS = ["issue_comment", "pull_request_review_comment", "issues", "pull_request"] as const
|
||||
const REPO_EVENTS = ["schedule", "workflow_dispatch"] as const
|
||||
const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const
|
||||
|
||||
type UserEvent = (typeof USER_EVENTS)[number]
|
||||
type RepoEvent = (typeof REPO_EVENTS)[number]
|
||||
|
||||
// Parses GitHub remote URLs in various formats:
|
||||
// - https://github.com/owner/repo.git
|
||||
@@ -147,6 +158,29 @@ export function parseGitHubRemote(url: string): { owner: string; repo: string }
|
||||
return { owner: match[1], repo: match[2] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts displayable text from assistant response parts.
|
||||
* Returns null for tool-only or reasoning-only responses (signals summary needed).
|
||||
* Throws for truly unusable responses (empty, step-start only, etc.).
|
||||
*/
|
||||
export function extractResponseText(parts: MessageV2.Part[]): string | null {
|
||||
// Priority 1: Look for text parts
|
||||
const textPart = parts.findLast((p) => p.type === "text")
|
||||
if (textPart) return textPart.text
|
||||
|
||||
// Priority 2: Reasoning-only - return null to signal summary needed
|
||||
const reasoningPart = parts.findLast((p) => p.type === "reasoning")
|
||||
if (reasoningPart) return null
|
||||
|
||||
// Priority 3: Tool-only - return null to signal summary needed
|
||||
const toolParts = parts.filter((p) => p.type === "tool" && p.state.status === "completed")
|
||||
if (toolParts.length > 0) return null
|
||||
|
||||
// No usable parts - throw with debug info
|
||||
const partTypes = parts.map((p) => p.type).join(", ") || "none"
|
||||
throw new Error(`Failed to parse response. Part types found: [${partTypes}]`)
|
||||
}
|
||||
|
||||
export const GithubCommand = cmd({
|
||||
command: "github",
|
||||
describe: "manage GitHub agent",
|
||||
@@ -397,27 +431,38 @@ export const GithubRunCommand = cmd({
|
||||
core.setFailed(`Unsupported event type: ${context.eventName}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Determine event category for routing
|
||||
// USER_EVENTS: have actor, issueId, support reactions/comments
|
||||
// REPO_EVENTS: no actor/issueId, output to logs/PR only
|
||||
const isUserEvent = USER_EVENTS.includes(context.eventName as UserEvent)
|
||||
const isRepoEvent = REPO_EVENTS.includes(context.eventName as RepoEvent)
|
||||
const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName)
|
||||
const isIssuesEvent = context.eventName === "issues"
|
||||
const isScheduleEvent = context.eventName === "schedule"
|
||||
const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch"
|
||||
|
||||
const { providerID, modelID } = normalizeModel()
|
||||
const runId = normalizeRunId()
|
||||
const share = normalizeShare()
|
||||
const oidcBaseUrl = normalizeOidcBaseUrl()
|
||||
const { owner, repo } = context.repo
|
||||
// For schedule events, payload has no issue/comment data
|
||||
// For repo events (schedule, workflow_dispatch), payload has no issue/comment data
|
||||
const payload = context.payload as
|
||||
| IssueCommentEvent
|
||||
| IssuesEvent
|
||||
| PullRequestReviewCommentEvent
|
||||
| WorkflowDispatchEvent
|
||||
| WorkflowRunEvent
|
||||
| PullRequestEvent
|
||||
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
|
||||
// workflow_dispatch has an actor (the user who triggered it), schedule does not
|
||||
const actor = isScheduleEvent ? undefined : context.actor
|
||||
|
||||
const issueId = isScheduleEvent
|
||||
const issueId = isRepoEvent
|
||||
? undefined
|
||||
: context.eventName === "issue_comment"
|
||||
? (payload as IssueCommentEvent).issue.number
|
||||
: context.eventName === "issue_comment" || context.eventName === "issues"
|
||||
? (payload as IssueCommentEvent | IssuesEvent).issue.number
|
||||
: (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number
|
||||
const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
|
||||
const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai"
|
||||
@@ -462,8 +507,8 @@ export const GithubRunCommand = cmd({
|
||||
if (!useGithubToken) {
|
||||
await configureGit(appToken)
|
||||
}
|
||||
// Skip permission check for schedule events (no actor to check)
|
||||
if (!isScheduleEvent) {
|
||||
// Skip permission check and reactions for repo events (no actor to check, no issue to react to)
|
||||
if (isUserEvent) {
|
||||
await assertPermissions()
|
||||
await addReaction(commentType)
|
||||
}
|
||||
@@ -480,25 +525,30 @@ export const GithubRunCommand = cmd({
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
|
||||
// Handle 4 cases
|
||||
// 1. Schedule (no issue/PR context)
|
||||
// 2. Issue
|
||||
// 3. Local PR
|
||||
// 4. Fork PR
|
||||
if (isScheduleEvent) {
|
||||
// Schedule event - no issue/PR context, output goes to logs
|
||||
const branch = await checkoutNewBranch("schedule")
|
||||
// Handle event types:
|
||||
// REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only
|
||||
// USER_EVENTS on PR (pull_request, pull_request_review_comment, issue_comment on PR): work on PR branch
|
||||
// USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR
|
||||
if (isRepoEvent) {
|
||||
// Repo event - no issue/PR context, output goes to logs
|
||||
if (isWorkflowDispatchEvent && actor) {
|
||||
console.log(`Triggered by: ${actor}`)
|
||||
}
|
||||
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
|
||||
const branch = await checkoutNewBranch(branchPrefix)
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const response = await chat(userPrompt, promptFiles)
|
||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
||||
if (dirty) {
|
||||
const summary = await summarize(response)
|
||||
await pushToNewBranch(summary, branch, uncommittedChanges, true)
|
||||
// workflow_dispatch has an actor for co-author attribution, schedule does not
|
||||
await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
|
||||
const triggerType = isWorkflowDispatchEvent ? "workflow_dispatch" : "scheduled workflow"
|
||||
const pr = await createPR(
|
||||
repoData.data.default_branch,
|
||||
branch,
|
||||
summary,
|
||||
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
|
||||
`${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
|
||||
)
|
||||
console.log(`Created PR #${pr}`)
|
||||
} else {
|
||||
@@ -573,7 +623,7 @@ export const GithubRunCommand = cmd({
|
||||
} else if (e instanceof Error) {
|
||||
msg = e.message
|
||||
}
|
||||
if (!isScheduleEvent) {
|
||||
if (isUserEvent) {
|
||||
await createComment(`${msg}${footer()}`)
|
||||
await removeReaction(commentType)
|
||||
}
|
||||
@@ -628,9 +678,15 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
|
||||
function isIssueCommentEvent(
|
||||
event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent,
|
||||
event:
|
||||
| IssueCommentEvent
|
||||
| IssuesEvent
|
||||
| PullRequestReviewCommentEvent
|
||||
| WorkflowDispatchEvent
|
||||
| WorkflowRunEvent
|
||||
| PullRequestEvent,
|
||||
): event is IssueCommentEvent {
|
||||
return "issue" in event
|
||||
return "issue" in event && "comment" in event
|
||||
}
|
||||
|
||||
function getReviewCommentContext() {
|
||||
@@ -652,10 +708,11 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
async function getUserPrompt() {
|
||||
const customPrompt = process.env["PROMPT"]
|
||||
// For schedule events, PROMPT is required since there's no comment to extract from
|
||||
if (isScheduleEvent) {
|
||||
// For repo events and issues events, PROMPT is required since there's no comment to extract from
|
||||
if (isRepoEvent || isIssuesEvent) {
|
||||
if (!customPrompt) {
|
||||
throw new Error("PROMPT input is required for scheduled events")
|
||||
const eventType = isRepoEvent ? "scheduled and workflow_dispatch" : "issues"
|
||||
throw new Error(`PROMPT input is required for ${eventType} events`)
|
||||
}
|
||||
return { userPrompt: customPrompt, promptFiles: [] }
|
||||
}
|
||||
@@ -856,10 +913,41 @@ export const GithubRunCommand = cmd({
|
||||
)
|
||||
}
|
||||
|
||||
const match = result.parts.findLast((p) => p.type === "text")
|
||||
if (!match) throw new Error("Failed to parse the text response")
|
||||
const text = extractResponseText(result.parts)
|
||||
if (text) return text
|
||||
|
||||
return match.text
|
||||
// No text part (tool-only or reasoning-only) - ask agent to summarize
|
||||
console.log("Requesting summary from agent...")
|
||||
const summary = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: Identifier.ascending("message"),
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
tools: { "*": false }, // Disable all tools to force text response
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (summary.info.role === "assistant" && summary.info.error) {
|
||||
console.error(summary.info)
|
||||
throw new Error(
|
||||
`${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
const summaryText = extractResponseText(summary.parts)
|
||||
if (!summaryText) {
|
||||
throw new Error("Failed to get summary from agent")
|
||||
}
|
||||
|
||||
return summaryText
|
||||
}
|
||||
|
||||
async function getOidcToken() {
|
||||
@@ -923,7 +1011,7 @@ export const GithubRunCommand = cmd({
|
||||
await $`git config --local ${config} "${gitConfig}"`
|
||||
}
|
||||
|
||||
async function checkoutNewBranch(type: "issue" | "schedule") {
|
||||
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
|
||||
console.log("Checking out new branch...")
|
||||
const branch = generateBranchName(type)
|
||||
await $`git checkout -b ${branch}`
|
||||
@@ -952,16 +1040,16 @@ export const GithubRunCommand = cmd({
|
||||
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
|
||||
}
|
||||
|
||||
function generateBranchName(type: "issue" | "pr" | "schedule") {
|
||||
function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:-]/g, "")
|
||||
.replace(/\.\d{3}Z/, "")
|
||||
.split("T")
|
||||
.join("")
|
||||
if (type === "schedule") {
|
||||
if (type === "schedule" || type === "dispatch") {
|
||||
const hex = crypto.randomUUID().slice(0, 6)
|
||||
return `opencode/scheduled-${hex}-${timestamp}`
|
||||
return `opencode/${type}-${hex}-${timestamp}`
|
||||
}
|
||||
return `opencode/${type}${issueId}-${timestamp}`
|
||||
}
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
import { Server } from "../../server/server"
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("port", {
|
||||
alias: ["p"],
|
||||
type: "number",
|
||||
describe: "port to listen on",
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
}),
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a headless opencode server",
|
||||
handler: async (args) => {
|
||||
const hostname = args.hostname
|
||||
const port = args.port
|
||||
const server = Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
})
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -82,12 +104,21 @@ async function getAllSessions(): Promise<Session.Info[]> {
|
||||
return sessions
|
||||
}
|
||||
|
||||
async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
|
||||
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
|
||||
const sessions = await getAllSessions()
|
||||
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
|
||||
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
|
||||
const MS_IN_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
|
||||
const cutoffTime = (() => {
|
||||
if (days === undefined) return 0
|
||||
if (days === 0) {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
return now.getTime()
|
||||
}
|
||||
return Date.now() - days * MS_IN_DAY
|
||||
})()
|
||||
|
||||
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
|
||||
|
||||
if (projectFilter !== undefined) {
|
||||
if (projectFilter === "") {
|
||||
@@ -112,6 +143,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro
|
||||
},
|
||||
},
|
||||
toolUsage: {},
|
||||
modelUsage: {},
|
||||
dateRange: {
|
||||
earliest: Date.now(),
|
||||
latest: Date.now(),
|
||||
@@ -145,17 +177,43 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +230,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro
|
||||
sessionTokens,
|
||||
sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
|
||||
sessionToolUsage,
|
||||
sessionModelUsage,
|
||||
earliestTime: session.time.created,
|
||||
latestTime: session.time.updated,
|
||||
}
|
||||
@@ -195,10 +254,24 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro
|
||||
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) / DAYS_IN_SECOND))
|
||||
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
|
||||
stats.dateRange = {
|
||||
earliest: earliestTime,
|
||||
latest: latestTime,
|
||||
@@ -219,7 +292,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro
|
||||
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 {
|
||||
@@ -258,6 +331,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)
|
||||
|
||||
@@ -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",
|
||||
@@ -539,7 +548,7 @@ function App() {
|
||||
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
|
||||
const error = evt.properties.error
|
||||
const message = (() => {
|
||||
if (!error) return "An error occured"
|
||||
if (!error) return "An error occurred"
|
||||
|
||||
if (typeof error === "object") {
|
||||
const data = error.data
|
||||
|
||||
@@ -37,11 +37,9 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
const recents = local.model.recent()
|
||||
|
||||
const recentList = showExtra()
|
||||
? recents
|
||||
.filter(
|
||||
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
||||
)
|
||||
.slice(0, 5)
|
||||
? recents.filter(
|
||||
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
||||
)
|
||||
: []
|
||||
|
||||
const favoriteOptions = favorites.flatMap((item) => {
|
||||
@@ -152,7 +150,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
if (inFavorites) return false
|
||||
const inRecents = recents.some(
|
||||
const inRecents = recentList.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
if (inRecents) return false
|
||||
@@ -182,7 +180,10 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
// Apply fuzzy filtering to each section separately, maintaining section order
|
||||
if (q) {
|
||||
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
|
||||
const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj)
|
||||
const filteredRecents = fuzzysort
|
||||
.go(q, recentOptions, { keys: ["title"] })
|
||||
.map((x) => x.obj)
|
||||
.slice(0, 5)
|
||||
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
|
||||
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
|
||||
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { Link } from "../ui/link"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2"
|
||||
@@ -128,7 +129,7 @@ function AutoMethod(props: AutoMethodProps) {
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
<text fg={theme.primary}>{props.authorization.url}</text>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>Waiting for authorization...</text>
|
||||
@@ -170,7 +171,7 @@ function CodeMethod(props: CodeMethodProps) {
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
<text fg={theme.primary}>{props.authorization.url}</text>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>Invalid code</text>
|
||||
</Show>
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { useKV } from "../context/kv"
|
||||
import "opentui-spinner/solid"
|
||||
|
||||
export function DialogSessionList() {
|
||||
@@ -16,6 +17,7 @@ export function DialogSessionList() {
|
||||
const { theme } = useTheme()
|
||||
const route = useRoute()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
|
||||
@@ -45,7 +47,11 @@ export function DialogSessionList() {
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
gutter: isWorking ? <spinner frames={spinnerFrames} interval={80} color={theme.primary} /> : undefined,
|
||||
gutter: isWorking ? (
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
|
||||
</Show>
|
||||
) : undefined,
|
||||
}
|
||||
})
|
||||
.slice(0, 150)
|
||||
|
||||
@@ -12,6 +12,38 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Locale } from "@/util/locale"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
return hashIndex !== -1 ? input.substring(0, hashIndex) : input
|
||||
}
|
||||
|
||||
function extractLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
if (hashIndex === -1) {
|
||||
return { baseQuery: input }
|
||||
}
|
||||
|
||||
const baseName = input.substring(0, hashIndex)
|
||||
const linePart = input.substring(hashIndex + 1)
|
||||
const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
|
||||
|
||||
if (!lineMatch) {
|
||||
return { baseQuery: baseName }
|
||||
}
|
||||
|
||||
const startLine = Number(lineMatch[1])
|
||||
const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
|
||||
|
||||
return {
|
||||
lineRange: {
|
||||
baseName,
|
||||
startLine,
|
||||
endLine,
|
||||
},
|
||||
baseQuery: baseName,
|
||||
}
|
||||
}
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (e: KeyEvent) => void
|
||||
@@ -142,9 +174,11 @@ export function Autocomplete(props: {
|
||||
async (query) => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
|
||||
const { lineRange, baseQuery } = extractLineRange(query ?? "")
|
||||
|
||||
// Get files from SDK
|
||||
const result = await sdk.client.find.files({
|
||||
query: query ?? "",
|
||||
query: baseQuery,
|
||||
})
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
@@ -153,15 +187,27 @@ export function Autocomplete(props: {
|
||||
if (!result.error && result.data) {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...result.data.map(
|
||||
(item): AutocompleteOption => ({
|
||||
display: Locale.truncateMiddle(item, width),
|
||||
...result.data.map((item): AutocompleteOption => {
|
||||
let url = `file://${process.cwd()}/${item}`
|
||||
let filename = item
|
||||
if (lineRange && !item.endsWith("/")) {
|
||||
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
|
||||
const urlObj = new URL(url)
|
||||
urlObj.searchParams.set("start", String(lineRange.startLine))
|
||||
if (lineRange.endLine !== undefined) {
|
||||
urlObj.searchParams.set("end", String(lineRange.endLine))
|
||||
}
|
||||
url = urlObj.toString()
|
||||
}
|
||||
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
onSelect: () => {
|
||||
insertPart(item, {
|
||||
insertPart(filename, {
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: item,
|
||||
url: `file://${process.cwd()}/${item}`,
|
||||
filename,
|
||||
url,
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
@@ -173,8 +219,8 @@ export function Autocomplete(props: {
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -213,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 + " "
|
||||
@@ -383,8 +429,8 @@ export function Autocomplete(props: {
|
||||
return prev
|
||||
}
|
||||
|
||||
const result = fuzzysort.go(currentFilter, mixed, {
|
||||
keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""],
|
||||
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
|
||||
keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""],
|
||||
limit: 10,
|
||||
scoreFn: (objResults) => {
|
||||
const displayResult = objResults[0]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user