mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-27 09:04:41 +00:00
Compare commits
349 Commits
startup
...
effect/too
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eeb1750fa | ||
|
|
b242a8d8e4 | ||
|
|
9c6f1edfd7 | ||
|
|
ef7d1f7efa | ||
|
|
b7a06e1939 | ||
|
|
311ba4179a | ||
|
|
ad3b350672 | ||
|
|
590523dcd1 | ||
|
|
b8fb75a94a | ||
|
|
98a31e30cc | ||
|
|
c333e914ee | ||
|
|
c7760b433b | ||
|
|
2e6ac8ff49 | ||
|
|
1ebc92fd36 | ||
|
|
9f94bdb496 | ||
|
|
28f5176ffd | ||
|
|
38450443b1 | ||
|
|
da1d37274f | ||
|
|
17e8f577d6 | ||
|
|
c7d23098d1 | ||
|
|
bcf18edde4 | ||
|
|
9a2482ac09 | ||
|
|
54443bfb7e | ||
|
|
ec20efc11a | ||
|
|
83ed1c4414 | ||
|
|
1d363fa19f | ||
|
|
1b028d0632 | ||
|
|
d500a8432a | ||
|
|
2d502d6ffe | ||
|
|
2ad190e482 | ||
|
|
16742af7f3 | ||
|
|
652313e036 | ||
|
|
1a4a6eabe2 | ||
|
|
ba244a6e62 | ||
|
|
7cb690d7e5 | ||
|
|
31ad6e85ba | ||
|
|
ea04b23745 | ||
|
|
05c3cfb2aa | ||
|
|
f54e4b60cc | ||
|
|
97c15a087d | ||
|
|
b90de755f9 | ||
|
|
8864fdce2f | ||
|
|
5179b87aef | ||
|
|
66a56551be | ||
|
|
7123aad5a8 | ||
|
|
d6fc5f414b | ||
|
|
77fc88c8ad | ||
|
|
cafc2b204b | ||
|
|
36709aae5f | ||
|
|
fac0dd8862 | ||
|
|
73e107250d | ||
|
|
b746aec493 | ||
|
|
ad40b65b0b | ||
|
|
971383661a | ||
|
|
b0017bf1b9 | ||
|
|
0c0c6f3bdb | ||
|
|
b480a38d31 | ||
|
|
4167e25c7e | ||
|
|
1041ae91d1 | ||
|
|
898456a25c | ||
|
|
53d0b58ebf | ||
|
|
2b0baf97bd | ||
|
|
0dbfefa080 | ||
|
|
d1c49ba210 | ||
|
|
3ea72aec21 | ||
|
|
9717383823 | ||
|
|
5d9e780029 | ||
|
|
aa11fa865d | ||
|
|
9a64bdb539 | ||
|
|
71693cc24b | ||
|
|
700f57112a | ||
|
|
0a80ef4278 | ||
|
|
4f9667c4bb | ||
|
|
be142b00bd | ||
|
|
45c2573979 | ||
|
|
79e9d19019 | ||
|
|
958a80cc05 | ||
|
|
4647aa80ac | ||
|
|
a379eb3867 | ||
|
|
cbe1337f24 | ||
|
|
50f6aa3763 | ||
|
|
0dcdf5f529 | ||
|
|
4586b41ffd | ||
|
|
35884defd8 | ||
|
|
15dc33d1a3 | ||
|
|
1398674e53 | ||
|
|
afc4c831eb | ||
|
|
ec64ceabec | ||
|
|
56644be95a | ||
|
|
00d3b831fc | ||
|
|
b848b7ebae | ||
|
|
e837dcc1c5 | ||
|
|
024979f3fd | ||
|
|
bc608fb081 | ||
|
|
9838f56a6f | ||
|
|
98b3340cee | ||
|
|
5e684c6e80 | ||
|
|
2c1d8a90d5 | ||
|
|
8994cbfc0f | ||
|
|
42a773481e | ||
|
|
539b01f20f | ||
|
|
814a515a8a | ||
|
|
235a82aea9 | ||
|
|
9330bc5339 | ||
|
|
1238d1f61a | ||
|
|
1d3232b388 | ||
|
|
5c1bb5de86 | ||
|
|
7c5ed771c3 | ||
|
|
31c4a4fb47 | ||
|
|
037077285a | ||
|
|
41c77ccb33 | ||
|
|
546748a461 | ||
|
|
c9c93eac00 | ||
|
|
3f1a4abe6d | ||
|
|
431e0586ad | ||
|
|
fde201c286 | ||
|
|
d3debc191f | ||
|
|
34f43fff89 | ||
|
|
49623aa519 | ||
|
|
f1340472ec | ||
|
|
a8b28826a0 | ||
|
|
a03a2b6eab | ||
|
|
ad78b79b8a | ||
|
|
9a006d8700 | ||
|
|
3a0bf2f39f | ||
|
|
b556979634 | ||
|
|
691644eeeb | ||
|
|
4aebaaf067 | ||
|
|
77b3b46788 | ||
|
|
36dfe1646b | ||
|
|
6926dc26d1 | ||
|
|
eb74e4a6d2 | ||
|
|
85d8e143bf | ||
|
|
8e1b53b32c | ||
|
|
0a7dfc03ee | ||
|
|
4c27e7fc64 | ||
|
|
0f5626d2e4 | ||
|
|
5ea95451dd | ||
|
|
9239d877b9 | ||
|
|
fc68c24433 | ||
|
|
db9619dad6 | ||
|
|
84d9b38873 | ||
|
|
8035c3435b | ||
|
|
71e7603d71 | ||
|
|
40e49c5b49 | ||
|
|
afe9b97274 | ||
|
|
3b3549902d | ||
|
|
e9a9c75c1f | ||
|
|
2b171828b0 | ||
|
|
8dd817023a | ||
|
|
0d6c601365 | ||
|
|
5460bf9989 | ||
|
|
eb3bfffad4 | ||
|
|
e2d03ce38c | ||
|
|
32f9dc6383 | ||
|
|
c529529f84 | ||
|
|
13bac9c91a | ||
|
|
fe53af4819 | ||
|
|
e82c5a9a28 | ||
|
|
3236f228fb | ||
|
|
0e0e7a4a4b | ||
|
|
10a3d6c54e | ||
|
|
832b8e252e | ||
|
|
040f551c57 | ||
|
|
cc818f8032 | ||
|
|
d5337b41f4 | ||
|
|
9f7a76d6c0 | ||
|
|
6a16db4b92 | ||
|
|
9ad6588f3e | ||
|
|
fb6bf0b35e | ||
|
|
f80343b875 | ||
|
|
9b805e1cc4 | ||
|
|
2e0d5d2308 | ||
|
|
38e0dc9ccd | ||
|
|
40aeaa120d | ||
|
|
6a64177589 | ||
|
|
5dc47905a9 | ||
|
|
dc0044882c | ||
|
|
45ae7dc653 | ||
|
|
129fe1e350 | ||
|
|
214a6c6cf1 | ||
|
|
3f249aba6d | ||
|
|
5c6ec1caac | ||
|
|
24f9df5463 | ||
|
|
12b8e1c2be | ||
|
|
d70099b059 | ||
|
|
ce845a0b1b | ||
|
|
05d3e65f76 | ||
|
|
51618e9cef | ||
|
|
e78944e9a4 | ||
|
|
bfdc38e421 | ||
|
|
83023e4f0f | ||
|
|
d0a57305ef | ||
|
|
27a70ad70f | ||
|
|
0bbf26a1ce | ||
|
|
83cdb4de64 | ||
|
|
4989632245 | ||
|
|
d460614cd7 | ||
|
|
7866dbcfcc | ||
|
|
e71a21e0a8 | ||
|
|
1071aca91f | ||
|
|
b3d0446d13 | ||
|
|
949191ab74 | ||
|
|
92cd908fb5 | ||
|
|
6fcc970def | ||
|
|
52a7a04ad8 | ||
|
|
37b8662a9d | ||
|
|
ddcb32ae0b | ||
|
|
2c056c90da | ||
|
|
812d1bb32a | ||
|
|
9a58c43ef4 | ||
|
|
63585db6a7 | ||
|
|
bd44489ada | ||
|
|
a6ef9e9206 | ||
|
|
6e09a1d904 | ||
|
|
4f21757e0d | ||
|
|
2dbcd79fd2 | ||
|
|
48a7f0fd93 | ||
|
|
d69962b0f7 | ||
|
|
a6f23cb08e | ||
|
|
0540751897 | ||
|
|
baa204193c | ||
|
|
aeece6166b | ||
|
|
0d7e62a532 | ||
|
|
41aa254db4 | ||
|
|
d178d8249f | ||
|
|
e6f5214779 | ||
|
|
84f60d97a0 | ||
|
|
cbf4b68fee | ||
|
|
bd4527b4f2 | ||
|
|
f4a9fe29a3 | ||
|
|
5a0bfa7061 | ||
|
|
1ac1a0287c | ||
|
|
8e09e8c612 | ||
|
|
84e62fc662 | ||
|
|
a7ea93528b | ||
|
|
d90e3a2833 | ||
|
|
1c74c2741a | ||
|
|
5d2f8d77f9 | ||
|
|
81be544981 | ||
|
|
773c1192dc | ||
|
|
5ddfe4ada5 | ||
|
|
a93d98bd94 | ||
|
|
54ed87d53c | ||
|
|
8ee939c741 | ||
|
|
1b0096bf61 | ||
|
|
8006c29db3 | ||
|
|
3f1c96a0bb | ||
|
|
3558deba4a | ||
|
|
c3ddc85cca | ||
|
|
a800583aea | ||
|
|
171e69c2fc | ||
|
|
822bb7b336 | ||
|
|
47cf267c23 | ||
|
|
976aae7e42 | ||
|
|
0ca51eebcf | ||
|
|
3256886e25 | ||
|
|
d2194f6dde | ||
|
|
bfd4787fcd | ||
|
|
58dce0148a | ||
|
|
79635b8b41 | ||
|
|
331dacf9db | ||
|
|
4ba7d3b406 | ||
|
|
a43783a6d4 | ||
|
|
37c5295111 | ||
|
|
56102ff642 | ||
|
|
1b86c27fb8 | ||
|
|
fe43bdb699 | ||
|
|
a849a17e93 | ||
|
|
0292f1b559 | ||
|
|
5dfe86dcb1 | ||
|
|
4b4dd2b882 | ||
|
|
bc949af623 | ||
|
|
9e7c136de7 | ||
|
|
fee3c196c5 | ||
|
|
6c047391bb | ||
|
|
350df0b261 | ||
|
|
fbabc97c4c | ||
|
|
7daea69e13 | ||
|
|
0772a95918 | ||
|
|
dadddc9c8c | ||
|
|
6708c3f6cf | ||
|
|
ba22976568 | ||
|
|
0afeaea21f | ||
|
|
b07b5a9b7f | ||
|
|
dbbe931a18 | ||
|
|
e14e874e51 | ||
|
|
544315dff7 | ||
|
|
f13da808ff | ||
|
|
e416e59ea6 | ||
|
|
cb69501098 | ||
|
|
a64f604d54 | ||
|
|
d7093abf61 | ||
|
|
60af447908 | ||
|
|
1cdc558ac0 | ||
|
|
3849822769 | ||
|
|
e9a17e4480 | ||
|
|
68809365df | ||
|
|
8da511dfa8 | ||
|
|
69381f6aea | ||
|
|
df6508530f | ||
|
|
335356280c | ||
|
|
03d84f49c2 | ||
|
|
2cbdf04ec9 | ||
|
|
410fbd8a00 | ||
|
|
e5cbecf17c | ||
|
|
ca3af5dc6a | ||
|
|
9e740d9947 | ||
|
|
d4694d058c | ||
|
|
469c3a4204 | ||
|
|
4cb29967f6 | ||
|
|
e718db624f | ||
|
|
15b27e0d18 | ||
|
|
c523aac586 | ||
|
|
51fcd04a70 | ||
|
|
4d7cbdcbef | ||
|
|
59c530cc6c | ||
|
|
c2ca1494e5 | ||
|
|
4ee426ba54 | ||
|
|
510374207d | ||
|
|
aedbecedf7 | ||
|
|
9c00669927 | ||
|
|
b9f6b40e3a | ||
|
|
ad06d8f496 | ||
|
|
2fc06c5a17 | ||
|
|
52877d8765 | ||
|
|
8f957b8f90 | ||
|
|
0befa1e57e | ||
|
|
f015154314 | ||
|
|
689d9e14ea | ||
|
|
66e8c57ed1 | ||
|
|
b698f14e55 | ||
|
|
cec1255b36 | ||
|
|
88226f3061 | ||
|
|
8c53b2b470 | ||
|
|
f2d3a4c70f | ||
|
|
4b9b86b544 | ||
|
|
f54abe58cf | ||
|
|
d954026dd8 | ||
|
|
4ad8116ce3 | ||
|
|
5c7088338c | ||
|
|
389daa03df | ||
|
|
1cbe7b0854 | ||
|
|
050d71bcf9 | ||
|
|
ffde837e83 | ||
|
|
536abea2e2 | ||
|
|
c7a52b6a2d | ||
|
|
c4ccb50c37 | ||
|
|
5aaf1ddfb7 |
5
.github/VOUCHED.td
vendored
5
.github/VOUCHED.td
vendored
@@ -10,6 +10,9 @@
|
||||
adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-atharvau AI review spamming literally every PR
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
edemaine
|
||||
-florianleibert
|
||||
fwang
|
||||
@@ -17,7 +20,9 @@ iamdavidhill
|
||||
jayair
|
||||
kitlangton
|
||||
kommander
|
||||
-opencode2026
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-OpenCodeEngineer bot that spams issues
|
||||
|
||||
10
.github/actions/setup-bun/action.yml
vendored
10
.github/actions/setup-bun/action.yml
vendored
@@ -41,5 +41,13 @@ runs:
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
run: |
|
||||
# Workaround for patched peer variants
|
||||
# e.g. ./patches/ for standard-openapi
|
||||
# https://github.com/oven-sh/bun/issues/28147
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
bun install --linker hoisted
|
||||
else
|
||||
bun install
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
24
.github/workflows/close-issues.yml
vendored
Normal file
24
.github/workflows/close-issues.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: close-issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # Daily at 2:00 AM
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
close:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Close stale issues
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bun script/github/close-issues.ts
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
||||
2
.github/workflows/nix-hashes.yml
vendored
2
.github/workflows/nix-hashes.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
|
||||
# Extract hash from build log with portability
|
||||
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
|
||||
if [ -z "$HASH" ]; then
|
||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||
|
||||
33
.github/workflows/stale-issues.yml
vendored
33
.github/workflows/stale-issues.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: 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"
|
||||
28
.github/workflows/test.yml
vendored
28
.github/workflows/test.yml
vendored
@@ -50,20 +50,17 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: unit
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
playwright: bunx playwright install --with-deps
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
playwright: bunx playwright install
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -76,9 +73,28 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install Playwright browsers
|
||||
- name: Read Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}/.playwright-browsers
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
||||
|
||||
- name: Install Playwright system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/app
|
||||
run: ${{ matrix.settings.playwright }}
|
||||
run: bunx playwright install-deps chromium
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install chromium
|
||||
|
||||
- name: Run app e2e tests
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
|
||||
2
.github/workflows/vouch-manage-by-issue.yml
vendored
2
.github/workflows/vouch-manage-by-issue.yml
vendored
@@ -33,6 +33,6 @@ jobs:
|
||||
with:
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
roles: admin,maintain
|
||||
roles: admin,maintain,write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
8
.opencode/.gitignore
vendored
8
.opencode/.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
plans/
|
||||
bun.lock
|
||||
node_modules
|
||||
plans
|
||||
package.json
|
||||
package-lock.json
|
||||
bun.lock
|
||||
.gitignore
|
||||
package-lock.json
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
description: Translate content for a specified locale while preserving technical terms
|
||||
mode: subagent
|
||||
model: opencode/gemini-3-pro
|
||||
model: opencode/gpt-5.4
|
||||
---
|
||||
|
||||
You are a professional translator and localization specialist.
|
||||
|
||||
23
.opencode/command/changelog.md
Normal file
23
.opencode/command/changelog.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
model: opencode/kimi-k2.5
|
||||
---
|
||||
|
||||
create UPCOMING_CHANGELOG.md
|
||||
|
||||
it should have sections
|
||||
|
||||
```
|
||||
## TUI
|
||||
|
||||
## Desktop
|
||||
|
||||
## Core
|
||||
|
||||
## Misc
|
||||
```
|
||||
|
||||
fetch the latest github release for this repository to determine the last release version.
|
||||
|
||||
find each PR that was merged since the last release
|
||||
|
||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
|
||||
@@ -1,7 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-pr-search.txt"
|
||||
|
||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||
...options,
|
||||
@@ -24,7 +22,16 @@ interface PR {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: DESCRIPTION,
|
||||
description: `Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
- Labels
|
||||
- Description snippet
|
||||
|
||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
|
||||
args: {
|
||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
- Labels
|
||||
- Description snippet
|
||||
|
||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
|
||||
@@ -1,7 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-triage.txt"
|
||||
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
zen: ["fwang", "MrMushrooooom"],
|
||||
@@ -40,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: DESCRIPTION,
|
||||
description: `Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||
args: {
|
||||
assignee: tool.schema
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
181
bun.lock
181
bun.lock
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -41,12 +41,14 @@
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
@@ -77,7 +79,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -111,7 +113,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -128,7 +130,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -138,7 +140,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -162,7 +164,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -186,7 +188,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -219,7 +221,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -227,7 +229,7 @@
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"effect": "catalog:",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "^10",
|
||||
"electron-updater": "^6",
|
||||
@@ -250,7 +252,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -279,7 +281,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -295,7 +297,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -324,11 +326,10 @@
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -337,8 +338,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -351,11 +352,13 @@
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.3.3",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -366,6 +369,8 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
@@ -400,14 +405,15 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
@@ -416,7 +422,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -440,7 +446,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -451,7 +457,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -486,7 +492,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -532,7 +538,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -543,7 +549,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -583,6 +589,8 @@
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
},
|
||||
"overrides": {
|
||||
@@ -591,6 +599,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/platform-node": "4.0.0-beta.37",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -604,7 +613,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -612,9 +621,9 @@
|
||||
"ai": "5.0.124",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.37",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -972,6 +981,10 @@
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.37", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.37", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.37", "ioredis": "^5.7.0" } }, "sha512-dCfTNYGAT+1K+nu/0jw3FL/0DJXcobZCJs9SD5XJbj1DewWPhR9/AptP6zLGj8vdP8hXem6Aa53nze3HSujW3w=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.40", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.40" } }, "sha512-WMRVG7T8ZDALKCOacsx2ZZj3Ccaoq8YGeD9q7ZL4q8RwQv8Nmrl+4+KZl95/zHCqXzgK9oUJOlBfQ7CZr6PQOQ=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
|
||||
@@ -1100,10 +1113,6 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
@@ -1168,6 +1177,8 @@
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
|
||||
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
|
||||
|
||||
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
|
||||
|
||||
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
|
||||
@@ -1314,7 +1325,7 @@
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||
|
||||
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
||||
|
||||
@@ -1438,21 +1449,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.90", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -1880,6 +1891,8 @@
|
||||
|
||||
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
|
||||
|
||||
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
|
||||
|
||||
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
|
||||
|
||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
|
||||
@@ -1958,10 +1971,14 @@
|
||||
|
||||
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="],
|
||||
|
||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
|
||||
|
||||
"@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
|
||||
|
||||
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
|
||||
@@ -2044,7 +2061,7 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
|
||||
|
||||
@@ -2052,6 +2069,8 @@
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
@@ -2438,7 +2457,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
|
||||
|
||||
@@ -2536,6 +2555,8 @@
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
@@ -2722,9 +2743,9 @@
|
||||
|
||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="],
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
|
||||
|
||||
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
||||
|
||||
@@ -2738,7 +2759,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
|
||||
"effect": ["effect@4.0.0-beta.37", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AVMXXtb6n62W4uvo1EvT7FJ41HfDvQRX8IY2FGPvfP361dtBArKK2JtE5vmFXTsxkW90WUdvJZYpVATGIzr/BA=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -2868,7 +2889,7 @@
|
||||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
|
||||
|
||||
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
|
||||
|
||||
@@ -3006,6 +3027,8 @@
|
||||
|
||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
@@ -3014,6 +3037,8 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
@@ -3176,6 +3201,8 @@
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
@@ -3408,10 +3435,14 @@
|
||||
|
||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
|
||||
@@ -3598,7 +3629,7 @@
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
@@ -3760,6 +3791,10 @@
|
||||
|
||||
"opencode": ["opencode@workspace:packages/opencode"],
|
||||
|
||||
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
|
||||
|
||||
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
|
||||
|
||||
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
|
||||
|
||||
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
|
||||
@@ -3892,6 +3927,8 @@
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"poe-oauth": ["poe-oauth@0.0.3", "", {}, "sha512-KgxDylcuq/mov8URSplrBGjrIjkQwjN/Ml8BhqaGsAvHzYN3yhuROdv1sDRfwqncg7TT8XzJvMeJAWmv/4NDLw=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
@@ -4022,6 +4059,10 @@
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
@@ -4084,6 +4125,8 @@
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
|
||||
@@ -4216,7 +4259,7 @@
|
||||
|
||||
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
|
||||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
|
||||
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
|
||||
|
||||
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
|
||||
|
||||
@@ -4280,6 +4323,8 @@
|
||||
|
||||
"stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
@@ -4986,12 +5031,18 @@
|
||||
|
||||
"@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
|
||||
|
||||
"@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -5022,16 +5073,14 @@
|
||||
|
||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
|
||||
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
@@ -5080,6 +5129,8 @@
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.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-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
@@ -5162,8 +5213,6 @@
|
||||
|
||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
@@ -5352,6 +5401,8 @@
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
|
||||
|
||||
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
|
||||
|
||||
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
@@ -5418,6 +5469,10 @@
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"gitlab-ai-provider/openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="],
|
||||
|
||||
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
@@ -5494,6 +5549,10 @@
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||
@@ -6244,12 +6303,18 @@
|
||||
|
||||
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode-poe-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@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=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@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=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.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-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772091128,
|
||||
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
|
||||
"lastModified": 1773909469,
|
||||
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3f0336406035444b4a24b942788334af5f906259",
|
||||
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
todoread: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||
|
||||
@@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
||||
properties: {
|
||||
product: zenLiteProduct.id,
|
||||
price: zenLitePrice.id,
|
||||
priceInr: 92900,
|
||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||
},
|
||||
})
|
||||
@@ -201,6 +202,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
||||
@@ -219,6 +224,9 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
ZEN_BLACK_PRICE,
|
||||
ZEN_LITE_PRICE,
|
||||
new sst.Secret("ZEN_LIMITS"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
|
||||
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
|
||||
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
|
||||
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
|
||||
"x86_64-linux": "sha256-YI/VXZYi/5BEKRGWCHVqEBmMgBP5VMVJyL06OJlfQxY=",
|
||||
"aarch64-linux": "sha256-HvGPC4TuLnCNAty8nr+JwnPkV+MtrPnso3VPmgCe06Y=",
|
||||
"aarch64-darwin": "sha256-DKzYPvFsKy8utQZbiWWPWukPEle/SuFQz1FakWzObA8=",
|
||||
"x86_64-darwin": "sha256-311yDcV1P3gaFh75j3uoe3eTuZJn48E7OVgNjLxSpIo="
|
||||
}
|
||||
}
|
||||
|
||||
16
package.json
16
package.json
@@ -4,11 +4,12 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.10",
|
||||
"packageManager": "bun@1.3.11",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky",
|
||||
@@ -24,7 +25,8 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.9",
|
||||
"@effect/platform-node": "4.0.0-beta.37",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
@@ -41,9 +43,9 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.37",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -110,6 +112,8 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,8 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
|
||||
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
|
||||
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||
|
||||
### Wait on state
|
||||
@@ -182,6 +184,9 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
- Avoid race-prone flows that assume work is finished after an action
|
||||
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
||||
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
||||
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
|
||||
- Do not treat a visible element as proof that the app will route the next action to it
|
||||
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
|
||||
|
||||
### Add hooks
|
||||
|
||||
@@ -189,11 +194,16 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
||||
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
||||
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
||||
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
|
||||
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
|
||||
|
||||
### Prefer helpers
|
||||
|
||||
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
||||
- Use direct locators when the interaction is simple and a helper would not add clarity
|
||||
- Prefer helpers that both perform an action and verify the app consumed it
|
||||
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
|
||||
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, type Locator, type Page } from "@playwright/test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
@@ -8,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
projectSwitchSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectCloseMenuSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
@@ -16,11 +18,22 @@ import {
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
promptSelector,
|
||||
terminalSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
|
||||
const phase = new WeakMap<Page, "test" | "cleanup">()
|
||||
|
||||
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
||||
phase.set(page, value)
|
||||
}
|
||||
|
||||
export function healthPhase(page: Page) {
|
||||
return phase.get(page) ?? "test"
|
||||
}
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page
|
||||
.evaluate(() => {
|
||||
@@ -61,6 +74,15 @@ async function terminalReady(page: Page, term?: Locator) {
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalFocusIdle(page: Page, term?: Locator) {
|
||||
const next = term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||
return (state?.focusing ?? 0) === 0
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||
const next = input.term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
@@ -73,6 +95,29 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string })
|
||||
)
|
||||
}
|
||||
|
||||
async function promptSlashActive(page: Page, id: string) {
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||
if (state?.popover !== "slash") return false
|
||||
if (!state.slash.ids.includes(id)) return false
|
||||
return state.slash.active === id
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function promptSlashSelects(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
|
||||
return page.evaluate((input) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||
if (!state) return false
|
||||
return state.selected === input.id && state.selects >= input.count
|
||||
}, input)
|
||||
}
|
||||
|
||||
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
@@ -81,6 +126,43 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti
|
||||
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
await waitTerminalReady(page, { term, timeout })
|
||||
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function showPromptSlash(
|
||||
page: Page,
|
||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||
) {
|
||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||
const timeout = input.timeout ?? 10_000
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await prompt.click().catch(() => false)
|
||||
await prompt.fill(input.text).catch(() => false)
|
||||
return promptSlashActive(page, input.id).catch(() => false)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export async function runPromptSlash(
|
||||
page: Page,
|
||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||
) {
|
||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||
const timeout = input.timeout ?? 10_000
|
||||
const count = await promptSlashSelects(page)
|
||||
await showPromptSlash(page, input)
|
||||
await prompt.press("Enter")
|
||||
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||
const term = input.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input.timeout ?? 10_000
|
||||
@@ -93,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
|
||||
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
export async function openPalette(page: Page, key = "K") {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
await page.keyboard.press(`${modKey}+${key}`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
@@ -125,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await expect(button).toBeVisible()
|
||||
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||
}
|
||||
|
||||
async function errorBoundaryText(page: Page) {
|
||||
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||
if (!(await title.isVisible().catch(() => false))) return
|
||||
|
||||
const description = await page
|
||||
.getByText(/an error occurred while loading the application\./i)
|
||||
.first()
|
||||
.textContent()
|
||||
.catch(() => "")
|
||||
const detail = await page
|
||||
.getByRole("textbox", { name: /error details/i })
|
||||
.first()
|
||||
.inputValue()
|
||||
.catch(async () =>
|
||||
(
|
||||
(await page
|
||||
.getByRole("textbox", { name: /error details/i })
|
||||
.first()
|
||||
.textContent()
|
||||
.catch(() => "")) ?? ""
|
||||
).trim(),
|
||||
)
|
||||
|
||||
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
export async function assertHealthy(page: Page, context: string) {
|
||||
const text = await errorBoundaryText(page)
|
||||
if (!text) return
|
||||
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||
throw new Error(`Error boundary during ${context}\n${text}`)
|
||||
}
|
||||
|
||||
async function waitSidebarButton(page: Page, context: string) {
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
||||
await assertHealthy(page, context)
|
||||
return button
|
||||
}
|
||||
|
||||
export async function toggleSidebar(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
@@ -138,7 +260,7 @@ export async function toggleSidebar(page: Page) {
|
||||
export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const button = await waitSidebarButton(page, "openSidebar")
|
||||
await button.click()
|
||||
|
||||
const opened = await expect(button)
|
||||
@@ -155,7 +277,7 @@ export async function openSidebar(page: Page) {
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const button = await waitSidebarButton(page, "closeSidebar")
|
||||
await button.click()
|
||||
|
||||
const closed = await expect(button)
|
||||
@@ -170,6 +292,7 @@ export async function closeSidebar(page: Page) {
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
await assertHealthy(page, "openSettings")
|
||||
await defocus(page)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
@@ -182,6 +305,8 @@ export async function openSettings(page: Page) {
|
||||
|
||||
if (opened) return dialog
|
||||
|
||||
await assertHealthy(page, "openSettings")
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
return dialog
|
||||
@@ -243,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
||||
|
||||
export async function createTestProject() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||
const id = `e2e-${path.basename(root)}`
|
||||
|
||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
||||
|
||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||
@@ -268,12 +395,24 @@ export function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function probeSession(page: Page) {
|
||||
return page
|
||||
.evaluate(() => {
|
||||
const win = window as E2EWindow
|
||||
const current = win.__opencode_e2e?.model?.current
|
||||
if (!current) return null
|
||||
return { dir: current.dir, sessionID: current.sessionID }
|
||||
})
|
||||
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
||||
}
|
||||
|
||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
let prev = ""
|
||||
let next = ""
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSlug")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (skip.includes(slug)) return ""
|
||||
@@ -291,6 +430,97 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
return next
|
||||
}
|
||||
|
||||
export async function resolveSlug(slug: string) {
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
const resolved = await resolveDirectory(directory)
|
||||
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||
}
|
||||
|
||||
export async function waitDir(page: Page, directory: string) {
|
||||
const target = await resolveDirectory(directory)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitDir")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
return resolveSlug(slug)
|
||||
.then((item) => item.directory)
|
||||
.catch(() => "")
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(target)
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||
const target = await resolveDirectory(input.directory)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSession")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return false
|
||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||
if (!resolved || resolved.directory !== target) return false
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (input.sessionID && current !== input.sessionID) return false
|
||||
if (!input.sessionID && current) return false
|
||||
|
||||
const state = await probeSession(page)
|
||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||
if (!input.sessionID && state?.sessionID) return false
|
||||
if (state?.dir) {
|
||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||
if (dir !== target) return false
|
||||
}
|
||||
|
||||
return page
|
||||
.locator(promptSelector)
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||
const sdk = createSdk(directory)
|
||||
const target = await resolveDirectory(directory)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session
|
||||
.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!data?.directory) return ""
|
||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(target)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = await sdk.session
|
||||
.messages({ sessionID, limit: 20 })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
return items.some((item) => item.info.role === "user")
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
@@ -702,8 +932,14 @@ export async function openStatusPopover(page: Page) {
|
||||
}
|
||||
|
||||
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
await openSidebar(page)
|
||||
const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
|
||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await expect(trigger).toBeVisible()
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
@@ -712,7 +948,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||
|
||||
const clicked = await trigger
|
||||
.click({ timeout: 1500 })
|
||||
.click({ force: true, timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
|
||||
|
||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
|
||||
|
||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||
await expect(nav.getByText("No projects open")).toBeVisible()
|
||||
await expect(nav.getByText("Open a project to get started")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openPalette } from "../actions"
|
||||
import { closeDialog, openPalette } from "../actions"
|
||||
|
||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openPalette(page, "P")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import type { E2EWindow } from "../src/testing/terminal"
|
||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import {
|
||||
healthPhase,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
createTestProject,
|
||||
setHealthPhase,
|
||||
seedProjects,
|
||||
sessionIDFromUrl,
|
||||
waitSlug,
|
||||
waitSession,
|
||||
} from "./actions"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
export const settingsKey = "settings.v3"
|
||||
@@ -27,6 +36,29 @@ type WorkerFixtures = {
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
page: async ({ page }, use) => {
|
||||
let boundary: string | undefined
|
||||
setHealthPhase(page, "test")
|
||||
const consoleHandler = (msg: { text(): string }) => {
|
||||
const text = msg.text()
|
||||
if (!text.includes("[e2e:error-boundary]")) return
|
||||
if (healthPhase(page) === "cleanup") {
|
||||
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
|
||||
return
|
||||
}
|
||||
boundary ||= text
|
||||
console.log(text)
|
||||
}
|
||||
const pageErrorHandler = (err: Error) => {
|
||||
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
|
||||
}
|
||||
page.on("console", consoleHandler)
|
||||
page.on("pageerror", pageErrorHandler)
|
||||
await use(page)
|
||||
page.off("console", consoleHandler)
|
||||
page.off("pageerror", pageErrorHandler)
|
||||
if (boundary) throw new Error(boundary)
|
||||
},
|
||||
directory: [
|
||||
async ({}, use) => {
|
||||
const directory = await getWorktree()
|
||||
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await waitSession(page, { directory, sessionID })
|
||||
}
|
||||
await use(gotoSession)
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const root = await createTestProject()
|
||||
const slug = dirSlug(root)
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await waitSession(page, { directory: root, sessionID })
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
const slug = await waitSlug(page)
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
} finally {
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -95,6 +129,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
|
||||
const win = window as E2EWindow
|
||||
win.__opencode_e2e = {
|
||||
...win.__opencode_e2e,
|
||||
model: {
|
||||
enabled: true,
|
||||
},
|
||||
prompt: {
|
||||
enabled: true,
|
||||
},
|
||||
terminal: {
|
||||
enabled: true,
|
||||
terminals: {},
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
||||
import {
|
||||
defocus,
|
||||
createTestProject,
|
||||
cleanupTestProject,
|
||||
openSidebar,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { dirSlug, resolveDirectory } from "../utils"
|
||||
|
||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||
await page.evaluate(
|
||||
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
||||
const key = "opencode.global.dat:layout"
|
||||
const raw = localStorage.getItem(key)
|
||||
const data = raw ? JSON.parse(raw) : {}
|
||||
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
||||
const current =
|
||||
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
||||
? sidebar.workspaces
|
||||
: {}
|
||||
const next = { ...current }
|
||||
|
||||
if (enabled) next[directory] = true
|
||||
if (!enabled) delete next[directory]
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
...data,
|
||||
sidebar: {
|
||||
...sidebar,
|
||||
workspaces: next,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, enabled },
|
||||
)
|
||||
}
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
@@ -76,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
await withProject(
|
||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||
await defocus(page)
|
||||
await workspaces(page, directory, true)
|
||||
await page.reload()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await openSidebar(page)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
@@ -100,11 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click({ force: true })
|
||||
|
||||
// A new workspace can be discovered via a transient slug before the route and sidebar
|
||||
// settle to the canonical workspace path on Windows, so interact with either and assert
|
||||
// against the resolved workspace slug.
|
||||
await waitSlug(page)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||
await waitSession(page, { directory: space })
|
||||
|
||||
// Create a session by sending a prompt
|
||||
const prompt = page.locator(promptSelector)
|
||||
@@ -118,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||
trackSession(created, space)
|
||||
await waitSessionSaved(space, created)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
@@ -125,14 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
await otherButton.click({ force: true })
|
||||
await waitSession(page, { directory: other })
|
||||
|
||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(rootButton).toBeVisible()
|
||||
await rootButton.click()
|
||||
await rootButton.click({ force: true })
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
||||
await waitSession(page, { directory: space, sessionID: created })
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
|
||||
@@ -1,109 +1,94 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
||||
import {
|
||||
openSidebar,
|
||||
resolveSlug,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitDir,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||
function item(space: { slug: string; raw: string }) {
|
||||
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||
}
|
||||
|
||||
function button(space: { slug: string; raw: string }) {
|
||||
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
|
||||
}
|
||||
|
||||
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||
await openSidebar(page)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
||||
}
|
||||
|
||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await openSidebar(page)
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
const slug = await waitSlug(page, [root, ...seen])
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
return { slug, directory }
|
||||
}
|
||||
|
||||
async function openWorkspaceNewSession(page: Page, slug: string) {
|
||||
await waitWorkspaceReady(page, slug)
|
||||
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
await item.hover()
|
||||
|
||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click({ force: true })
|
||||
|
||||
const next = await waitSlug(page)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||
await waitDir(page, next.directory)
|
||||
return next
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||
const next = await openWorkspaceNewSession(page, slug)
|
||||
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
|
||||
await waitWorkspaceReady(page, space)
|
||||
|
||||
const row = page.locator(item(space)).first()
|
||||
await row.hover()
|
||||
|
||||
const next = page.locator(button(space)).first()
|
||||
await expect(next).toBeVisible()
|
||||
await next.click({ force: true })
|
||||
|
||||
await waitSession(page, { directory: space.directory })
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(
|
||||
page: Page,
|
||||
space: { slug: string; raw: string; directory: string },
|
||||
text: string,
|
||||
) {
|
||||
await openWorkspaceNewSession(page, space)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.click()
|
||||
await expect(prompt).toBeFocused()
|
||||
await prompt.fill(text)
|
||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
||||
return { sessionID, slug: next }
|
||||
}
|
||||
|
||||
async function sessionDirectory(directory: string, sessionID: string) {
|
||||
const info = await createSdk(directory)
|
||||
.session.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
await waitSessionSaved(space.directory, sessionID)
|
||||
await createSdk(space.directory)
|
||||
.session.abort({ sessionID })
|
||||
.catch(() => undefined)
|
||||
if (!info) return ""
|
||||
return info.directory
|
||||
return sessionID
|
||||
}
|
||||
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
|
||||
const first = await createWorkspace(page, root, [])
|
||||
trackDirectory(first.directory)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
await waitWorkspaceReady(page, first)
|
||||
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
trackDirectory(second.directory)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
await waitWorkspaceReady(page, second)
|
||||
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
trackSession(firstSession.sessionID, first.directory)
|
||||
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
trackSession(secondSession.sessionID, second.directory)
|
||||
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
trackSession(thirdSession.sessionID, first.directory)
|
||||
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
import { test, expect } from "../fixtures"
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
confirmDialog,
|
||||
openSidebar,
|
||||
openWorkspaceMenu,
|
||||
resolveSlug,
|
||||
setWorkspacesEnabled,
|
||||
slugFromUrl,
|
||||
waitDir,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
@@ -27,15 +29,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const slug = await waitSlug(page, [rootSlug])
|
||||
const dir = base64Decode(slug)
|
||||
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||
await waitDir(page, next.directory)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
@@ -47,7 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
return { rootSlug, slug, directory: dir }
|
||||
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||
}
|
||||
|
||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||
@@ -79,15 +81,15 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const workspaceSlug = await waitSlug(page, [slug])
|
||||
const workspaceDir = base64Decode(workspaceSlug)
|
||||
const next = await resolveSlug(await waitSlug(page, [slug]))
|
||||
await waitDir(page, next.directory)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
@@ -99,9 +101,9 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||
|
||||
await cleanupTestProject(workspaceDir)
|
||||
await cleanupTestProject(next.directory)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,7 +121,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||
|
||||
const activeDir = base64Decode(slugFromUrl(page.url()))
|
||||
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||
|
||||
await openSidebar(page)
|
||||
@@ -331,9 +333,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const slug = await waitSlug(page, [rootSlug, prev])
|
||||
const dir = base64Decode(slug)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||
await waitDir(page, next.directory)
|
||||
workspaces.push(next)
|
||||
|
||||
await openSidebar(page)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await edge(page, "start")
|
||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, draft)
|
||||
await wait(page, "")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,12 +7,18 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
|
||||
await expect(page).toHaveURL(/\/session\/?$/)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("line one")
|
||||
await page.keyboard.press("Shift+Enter")
|
||||
await page.keyboard.type("line two")
|
||||
await prompt.focus()
|
||||
await expect(prompt).toBeFocused()
|
||||
|
||||
await prompt.pressSequentially("line one")
|
||||
await expect(prompt).toBeFocused()
|
||||
|
||||
await prompt.press("Shift+Enter")
|
||||
await expect(page).toHaveURL(/\/session\/?$/)
|
||||
await expect(prompt).toBeFocused()
|
||||
|
||||
await prompt.pressSequentially("line two")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/?$/)
|
||||
await expect(prompt).toContainText("line one")
|
||||
await expect(prompt).toContainText("line two")
|
||||
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
|
||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
@@ -7,29 +7,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
|
||||
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await prompt.fill("/terminal")
|
||||
await expect(slash).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||
await waitTerminalFocusIdle(page, { term: terminal })
|
||||
|
||||
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
|
||||
// which can steal focus from the prompt and prevent fill() from triggering
|
||||
// the slash popover. Re-attempt click+fill until all retries are exhausted
|
||||
// and the popover appears.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await prompt.click().catch(() => false)
|
||||
await prompt.fill("/terminal").catch(() => false)
|
||||
return slash.isVisible().catch(() => false)
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
await page.keyboard.press("Enter")
|
||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||
await expect(terminal).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -13,10 +13,14 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
|
||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
||||
|
||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
||||
export const promptModelSelector = '[data-component="prompt-model-control"]'
|
||||
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
|
||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
||||
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
||||
export const settingsFontSelector = '[data-action="settings-font"]'
|
||||
export const settingsCodeFontSelector = '[data-action="settings-code-font"]'
|
||||
export const settingsUIFontSelector = '[data-action="settings-ui-font"]'
|
||||
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||
|
||||
@@ -93,7 +93,7 @@ async function todoDock(page: any, sessionID: string) {
|
||||
|
||||
const write = async (driver: ComposerDriverState | undefined) => {
|
||||
await page.evaluate(
|
||||
(input) => {
|
||||
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
|
||||
const win = window as ComposerWindow
|
||||
const composer = win.__opencode_e2e?.composer
|
||||
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
|
||||
@@ -118,7 +118,7 @@ async function todoDock(page: any, sessionID: string) {
|
||||
}
|
||||
|
||||
const read = () =>
|
||||
page.evaluate((sessionID) => {
|
||||
page.evaluate((sessionID: string) => {
|
||||
const win = window as ComposerWindow
|
||||
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
|
||||
}, sessionID) as Promise<ComposerProbeState | null>
|
||||
@@ -186,6 +186,8 @@ async function withMockPermission<T>(
|
||||
opts: { child?: any } | undefined,
|
||||
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
||||
) {
|
||||
const listUrl = /\/permission(?:\?.*)?$/
|
||||
const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
|
||||
let pending = [
|
||||
{
|
||||
...request,
|
||||
@@ -204,7 +206,8 @@ async function withMockPermission<T>(
|
||||
|
||||
const reply = async (route: any) => {
|
||||
const url = new URL(route.request().url())
|
||||
const id = url.pathname.split("/").pop()
|
||||
const parts = url.pathname.split("/").filter(Boolean)
|
||||
const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1)
|
||||
pending = pending.filter((item) => item.id !== id)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -213,8 +216,10 @@ async function withMockPermission<T>(
|
||||
})
|
||||
}
|
||||
|
||||
await page.route("**/permission", list)
|
||||
await page.route("**/session/*/permissions/*", reply)
|
||||
await page.route(listUrl, list)
|
||||
for (const item of replyUrls) {
|
||||
await page.route(item, reply)
|
||||
}
|
||||
|
||||
const sessionList = opts?.child
|
||||
? async (route: any) => {
|
||||
@@ -242,8 +247,10 @@ async function withMockPermission<T>(
|
||||
try {
|
||||
return await fn(state)
|
||||
} finally {
|
||||
await page.unroute("**/permission", list)
|
||||
await page.unroute("**/session/*/permissions/*", reply)
|
||||
await page.unroute(listUrl, list)
|
||||
for (const item of replyUrls) {
|
||||
await page.unroute(item, reply)
|
||||
}
|
||||
if (sessionList) await page.unroute("**/session?*", sessionList)
|
||||
}
|
||||
}
|
||||
|
||||
405
packages/app/e2e/session/session-model-persistence.spec.ts
Normal file
405
packages/app/e2e/session/session-model-persistence.spec.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import type { Locator, Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
openSidebar,
|
||||
resolveSlug,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionIdle,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import {
|
||||
promptAgentSelector,
|
||||
promptModelSelector,
|
||||
promptSelector,
|
||||
promptVariantSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceNewSessionSelector,
|
||||
} from "../selectors"
|
||||
import { createSdk, sessionPath } from "../utils"
|
||||
|
||||
type Footer = {
|
||||
agent: string
|
||||
model: string
|
||||
variant: string
|
||||
}
|
||||
|
||||
type Probe = {
|
||||
dir?: string
|
||||
sessionID?: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string; name?: string }
|
||||
variant?: string | null
|
||||
pick?: {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string | null
|
||||
}
|
||||
variants?: string[]
|
||||
models?: Array<{ providerID: string; modelID: string; name: string }>
|
||||
agents?: Array<{ name: string }>
|
||||
}
|
||||
|
||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
|
||||
|
||||
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
||||
|
||||
async function probe(page: Page): Promise<Probe | null> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
current?: Probe
|
||||
}
|
||||
}
|
||||
}
|
||||
return win.__opencode_e2e?.model?.current ?? null
|
||||
})
|
||||
}
|
||||
|
||||
async function currentModel(page: Page) {
|
||||
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
|
||||
const value = await probe(page).then(modelKey)
|
||||
if (!value) throw new Error("Failed to resolve current model key")
|
||||
return value
|
||||
}
|
||||
|
||||
async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((key) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
return !!win.__opencode_e2e?.model?.controls?.[key]
|
||||
}, key),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function pickAgent(page: Page, value: string) {
|
||||
await waitControl(page, "setAgent")
|
||||
await page.evaluate((value) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: {
|
||||
setAgent?: (value: string | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fn = win.__opencode_e2e?.model?.controls?.setAgent
|
||||
if (!fn) throw new Error("Model e2e agent control is not enabled")
|
||||
fn(value)
|
||||
}, value)
|
||||
}
|
||||
|
||||
async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
|
||||
await waitControl(page, "setModel")
|
||||
await page.evaluate((value) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: {
|
||||
setModel?: (value: { providerID: string; modelID: string } | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fn = win.__opencode_e2e?.model?.controls?.setModel
|
||||
if (!fn) throw new Error("Model e2e model control is not enabled")
|
||||
fn(value)
|
||||
}, value)
|
||||
}
|
||||
|
||||
async function pickVariant(page: Page, value: string) {
|
||||
await waitControl(page, "setVariant")
|
||||
await page.evaluate((value) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: {
|
||||
setVariant?: (value: string | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fn = win.__opencode_e2e?.model?.controls?.setVariant
|
||||
if (!fn) throw new Error("Model e2e variant control is not enabled")
|
||||
fn(value)
|
||||
}, value)
|
||||
}
|
||||
|
||||
async function read(page: Page): Promise<Footer> {
|
||||
return {
|
||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
|
||||
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFooter(page: Page, expected: Partial<Footer>) {
|
||||
let hit: Footer | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await read(page)
|
||||
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
|
||||
if (ok) hit = state
|
||||
return ok
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
if (!hit) throw new Error("Failed to resolve prompt footer state")
|
||||
return hit
|
||||
}
|
||||
|
||||
async function waitModel(page: Page, value: string) {
|
||||
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
|
||||
}
|
||||
|
||||
async function choose(page: Page, root: string, value: string) {
|
||||
const select = page.locator(root)
|
||||
await expect(select).toBeVisible()
|
||||
await pickAgent(page, value)
|
||||
}
|
||||
|
||||
async function variantCount(page: Page) {
|
||||
return (await probe(page))?.variants?.length ?? 0
|
||||
}
|
||||
|
||||
async function agents(page: Page) {
|
||||
return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
|
||||
}
|
||||
|
||||
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
||||
const current = await read(page)
|
||||
if ((await variantCount(page)) >= 2) return current
|
||||
|
||||
const cfg = await createSdk(directory)
|
||||
.config.get()
|
||||
.then((x) => x.data)
|
||||
const visible = new Set(await agents(page))
|
||||
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
|
||||
const value = item[1]
|
||||
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
|
||||
})
|
||||
const name = entry?.[0]
|
||||
test.skip(!name, "no agent with alternate variants available")
|
||||
if (!name) return current
|
||||
|
||||
await choose(page, promptAgentSelector, name)
|
||||
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
|
||||
return waitFooter(page, { agent: name })
|
||||
}
|
||||
|
||||
async function chooseDifferentVariant(page: Page): Promise<Footer> {
|
||||
const current = await read(page)
|
||||
const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
|
||||
if (!next) throw new Error("Current model has no alternate variant to select")
|
||||
|
||||
await pickVariant(page, next)
|
||||
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
||||
}
|
||||
|
||||
async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
|
||||
const current = await currentModel(page)
|
||||
const next = (await probe(page))?.models?.find((item) => {
|
||||
const key = `${item.providerID}:${item.modelID}`
|
||||
return key !== current && !skip.includes(key)
|
||||
})
|
||||
if (!next) throw new Error("Failed to choose a different model")
|
||||
await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
|
||||
await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
|
||||
return read(page)
|
||||
}
|
||||
|
||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await waitSession(page, { directory, sessionID })
|
||||
}
|
||||
|
||||
async function submit(page: Page, value: string) {
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.click()
|
||||
await prompt.fill(value)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
|
||||
return id
|
||||
}
|
||||
|
||||
async function waitUser(directory: string, sessionID: string) {
|
||||
const sdk = createSdk(directory)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
|
||||
return items.some((item) => item.info.role === "user")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
await sdk.session.abort({ sessionID }).catch(() => undefined)
|
||||
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await openSidebar(page)
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||
await waitSession(page, { directory: next.directory })
|
||||
return next
|
||||
}
|
||||
|
||||
async function waitWorkspace(page: Page, slug: string) {
|
||||
await openSidebar(page)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function newWorkspaceSession(page: Page, slug: string) {
|
||||
await waitWorkspace(page, slug)
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
await item.hover()
|
||||
|
||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click({ force: true })
|
||||
|
||||
const next = await resolveSlug(await waitSlug(page))
|
||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||
}
|
||||
|
||||
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
const first = await submit(page, `session variant ${Date.now()}`)
|
||||
trackSession(first)
|
||||
await waitUser(directory, first)
|
||||
|
||||
await page.reload()
|
||||
await waitSession(page, { directory, sessionID: first })
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await gotoSession()
|
||||
const fresh = await read(page)
|
||||
expect(fresh.model).not.toBe(firstState.model)
|
||||
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const second = await submit(page, `session model ${Date.now()}`)
|
||||
trackSession(second)
|
||||
await waitUser(directory, second)
|
||||
|
||||
await goto(page, directory, first)
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await goto(page, directory, second)
|
||||
await waitFooter(page, secondState)
|
||||
|
||||
await gotoSession()
|
||||
await waitFooter(page, fresh)
|
||||
})
|
||||
})
|
||||
|
||||
test("session model restore across workspaces", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
const first = await submit(page, `root session ${Date.now()}`)
|
||||
trackSession(first, root)
|
||||
await waitUser(root, first)
|
||||
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
|
||||
const one = await createWorkspace(page, slug, [])
|
||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||
trackDirectory(oneDir)
|
||||
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const secondKey = await currentModel(page)
|
||||
const second = await submit(page, `workspace one ${Date.now()}`)
|
||||
trackSession(second, oneDir)
|
||||
await waitUser(oneDir, second)
|
||||
|
||||
const two = await createWorkspace(page, slug, [one.slug])
|
||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||
trackDirectory(twoDir)
|
||||
|
||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
||||
const third = await submit(page, `workspace two ${Date.now()}`)
|
||||
trackSession(third, twoDir)
|
||||
await waitUser(twoDir, third)
|
||||
|
||||
await goto(page, root, first)
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await goto(page, oneDir, second)
|
||||
await waitFooter(page, secondState)
|
||||
|
||||
await goto(page, twoDir, third)
|
||||
await waitFooter(page, thirdState)
|
||||
|
||||
await goto(page, root, first)
|
||||
await waitFooter(page, firstState)
|
||||
})
|
||||
})
|
||||
|
||||
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await ensureVariant(page, directory)
|
||||
const updated = await chooseDifferentVariant(page)
|
||||
|
||||
const available = await agents(page)
|
||||
const other = available.find((name) => name !== updated.agent)
|
||||
test.skip(!other, "only one agent available")
|
||||
if (!other) return
|
||||
|
||||
await choose(page, promptAgentSelector, other)
|
||||
await waitFooter(page, { agent: other, variant: updated.variant })
|
||||
|
||||
await choose(page, promptAgentSelector, updated.agent)
|
||||
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||
})
|
||||
})
|
||||
@@ -123,6 +123,215 @@ async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||
}, file)
|
||||
}
|
||||
|
||||
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
|
||||
const row = page.locator(`[data-file="${file}"]`).first()
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
const line = row.locator('diffs-container [data-line="2"]').first()
|
||||
await expect(line).toBeVisible()
|
||||
await line.hover()
|
||||
|
||||
const add = row.getByRole("button", { name: /^Comment$/ }).first()
|
||||
await expect(add).toBeVisible()
|
||||
await add.click()
|
||||
|
||||
const area = row.locator('[data-slot="line-comment-textarea"]').first()
|
||||
await expect(area).toBeVisible()
|
||||
await area.fill(note)
|
||||
|
||||
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
||||
await expect(submit).toBeEnabled()
|
||||
await submit.click()
|
||||
|
||||
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
||||
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
||||
}
|
||||
|
||||
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||
const row = page.locator(`[data-file="${file}"]`).first()
|
||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
||||
const tools = row.locator('[data-slot="line-comment-tools"]').first()
|
||||
|
||||
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
||||
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
||||
view.boundingBox(),
|
||||
pop.boundingBox(),
|
||||
tools.boundingBox(),
|
||||
])
|
||||
|
||||
if (!viewBox || !popBox || !toolsBox) return null
|
||||
|
||||
return {
|
||||
width,
|
||||
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
||||
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
||||
}
|
||||
}
|
||||
|
||||
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||
const row = page.locator(`[data-file="${file}"]`).first()
|
||||
await expect(row).toBeVisible()
|
||||
await row.hover()
|
||||
|
||||
const open = row.getByRole("button", { name: /^Open file$/i }).first()
|
||||
await expect(open).toBeVisible()
|
||||
await open.click()
|
||||
|
||||
const tab = page.getByRole("tab", { name: file }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
await expect(viewer).toBeVisible()
|
||||
return viewer
|
||||
}
|
||||
|
||||
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
await expect(viewer).toBeVisible()
|
||||
|
||||
const line = viewer.locator('diffs-container [data-line="2"]').first()
|
||||
await expect(line).toBeVisible()
|
||||
await line.hover()
|
||||
|
||||
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
|
||||
await expect(add).toBeVisible()
|
||||
await add.click()
|
||||
|
||||
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
|
||||
await expect(area).toBeVisible()
|
||||
await area.fill(note)
|
||||
|
||||
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
||||
await expect(submit).toBeEnabled()
|
||||
await submit.click()
|
||||
|
||||
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
||||
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
||||
}
|
||||
|
||||
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
|
||||
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
||||
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
|
||||
|
||||
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
||||
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
||||
view.boundingBox(),
|
||||
pop.boundingBox(),
|
||||
tools.boundingBox(),
|
||||
])
|
||||
|
||||
if (!viewBox || !popBox || !toolsBox) return null
|
||||
|
||||
return {
|
||||
width,
|
||||
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
||||
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
||||
}
|
||||
}
|
||||
|
||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-comment-${Date.now()}`
|
||||
const file = `review-comment-${tag}.txt`
|
||||
const note = `comment ${tag}`
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, file, tag)
|
||||
await comment(page, file, note)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-file-comment-${Date.now()}`
|
||||
const file = `review-file-comment-${tag}.txt`
|
||||
const note = `comment ${tag}`
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, file, tag)
|
||||
await openReviewFile(page, file)
|
||||
await fileComment(page, note)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||
test.setTimeout(180_000)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
|
||||
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("P")
|
||||
expect(initialKeybind).toContain("K")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
await waitTerminalFocusIdle(page, { term: terminal })
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect, settingsKey } from "../fixtures"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
import {
|
||||
settingsColorSchemeSelector,
|
||||
settingsFontSelector,
|
||||
settingsCodeFontSelector,
|
||||
settingsLanguageSelectSelector,
|
||||
settingsNotificationsAgentSelector,
|
||||
settingsNotificationsErrorsSelector,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
settingsSoundsErrorsSelector,
|
||||
settingsSoundsPermissionsSelector,
|
||||
settingsThemeSelector,
|
||||
settingsUIFontSelector,
|
||||
settingsUpdatesStartupSelector,
|
||||
} from "../selectors"
|
||||
|
||||
@@ -152,74 +153,28 @@ test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsFontSelector)
|
||||
await expect(select).toBeVisible()
|
||||
const input = dialog.locator(settingsCodeFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
||||
|
||||
const initialFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||
})
|
||||
const initialFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const initialUIFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(initialFontFamily).toContain("IBM Plex Mono")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
const next = "Test Mono"
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
await items.nth(2).click()
|
||||
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
|
||||
|
||||
const newFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||
})
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
})
|
||||
|
||||
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
|
||||
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
|
||||
await expect(colorSchemeSelect).toBeVisible()
|
||||
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
|
||||
const fontSelect = dialog.locator(settingsFontSelector)
|
||||
await expect(fontSelect).toBeVisible()
|
||||
|
||||
const initialFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
|
||||
const initialSettings = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const currentFont =
|
||||
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const fontItems = page.locator('[data-slot="select-select-item"]')
|
||||
expect(await fontItems.count()).toBeGreaterThan(1)
|
||||
|
||||
if (currentFont) {
|
||||
await fontItems.filter({ hasNotText: currentFont }).first().click()
|
||||
}
|
||||
if (!currentFont) {
|
||||
await fontItems.nth(1).click()
|
||||
}
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially(next)
|
||||
await expect(input).toHaveValue(next)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -230,7 +185,218 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: expect.any(String),
|
||||
font: next,
|
||||
},
|
||||
})
|
||||
|
||||
const newFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const newUIFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(newFontFamily).toContain(next)
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
expect(newUIFamily).toBe(initialUIFamily)
|
||||
})
|
||||
|
||||
test("typing a UI font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsUIFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
||||
|
||||
const initialFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
const initialCodeFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(initialFontFamily).toContain("Inter")
|
||||
|
||||
const next = "Test Sans"
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially(next)
|
||||
await expect(input).toHaveValue(next)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: next,
|
||||
},
|
||||
})
|
||||
|
||||
const newFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
const newCodeFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(newFontFamily).toContain(next)
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
expect(newCodeFamily).toBe(initialCodeFamily)
|
||||
})
|
||||
|
||||
test("clearing the code font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsCodeFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially("Reset Mono")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: "Reset Mono",
|
||||
},
|
||||
})
|
||||
|
||||
await input.clear()
|
||||
await input.press("Space")
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: "",
|
||||
},
|
||||
})
|
||||
|
||||
const fontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(fontFamily).toContain("IBM Plex Mono")
|
||||
expect(fontFamily).not.toContain("Reset Mono")
|
||||
})
|
||||
|
||||
test("clearing the UI font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsUIFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially("Reset Sans")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: "Reset Sans",
|
||||
},
|
||||
})
|
||||
|
||||
await input.clear()
|
||||
await input.press("Space")
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: "",
|
||||
},
|
||||
})
|
||||
|
||||
const fontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(fontFamily).toContain("Inter")
|
||||
expect(fontFamily).not.toContain("Reset Sans")
|
||||
})
|
||||
|
||||
test("color scheme, code font, and UI font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
|
||||
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
|
||||
await expect(colorSchemeSelect).toBeVisible()
|
||||
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
|
||||
const code = dialog.locator(settingsCodeFontSelector)
|
||||
const ui = dialog.locator(settingsUIFontSelector)
|
||||
await expect(code).toBeVisible()
|
||||
await expect(ui).toBeVisible()
|
||||
|
||||
const initialMono = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const initialSans = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
|
||||
const initialSettings = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
||||
const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
||||
|
||||
await code.click()
|
||||
await code.clear()
|
||||
await code.pressSequentially(mono)
|
||||
await expect(code).toHaveValue(mono)
|
||||
|
||||
await ui.click()
|
||||
await ui.clear()
|
||||
await ui.pressSequentially(sans)
|
||||
await expect(ui).toHaveValue(sans)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: mono,
|
||||
uiFont: sans,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -239,11 +405,18 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const updatedFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
expect(updatedFontFamily).not.toBe(initialFontFamily)
|
||||
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
|
||||
const updatedMono = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const updatedSans = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(updatedMono).toContain(mono)
|
||||
expect(updatedMono).not.toBe(initialMono)
|
||||
expect(updatedSans).toContain(sans)
|
||||
expect(updatedSans).not.toBe(initialSans)
|
||||
expect(updatedSettings?.appearance?.font).toBe(mono)
|
||||
expect(updatedSettings?.appearance?.uiFont).toBe(sans)
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
await page.reload()
|
||||
@@ -259,7 +432,8 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: updatedSettings?.appearance?.font,
|
||||
font: mono,
|
||||
uiFont: sans,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -270,17 +444,32 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
return await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
})
|
||||
.not.toBe(initialFontFamily)
|
||||
.toContain(mono)
|
||||
|
||||
const rehydratedFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
|
||||
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
})
|
||||
.toContain(sans)
|
||||
|
||||
const rehydratedMono = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const rehydratedSans = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(rehydratedMono).toContain(mono)
|
||||
expect(rehydratedMono).not.toBe(initialMono)
|
||||
expect(rehydratedSans).toContain(sans)
|
||||
expect(rehydratedSans).not.toBe(initialSans)
|
||||
expect(rehydratedSettings?.appearance?.font).toBe(mono)
|
||||
expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
|
||||
})
|
||||
|
||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||
import {
|
||||
defocus,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
closeSidebar,
|
||||
createTestProject,
|
||||
hoverSessionItem,
|
||||
openSidebar,
|
||||
waitSession,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
const card = page.locator('[data-component="hover-card-content"]')
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
await project.hover()
|
||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||
|
||||
await page.mouse.down()
|
||||
await expect(card).toHaveCount(0)
|
||||
await page.mouse.up()
|
||||
|
||||
await waitSession(page, { directory: other })
|
||||
await expect(card).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
await defocus(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
|
||||
let hit = false
|
||||
for (let i = 0; i < 20; i++) {
|
||||
hit = await project.evaluate((el) => {
|
||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||
})
|
||||
if (hit) break
|
||||
await page.keyboard.press("Tab")
|
||||
}
|
||||
|
||||
expect(hit).toBe(true)
|
||||
|
||||
await page.keyboard.press("Enter")
|
||||
await waitSession(page, { directory: other })
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -14,7 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
}
|
||||
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
await waitTerminalFocusIdle(page, { term: terminals.first() })
|
||||
await expect(terminals).toHaveCount(1)
|
||||
|
||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { runTerminal, waitTerminalReady } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||
|
||||
type State = {
|
||||
@@ -130,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({
|
||||
.toEqual({ count: 1, first: true })
|
||||
})
|
||||
})
|
||||
|
||||
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const rename = `E2E term ${Date.now()}`
|
||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await expect(tab).toContainText(/Terminal 1/)
|
||||
await tab.click({ button: "right" })
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||
await expect(menu).toHaveCount(0)
|
||||
|
||||
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(rename)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(input).toHaveCount(0)
|
||||
await expect(tab).toContainText(rename)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return state?.all[0]?.title
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe(rename)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.25",
|
||||
"version": "1.3.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -51,12 +51,14 @@
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
|
||||
@@ -6,9 +6,10 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Duration, Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
@@ -31,12 +32,11 @@ import { FileProvider } from "@/context/file"
|
||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
@@ -46,21 +46,13 @@ import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const Loading = () => <div class="size-full" />
|
||||
|
||||
const HomeRoute = () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
const SessionRoute = () => (
|
||||
<SessionProviders>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
|
||||
@@ -84,9 +76,9 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
function MarkedProviderWithNativeParser(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||
function QueryProvider(props: ParentProps) {
|
||||
const client = new QueryClient()
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
@@ -124,13 +116,15 @@ function SessionProviders(props: ParentProps) {
|
||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
return (
|
||||
<AppShellProviders>
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
<Suspense fallback={<Loading />}>
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</AppShellProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(props: ParentProps) {
|
||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
@@ -139,14 +133,16 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
void window.api?.setTitlebar?.({ mode })
|
||||
}}
|
||||
>
|
||||
<LanguageProvider>
|
||||
<LanguageProvider locale={props.locale}>
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<DialogProvider>
|
||||
<MarkedProviderWithNativeParser>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProviderWithNativeParser>
|
||||
</DialogProvider>
|
||||
<QueryProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
</UiI18nBridge>
|
||||
</LanguageProvider>
|
||||
@@ -265,6 +261,15 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
|
||||
)
|
||||
}
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
const server = useServer()
|
||||
return (
|
||||
<Show when={server.key} keyed>
|
||||
{props.children}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppInterface(props: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
@@ -275,20 +280,22 @@ export function AppInterface(props: {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={SessionIndexRoute} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={SessionIndexRoute} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
</ConnectionGate>
|
||||
</ServerProvider>
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
<div
|
||||
classList={{
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
||||
"col-span-2": !!props.wide,
|
||||
}}
|
||||
>
|
||||
@@ -363,11 +363,7 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
style={{
|
||||
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
|
||||
"border-color": "color-mix(in srgb, white 14%, transparent)",
|
||||
}}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<Cell
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -9,22 +9,26 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DialogSelectModel } from "./dialog-select-model"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
const dialog = useDialog()
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
@@ -36,26 +40,41 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
timer.current = undefined
|
||||
})
|
||||
|
||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||
const methods = createMemo(
|
||||
const provider = createMemo(
|
||||
() =>
|
||||
globalSync.data.provider_auth[props.provider] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
],
|
||||
providers.all().find((x) => x.id === props.provider) ??
|
||||
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
])
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const cached = globalSync.data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await globalSDK.client.provider.auth()
|
||||
if (!alive.value) return fallback()
|
||||
globalSync.set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
},
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error",
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
@@ -77,6 +96,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.prompt") {
|
||||
draft.state = "prompt"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
@@ -120,7 +144,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function selectMethod(index: number) {
|
||||
async function selectMethod(index: number, inputs?: Record<string, string>) {
|
||||
if (timer.current !== undefined) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
@@ -130,6 +154,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.type === "oauth") {
|
||||
if (method.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await globalSDK.client.provider.oauth
|
||||
@@ -137,6 +165,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
inputs,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
@@ -163,6 +192,126 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function OAuthPromptsView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: {} as Record<string, string>,
|
||||
index: 0,
|
||||
})
|
||||
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const value = method()
|
||||
if (value?.type !== "oauth") return []
|
||||
return value.prompts ?? []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
const actual = value[prompt.when.key]
|
||||
if (actual === undefined) return false
|
||||
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
|
||||
}
|
||||
const current = createMemo(() => {
|
||||
const all = prompts()
|
||||
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
|
||||
if (index === -1) return
|
||||
return {
|
||||
index,
|
||||
prompt: all[index],
|
||||
}
|
||||
})
|
||||
const valid = createMemo(() => {
|
||||
const item = current()
|
||||
if (!item || item.prompt.type !== "text") return false
|
||||
const value = formStore.value[item.prompt.key] ?? ""
|
||||
return value.trim().length > 0
|
||||
})
|
||||
|
||||
async function next(index: number, value: Record<string, string>) {
|
||||
if (store.methodIndex === undefined) return
|
||||
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
|
||||
if (next !== -1) {
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const item = current()
|
||||
if (!item || item.prompt.type !== "text") return
|
||||
if (!valid()) return
|
||||
await next(item.index, formStore.value)
|
||||
}
|
||||
|
||||
const item = () => current()
|
||||
const text = createMemo(() => {
|
||||
const prompt = item()?.prompt
|
||||
if (!prompt || prompt.type !== "text") return
|
||||
return prompt
|
||||
})
|
||||
const select = createMemo(() => {
|
||||
const prompt = item()?.prompt
|
||||
if (!prompt || prompt.type !== "select") return
|
||||
return prompt
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<Switch>
|
||||
<Match when={item()?.prompt.type === "text"}>
|
||||
<TextField
|
||||
type="text"
|
||||
label={text()?.message ?? ""}
|
||||
placeholder={text()?.placeholder}
|
||||
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
|
||||
onChange={(value) => {
|
||||
const prompt = text()
|
||||
if (!prompt) return
|
||||
setFormStore("value", prompt.key, value)
|
||||
}}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
|
||||
{language.t("common.continue")}
|
||||
</Button>
|
||||
</Match>
|
||||
<Match when={item()?.prompt.type === "select"}>
|
||||
<div class="w-full flex flex-col gap-1.5">
|
||||
<div class="text-14-regular text-text-base">{select()?.message}</div>
|
||||
<div>
|
||||
<List
|
||||
items={select()?.options ?? []}
|
||||
key={(x) => x.value}
|
||||
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
||||
onSelect={(value) => {
|
||||
if (!value) return
|
||||
const prompt = select()
|
||||
if (!prompt) return
|
||||
const nextValue = {
|
||||
...formStore.value,
|
||||
[prompt.key]: value.value,
|
||||
}
|
||||
setFormStore("value", prompt.key, value.value)
|
||||
void next(item()!.index, nextValue)
|
||||
}}
|
||||
>
|
||||
{(option) => (
|
||||
<div class="w-full flex items-center gap-x-2">
|
||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||
</div>
|
||||
<span>{option.label}</span>
|
||||
<span class="text-14-regular text-text-weak">{option.hint}</span>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
||||
@@ -172,8 +321,12 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto) return
|
||||
if (loading()) return
|
||||
if (methods().length === 1) {
|
||||
auto = true
|
||||
selectMethod(0)
|
||||
}
|
||||
})
|
||||
@@ -191,7 +344,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
|
||||
function goBack() {
|
||||
if (methods().length === 1) {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
all()
|
||||
return
|
||||
}
|
||||
if (store.authorization) {
|
||||
@@ -202,7 +355,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
all()
|
||||
}
|
||||
|
||||
function MethodSelection() {
|
||||
@@ -301,7 +454,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
{language.t("common.submit")}
|
||||
{language.t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -314,12 +467,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -368,7 +515,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
{language.t("common.submit")}
|
||||
{language.t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -386,10 +533,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
if (store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
|
||||
const result = await globalSDK.client.provider.oauth
|
||||
.callback({
|
||||
providerID: props.provider,
|
||||
@@ -459,6 +602,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
@@ -470,6 +621,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<OAuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
|
||||
@@ -34,7 +34,6 @@ export type FormState = {
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
err: {
|
||||
providerID?: string
|
||||
name?: string
|
||||
|
||||
@@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
|
||||
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||
{ row: "h1", key: "", value: "", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
@@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
|
||||
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
@@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
|
||||
apiKey: "",
|
||||
models: [modelRow()],
|
||||
headers: [headerRow()],
|
||||
saving: false,
|
||||
err: {},
|
||||
})
|
||||
|
||||
@@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
|
||||
return output.result
|
||||
}
|
||||
|
||||
const save = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
if (form.saving) return
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
const result = validate()
|
||||
if (!result) return
|
||||
|
||||
setForm("saving", true)
|
||||
|
||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
const auth = result.key
|
||||
? globalSDK.client.auth.set({
|
||||
if (result.key) {
|
||||
await globalSDK.client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
},
|
||||
})
|
||||
: Promise.resolve()
|
||||
}
|
||||
|
||||
auth
|
||||
.then(() =>
|
||||
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
|
||||
)
|
||||
.then(() => {
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||
})
|
||||
await globalSync.updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => {
|
||||
setForm("saving", false)
|
||||
return result
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
},
|
||||
}))
|
||||
|
||||
const save = (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
if (saveMutation.isPending) return
|
||||
|
||||
const result = validate()
|
||||
if (!result) return
|
||||
saveMutation.mutate(result)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
||||
<Button
|
||||
class="w-auto self-start"
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="primary"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { useMutation } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
color: props.project.icon?.color || "pink",
|
||||
iconUrl: props.project.icon?.override || "",
|
||||
startup: props.project.commands?.start ?? "",
|
||||
saving: false,
|
||||
dragOver: false,
|
||||
iconHover: false,
|
||||
})
|
||||
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
setStore("iconUrl", "")
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
await Promise.resolve()
|
||||
.then(async () => {
|
||||
setStore("saving", true)
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
commands: { start },
|
||||
})
|
||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(props.project.worktree, {
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||
commands: { start: start || undefined },
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
commands: { start },
|
||||
})
|
||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
.finally(() => {
|
||||
setStore("saving", false)
|
||||
})
|
||||
dialog.close()
|
||||
},
|
||||
}))
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
if (saveMutation.isPending) return
|
||||
saveMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
||||
{store.saving ? language.t("common.saving") : language.t("common.save")}
|
||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -66,6 +66,7 @@ export const DialogFork: Component = () => {
|
||||
directory: sdk.directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
const dir = base64Encode(sdk.directory)
|
||||
|
||||
sdk.client.session
|
||||
.fork({ sessionID, messageID: item.id })
|
||||
@@ -75,10 +76,8 @@ export const DialogFork: Component = () => {
|
||||
return
|
||||
}
|
||||
dialog.close()
|
||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||
requestAnimationFrame(() => {
|
||||
prompt.set(restored)
|
||||
})
|
||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
||||
navigate(`/${dir}/session/${forked.data.id}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Component, createMemo, createSignal, Show } from "solid-js"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Component, createEffect, createMemo, on, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
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"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
const statusLabels = {
|
||||
@@ -17,7 +20,48 @@ export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
const [state, setState] = createStore({
|
||||
done: false,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.mcp_ready,
|
||||
(ready, prev) => {
|
||||
if (!ready && prev) setState("done", false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (state.done || state.loading) return
|
||||
if (sync.data.mcp_ready) {
|
||||
setState("done", true)
|
||||
return
|
||||
}
|
||||
|
||||
setState("loading", true)
|
||||
void sdk.client.mcp
|
||||
.status()
|
||||
.then((result) => {
|
||||
sync.set("mcp", result.data ?? {})
|
||||
sync.set("mcp_ready", true)
|
||||
setState("done", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setState("done", true)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setState("loading", false)
|
||||
})
|
||||
})
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
@@ -25,10 +69,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
try {
|
||||
const toggle = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
@@ -38,10 +80,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
const totalCount = createMemo(() => items().length)
|
||||
@@ -59,7 +99,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
filterKeys={["name", "status"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
onSelect={(x) => {
|
||||
if (x) toggle(x.name)
|
||||
if (!x || toggle.isPending) return
|
||||
toggle.mutate(x.name)
|
||||
}}
|
||||
>
|
||||
{(i) => {
|
||||
@@ -83,7 +124,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
<Show when={statusLabel()}>
|
||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||
</Show>
|
||||
<Show when={loading() === i.name}>
|
||||
<Show when={toggle.isPending && toggle.variables === i.name}>
|
||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -92,7 +133,14 @@ export const DialogSelectMcp: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggle.isPending && toggle.variables === i.name}
|
||||
onChange={() => {
|
||||
if (toggle.isPending) return
|
||||
toggle.mutate(i.name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,17 +8,29 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const DialogSelectModelUnpaid: Component = () => {
|
||||
const local = useLocal()
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||
const model = props.model ?? useLocal().model
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const language = useLanguage()
|
||||
|
||||
const connect = (provider: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
|
||||
})
|
||||
}
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") return
|
||||
@@ -35,8 +47,8 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
<List
|
||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||
ref={(ref) => (listRef = ref)}
|
||||
items={local.model.list}
|
||||
current={local.model.current()}
|
||||
items={model.list}
|
||||
current={model.current()}
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
itemWrapper={(item, node) => (
|
||||
<Tooltip
|
||||
@@ -55,7 +67,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
onSelect={(x) => {
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
dialog.close()
|
||||
@@ -89,7 +101,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||
connect(x.id)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
@@ -120,9 +132,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
||||
icon="dot-grid"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}}
|
||||
onClick={all}
|
||||
>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</Button>
|
||||
|
||||
@@ -10,27 +10,28 @@ import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogManageModels } from "./dialog-manage-models"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
const ModelList: Component<{
|
||||
provider?: string
|
||||
class?: string
|
||||
onSelect: () => void
|
||||
action?: JSX.Element
|
||||
model?: ModelState
|
||||
}> = (props) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? useLocal().model
|
||||
const language = useLanguage()
|
||||
|
||||
const models = createMemo(() =>
|
||||
local.model
|
||||
model
|
||||
.list()
|
||||
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
|
||||
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
|
||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||
)
|
||||
|
||||
@@ -41,7 +42,7 @@ const ModelList: Component<{
|
||||
emptyMessage={language.t("dialog.model.empty")}
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={models}
|
||||
current={local.model.current()}
|
||||
current={model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
groupBy={(x) => x.provider.name}
|
||||
@@ -63,7 +64,7 @@ const ModelList: Component<{
|
||||
</Tooltip>
|
||||
)}
|
||||
onSelect={(x) => {
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
props.onSelect()
|
||||
@@ -88,6 +89,7 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
|
||||
|
||||
export function ModelSelectorPopover(props: {
|
||||
provider?: string
|
||||
model?: ModelState
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
@@ -103,12 +105,16 @@ export function ModelSelectorPopover(props: {
|
||||
|
||||
const handleManage = () => {
|
||||
setStore("open", false)
|
||||
dialog.show(() => <DialogManageModels />)
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModels />)
|
||||
})
|
||||
}
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
setStore("open", false)
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -151,6 +157,7 @@ export function ModelSelectorPopover(props: {
|
||||
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
|
||||
<ModelList
|
||||
provider={props.provider}
|
||||
model={props.model}
|
||||
onSelect={() => setStore("open", false)}
|
||||
class="p-1"
|
||||
action={
|
||||
@@ -184,30 +191,33 @@ export function ModelSelectorPopover(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
const manage = () => {
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModels />)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={language.t("dialog.model.select.title")}
|
||||
action={
|
||||
<Button
|
||||
class="h-7 -my-1 text-14-medium"
|
||||
icon="plus-small"
|
||||
tabIndex={-1}
|
||||
onClick={() => dialog.show(() => <DialogSelectProvider />)}
|
||||
>
|
||||
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||
onClick={() => dialog.show(() => <DialogManageModels />)}
|
||||
>
|
||||
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
||||
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
|
||||
{language.t("dialog.model.manage")}
|
||||
</Button>
|
||||
</Dialog>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||
@@ -186,7 +187,6 @@ export function DialogSelectServer() {
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
@@ -198,7 +198,6 @@ export function DialogSelectServer() {
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
busy: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
@@ -209,7 +208,6 @@ export function DialogSelectServer() {
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
@@ -224,10 +222,78 @@ export function DialogSelectServer() {
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const addMutation = useMutation(() => ({
|
||||
mutationFn: async (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select(conn, true)
|
||||
},
|
||||
}))
|
||||
|
||||
const editMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||
if (input.original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(input.value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = input.original.displayName
|
||||
if (
|
||||
normalized === input.original.http.url &&
|
||||
name === existingName &&
|
||||
username === input.original.http.username &&
|
||||
password === input.original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(input.original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
},
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const active = server.key
|
||||
const newConn = server.add(next)
|
||||
@@ -291,12 +357,12 @@ export function DialogSelectServer() {
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
server.setActive(ServerConnection.key(conn))
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||
}
|
||||
|
||||
const handleAddChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
@@ -304,12 +370,12 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleAddNameChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleAddUsernameChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { username: value, error: "" })
|
||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
@@ -317,7 +383,7 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleAddPasswordChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { password: value, error: "" })
|
||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
@@ -325,7 +391,7 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleEditChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
@@ -333,12 +399,12 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleEditUsernameChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { username: value, error: "" })
|
||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
@@ -346,85 +412,13 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleEditPasswordChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { password: value, error: "" })
|
||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleAdd(value: string) {
|
||||
if (store.addServer.adding) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("addServer", { adding: true, error: "" })
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("addServer", { adding: false })
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select(conn, true)
|
||||
}
|
||||
|
||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||
if (store.editServer.busy || original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = original.displayName
|
||||
if (
|
||||
normalized === original.http.url &&
|
||||
name === existingName &&
|
||||
username === original.http.username &&
|
||||
password === original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("editServer", { busy: true, error: "" })
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("editServer", { busy: false })
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
@@ -464,23 +458,26 @@ export function DialogSelectServer() {
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
void handleAdd(store.addServer.url)
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { error: "" })
|
||||
addMutation.mutate(store.addServer.url)
|
||||
return
|
||||
}
|
||||
const original = editing()
|
||||
if (!original) return
|
||||
void handleEdit(original, store.editServer.value)
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { error: "" })
|
||||
editMutation.mutate({ original, value: store.editServer.value })
|
||||
}
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||
|
||||
const formTitle = createMemo(() => {
|
||||
if (!isFormMode()) return language.t("dialog.server.title")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import {
|
||||
@@ -28,7 +27,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -37,6 +35,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { promptEnabled, promptProbe } from "@/testing/prompt"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||
@@ -121,7 +120,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 52
|
||||
const inset = 56
|
||||
const space = `${inset}px`
|
||||
|
||||
const scrollCursorIntoView = () => {
|
||||
@@ -244,6 +243,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status()?.type !== "idle")
|
||||
const tip = () => {
|
||||
if (working()) {
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.stop")}</span>
|
||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const imageAttachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -411,7 +427,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||
|
||||
const pick = () => fileInputRef?.click()
|
||||
@@ -556,6 +571,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const open = recent()
|
||||
const seen = new Set(open)
|
||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||
if (!query.trim()) return [...agents, ...pinned]
|
||||
const paths = await files.searchFilesAndDirectories(query)
|
||||
const fileOptions: AtOption[] = paths
|
||||
.filter((path) => !seen.has(path))
|
||||
@@ -606,6 +622,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||
if (!cmd) return
|
||||
promptProbe.select(cmd.id)
|
||||
closePopover()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
@@ -694,6 +711,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
})
|
||||
|
||||
if (promptEnabled()) {
|
||||
createEffect(() => {
|
||||
promptProbe.set({
|
||||
popover: store.popover,
|
||||
slash: {
|
||||
active: slashActive() ?? null,
|
||||
ids: slashFlat().map((cmd) => cmd.id),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => promptProbe.clear())
|
||||
}
|
||||
|
||||
const selectPopoverActive = () => {
|
||||
if (store.popover === "at") {
|
||||
const items = atFlat()
|
||||
@@ -1012,9 +1043,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isFocused,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
focusEditor: () => {
|
||||
@@ -1031,6 +1061,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
})
|
||||
const acceptLabel = createMemo(() =>
|
||||
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
|
||||
)
|
||||
const toggleAccept = () => {
|
||||
if (!params.id) {
|
||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
return
|
||||
}
|
||||
|
||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||
}
|
||||
|
||||
const { abort, handleSubmit } = createPromptSubmit({
|
||||
info,
|
||||
@@ -1200,6 +1241,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
// Note: Shift+Enter is handled earlier, before IME check
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (event.repeat) return
|
||||
if (
|
||||
working() &&
|
||||
prompt
|
||||
.current()
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
.trim().length === 0 &&
|
||||
imageAttachments().length === 0 &&
|
||||
commentCount() === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
handleSubmit(event)
|
||||
}
|
||||
}
|
||||
@@ -1328,18 +1383,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.currentTarget.files?.[0]
|
||||
if (file) void addAttachment(file)
|
||||
const list = e.currentTarget.files
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1 pointer-events-auto">
|
||||
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={buttons()}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1"
|
||||
class="pointer-events-auto"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
@@ -1363,81 +1437,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Icon name="plus" class="size-4.5" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<Tooltip
|
||||
placement="top"
|
||||
inactive={!prompt.dirty() && !working()}
|
||||
value={
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.stop")}</span>
|
||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={buttons()}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div class="pointer-events-auto">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t(
|
||||
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
|
||||
)}
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-permissions"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!params.id) {
|
||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
return
|
||||
}
|
||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||
}}
|
||||
classList={{
|
||||
"size-6 flex items-center justify-center": true,
|
||||
"text-text-base": !accepting(),
|
||||
"hover:bg-surface-success-base": accepting(),
|
||||
}}
|
||||
aria-label={
|
||||
accepting()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable")
|
||||
}
|
||||
aria-pressed={accepting()}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-double-right"
|
||||
size="small"
|
||||
classList={{ "text-icon-success-base": accepting() }}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1457,43 +1456,84 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={control()}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<div data-component="prompt-agent-control">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div data-component="prompt-model-control">
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={control()}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id}
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
@@ -1502,56 +1542,52 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-component="prompt-variant-control">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
gutter={8}
|
||||
title={acceptLabel()}
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={control()}
|
||||
<Button
|
||||
data-action="prompt-permissions"
|
||||
variant="ghost"
|
||||
/>
|
||||
onClick={toggleAccept}
|
||||
classList={{
|
||||
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
|
||||
"text-text-base": !accepting(),
|
||||
"hover:bg-surface-success-base": accepting(),
|
||||
}}
|
||||
style={control()}
|
||||
aria-label={acceptLabel()}
|
||||
aria-pressed={accepting()}
|
||||
>
|
||||
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { attachmentMime } from "./files"
|
||||
import { pasteMode } from "./paste"
|
||||
|
||||
describe("attachmentMime", () => {
|
||||
test("keeps PDFs when the browser reports the mime", async () => {
|
||||
@@ -22,3 +23,22 @@ describe("attachmentMime", () => {
|
||||
expect(await attachmentMime(file)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pasteMode", () => {
|
||||
test("uses native paste for short single-line text", () => {
|
||||
expect(pasteMode("hello world")).toBe("native")
|
||||
})
|
||||
|
||||
test("uses manual paste for multiline text", () => {
|
||||
expect(
|
||||
pasteMode(`{
|
||||
"ok": true
|
||||
}`),
|
||||
).toBe("manual")
|
||||
expect(pasteMode("a\r\nb")).toBe("manual")
|
||||
})
|
||||
|
||||
test("uses manual paste for large text", () => {
|
||||
expect(pasteMode("x".repeat(8000))).toBe("manual")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { getCursorPosition } from "./editor-dom"
|
||||
import { attachmentMime } from "./files"
|
||||
const LARGE_PASTE_CHARS = 8000
|
||||
const LARGE_PASTE_BREAKS = 120
|
||||
import { normalizePaste, pasteMode } from "./paste"
|
||||
|
||||
function dataUrl(file: File, mime: string) {
|
||||
return new Promise<string>((resolve) => {
|
||||
@@ -25,20 +24,8 @@ function dataUrl(file: File, mime: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function largePaste(text: string) {
|
||||
if (text.length >= LARGE_PASTE_CHARS) return true
|
||||
let breaks = 0
|
||||
for (const char of text) {
|
||||
if (char !== "\n") continue
|
||||
breaks += 1
|
||||
if (breaks >= LARGE_PASTE_BREAKS) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isFocused: () => boolean
|
||||
isDialogActive: () => boolean
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
focusEditor: () => void
|
||||
@@ -84,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
const addAttachment = (file: File) => add(file)
|
||||
|
||||
const addAttachments = async (files: File[], toast = true) => {
|
||||
let found = false
|
||||
|
||||
for (const file of files) {
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
|
||||
if (!found && files.length > 0 && toast) warn()
|
||||
return found
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const current = prompt.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
@@ -91,25 +90,20 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
}
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
if (!input.isFocused()) return
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const items = Array.from(clipboardData.items)
|
||||
const fileItems = items.filter((item) => item.kind === "file")
|
||||
const files = Array.from(clipboardData.items).flatMap((item) => {
|
||||
if (item.kind !== "file") return []
|
||||
const file = item.getAsFile()
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
if (fileItems.length > 0) {
|
||||
let found = false
|
||||
for (const item of fileItems) {
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
if (!found) warn()
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -126,16 +120,23 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
if (!plainText) return
|
||||
|
||||
if (largePaste(plainText)) {
|
||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
||||
const text = normalizePaste(plainText)
|
||||
|
||||
const put = () => {
|
||||
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||
input.focusEditor()
|
||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
||||
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
||||
if (pasteMode(text) === "manual") {
|
||||
put()
|
||||
return
|
||||
}
|
||||
|
||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
|
||||
if (inserted) return
|
||||
|
||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
put()
|
||||
}
|
||||
|
||||
const handleGlobalDragOver = (event: DragEvent) => {
|
||||
@@ -176,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
let found = false
|
||||
for (const file of Array.from(dropped)) {
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
if (!found && dropped.length > 0) warn()
|
||||
await addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -198,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps multiple uploaded attachments in order", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{
|
||||
type: "image",
|
||||
id: "img_2",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
dataUrl: "data:application/pdf;base64,BBB",
|
||||
},
|
||||
],
|
||||
text: "check these",
|
||||
messageID: "msg_multi",
|
||||
sessionID: "ses_multi",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
||||
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
|
||||
|
||||
export { ACCEPTED_FILE_TYPES }
|
||||
|
||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||
const IMAGE_EXTS = new Map([
|
||||
@@ -18,61 +20,6 @@ const TEXT_MIMES = new Set([
|
||||
"application/yaml",
|
||||
])
|
||||
|
||||
export const ACCEPTED_FILE_TYPES = [
|
||||
...ACCEPTED_IMAGE_TYPES,
|
||||
"application/pdf",
|
||||
"text/*",
|
||||
"application/json",
|
||||
"application/ld+json",
|
||||
"application/toml",
|
||||
"application/x-toml",
|
||||
"application/x-yaml",
|
||||
"application/xml",
|
||||
"application/yaml",
|
||||
".c",
|
||||
".cc",
|
||||
".cjs",
|
||||
".conf",
|
||||
".cpp",
|
||||
".css",
|
||||
".csv",
|
||||
".cts",
|
||||
".env",
|
||||
".go",
|
||||
".gql",
|
||||
".graphql",
|
||||
".h",
|
||||
".hh",
|
||||
".hpp",
|
||||
".htm",
|
||||
".html",
|
||||
".ini",
|
||||
".java",
|
||||
".js",
|
||||
".json",
|
||||
".jsx",
|
||||
".log",
|
||||
".md",
|
||||
".mdx",
|
||||
".mjs",
|
||||
".mts",
|
||||
".py",
|
||||
".rb",
|
||||
".rs",
|
||||
".sass",
|
||||
".scss",
|
||||
".sh",
|
||||
".sql",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".txt",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
".zsh",
|
||||
]
|
||||
|
||||
const SAMPLE = 4096
|
||||
|
||||
function kind(type: string) {
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
|
||||
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||
const value = "a\nb\nc"
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
|
||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||
|
||||
@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
|
||||
const atStart = position === 0
|
||||
const atEnd = position === text.length
|
||||
if (inHistory) return atStart || atEnd
|
||||
if (direction === "up") return position === 0
|
||||
if (direction === "up") return position === 0 && text.length === 0
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
|
||||
24
packages/app/src/components/prompt-input/paste.ts
Normal file
24
packages/app/src/components/prompt-input/paste.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const LARGE_PASTE_CHARS = 8000
|
||||
const LARGE_PASTE_BREAKS = 120
|
||||
|
||||
function largePaste(text: string) {
|
||||
if (text.length >= LARGE_PASTE_CHARS) return true
|
||||
let breaks = 0
|
||||
for (const char of text) {
|
||||
if (char !== "\n") continue
|
||||
breaks += 1
|
||||
if (breaks >= LARGE_PASTE_BREAKS) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function normalizePaste(text: string) {
|
||||
if (!text.includes("\r")) return text
|
||||
return text.replace(/\r\n?/g, "\n")
|
||||
}
|
||||
|
||||
export function pasteMode(text: string) {
|
||||
if (largePaste(text)) return "manual"
|
||||
if (text.includes("\n") || text.includes("\r")) return "manual"
|
||||
return "native"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const optimistic: Array<{
|
||||
}> = []
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
|
||||
@@ -86,6 +87,11 @@ beforeAll(async () => {
|
||||
agent: {
|
||||
current: () => ({ name: "agent" }),
|
||||
},
|
||||
session: {
|
||||
promote(directory: string, sessionID: string) {
|
||||
promoted.push({ directory, sessionID })
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -201,6 +207,7 @@ beforeEach(() => {
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
params = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
@@ -240,6 +247,11 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(promoted).toEqual([
|
||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
})
|
||||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
|
||||
@@ -296,6 +296,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
const currentModel = local.model.current()
|
||||
const currentAgent = local.agent.current()
|
||||
const variant = local.model.variant.current()
|
||||
if (!currentModel || !currentAgent) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||
@@ -370,6 +371,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
@@ -387,7 +389,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const variant = local.model.variant.current()
|
||||
const context = prompt.context.items().slice()
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
@@ -32,6 +33,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
@@ -50,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const context = createMemo(() => metrics().context)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
@@ -92,6 +93,7 @@ const emptyUserMessages: UserMessage[] = []
|
||||
export function SessionContextTab() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
@@ -130,7 +132,7 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
@@ -267,14 +269,14 @@ export function SessionContextTab() {
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
class="@container h-full pb-10"
|
||||
class="@container h-full"
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
|
||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||
<For each={stats}>
|
||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||
|
||||
@@ -16,9 +16,11 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
@@ -132,6 +134,7 @@ export function SessionHeader() {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
@@ -218,6 +221,9 @@ export function SessionHeader() {
|
||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||
)
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
const tint = createMemo(() =>
|
||||
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
|
||||
)
|
||||
|
||||
const selectApp = (app: OpenApp) => {
|
||||
if (!options().some((item) => item.id === app)) return
|
||||
@@ -268,12 +274,11 @@ export function SessionHeader() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
aria-label={language.t("session.header.searchFiles")}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
|
||||
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||
{language.t("session.header.search.placeholder", {
|
||||
project: name(),
|
||||
@@ -320,7 +325,7 @@ export function SessionHeader() {
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default"
|
||||
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
@@ -330,10 +335,9 @@ export function SessionHeader() {
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
|
||||
<Spinner class="size-3.5 text-icon-base" />
|
||||
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
})
|
||||
let input: HTMLInputElement | undefined
|
||||
let blurFrame: number | undefined
|
||||
let editRequested = false
|
||||
|
||||
const isDefaultTitle = () => {
|
||||
const number = props.terminal.titleNumber
|
||||
@@ -168,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
left: `${store.menuPosition.x}px`,
|
||||
top: `${store.menuPosition.y}px`,
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!editRequested) return
|
||||
e.preventDefault()
|
||||
editRequested = false
|
||||
requestAnimationFrame(() => edit())
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={edit}>
|
||||
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
|
||||
<Icon name="edit" class="w-4 h-4 mr-2" />
|
||||
{language.t("common.rename")}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -1,27 +1,43 @@
|
||||
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
run: 0,
|
||||
}
|
||||
|
||||
type ThemeOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
demoSoundState.run += 1
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
@@ -29,12 +45,19 @@ const stopDemoSound = () => {
|
||||
demoSoundState.cleanup = undefined
|
||||
}
|
||||
|
||||
const playDemoSound = (src: string | undefined) => {
|
||||
const playDemoSound = (id: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!src) return
|
||||
if (!id) return
|
||||
|
||||
const run = ++demoSoundState.run
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
demoSoundState.cleanup = playSound(src)
|
||||
void playSoundById(id).then((cleanup) => {
|
||||
if (demoSoundState.run !== run) {
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
demoSoundState.cleanup = cleanup
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
@@ -44,6 +67,10 @@ export const SettingsGeneral: Component = () => {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
@@ -104,9 +131,7 @@ export const SettingsGeneral: Component = () => {
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo(() =>
|
||||
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
|
||||
)
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
@@ -126,25 +151,10 @@ export const SettingsGeneral: Component = () => {
|
||||
})),
|
||||
)
|
||||
|
||||
const fontOptions = [
|
||||
{ value: "ibm-plex-mono", label: "font.option.ibmPlexMono" },
|
||||
{ value: "cascadia-code", label: "font.option.cascadiaCode" },
|
||||
{ value: "fira-code", label: "font.option.firaCode" },
|
||||
{ value: "hack", label: "font.option.hack" },
|
||||
{ value: "inconsolata", label: "font.option.inconsolata" },
|
||||
{ value: "intel-one-mono", label: "font.option.intelOneMono" },
|
||||
{ value: "iosevka", label: "font.option.iosevka" },
|
||||
{ value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
|
||||
{ value: "meslo-lgs", label: "font.option.mesloLgs" },
|
||||
{ value: "roboto-mono", label: "font.option.robotoMono" },
|
||||
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
|
||||
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
|
||||
{ value: "geist-mono", label: "font.option.geistMono" },
|
||||
] as const
|
||||
const fontOptionsList = [...fontOptions]
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
const mono = () => monoInput(settings.appearance.font())
|
||||
const sans = () => sansInput(settings.appearance.uiFont())
|
||||
|
||||
const soundSelectProps = (
|
||||
enabled: () => boolean,
|
||||
@@ -158,7 +168,7 @@ export const SettingsGeneral: Component = () => {
|
||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.src)
|
||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
@@ -169,7 +179,7 @@ export const SettingsGeneral: Component = () => {
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.src)
|
||||
playDemoSound(option.id)
|
||||
},
|
||||
variant: "secondary" as const,
|
||||
size: "small" as const,
|
||||
@@ -311,28 +321,50 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextField
|
||||
data-action="settings-ui-font"
|
||||
label={language.t("settings.general.row.uiFont.title")}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={sans()}
|
||||
onChange={(value) => settings.appearance.setUIFont(value)}
|
||||
placeholder={sansDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="text-12-regular"
|
||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-font"
|
||||
options={fontOptionsList}
|
||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => language.t(o.label)}
|
||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||
>
|
||||
{(option) => (
|
||||
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||
{option ? language.t(option.label) : ""}
|
||||
</span>
|
||||
)}
|
||||
</Select>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextField
|
||||
data-action="settings-code-font"
|
||||
label={language.t("settings.general.row.font.title")}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={mono()}
|
||||
onChange={(value) => settings.appearance.setFont(value)}
|
||||
placeholder={monoDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="text-12-regular"
|
||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
|
||||
443
packages/app/src/components/status-popover-body.tsx
Normal file
443
packages/app/src/components/status-popover-body.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
if (parts.length === 1) return value
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||
{parts.slice(1).join(file)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: ServerConnection.Any[],
|
||||
active: ServerConnection.Key | undefined,
|
||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (ServerConnection.key(a) === active) return -1
|
||||
if (ServerConnection.key(b) === active) return 1
|
||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
setStatus(reconcile(results))
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const id = setInterval(() => void refresh(), pollMs)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
clearInterval(id)
|
||||
})
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
url: undefined as string | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("url", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState("url", normalizeServerUrl(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
const u = state.url
|
||||
if (!u) return
|
||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||
},
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const sync = useSync()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
|
||||
const [load, setLoad] = createStore({
|
||||
lspDone: false,
|
||||
lspLoading: false,
|
||||
mcpDone: false,
|
||||
mcpLoading: false,
|
||||
})
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.shown()) return
|
||||
|
||||
if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) {
|
||||
setLoad("mcpLoading", true)
|
||||
void sdk.client.mcp
|
||||
.status()
|
||||
.then((result) => {
|
||||
sync.set("mcp", result.data ?? {})
|
||||
sync.set("mcp_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoad("mcpDone", true)
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoad("mcpLoading", false)
|
||||
})
|
||||
}
|
||||
|
||||
if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) {
|
||||
setLoad("lspLoading", true)
|
||||
void sdk.client.lsp
|
||||
.status()
|
||||
.then((result) => {
|
||||
sync.set("lsp", result.data ?? [])
|
||||
sync.set("lsp_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoad("lspDone", true)
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoad("lspLoading", false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers, props.shown)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="servers">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const blocked = () => health[key]?.healthy === false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"hover:bg-surface-raised-base-hover": !blocked(),
|
||||
"cursor-not-allowed": blocked(),
|
||||
}}
|
||||
aria-disabled={blocked()}
|
||||
onClick={() => {
|
||||
if (blocked()) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
dimmed={blocked()}
|
||||
status={health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={key === defaultServer.key()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={mcpNames().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpNames()}>
|
||||
{(name) => {
|
||||
const status = () => mcpStatus(name)
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="lsp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={lspItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={lspItems()}>
|
||||
{(item) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "error",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,203 +1,24 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
if (parts.length === 1) return value
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||
{parts.slice(1).join(file)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: ServerConnection.Any[],
|
||||
active: ServerConnection.Key | undefined,
|
||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (ServerConnection.key(a) === active) return -1
|
||||
if (ServerConnection.key(b) === active) return 1
|
||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
setStatus(reconcile(results))
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const id = setInterval(() => void refresh(), pollMs)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
clearInterval(id)
|
||||
})
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
url: undefined as string | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("url", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState("url", normalizeServerUrl(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
const u = state.url
|
||||
if (!u) return
|
||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||
},
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggle = (input: {
|
||||
sync: ReturnType<typeof useSync>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) => {
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
|
||||
try {
|
||||
const status = input.sync.data.mcp[name]
|
||||
await (status?.status === "connected"
|
||||
? input.sdk.client.mcp.disconnect({ name })
|
||||
: input.sdk.client.mcp.connect({ name }))
|
||||
const result = await input.sdk.client.mcp.status()
|
||||
if (result.data) input.sync.set("mcp", result.data)
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, toggle }
|
||||
}
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
|
||||
export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const mcp = useMcpToggle({ sync, sdk, language })
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
const overallHealthy = createMemo(() => {
|
||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
||||
const healthy = createMemo(() => {
|
||||
const serverHealthy = server.healthy() === true
|
||||
const anyMcpIssue = mcpNames().some((name) => {
|
||||
const status = mcpStatus(name)
|
||||
return status !== "connected" && status !== "disabled"
|
||||
})
|
||||
return serverHealthy && !anyMcpIssue
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
|
||||
return serverHealthy && !issue
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -219,9 +40,9 @@ export function StatusPopover() {
|
||||
<div
|
||||
classList={{
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
"bg-icon-success-base": ready() && healthy(),
|
||||
"bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
|
||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -231,193 +52,15 @@ export function StatusPopover() {
|
||||
placement="bottom-end"
|
||||
shift={-168}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
<Show when={shown()}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||
}
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="servers">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const isBlocked = () => health[key]?.healthy === false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
||||
"cursor-not-allowed": isBlocked(),
|
||||
}}
|
||||
aria-disabled={isBlocked()}
|
||||
onClick={() => {
|
||||
if (isBlocked()) return
|
||||
server.setActive(key)
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
dimmed={isBlocked()}
|
||||
status={health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={key === defaultServer.key()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={mcpNames().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
{language.t("dialog.mcp.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpNames()}>
|
||||
{(name) => {
|
||||
const status = () => mcpStatus(name)
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => mcp.toggle(name)}
|
||||
disabled={mcp.loading() === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={mcp.loading() === name}
|
||||
onChange={() => mcp.toggle(name)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="lsp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={lspItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
{language.t("dialog.lsp.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={lspItems()}>
|
||||
{(item) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "error",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Body shown={shown} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
@@ -65,14 +68,11 @@ const debugTerminal = (...values: unknown[]) => {
|
||||
console.debug("[terminal]", ...values)
|
||||
}
|
||||
|
||||
const errorStatus = (err: unknown) => {
|
||||
const errorName = (err: unknown) => {
|
||||
if (!err || typeof err !== "object") return
|
||||
if (!("data" in err)) return
|
||||
const data = err.data
|
||||
if (!data || typeof data !== "object") return
|
||||
if (!("statusCode" in data)) return
|
||||
const status = data.statusCode
|
||||
return typeof status === "number" ? status : undefined
|
||||
if (!("name" in err)) return
|
||||
const errorName = err.name
|
||||
return typeof errorName === "string" ? errorName : undefined
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
@@ -168,6 +168,12 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const url = sdk.url
|
||||
const auth = server.current?.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
@@ -218,7 +224,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const pushSize = (cols: number, rows: number) => {
|
||||
return sdk.client.pty
|
||||
return client.pty
|
||||
.update({
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
@@ -477,11 +483,11 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const gone = () =>
|
||||
sdk.client.pty
|
||||
client.pty
|
||||
.get({ ptyID: id })
|
||||
.then(() => false)
|
||||
.catch((err) => {
|
||||
if (errorStatus(err) === 404) return true
|
||||
if (errorName(err) === "NotFoundError") return true
|
||||
debugTerminal("failed to inspect terminal session", err)
|
||||
return false
|
||||
})
|
||||
@@ -509,14 +515,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (disposed) return
|
||||
drop?.()
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${id}/connect`)
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(seek))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.username = server.current?.http.username ?? "opencode"
|
||||
url.password = server.current?.http.password ?? ""
|
||||
const next = new URL(url + `/pty/${id}/connect`)
|
||||
next.searchParams.set("directory", directory)
|
||||
next.searchParams.set("cursor", String(seek))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
next.username = username
|
||||
next.password = password
|
||||
|
||||
const socket = new WebSocket(url)
|
||||
const socket = new WebSocket(next)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -77,6 +77,7 @@ export function Titlebar() {
|
||||
|
||||
const canBack = createMemo(() => history.index > 0)
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
|
||||
const back = () => {
|
||||
const next = backPath(history)
|
||||
@@ -217,47 +218,72 @@ export function Titlebar() {
|
||||
</TooltipKeybind>
|
||||
<div class="hidden xl:flex items-center shrink-0">
|
||||
<Show when={params.dir}>
|
||||
<TooltipKeybind
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
<div
|
||||
class="flex items-center shrink-0 w-8 mr-1"
|
||||
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
<div
|
||||
class="transition-opacity"
|
||||
classList={{
|
||||
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
|
||||
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
disabled={layout.sidebar.opened()}
|
||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasProjects()}>
|
||||
<div
|
||||
class="flex items-center gap-0 transition-transform"
|
||||
classList={{
|
||||
"translate-x-0": !layout.sidebar.opened(),
|
||||
"-translate-x-[36px]": layout.sidebar.opened(),
|
||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||
"duration-180 ease-in": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
|
||||
89
packages/app/src/constants/file-picker.ts
Normal file
89
packages/app/src/constants/file-picker.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
|
||||
export const ACCEPTED_FILE_TYPES = [
|
||||
...ACCEPTED_IMAGE_TYPES,
|
||||
"application/pdf",
|
||||
"text/*",
|
||||
"application/json",
|
||||
"application/ld+json",
|
||||
"application/toml",
|
||||
"application/x-toml",
|
||||
"application/x-yaml",
|
||||
"application/xml",
|
||||
"application/yaml",
|
||||
".c",
|
||||
".cc",
|
||||
".cjs",
|
||||
".conf",
|
||||
".cpp",
|
||||
".css",
|
||||
".csv",
|
||||
".cts",
|
||||
".env",
|
||||
".go",
|
||||
".gql",
|
||||
".graphql",
|
||||
".h",
|
||||
".hh",
|
||||
".hpp",
|
||||
".htm",
|
||||
".html",
|
||||
".ini",
|
||||
".java",
|
||||
".js",
|
||||
".json",
|
||||
".jsx",
|
||||
".log",
|
||||
".md",
|
||||
".mdx",
|
||||
".mjs",
|
||||
".mts",
|
||||
".py",
|
||||
".rb",
|
||||
".rs",
|
||||
".sass",
|
||||
".scss",
|
||||
".sh",
|
||||
".sql",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".txt",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
".zsh",
|
||||
]
|
||||
|
||||
const MIME_EXT = new Map([
|
||||
["image/png", "png"],
|
||||
["image/jpeg", "jpg"],
|
||||
["image/gif", "gif"],
|
||||
["image/webp", "webp"],
|
||||
["application/pdf", "pdf"],
|
||||
["application/json", "json"],
|
||||
["application/ld+json", "jsonld"],
|
||||
["application/toml", "toml"],
|
||||
["application/x-toml", "toml"],
|
||||
["application/x-yaml", "yaml"],
|
||||
["application/xml", "xml"],
|
||||
["application/yaml", "yaml"],
|
||||
])
|
||||
|
||||
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
|
||||
|
||||
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
|
||||
new Set(
|
||||
ACCEPTED_FILE_TYPES.flatMap((item) => {
|
||||
if (item.startsWith(".")) return [item.slice(1)]
|
||||
if (item === "text/*") return TEXT_EXT
|
||||
const out = MIME_EXT.get(item)
|
||||
return out ? [out] : []
|
||||
}),
|
||||
),
|
||||
).sort()
|
||||
|
||||
export function filePickerFilters(ext?: string[]) {
|
||||
if (!ext || ext.length === 0) return undefined
|
||||
return [{ name: "Files", extensions: ext }]
|
||||
}
|
||||
@@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("matchKeybind supports bracket keys", () => {
|
||||
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
|
||||
const prev = keybinds[0]
|
||||
const next = keybinds[1]
|
||||
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("formatKeybind returns human readable output", () => {
|
||||
const display = formatKeybind("ctrl+alt+arrowup")
|
||||
|
||||
@@ -40,4 +59,11 @@ describe("command keybind helpers", () => {
|
||||
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||
expect(formatKeybind("none")).toBe("")
|
||||
})
|
||||
|
||||
test("formatKeybind prefers the first combo", () => {
|
||||
const display = formatKeybind("mod+k,mod+p")
|
||||
|
||||
expect(display.includes("K") || display.includes("k")).toBe(true)
|
||||
expect(display.includes("P") || display.includes("p")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
@@ -238,9 +238,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
})
|
||||
const warnedDuplicates = new Set<string>()
|
||||
|
||||
type CommandCatalog = Record<string, CommandCatalogItem>
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
createStore<Record<string, CommandCatalogItem>>({}),
|
||||
createStore<CommandCatalog>({}),
|
||||
)
|
||||
|
||||
const bind = (id: string, def: KeybindConfig | undefined) => {
|
||||
@@ -259,7 +260,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
if (seen.has(opt.id)) {
|
||||
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||
warnedDuplicates.add(opt.id)
|
||||
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
|
||||
console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -274,16 +275,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
createEffect(() => {
|
||||
if (!catalogReady()) return
|
||||
|
||||
for (const opt of registered()) {
|
||||
const id = actionId(opt.id)
|
||||
setCatalog(id, {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
})
|
||||
}
|
||||
setCatalog(
|
||||
registered().reduce((acc, opt) => {
|
||||
const id = actionId(opt.id)
|
||||
acc[id] = {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
}
|
||||
return acc
|
||||
}, {} as CommandCatalog),
|
||||
)
|
||||
})
|
||||
|
||||
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
|
||||
|
||||
@@ -9,23 +9,13 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import {
|
||||
createContext,
|
||||
getOwner,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
Switch,
|
||||
untrack,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
@@ -80,6 +70,8 @@ function createGlobalSync() {
|
||||
|
||||
let active = true
|
||||
let projectWritten = false
|
||||
let bootedAt = 0
|
||||
let bootingRoot = false
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
@@ -162,6 +154,7 @@ function createGlobalSync() {
|
||||
queue.clear(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
clearProviderRev(directory)
|
||||
clearSessionPrefetchDirectory(directory)
|
||||
},
|
||||
translate: language.t,
|
||||
@@ -258,6 +251,12 @@ function createGlobalSync() {
|
||||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
global: {
|
||||
config: globalStore.config,
|
||||
path: globalStore.path,
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
@@ -278,15 +277,20 @@ function createGlobalSync() {
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const event = e.details
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: queue.refresh,
|
||||
refresh: () => {
|
||||
if (recent) return
|
||||
queue.refresh()
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (recent) return
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
queue.push(directory)
|
||||
}
|
||||
@@ -309,7 +313,10 @@ function createGlobalSync() {
|
||||
loadLsp: () => {
|
||||
sdkFor(directory)
|
||||
.lsp.status()
|
||||
.then((x) => setStore("lsp", x.data ?? []))
|
||||
.then((x) => {
|
||||
setStore("lsp", x.data ?? [])
|
||||
setStore("lsp_ready", true)
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -325,17 +332,19 @@ function createGlobalSync() {
|
||||
})
|
||||
|
||||
async function bootstrap() {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
connectErrorTitle: language.t("dialog.server.add.error"),
|
||||
connectErrorDescription: language.t("error.globalSync.connectFailed", {
|
||||
url: globalSDK.url,
|
||||
}),
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
bootingRoot = true
|
||||
try {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
bootedAt = Date.now()
|
||||
} finally {
|
||||
bootingRoot = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -378,6 +387,7 @@ function createGlobalSync() {
|
||||
return globalStore.error
|
||||
},
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
bootstrap,
|
||||
updateConfig,
|
||||
project: projectApi,
|
||||
@@ -391,13 +401,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
||||
|
||||
export function GlobalSyncProvider(props: ParentProps) {
|
||||
const value = createGlobalSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={value.ready}>
|
||||
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
}
|
||||
|
||||
export function useGlobalSync() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
@@ -15,7 +16,7 @@ import { retry } from "@opencode-ai/util/retry"
|
||||
import { batch } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { cmp, normalizeProviderList } from "./utils"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
@@ -31,73 +32,108 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
function waitForPaint() {
|
||||
return new Promise<void>((resolve) => {
|
||||
let done = false
|
||||
const finish = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(finish, 50)
|
||||
if (typeof requestAnimationFrame !== "function") return
|
||||
requestAnimationFrame(() => {
|
||||
clearTimeout(timer)
|
||||
finish()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function errors(list: PromiseSettledResult<unknown>[]) {
|
||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||
}
|
||||
|
||||
const providerRev = new Map<string, number>()
|
||||
|
||||
export function clearProviderRev(directory: string) {
|
||||
providerRev.delete(directory)
|
||||
}
|
||||
|
||||
function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
|
||||
function showErrors(input: {
|
||||
errors: unknown[]
|
||||
title: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
}) {
|
||||
if (input.errors.length === 0) return
|
||||
const message = formatServerError(input.errors[0], input.translate)
|
||||
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.title,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: OpencodeClient
|
||||
connectErrorTitle: string
|
||||
connectErrorDescription: string
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
}) {
|
||||
const health = await input.globalSDK.global
|
||||
.health()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!health?.healthy) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.connectErrorTitle,
|
||||
description: input.connectErrorDescription,
|
||||
})
|
||||
input.setGlobalStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = [
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.auth().then((x) => {
|
||||
input.setGlobalStore("provider_auth", x.data ?? {})
|
||||
}),
|
||||
),
|
||||
const fast = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
||||
if (errors.length) {
|
||||
const message = formatServerError(errors[0], input.translate)
|
||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.requestFailedTitle,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
showErrors({
|
||||
errors: errors(await runAll(fast)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
await waitForPaint()
|
||||
showErrors({
|
||||
errors: errors(await runAll(slow)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
input.setGlobalStore("ready", true)
|
||||
}
|
||||
|
||||
@@ -111,6 +147,44 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
||||
}, {})
|
||||
}
|
||||
|
||||
function projectID(directory: string, projects: Project[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
}
|
||||
|
||||
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
|
||||
setStore("session", (list) => {
|
||||
const next = list.slice()
|
||||
const idx = next.findIndex((item) => item.id >= session.id)
|
||||
if (idx === -1) return [...next, session]
|
||||
if (next[idx]?.id === session.id) {
|
||||
next[idx] = session
|
||||
return next
|
||||
}
|
||||
next.splice(idx, 0, session)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function warmSessions(input: {
|
||||
ids: string[]
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
sdk: OpencodeClient
|
||||
}) {
|
||||
const known = new Set(input.store.session.map((item) => item.id))
|
||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||
if (ids.length === 0) return Promise.resolve()
|
||||
return Promise.all(
|
||||
ids.map((sessionID) =>
|
||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
||||
const session = x.data
|
||||
if (!session?.id) return
|
||||
mergeSession(input.setStore, session)
|
||||
}),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: OpencodeClient
|
||||
@@ -119,88 +193,166 @@ export async function bootstrapDirectory(input: {
|
||||
vcsCache: VcsCache
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
}) {
|
||||
if (input.store.status !== "complete") input.setStore("status", "loading")
|
||||
|
||||
const blockingRequests = {
|
||||
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
|
||||
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
|
||||
global: {
|
||||
config: Config
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
}
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
|
||||
if (seededProject) input.setStore("project", seededProject)
|
||||
if (seededPath) input.setStore("path", seededPath)
|
||||
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
||||
input.setStore("provider", input.global.provider)
|
||||
}
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", input.global.config)
|
||||
}
|
||||
if (loading || input.store.provider.all.length === 0) {
|
||||
input.setStore("provider_ready", false)
|
||||
}
|
||||
input.setStore("mcp_ready", false)
|
||||
input.setStore("mcp", {})
|
||||
input.setStore("lsp_ready", false)
|
||||
input.setStore("lsp", [])
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
try {
|
||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
||||
} catch (err) {
|
||||
console.error("Failed to bootstrap instance", err)
|
||||
const fast = [
|
||||
() =>
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
seededPath
|
||||
? Promise.resolve()
|
||||
: retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.mcp.status().then((x) => {
|
||||
input.setStore("mcp", x.data!)
|
||||
input.setStore("mcp_ready", true)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const errs = errors(await runAll(fast))
|
||||
if (errs.length > 0) {
|
||||
console.error("Failed to bootstrap instance", errs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
description: formatServerError(errs[0], input.translate),
|
||||
})
|
||||
input.setStore("status", "partial")
|
||||
return
|
||||
}
|
||||
|
||||
if (input.store.status !== "complete") input.setStore("status", "partial")
|
||||
await waitForPaint()
|
||||
const slowErrs = errors(await runAll(slow))
|
||||
if (slowErrs.length > 0) {
|
||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(slowErrs[0], input.translate),
|
||||
})
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
|
||||
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
|
||||
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
|
||||
input.loadSessions(input.directory),
|
||||
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
|
||||
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
|
||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
||||
providerRev.set(input.directory, rev)
|
||||
void retry(() => input.sdk.provider.list())
|
||||
.then((x) => {
|
||||
if (providerRev.get(input.directory) !== rev) return
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
input.setStore("provider_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (providerRev.get(input.directory) !== rev) return
|
||||
console.error("Failed to refresh provider list", err)
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
input.sdk.question.list().then((x) => {
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
input.setStore("status", "complete")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ export function createChildStoreManager(input: {
|
||||
project: "",
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
provider_ready: false,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
@@ -173,7 +174,9 @@ export function createChildStoreManager(input: {
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: false,
|
||||
mcp: {},
|
||||
lsp_ready: false,
|
||||
lsp: [],
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
@@ -226,6 +229,15 @@ export function createChildStoreManager(input: {
|
||||
return childStore
|
||||
}
|
||||
|
||||
function peek(directory: string, options: ChildOptions = {}) {
|
||||
const childStore = ensureChild(directory)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
}
|
||||
return childStore
|
||||
}
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
const cached = metaCache.get(directory)
|
||||
@@ -256,6 +268,7 @@ export function createChildStoreManager(input: {
|
||||
children,
|
||||
ensureChild,
|
||||
child,
|
||||
peek,
|
||||
projectMeta,
|
||||
projectIcon,
|
||||
mark,
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
project: Project[]
|
||||
@@ -211,6 +213,7 @@ export function applyDirectoryEvent(input: {
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) break
|
||||
const parts = input.store.part[part.messageID]
|
||||
if (!parts) {
|
||||
input.setStore("part", part.messageID, [part])
|
||||
|
||||
@@ -38,6 +38,7 @@ export type State = {
|
||||
project: string
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
provider_ready: boolean
|
||||
provider: ProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
@@ -58,9 +59,11 @@ export type State = {
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
}
|
||||
lsp_ready: boolean
|
||||
lsp: LspStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
limit: number
|
||||
|
||||
35
packages/app/src/context/global-sync/utils.test.ts
Normal file
35
packages/app/src/context/global-sync/utils.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
||||
import { normalizeAgentList } from "./utils"
|
||||
|
||||
const agent = (name = "build") =>
|
||||
({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: {},
|
||||
options: {},
|
||||
}) as Agent
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("keeps array payloads", () => {
|
||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("wraps a single agent payload", () => {
|
||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
||||
})
|
||||
|
||||
test("extracts agents from keyed objects", () => {
|
||||
expect(
|
||||
normalizeAgentList({
|
||||
build: agent("build"),
|
||||
docs: agent("docs"),
|
||||
}),
|
||||
).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("drops invalid payloads", () => {
|
||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function isAgent(input: unknown): input is Agent {
|
||||
if (!input || typeof input !== "object") return false
|
||||
const item = input as { name?: unknown; mode?: unknown }
|
||||
if (typeof item.name !== "string") return false
|
||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||
}
|
||||
|
||||
export function normalizeAgentList(input: unknown): Agent[] {
|
||||
if (Array.isArray(input)) return input.filter(isAgent)
|
||||
if (isAgent(input)) return [input]
|
||||
if (!input || typeof input !== "object") return []
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { dict as zht } from "@/i18n/zht"
|
||||
import { dict as ko } from "@/i18n/ko"
|
||||
import { dict as de } from "@/i18n/de"
|
||||
import { dict as es } from "@/i18n/es"
|
||||
import { dict as fr } from "@/i18n/fr"
|
||||
import { dict as da } from "@/i18n/da"
|
||||
import { dict as ja } from "@/i18n/ja"
|
||||
import { dict as pl } from "@/i18n/pl"
|
||||
import { dict as ru } from "@/i18n/ru"
|
||||
import { dict as ar } from "@/i18n/ar"
|
||||
import { dict as no } from "@/i18n/no"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as tr } from "@/i18n/tr"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
||||
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
|
||||
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
|
||||
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
|
||||
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
|
||||
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
|
||||
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
|
||||
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
|
||||
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
|
||||
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
|
||||
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
|
||||
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
|
||||
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
|
||||
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
|
||||
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
|
||||
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
|
||||
|
||||
export type Locale =
|
||||
| "en"
|
||||
@@ -59,6 +27,7 @@ export type Locale =
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||
@@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
}
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn })
|
||||
const DICT: Record<Locale, Dictionary> = {
|
||||
en: base,
|
||||
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
|
||||
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
|
||||
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
|
||||
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
|
||||
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
|
||||
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
|
||||
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
|
||||
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
|
||||
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
|
||||
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
|
||||
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
|
||||
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
|
||||
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
|
||||
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
|
||||
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
|
||||
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
|
||||
const dicts = new Map<Locale, Dictionary>([["en", base]])
|
||||
|
||||
const merge = (app: Promise<Source>, ui: Promise<Source>) =>
|
||||
Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
|
||||
|
||||
const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
||||
zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
|
||||
zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
|
||||
ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
|
||||
de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
|
||||
es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
|
||||
fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
|
||||
da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
|
||||
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
|
||||
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
|
||||
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
|
||||
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
|
||||
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
|
||||
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
|
||||
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
||||
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
||||
}
|
||||
|
||||
function loadDict(locale: Locale) {
|
||||
const hit = dicts.get(locale)
|
||||
if (hit) return Promise.resolve(hit)
|
||||
if (locale === "en") return Promise.resolve(base)
|
||||
const load = loaders[locale]
|
||||
return load().then((next: Dictionary) => {
|
||||
dicts.set(locale, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function loadLocaleDict(locale: Locale) {
|
||||
return loadDict(locale).then(() => undefined)
|
||||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
@@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
|
||||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
||||
]
|
||||
|
||||
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
||||
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||
zh,
|
||||
zht,
|
||||
ko,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
da,
|
||||
ja,
|
||||
pl,
|
||||
ru,
|
||||
ar,
|
||||
no,
|
||||
br,
|
||||
th,
|
||||
bs,
|
||||
tr,
|
||||
}
|
||||
void PARITY_CHECK
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof navigator !== "object") return "en"
|
||||
|
||||
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
|
||||
return "en"
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string): Locale {
|
||||
export function normalizeLocale(value: string): Locale {
|
||||
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
|
||||
}
|
||||
|
||||
function readStoredLocale() {
|
||||
if (typeof localStorage !== "object") return
|
||||
try {
|
||||
const raw = localStorage.getItem("opencode.global.dat:language")
|
||||
if (!raw) return
|
||||
const next = JSON.parse(raw) as { locale?: string }
|
||||
if (typeof next?.locale !== "string") return
|
||||
return normalizeLocale(next.locale)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const warm = readStoredLocale() ?? detectLocale()
|
||||
if (warm !== "en") void loadDict(warm)
|
||||
|
||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||
name: "Language",
|
||||
init: () => {
|
||||
init: (props: { locale?: Locale }) => {
|
||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language", ["language.v1"]),
|
||||
createStore({
|
||||
locale: detectLocale() as Locale,
|
||||
locale: initial,
|
||||
}),
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
console.log("locale", locale())
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
|
||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||
const [dict] = createResource(locale, loadDict, {
|
||||
initialValue: dicts.get(initial) ?? base,
|
||||
})
|
||||
|
||||
const t = i18n.translator(dict, i18n.resolveTemplate)
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
||||
key: keyof Dictionary,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
|
||||
const label = (value: Locale) => t(LABEL_KEY[value])
|
||||
|
||||
|
||||
@@ -1,252 +1,440 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { modelEnabled, modelProbe } from "@/testing/model-selection"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useModels } from "@/context/models"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type State = {
|
||||
agent?: string
|
||||
model?: ModelKey
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
type Saved = {
|
||||
session: Record<string, State | undefined>
|
||||
}
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const handoff = new Map<string, State>()
|
||||
|
||||
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
|
||||
|
||||
const migrate = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return { session: {} }
|
||||
|
||||
const item = value as {
|
||||
session?: Record<string, State | undefined>
|
||||
pick?: Record<string, State | undefined>
|
||||
}
|
||||
|
||||
if (item.session && typeof item.session === "object") return { session: item.session }
|
||||
if (!item.pick || typeof item.pick !== "object") return { session: {} }
|
||||
|
||||
return {
|
||||
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
|
||||
}
|
||||
}
|
||||
|
||||
const clone = (value: State | undefined) => {
|
||||
if (!value) return undefined
|
||||
return {
|
||||
...value,
|
||||
model: value.model ? { ...value.model } : undefined,
|
||||
} satisfies State
|
||||
}
|
||||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const providers = useProviders()
|
||||
const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
|
||||
const models = useModels()
|
||||
|
||||
function isModelValid(model: ModelKey) {
|
||||
const provider = providers.all().find((x) => x.id === model.providerID)
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const [saved, setSaved] = persisted(
|
||||
{
|
||||
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
|
||||
migrate,
|
||||
},
|
||||
createStore<Saved>({
|
||||
session: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
current?: string
|
||||
draft?: State
|
||||
last?: {
|
||||
type: "agent" | "model" | "variant"
|
||||
agent?: string
|
||||
model?: ModelKey | null
|
||||
variant?: string | null
|
||||
}
|
||||
}>({
|
||||
current: list()[0]?.name,
|
||||
draft: undefined,
|
||||
last: undefined,
|
||||
})
|
||||
|
||||
const validModel = (model: ModelKey) => {
|
||||
const provider = providers.all().find((item) => item.id === model.providerID)
|
||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
const firstModel = (...items: Array<() => ModelKey | undefined>) => {
|
||||
for (const item of items) {
|
||||
const model = item()
|
||||
if (!model) continue
|
||||
if (isModelValid(model)) return model
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
|
||||
const pickAgent = (name: string | undefined) => {
|
||||
const items = list()
|
||||
if (items.length === 0) return undefined
|
||||
return items.find((item) => item.name === name) ?? items[0]
|
||||
}
|
||||
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const models = useModels()
|
||||
createEffect(() => {
|
||||
const items = list()
|
||||
if (items.length === 0) {
|
||||
if (store.current !== undefined) setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
if (items.some((item) => item.name === store.current)) return
|
||||
setStore("current", items[0]?.name)
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
current?: string
|
||||
}>({
|
||||
current: list()[0]?.name,
|
||||
const scope = createMemo<State | undefined>(() => {
|
||||
const session = id()
|
||||
if (!session) return store.draft
|
||||
return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const session = id()
|
||||
if (!session) return
|
||||
|
||||
const key = handoffKey(sdk.directory, session)
|
||||
const next = handoff.get(key)
|
||||
if (!next) return
|
||||
if (saved.session[session] !== undefined) {
|
||||
handoff.delete(key)
|
||||
return
|
||||
}
|
||||
|
||||
setSaved("session", session, clone(next))
|
||||
handoff.delete(key)
|
||||
})
|
||||
|
||||
const configuredModel = () => {
|
||||
if (!sync.data.config.model) return
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
const model = { providerID, modelID }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
|
||||
const recentModel = () => {
|
||||
for (const item of models.recent.list()) {
|
||||
if (validModel(item)) return item
|
||||
}
|
||||
}
|
||||
|
||||
const defaultModel = () => {
|
||||
const defaults = providers.default()
|
||||
for (const provider of providers.connected()) {
|
||||
const configured = defaults[provider.id]
|
||||
if (configured) {
|
||||
const model = { providerID: provider.id, modelID: configured }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
|
||||
const first = Object.values(provider.models)[0]
|
||||
if (!first) continue
|
||||
const model = { providerID: provider.id, modelID: first.id }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel())
|
||||
|
||||
const agent = {
|
||||
list,
|
||||
current() {
|
||||
return pickAgent(scope()?.agent ?? store.current)
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const item = pickAgent(name)
|
||||
if (!item) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setStore("current", item.name)
|
||||
setStore("last", {
|
||||
type: "agent",
|
||||
agent: item.name,
|
||||
model: item.model,
|
||||
variant: item.variant ?? null,
|
||||
})
|
||||
const prev = scope()
|
||||
const next = {
|
||||
agent: item.name,
|
||||
model: item.model ?? prev?.model,
|
||||
variant: item.variant ?? prev?.variant,
|
||||
} satisfies State
|
||||
const session = id()
|
||||
if (session) {
|
||||
setSaved("session", session, next)
|
||||
return
|
||||
}
|
||||
setStore("draft", next)
|
||||
})
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
const items = list()
|
||||
if (items.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
let next = items.findIndex((item) => item.name === agent.current()?.name) + direction
|
||||
if (next < 0) next = items.length - 1
|
||||
if (next >= items.length) next = 0
|
||||
const item = items[next]
|
||||
if (!item) return
|
||||
agent.set(item.name)
|
||||
},
|
||||
}
|
||||
|
||||
const current = () => {
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => agent.current()?.model,
|
||||
fallback,
|
||||
)
|
||||
if (!item) return undefined
|
||||
return models.find(item)
|
||||
}
|
||||
|
||||
const configured = () => {
|
||||
const item = agent.current()
|
||||
const model = current()
|
||||
if (!item || !model) return undefined
|
||||
return getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
}
|
||||
|
||||
const selected = () => scope()?.variant
|
||||
|
||||
const snapshot = () => {
|
||||
const model = current()
|
||||
return {
|
||||
list,
|
||||
current() {
|
||||
const available = list()
|
||||
if (available.length === 0) return undefined
|
||||
return available.find((x) => x.name === store.current) ?? available[0]
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const available = list()
|
||||
if (available.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
const match = name ? available.find((x) => x.name === name) : undefined
|
||||
const value = match ?? available[0]
|
||||
if (!value) return
|
||||
setStore("current", value.name)
|
||||
if (!value.model) return
|
||||
setModel({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
if (value.variant)
|
||||
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
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) return
|
||||
setModel({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
if (value.variant)
|
||||
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
|
||||
},
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
|
||||
variant: selected(),
|
||||
} satisfies State
|
||||
}
|
||||
|
||||
const write = (next: Partial<State>) => {
|
||||
const state = {
|
||||
...(scope() ?? { agent: agent.current()?.name }),
|
||||
...next,
|
||||
} satisfies State
|
||||
|
||||
const session = id()
|
||||
if (session) {
|
||||
setSaved("session", session, state)
|
||||
return
|
||||
}
|
||||
})()
|
||||
setStore("draft", state)
|
||||
}
|
||||
|
||||
const model = (() => {
|
||||
const models = useModels()
|
||||
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
|
||||
|
||||
const [ephemeral, setEphemeral] = createStore<{
|
||||
model: Record<string, ModelKey | undefined>
|
||||
}>({
|
||||
model: {},
|
||||
})
|
||||
const model = {
|
||||
ready: models.ready,
|
||||
current,
|
||||
recent,
|
||||
list: models.list,
|
||||
cycle(direction: 1 | -1) {
|
||||
const items = recent()
|
||||
const item = current()
|
||||
if (!item) return
|
||||
|
||||
const resolveConfigured = () => {
|
||||
if (!sync.data.config.model) return
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
const key = { providerID, modelID }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
const resolveRecent = () => {
|
||||
for (const item of models.recent.list()) {
|
||||
if (isModelValid(item)) return item
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDefault = () => {
|
||||
const defaults = providers.default()
|
||||
for (const provider of providers.connected()) {
|
||||
const configured = defaults[provider.id]
|
||||
if (configured) {
|
||||
const key = { providerID: provider.id, modelID: configured }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
const first = Object.values(provider.models)[0]
|
||||
if (!first) continue
|
||||
const key = { providerID: provider.id, modelID: first.id }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
|
||||
})
|
||||
|
||||
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 models.find(key)
|
||||
})
|
||||
|
||||
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const recentList = recent()
|
||||
const currentModel = current()
|
||||
if (!currentModel) return
|
||||
|
||||
const index = recentList.findIndex(
|
||||
(x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
|
||||
)
|
||||
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
|
||||
if (index === -1) return
|
||||
|
||||
let next = index + direction
|
||||
if (next < 0) next = recentList.length - 1
|
||||
if (next >= recentList.length) next = 0
|
||||
if (next < 0) next = items.length - 1
|
||||
if (next >= items.length) next = 0
|
||||
|
||||
const val = recentList[next]
|
||||
if (!val) return
|
||||
|
||||
model.set({
|
||||
providerID: val.provider.id,
|
||||
modelID: val.id,
|
||||
})
|
||||
}
|
||||
|
||||
const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
|
||||
const entry = items[next]
|
||||
if (!entry) return
|
||||
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
||||
},
|
||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
const currentAgent = agent.current()
|
||||
const next = model ?? fallbackModel()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||
if (model) models.setVisibility(model, true)
|
||||
if (options?.recent && model) models.recent.push(model)
|
||||
setStore("last", {
|
||||
type: "model",
|
||||
agent: agent.current()?.name,
|
||||
model: item ?? null,
|
||||
variant: selected(),
|
||||
})
|
||||
write({ model: item })
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
})
|
||||
}
|
||||
|
||||
setModel = set
|
||||
|
||||
return {
|
||||
ready: models.ready,
|
||||
current,
|
||||
recent,
|
||||
list: models.list,
|
||||
cycle,
|
||||
set,
|
||||
visible(model: ModelKey) {
|
||||
return models.visible(model)
|
||||
},
|
||||
visible(item: ModelKey) {
|
||||
return models.visible(item)
|
||||
},
|
||||
setVisibility(item: ModelKey, visible: boolean) {
|
||||
models.setVisibility(item, visible)
|
||||
},
|
||||
variant: {
|
||||
configured,
|
||||
selected,
|
||||
current() {
|
||||
return resolveModelVariant({
|
||||
variants: this.list(),
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
})
|
||||
},
|
||||
setVisibility(model: ModelKey, visible: boolean) {
|
||||
models.setVisibility(model, visible)
|
||||
list() {
|
||||
const item = current()
|
||||
if (!item?.variants) return []
|
||||
return Object.keys(item.variants)
|
||||
},
|
||||
variant: {
|
||||
configured() {
|
||||
const a = agent.current()
|
||||
const m = current()
|
||||
if (!a || !m) return undefined
|
||||
return getConfiguredAgentVariant({
|
||||
agent: { model: a.model, variant: a.variant },
|
||||
model: { providerID: m.provider.id, modelID: m.id, variants: m.variants },
|
||||
set(value: string | undefined) {
|
||||
batch(() => {
|
||||
const model = current()
|
||||
setStore("last", {
|
||||
type: "variant",
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||
variant: value ?? null,
|
||||
})
|
||||
},
|
||||
selected() {
|
||||
const m = current()
|
||||
if (!m) return undefined
|
||||
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
|
||||
},
|
||||
current() {
|
||||
return resolveModelVariant({
|
||||
variants: this.list(),
|
||||
write({ variant: value ?? null })
|
||||
})
|
||||
},
|
||||
cycle() {
|
||||
const items = this.list()
|
||||
if (items.length === 0) return
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants: items,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
})
|
||||
},
|
||||
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
|
||||
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
})()
|
||||
},
|
||||
}
|
||||
|
||||
const result = {
|
||||
slug: createMemo(() => base64Encode(sdk.directory)),
|
||||
model,
|
||||
agent,
|
||||
session: {
|
||||
reset() {
|
||||
setStore("draft", undefined)
|
||||
},
|
||||
promote(dir: string, session: string) {
|
||||
const next = clone(snapshot())
|
||||
if (!next) return
|
||||
|
||||
if (dir === sdk.directory) {
|
||||
setSaved("session", session, next)
|
||||
setStore("draft", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
handoff.set(handoffKey(dir, session), next)
|
||||
setStore("draft", undefined)
|
||||
},
|
||||
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
|
||||
const session = id()
|
||||
if (!session) return
|
||||
if (msg.sessionID !== session) return
|
||||
if (saved.session[session] !== undefined) return
|
||||
if (handoff.has(handoffKey(sdk.directory, session))) return
|
||||
|
||||
setSaved("session", session, {
|
||||
agent: msg.agent,
|
||||
model: msg.model,
|
||||
variant: msg.variant ?? null,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (modelEnabled()) {
|
||||
const probe = Symbol("model-probe")
|
||||
|
||||
modelProbe.bind(probe, {
|
||||
setAgent: agent.set,
|
||||
setModel: model.set,
|
||||
setVariant: model.variant.set,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const agent = result.agent.current()
|
||||
const model = result.model.current()
|
||||
modelProbe.set(probe, {
|
||||
dir: sdk.directory,
|
||||
sessionID: id(),
|
||||
last: store.last,
|
||||
agent: agent?.name,
|
||||
model: model
|
||||
? {
|
||||
providerID: model.provider.id,
|
||||
modelID: model.id,
|
||||
name: model.name,
|
||||
}
|
||||
: undefined,
|
||||
variant: result.model.variant.current() ?? null,
|
||||
selected: result.model.variant.selected(),
|
||||
configured: result.model.variant.configured(),
|
||||
pick: scope(),
|
||||
base: undefined,
|
||||
current: store.current,
|
||||
variants: result.model.variant.list(),
|
||||
models: result.model
|
||||
.list()
|
||||
.filter((item) => result.model.visible({ providerID: item.provider.id, modelID: item.id }))
|
||||
.map((item) => ({
|
||||
providerID: item.provider.id,
|
||||
modelID: item.id,
|
||||
name: item.name,
|
||||
})),
|
||||
agents: result.agent.list().map((item) => ({ name: item.name })),
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => modelProbe.clear(probe))
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,6 +44,16 @@ describe("model variant", () => {
|
||||
expect(value).toBe("high")
|
||||
})
|
||||
|
||||
test("lets an explicit default override the configured variant", () => {
|
||||
const value = resolveModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
selected: null,
|
||||
configured: "xhigh",
|
||||
})
|
||||
|
||||
expect(value).toBeUndefined()
|
||||
})
|
||||
|
||||
test("cycles from configured variant to next", () => {
|
||||
const value = cycleModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
@@ -63,4 +73,14 @@ describe("model variant", () => {
|
||||
|
||||
expect(value).toBe("low")
|
||||
})
|
||||
|
||||
test("cycles from an explicit default to the first variant", () => {
|
||||
const value = cycleModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
selected: null,
|
||||
configured: "xhigh",
|
||||
})
|
||||
|
||||
expect(value).toBe("low")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ type Model = AgentModel & {
|
||||
|
||||
type VariantInput = {
|
||||
variants: string[]
|
||||
selected: string | undefined
|
||||
selected: string | null | undefined
|
||||
configured: string | undefined
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
|
||||
}
|
||||
|
||||
export function resolveModelVariant(input: VariantInput) {
|
||||
if (input.selected === null) return undefined
|
||||
if (input.selected && input.variants.includes(input.selected)) return input.selected
|
||||
if (input.configured && input.variants.includes(input.configured)) return input.configured
|
||||
return undefined
|
||||
@@ -36,6 +37,7 @@ export function resolveModelVariant(input: VariantInput) {
|
||||
|
||||
export function cycleModelVariant(input: VariantInput) {
|
||||
if (input.variants.length === 0) return undefined
|
||||
if (input.selected === null) return input.variants[0]
|
||||
if (input.selected && input.variants.includes(input.selected)) {
|
||||
const index = input.variants.indexOf(input.selected)
|
||||
if (index === input.variants.length - 1) return undefined
|
||||
|
||||
@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.agent()))
|
||||
void playSoundById(settings.sounds.agent())
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.errors()))
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -151,6 +151,11 @@ const MAX_PROMPT_SESSIONS = 20
|
||||
|
||||
type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
type Scope = {
|
||||
dir: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
type PromptCacheEntry = {
|
||||
value: PromptSession
|
||||
dispose: VoidFunction
|
||||
@@ -245,6 +250,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
}
|
||||
|
||||
const owner = getOwner()
|
||||
const load = (dir: string, id: string | undefined) => {
|
||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||
const existing = cache.get(key)
|
||||
@@ -254,10 +260,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
return existing.value
|
||||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createPromptSession(dir, id),
|
||||
dispose,
|
||||
}))
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(dir, id),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
)
|
||||
|
||||
cache.set(key, entry)
|
||||
prune()
|
||||
@@ -265,6 +274,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(params.dir!, params.id))
|
||||
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
|
||||
|
||||
return {
|
||||
ready: () => session().ready(),
|
||||
@@ -280,8 +290,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
session().context.updateComment(path, commentID, next),
|
||||
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
||||
reset: () => session().reset(),
|
||||
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
|
||||
reset: (scope?: Scope) => pick(scope).reset(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Settings {
|
||||
appearance: {
|
||||
fontSize: number
|
||||
font: string
|
||||
uiFont: string
|
||||
}
|
||||
keybinds: Record<string, string>
|
||||
permissions: {
|
||||
@@ -42,13 +43,56 @@ export interface Settings {
|
||||
sounds: SoundSettings
|
||||
}
|
||||
|
||||
export const monoDefault = "IBM Plex Mono"
|
||||
export const sansDefault = "Inter"
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
|
||||
const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
|
||||
const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
|
||||
const monoKey = "ibm-plex-mono"
|
||||
|
||||
function input(font: string | undefined, key?: string) {
|
||||
if (!font || font === key || !font.trim()) return ""
|
||||
return font
|
||||
}
|
||||
|
||||
function family(font: string) {
|
||||
if (/^[\w-]+$/.test(font)) return font
|
||||
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
function stack(font: string | undefined, base: string, key?: string) {
|
||||
const value = input(font, key).trim()
|
||||
if (!value) return base
|
||||
return `${family(value)}, ${base}`
|
||||
}
|
||||
|
||||
export function monoInput(font: string | undefined) {
|
||||
return input(font, monoKey)
|
||||
}
|
||||
|
||||
export function sansInput(font: string | undefined) {
|
||||
return input(font)
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return stack(font, monoBase, monoKey)
|
||||
}
|
||||
|
||||
export function sansFontFamily(font: string | undefined) {
|
||||
return stack(font, sansBase)
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
followup: "steer",
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
},
|
||||
updates: {
|
||||
@@ -56,7 +100,8 @@ const defaultSettings: Settings = {
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
font: "ibm-plex-mono",
|
||||
font: "",
|
||||
uiFont: "",
|
||||
},
|
||||
keybinds: {},
|
||||
permissions: {
|
||||
@@ -77,29 +122,6 @@ const defaultSettings: Settings = {
|
||||
},
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
||||
const monoFonts: Record<string, string> = {
|
||||
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"cascadia-code": `"Cascadia Code Nerd Font", "Cascadia Code NF", "Cascadia Mono NF", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"fira-code": `"Fira Code Nerd Font", "FiraMono Nerd Font", "FiraMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
|
||||
}
|
||||
|
||||
function withFallback<T>(read: () => T | undefined, fallback: T) {
|
||||
return createMemo(() => read() ?? fallback)
|
||||
}
|
||||
@@ -111,7 +133,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
const root = document.documentElement
|
||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -167,7 +191,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
|
||||
setFont(value: string) {
|
||||
setStore("appearance", "font", value)
|
||||
setStore("appearance", "font", value.trim() ? value : "")
|
||||
},
|
||||
uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
|
||||
setUIFont(value: string) {
|
||||
setStore("appearance", "uiFont", value.trim() ? value : "")
|
||||
},
|
||||
},
|
||||
keybinds: {
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
@@ -178,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const messagePageSize = 200
|
||||
const initialMessagePageSize = 80
|
||||
const historyMessagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
@@ -336,7 +339,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
input.setStore("part", p.id, p.part)
|
||||
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
|
||||
if (filtered.length) input.setStore("part", p.id, filtered)
|
||||
}
|
||||
setMeta("limit", key, message.length)
|
||||
setMeta("cursor", key, next.cursor)
|
||||
@@ -460,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
const limit = meta.limit[key] ?? initialMessagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
@@ -557,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? messagePageSize
|
||||
const step = count ?? historyMessagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
const before = meta.cursor[key]
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
import { dict as ar } from "@/i18n/ar"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as da } from "@/i18n/da"
|
||||
import { dict as de } from "@/i18n/de"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as es } from "@/i18n/es"
|
||||
import { dict as fr } from "@/i18n/fr"
|
||||
import { dict as ja } from "@/i18n/ja"
|
||||
import { dict as ko } from "@/i18n/ko"
|
||||
import { dict as no } from "@/i18n/no"
|
||||
import { dict as pl } from "@/i18n/pl"
|
||||
import { dict as ru } from "@/i18n/ru"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as tr } from "@/i18n/tr"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { dict as zht } from "@/i18n/zht"
|
||||
const template = "Terminal {{number}}"
|
||||
|
||||
const numbered = Array.from(
|
||||
new Set([
|
||||
en["terminal.title.numbered"],
|
||||
ar["terminal.title.numbered"],
|
||||
br["terminal.title.numbered"],
|
||||
bs["terminal.title.numbered"],
|
||||
da["terminal.title.numbered"],
|
||||
de["terminal.title.numbered"],
|
||||
es["terminal.title.numbered"],
|
||||
fr["terminal.title.numbered"],
|
||||
ja["terminal.title.numbered"],
|
||||
ko["terminal.title.numbered"],
|
||||
no["terminal.title.numbered"],
|
||||
pl["terminal.title.numbered"],
|
||||
ru["terminal.title.numbered"],
|
||||
th["terminal.title.numbered"],
|
||||
tr["terminal.title.numbered"],
|
||||
zh["terminal.title.numbered"],
|
||||
zht["terminal.title.numbered"],
|
||||
]),
|
||||
)
|
||||
const numbered = [
|
||||
template,
|
||||
"محطة طرفية {{number}}",
|
||||
"Терминал {{number}}",
|
||||
"ターミナル {{number}}",
|
||||
"터미널 {{number}}",
|
||||
"เทอร์มินัล {{number}}",
|
||||
"终端 {{number}}",
|
||||
"終端機 {{number}}",
|
||||
]
|
||||
|
||||
export function defaultTitle(number: number) {
|
||||
return en["terminal.title.numbered"].replace("{{number}}", String(number))
|
||||
return template.replace("{{number}}", String(number))
|
||||
}
|
||||
|
||||
export function isDefaultTitle(title: string, number: number) {
|
||||
|
||||
@@ -185,6 +185,60 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||
const previous = index >= 0 ? store.all[index] : undefined
|
||||
if (index >= 0) {
|
||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||
}
|
||||
client.pty
|
||||
.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (previous) {
|
||||
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
|
||||
if (currentIndex >= 0) setStore("all", currentIndex, previous)
|
||||
}
|
||||
console.error("Failed to update terminal", error)
|
||||
})
|
||||
}
|
||||
|
||||
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const next = await client.pty
|
||||
.create({
|
||||
title: pty.title,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to clone terminal", error)
|
||||
return undefined
|
||||
})
|
||||
if (!next?.data) return
|
||||
|
||||
const active = store.active === pty.id
|
||||
|
||||
batch(() => {
|
||||
setStore("all", index, {
|
||||
id: next.data.id,
|
||||
title: next.data.title ?? pty.title,
|
||||
titleNumber: pty.titleNumber,
|
||||
buffer: undefined,
|
||||
cursor: undefined,
|
||||
scrollY: undefined,
|
||||
rows: undefined,
|
||||
cols: undefined,
|
||||
})
|
||||
if (active) {
|
||||
setStore("active", next.data.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
all: createMemo(() => store.all),
|
||||
@@ -216,24 +270,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
})
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||
const previous = index >= 0 ? store.all[index] : undefined
|
||||
if (index >= 0) {
|
||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||
}
|
||||
sdk.client.pty
|
||||
.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (previous) {
|
||||
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
|
||||
if (currentIndex >= 0) setStore("all", currentIndex, previous)
|
||||
}
|
||||
console.error("Failed to update terminal", error)
|
||||
})
|
||||
update(sdk.client, pty)
|
||||
},
|
||||
trim(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
@@ -248,37 +285,23 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
})
|
||||
},
|
||||
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,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to clone terminal", error)
|
||||
return undefined
|
||||
})
|
||||
if (!clone?.data) return
|
||||
|
||||
const active = store.active === pty.id
|
||||
|
||||
batch(() => {
|
||||
setStore("all", index, {
|
||||
id: clone.data.id,
|
||||
title: clone.data.title ?? pty.title,
|
||||
titleNumber: pty.titleNumber,
|
||||
// New PTY process, so start clean.
|
||||
buffer: undefined,
|
||||
cursor: undefined,
|
||||
scrollY: undefined,
|
||||
rows: undefined,
|
||||
cols: undefined,
|
||||
})
|
||||
if (active) {
|
||||
setStore("active", clone.data.id)
|
||||
}
|
||||
})
|
||||
await clone(sdk.client, id)
|
||||
},
|
||||
bind() {
|
||||
const client = sdk.client
|
||||
return {
|
||||
trim(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
if (index === -1) return
|
||||
setStore("all", index, (pty) => trimTerminal(pty))
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
update(client, pty)
|
||||
},
|
||||
async clone(id: string) {
|
||||
await clone(client, id)
|
||||
},
|
||||
}
|
||||
},
|
||||
open(id: string) {
|
||||
setStore("active", id)
|
||||
@@ -403,6 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
trim: (id: string) => workspace().trim(id),
|
||||
trimAll: () => workspace().trimAll(),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
bind: () => workspace(),
|
||||
open: (id: string) => workspace().open(id),
|
||||
close: (id: string) => workspace().close(id),
|
||||
move: (id: string, to: number) => workspace().move(id, to),
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useProviders() {
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
return projectStore.provider
|
||||
if (projectStore.provider_ready) return projectStore.provider
|
||||
}
|
||||
return globalSync.data.provider
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ export const dict = {
|
||||
"common.cancel": "إلغاء",
|
||||
"common.connect": "اتصال",
|
||||
"common.disconnect": "قطع الاتصال",
|
||||
"common.continue": "إرسال",
|
||||
"common.submit": "إرسال",
|
||||
"common.save": "حفظ",
|
||||
"common.saving": "جارٍ الحفظ...",
|
||||
@@ -563,8 +564,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن",
|
||||
"settings.general.row.theme.title": "السمة",
|
||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||
"settings.general.row.font.title": "الخط",
|
||||
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.font.title": "خط الكود",
|
||||
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات",
|
||||
"settings.general.row.uiFont.title": "خط الواجهة",
|
||||
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
|
||||
"settings.general.row.followup.title": "سلوك المتابعة",
|
||||
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
|
||||
"settings.general.row.followup.option.queue": "قائمة انتظار",
|
||||
@@ -591,19 +594,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "جارٍ التحقق...",
|
||||
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
|
||||
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "بلا",
|
||||
"sound.option.alert01": "تنبيه 01",
|
||||
"sound.option.alert02": "تنبيه 02",
|
||||
@@ -721,8 +711,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
|
||||
"settings.permissions.tool.todoread.title": "قراءة المهام",
|
||||
"settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
|
||||
"settings.permissions.tool.todowrite.title": "كتابة المهام",
|
||||
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
|
||||
"settings.permissions.tool.webfetch.title": "جلب الويب",
|
||||
|
||||
@@ -204,6 +204,7 @@ export const dict = {
|
||||
"common.cancel": "Cancelar",
|
||||
"common.connect": "Conectar",
|
||||
"common.disconnect": "Desconectar",
|
||||
"common.continue": "Enviar",
|
||||
"common.submit": "Enviar",
|
||||
"common.save": "Salvar",
|
||||
"common.saving": "Salvando...",
|
||||
@@ -570,8 +571,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||
"settings.general.row.font.title": "Fonte",
|
||||
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
|
||||
"settings.general.row.font.title": "Fonte de código",
|
||||
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
|
||||
"settings.general.row.uiFont.title": "Fonte da interface",
|
||||
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
|
||||
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
||||
"settings.general.row.followup.description":
|
||||
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
|
||||
@@ -599,19 +602,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Verificando...",
|
||||
"settings.updates.toast.latest.title": "Você está atualizado",
|
||||
"settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Nenhum",
|
||||
"sound.option.alert01": "Alerta 01",
|
||||
"sound.option.alert02": "Alerta 02",
|
||||
@@ -731,8 +721,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
|
||||
"settings.permissions.tool.todoread.title": "Ler Tarefas",
|
||||
"settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
|
||||
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
|
||||
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
|
||||
"settings.permissions.tool.webfetch.title": "Buscar Web",
|
||||
|
||||
@@ -221,6 +221,7 @@ export const dict = {
|
||||
"common.cancel": "Otkaži",
|
||||
"common.connect": "Poveži",
|
||||
"common.disconnect": "Prekini vezu",
|
||||
"common.continue": "Pošalji",
|
||||
"common.submit": "Pošalji",
|
||||
"common.save": "Sačuvaj",
|
||||
"common.saving": "Čuvanje...",
|
||||
@@ -635,8 +636,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
|
||||
"settings.general.row.font.title": "Font za kod",
|
||||
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
|
||||
"settings.general.row.uiFont.title": "UI font",
|
||||
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
||||
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
||||
"settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
|
||||
"settings.general.row.followup.option.queue": "Red čekanja",
|
||||
@@ -666,19 +669,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Provjera...",
|
||||
"settings.updates.toast.latest.title": "Sve je ažurno",
|
||||
"settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Nijedan",
|
||||
"sound.option.alert01": "Upozorenje 01",
|
||||
"sound.option.alert02": "Upozorenje 02",
|
||||
@@ -805,8 +795,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
|
||||
"settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
|
||||
"settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
|
||||
"settings.permissions.tool.webfetch.title": "Web preuzimanje",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user