mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-09 10:24:11 +00:00
Compare commits
444 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93a11ddedf | ||
|
|
94feb811ca | ||
|
|
b0ceec9b19 | ||
|
|
40b111d92c | ||
|
|
520110e864 | ||
|
|
d4a68b0f4e | ||
|
|
019cfd4a52 | ||
|
|
687210a55d | ||
|
|
b12eab782f | ||
|
|
99ea1351ce | ||
|
|
d40dffb854 | ||
|
|
0cd52f830c | ||
|
|
62f38087b8 | ||
|
|
79879b43ce | ||
|
|
a598ecac1f | ||
|
|
9ac54adbb2 | ||
|
|
6490fb0148 | ||
|
|
43811b62d2 | ||
|
|
de0f4ef80b | ||
|
|
9a7f54f21a | ||
|
|
27c8a08144 | ||
|
|
80c1c59ed3 | ||
|
|
7c6b8d7a8a | ||
|
|
4187a5fe7f | ||
|
|
d5c86b03ba | ||
|
|
bc25efdf72 | ||
|
|
c639200ede | ||
|
|
d5036cf01f | ||
|
|
d1ebe0767c | ||
|
|
19b1222cd8 | ||
|
|
7631060a82 | ||
|
|
85d0ed5989 | ||
|
|
3408c10057 | ||
|
|
ecaeb9e602 | ||
|
|
e772fc6e23 | ||
|
|
4b7abc0a2c | ||
|
|
805207e096 | ||
|
|
0e1f543646 | ||
|
|
fb331f6cb8 | ||
|
|
6bdd3528ac | ||
|
|
4efbfcd087 | ||
|
|
9401029b1d | ||
|
|
04f216902b | ||
|
|
8ad5262a87 | ||
|
|
79a706c664 | ||
|
|
515ef8e554 | ||
|
|
fedf9feba8 | ||
|
|
b5b93aea42 | ||
|
|
4abf8049c9 | ||
|
|
fbc08709d1 | ||
|
|
576a681a4f | ||
|
|
95d2d4d3a7 | ||
|
|
a486b74b14 | ||
|
|
7249b87bf6 | ||
|
|
c42d2602b4 | ||
|
|
89064c34c5 | ||
|
|
fde0b39b7c | ||
|
|
e9a3cfc083 | ||
|
|
e767801db2 | ||
|
|
898778daa9 | ||
|
|
13381580af | ||
|
|
def907ae4b | ||
|
|
71930621fd | ||
|
|
288a491651 | ||
|
|
24ed2d3a1d | ||
|
|
a25cd2da72 | ||
|
|
918795d868 | ||
|
|
84c5df19c7 | ||
|
|
e5b355e458 | ||
|
|
89f0a447f6 | ||
|
|
fcc927ee76 | ||
|
|
f256a65b59 | ||
|
|
6a5c1a74f1 | ||
|
|
6324d1c351 | ||
|
|
95ad6758af | ||
|
|
24cd84cda5 | ||
|
|
8069197329 | ||
|
|
3f7ca0494b | ||
|
|
18749c1f4e | ||
|
|
9497cfdf45 | ||
|
|
22353f0169 | ||
|
|
449c5b44b7 | ||
|
|
24dbc46548 | ||
|
|
83156e5153 | ||
|
|
53298145a2 | ||
|
|
2c58dd6203 | ||
|
|
a4bc883595 | ||
|
|
c07077f96c | ||
|
|
e31c27c26b | ||
|
|
12e8ef3178 | ||
|
|
b7ad8e459c | ||
|
|
5a1bf3a968 | ||
|
|
812597bb8b | ||
|
|
0ec5f6608b | ||
|
|
0e73869580 | ||
|
|
732a3dab8c | ||
|
|
400bc7973a | ||
|
|
ac88c6b637 | ||
|
|
d4fcc1b863 | ||
|
|
6c0dce6711 | ||
|
|
4afec6731d | ||
|
|
5d92219812 | ||
|
|
80a5c3d7ed | ||
|
|
981b80d40b | ||
|
|
3c5e1a98fc | ||
|
|
5ae4463b63 | ||
|
|
e0e32ed3a8 | ||
|
|
9b20679a61 | ||
|
|
26e1901bd0 | ||
|
|
9f00b8c8dc | ||
|
|
c35bd39829 | ||
|
|
266de27a0b | ||
|
|
0f1fdeceda | ||
|
|
8bf97ef9e5 | ||
|
|
683d234d80 | ||
|
|
229cdafcc4 | ||
|
|
154d0ebf53 | ||
|
|
2e9a63fe4f | ||
|
|
579902ace6 | ||
|
|
3da2f2f33b | ||
|
|
9ff423bebf | ||
|
|
1824db13cf | ||
|
|
36637b3be0 | ||
|
|
a45841396f | ||
|
|
102d8e72bb | ||
|
|
09a0e921ce | ||
|
|
28c8182bd5 | ||
|
|
bccd568993 | ||
|
|
fd8c2fb0cd | ||
|
|
d160cca179 | ||
|
|
b551195818 | ||
|
|
d7c2d5db3b | ||
|
|
1dd88aeae6 | ||
|
|
2875405514 | ||
|
|
8c0300c021 | ||
|
|
26b786dd3f | ||
|
|
afce869d3b | ||
|
|
b738d88ec4 | ||
|
|
83646e0366 | ||
|
|
c40ce47e92 | ||
|
|
b1c44c7e5c | ||
|
|
081f065942 | ||
|
|
8ddef975b7 | ||
|
|
2f78705f6e | ||
|
|
a0bc656215 | ||
|
|
47f00d23b3 | ||
|
|
40ebc34909 | ||
|
|
e08705f4ef | ||
|
|
7c748ef089 | ||
|
|
fba5a79c45 | ||
|
|
dbde377ab0 | ||
|
|
9adcf524e2 | ||
|
|
531b1941a0 | ||
|
|
a1c46e05ee | ||
|
|
1fe1457cfa | ||
|
|
aedd85d885 | ||
|
|
5b3d94ebaa | ||
|
|
1a6a3f4b54 | ||
|
|
3116cfc167 | ||
|
|
05529f66d7 | ||
|
|
ef09dddaa5 | ||
|
|
bf7af99a3f | ||
|
|
7555742bf0 | ||
|
|
fa20bc296b | ||
|
|
195731f347 | ||
|
|
72de9fe7a6 | ||
|
|
bec1148192 | ||
|
|
8c8d888140 | ||
|
|
d3a247bfff | ||
|
|
9ef319f25f | ||
|
|
64e2bf8bf0 | ||
|
|
556adad67b | ||
|
|
843bbc973a | ||
|
|
173804c097 | ||
|
|
4086a9ae8e | ||
|
|
2614342f97 | ||
|
|
4387f9fb9a | ||
|
|
31e2feb347 | ||
|
|
2896b8a863 | ||
|
|
0d38e69038 | ||
|
|
9679e0c59c | ||
|
|
41bc4ec7f0 | ||
|
|
912098928a | ||
|
|
222bddc41a | ||
|
|
9436cb575b | ||
|
|
d1686661c0 | ||
|
|
305007aa0c | ||
|
|
a2c28fc8d7 | ||
|
|
ce87121067 | ||
|
|
ecd7854853 | ||
|
|
57b8c62909 | ||
|
|
28dc5de6a8 | ||
|
|
c875a1fc90 | ||
|
|
1721c6efdf | ||
|
|
93592702c3 | ||
|
|
61d3f788b8 | ||
|
|
a3b281b2f3 | ||
|
|
c8622df762 | ||
|
|
c277ee8cbf | ||
|
|
a2face30f4 | ||
|
|
a219615fe5 | ||
|
|
af06175b1f | ||
|
|
2e8d8de58b | ||
|
|
310de8b1ea | ||
|
|
891875402c | ||
|
|
154cbf6996 | ||
|
|
64bafce665 | ||
|
|
5588453cbe | ||
|
|
5aaf8f8247 | ||
|
|
8c1f1f13dc | ||
|
|
b942e0b4dc | ||
|
|
f282613746 | ||
|
|
7c440ae82c | ||
|
|
b7bd561eaa | ||
|
|
6daa962aaa | ||
|
|
93a07e5a2a | ||
|
|
93e060272a | ||
|
|
acac05f22e | ||
|
|
b5a4671c64 | ||
|
|
a68fedd4a6 | ||
|
|
015cd404e4 | ||
|
|
9921809565 | ||
|
|
137336f373 | ||
|
|
d940d17918 | ||
|
|
0a5d5bc524 | ||
|
|
a30696f9bf | ||
|
|
25bdd77b1d | ||
|
|
2f12e8ee92 | ||
|
|
95211a8854 | ||
|
|
6b5cf936a2 | ||
|
|
17e62b050f | ||
|
|
ee84eb44ee | ||
|
|
82dd4b6908 | ||
|
|
185858749b | ||
|
|
39a504773c | ||
|
|
b7b734f51f | ||
|
|
dcff5b6596 | ||
|
|
4f7da2b757 | ||
|
|
60e616ec81 | ||
|
|
416964acd0 | ||
|
|
017ad2c7f7 | ||
|
|
e33eb1b058 | ||
|
|
857b8a4b56 | ||
|
|
3975329629 | ||
|
|
54e14c1a17 | ||
|
|
3741516fe3 | ||
|
|
76381f33d5 | ||
|
|
95d0d476e3 | ||
|
|
7508839b70 | ||
|
|
e88cbefabe | ||
|
|
a38bae684f | ||
|
|
08671e3155 | ||
|
|
0d557721cf | ||
|
|
e709808b32 | ||
|
|
0d22068c90 | ||
|
|
d116c227e0 | ||
|
|
3f07dffbb0 | ||
|
|
801e4a8a9d | ||
|
|
3adeed8f97 | ||
|
|
1275c71a63 | ||
|
|
ca8c23dd71 | ||
|
|
acc2bf5db9 | ||
|
|
96fbc30945 | ||
|
|
ba545ba9b3 | ||
|
|
188cc24bfc | ||
|
|
5e3162b7f4 | ||
|
|
f9aa209131 | ||
|
|
f86f654cda | ||
|
|
aadd2e13d7 | ||
|
|
531357b40c | ||
|
|
aa6b552c39 | ||
|
|
a3f1918489 | ||
|
|
a9fca05d8b | ||
|
|
824165eb79 | ||
|
|
562c9d76d9 | ||
|
|
c002ca03ba | ||
|
|
befb5d54fb | ||
|
|
69f5f657f2 | ||
|
|
0405b425f5 | ||
|
|
70cf609ce9 | ||
|
|
2f76b49df3 | ||
|
|
dfd5f38408 | ||
|
|
3b93e8d95c | ||
|
|
23631a9393 | ||
|
|
f1e0c31b8f | ||
|
|
30a25e4edc | ||
|
|
ea1aba4192 | ||
|
|
b9aad20be6 | ||
|
|
965f32ad63 | ||
|
|
cf828fff85 | ||
|
|
cf8b033be1 | ||
|
|
1bd5dc5382 | ||
|
|
06d63ca54c | ||
|
|
423778c93a | ||
|
|
8de9e47a5b | ||
|
|
d63ed3bbe3 | ||
|
|
4369d79636 | ||
|
|
3408f1a6ae | ||
|
|
34c58af796 | ||
|
|
37979ea44f | ||
|
|
50b5168c16 | ||
|
|
6b17645f2e | ||
|
|
52006c2fd9 | ||
|
|
26197ec95b | ||
|
|
43bb389e35 | ||
|
|
985090ef3c | ||
|
|
52eb8a7a8c | ||
|
|
1cabeb00d0 | ||
|
|
9564c1d6be | ||
|
|
1832eeffc9 | ||
|
|
e6d8315e29 | ||
|
|
784a17f7b3 | ||
|
|
04aef44fc3 | ||
|
|
c02dd067b2 | ||
|
|
141fdef588 | ||
|
|
3982c7d99a | ||
|
|
76745d0594 | ||
|
|
4850ecc419 | ||
|
|
43354eeabd | ||
|
|
7a9290dc9b | ||
|
|
cfbe9d329f | ||
|
|
f02499fa44 | ||
|
|
bd9d7b3221 | ||
|
|
c69474846f | ||
|
|
0dc80df6fd | ||
|
|
8e985e0a75 | ||
|
|
a4d31b6f95 | ||
|
|
c5dc075a88 | ||
|
|
eade8ee07b | ||
|
|
8fbba8de73 | ||
|
|
826664b559 | ||
|
|
d1f884033f | ||
|
|
0f3630d936 | ||
|
|
83d0e48e38 | ||
|
|
6c9b2c37a5 | ||
|
|
3ab41d548f | ||
|
|
d3d783e23d | ||
|
|
7aad2ee9ae | ||
|
|
f390ac251d | ||
|
|
7837bbc639 | ||
|
|
372dcc033c | ||
|
|
4158d7cda8 | ||
|
|
425abe2fbf | ||
|
|
744fb6aed0 | ||
|
|
e9f8e6aeec | ||
|
|
5dee3328d4 | ||
|
|
2f63152af3 | ||
|
|
c9891fe071 | ||
|
|
d35956fd92 | ||
|
|
7417e6eb38 | ||
|
|
5db089070a | ||
|
|
612b656d36 | ||
|
|
cb6ec0a564 | ||
|
|
12b8c42387 | ||
|
|
fa75d922ed | ||
|
|
e445dc0746 | ||
|
|
e84d441b82 | ||
|
|
377bf7ff21 | ||
|
|
b39c1f158f | ||
|
|
f23d8d343b | ||
|
|
91f2ac3cb2 | ||
|
|
ec720145fa | ||
|
|
f6948d0ffa | ||
|
|
d52ee41b3a | ||
|
|
ca5e85d6ea | ||
|
|
01cec84789 | ||
|
|
e62a15d421 | ||
|
|
d29dfe31e4 | ||
|
|
f15755684f | ||
|
|
16145af480 | ||
|
|
eace76e525 | ||
|
|
cc1d3732bc | ||
|
|
1798af72b0 | ||
|
|
2c82e6c6ae | ||
|
|
3577d829c2 | ||
|
|
29d02d643b | ||
|
|
23c803707d | ||
|
|
b51005ec4a | ||
|
|
dfbe553626 | ||
|
|
2af1ca7290 | ||
|
|
3e67104257 | ||
|
|
d1d7447493 | ||
|
|
c3faeae9d0 | ||
|
|
94baf1f721 | ||
|
|
9b8b9e28e2 | ||
|
|
2a56a1d6ef | ||
|
|
9e45313b0a | ||
|
|
d4c90b2dfb | ||
|
|
5b784871f0 | ||
|
|
e5f677dfb5 | ||
|
|
6a96810249 | ||
|
|
8b7fe7c09f | ||
|
|
0961632a9c | ||
|
|
abbf60080d | ||
|
|
33252a65b4 | ||
|
|
e70d984320 | ||
|
|
da7c874808 | ||
|
|
a19ef17bcb | ||
|
|
121d6a72c0 | ||
|
|
35f64b80fa | ||
|
|
feca42b025 | ||
|
|
53f118c57a | ||
|
|
786ae0a584 | ||
|
|
f73f88fb56 | ||
|
|
ac254fb442 | ||
|
|
597ae57bb1 | ||
|
|
a552652fcc | ||
|
|
511c7abaca | ||
|
|
f834915d3f | ||
|
|
65c21f8fe4 | ||
|
|
6b972329fd | ||
|
|
6ecd011e51 | ||
|
|
8e5db3083c | ||
|
|
d005e70f50 | ||
|
|
46122d9a0a | ||
|
|
81ac41e089 | ||
|
|
c0e71c4261 | ||
|
|
507f13a30c | ||
|
|
90f39bf672 | ||
|
|
95bf01a757 | ||
|
|
b6bbb95704 | ||
|
|
d713026a6a | ||
|
|
73c4d3644c | ||
|
|
571f5b31c9 | ||
|
|
644f0d4e92 | ||
|
|
d9f18e4006 | ||
|
|
2f4374c829 | ||
|
|
3542f3e406 | ||
|
|
f1caf84064 | ||
|
|
85126556b8 | ||
|
|
252b2c450d | ||
|
|
0c32afbc35 | ||
|
|
aef0e58ad7 | ||
|
|
1a6461e8bc | ||
|
|
e834a2e6c9 | ||
|
|
9d3f32065b | ||
|
|
e7ff7143b6 | ||
|
|
2c36cbb87c | ||
|
|
77fa8ddc88 | ||
|
|
4a56491e42 | ||
|
|
f51bd28ed8 | ||
|
|
6cd2a68851 | ||
|
|
9259d2bf52 | ||
|
|
e94ae550ea |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1,4 +1,5 @@
|
||||
# web + desktop packages
|
||||
packages/app/ @adamdotdevin
|
||||
packages/tauri/ @adamdotdevin
|
||||
packages/desktop/src-tauri/ @brendonovich
|
||||
packages/desktop/ @adamdotdevin
|
||||
|
||||
2
.github/actions/setup-bun/action.yml
vendored
2
.github/actions/setup-bun/action.yml
vendored
@@ -6,7 +6,7 @@ runs:
|
||||
- name: Mount Bun Cache
|
||||
uses: useblacksmith/stickydisk@v1
|
||||
with:
|
||||
key: ${{ github.repository }}-bun-cache
|
||||
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
|
||||
path: ~/.bun
|
||||
|
||||
- name: Setup Bun
|
||||
|
||||
@@ -23,6 +23,7 @@ runs:
|
||||
with:
|
||||
app-id: ${{ inputs.opencode-app-id }}
|
||||
private-key: ${{ inputs.opencode-app-secret }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Configure git user
|
||||
run: |
|
||||
|
||||
11
.github/workflows/beta.yml
vendored
11
.github/workflows/beta.yml
vendored
@@ -1,17 +1,12 @@
|
||||
name: beta
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
types: [opened, synchronize, labeled, unlabeled]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'contributor'))
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
212
.github/workflows/close-stale-prs.yml
vendored
212
.github/workflows/close-stale-prs.yml
vendored
@@ -18,6 +18,7 @@ permissions:
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Close inactive PRs
|
||||
uses: actions/github-script@v8
|
||||
@@ -25,59 +26,210 @@ jobs:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const DAYS_INACTIVE = 60
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
// Adaptive delay: fast for small batches, slower for large to respect
|
||||
// GitHub's 80 content-generating requests/minute limit
|
||||
const SMALL_BATCH_THRESHOLD = 10
|
||||
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
|
||||
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
|
||||
|
||||
const startTime = Date.now()
|
||||
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
|
||||
const { owner, repo } = context.repo
|
||||
const dryRun = context.payload.inputs?.dryRun === "true"
|
||||
const stalePrs = []
|
||||
|
||||
core.info(`Dry run mode: ${dryRun}`)
|
||||
core.info(`Cutoff date: ${cutoff.toISOString()}`)
|
||||
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
sort: "updated",
|
||||
direction: "asc",
|
||||
})
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
for (const pr of prs) {
|
||||
const lastUpdated = new Date(pr.updated_at)
|
||||
if (lastUpdated > cutoff) {
|
||||
core.info(`PR ${pr.number} is fresh`)
|
||||
continue
|
||||
async function withRetry(fn, description = 'API call') {
|
||||
let lastError
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await fn()
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const isRateLimited = error.status === 403 &&
|
||||
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
|
||||
|
||||
if (!isRateLimited) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Parse retry-after header, default to 60 seconds
|
||||
const retryAfter = error.response?.headers?.['retry-after']
|
||||
? parseInt(error.response.headers['retry-after'])
|
||||
: 60
|
||||
|
||||
// Exponential backoff: retryAfter * 2^attempt
|
||||
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
|
||||
|
||||
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
|
||||
|
||||
await sleep(backoffMs)
|
||||
}
|
||||
}
|
||||
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(first: 100, states: OPEN, after: $cursor) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
number
|
||||
title
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
committedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
reviews(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const allPrs = []
|
||||
let cursor = null
|
||||
let hasNextPage = true
|
||||
let pageCount = 0
|
||||
|
||||
while (hasNextPage) {
|
||||
pageCount++
|
||||
core.info(`Fetching page ${pageCount} of open PRs...`)
|
||||
|
||||
const result = await withRetry(
|
||||
() => github.graphql(query, { owner, repo, cursor }),
|
||||
`GraphQL page ${pageCount}`
|
||||
)
|
||||
|
||||
allPrs.push(...result.repository.pullRequests.nodes)
|
||||
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
|
||||
cursor = result.repository.pullRequests.pageInfo.endCursor
|
||||
|
||||
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
|
||||
|
||||
// Delay between pagination requests (use small batch delay for reads)
|
||||
if (hasNextPage) {
|
||||
await sleep(SMALL_BATCH_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Found ${allPrs.length} open pull requests`)
|
||||
|
||||
const stalePrs = allPrs.filter((pr) => {
|
||||
const dates = [
|
||||
new Date(pr.createdAt),
|
||||
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
|
||||
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
|
||||
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
|
||||
].filter((d) => d !== null)
|
||||
|
||||
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
|
||||
if (!lastActivity || lastActivity > cutoff) {
|
||||
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
|
||||
return false
|
||||
}
|
||||
|
||||
stalePrs.push(pr)
|
||||
}
|
||||
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
|
||||
return true
|
||||
})
|
||||
|
||||
if (!stalePrs.length) {
|
||||
core.info("No stale pull requests found.")
|
||||
return
|
||||
}
|
||||
|
||||
core.info(`Found ${stalePrs.length} stale pull requests`)
|
||||
|
||||
// ============================================
|
||||
// Close stale PRs
|
||||
// ============================================
|
||||
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
|
||||
? LARGE_BATCH_DELAY_MS
|
||||
: SMALL_BATCH_DELAY_MS
|
||||
|
||||
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
|
||||
|
||||
let closedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (const pr of stalePrs) {
|
||||
const issue_number = pr.number
|
||||
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`)
|
||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: closeComment,
|
||||
})
|
||||
try {
|
||||
// Add comment
|
||||
await withRetry(
|
||||
() => github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: closeComment,
|
||||
}),
|
||||
`Comment on PR #${issue_number}`
|
||||
)
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
state: "closed",
|
||||
})
|
||||
// Close PR
|
||||
await withRetry(
|
||||
() => github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
state: "closed",
|
||||
}),
|
||||
`Close PR #${issue_number}`
|
||||
)
|
||||
|
||||
core.info(`Closed PR #${issue_number} from ${pr.user.login}`)
|
||||
closedCount++
|
||||
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
|
||||
// Delay before processing next PR
|
||||
await sleep(requestDelayMs)
|
||||
} catch (error) {
|
||||
skippedCount++
|
||||
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
||||
core.info(`\n========== Summary ==========`)
|
||||
core.info(`Total open PRs found: ${allPrs.length}`)
|
||||
core.info(`Stale PRs identified: ${stalePrs.length}`)
|
||||
core.info(`PRs closed: ${closedCount}`)
|
||||
core.info(`PRs skipped (errors): ${skippedCount}`)
|
||||
core.info(`Elapsed time: ${elapsed}s`)
|
||||
core.info(`=============================`)
|
||||
|
||||
9
.github/workflows/deploy.yml
vendored
9
.github/workflows/deploy.yml
vendored
@@ -21,6 +21,15 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
# Workaround for Pulumi version conflict:
|
||||
# GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
|
||||
# from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
|
||||
# SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
|
||||
# Removing the system language plugin forces SST to use its bundled compatible version.
|
||||
# TODO: Remove when sst supports Pulumi >3.210.0
|
||||
- name: Fix Pulumi version conflict
|
||||
run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
|
||||
|
||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
177
.github/workflows/nix-hashes.yml
vendored
177
.github/workflows/nix-hashes.yml
vendored
@@ -6,13 +6,7 @@ permissions:
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
- "packages/*/package.json"
|
||||
- "flake.lock"
|
||||
- ".github/workflows/nix-hashes.yml"
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
@@ -21,120 +15,131 @@ on:
|
||||
- ".github/workflows/nix-hashes.yml"
|
||||
|
||||
jobs:
|
||||
nix-hashes:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
env:
|
||||
TITLE: node_modules hashes
|
||||
# Native runners required: bun install cross-compilation flags (--os/--cpu)
|
||||
# do not produce byte-identical node_modules as native installs.
|
||||
compute-hash:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- system: x86_64-linux
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- system: aarch64-linux
|
||||
runner: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- system: x86_64-darwin
|
||||
runner: macos-15-intel
|
||||
- system: aarch64-darwin
|
||||
runner: macos-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
|
||||
- name: Compute node_modules hash
|
||||
id: hash
|
||||
env:
|
||||
SYSTEM: ${{ matrix.system }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_LOG=$(mktemp)
|
||||
trap 'rm -f "$BUILD_LOG"' EXIT
|
||||
|
||||
# Build with fakeHash to trigger hash mismatch and reveal correct hash
|
||||
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)"
|
||||
|
||||
if [ -z "$HASH" ]; then
|
||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||
cat "$BUILD_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$HASH" > hash.txt
|
||||
echo "Computed hash for ${SYSTEM}: $HASH"
|
||||
|
||||
- name: Upload hash
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: hash-${{ matrix.system }}
|
||||
path: hash.txt
|
||||
retention-days: 1
|
||||
|
||||
update-hashes:
|
||||
needs: compute-hash
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
|
||||
- name: Pull latest changes
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
||||
git pull --rebase --autostash origin "$BRANCH"
|
||||
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
|
||||
|
||||
- name: Compute all node_modules hashes
|
||||
- name: Download hash artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: hashes
|
||||
pattern: hash-*
|
||||
|
||||
- name: Update hashes.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HASH_FILE="nix/hashes.json"
|
||||
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||
|
||||
if [ ! -f "$HASH_FILE" ]; then
|
||||
mkdir -p "$(dirname "$HASH_FILE")"
|
||||
echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
fi
|
||||
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
|
||||
for SYSTEM in $SYSTEMS; do
|
||||
echo "Computing hash for ${SYSTEM}..."
|
||||
BUILD_LOG=$(mktemp)
|
||||
trap 'rm -f "$BUILD_LOG"' EXIT
|
||||
|
||||
# The updater derivations use fakeHash, so they will fail and reveal the correct hash
|
||||
UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules"
|
||||
|
||||
nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
|
||||
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
|
||||
|
||||
if [ -z "$CORRECT_HASH" ]; then
|
||||
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
|
||||
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
|
||||
FILE="hashes/hash-${SYSTEM}/hash.txt"
|
||||
if [ -f "$FILE" ]; then
|
||||
HASH="$(tr -d '[:space:]' < "$FILE")"
|
||||
echo "${SYSTEM}: ${HASH}"
|
||||
jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json
|
||||
mv tmp.json "$HASH_FILE"
|
||||
else
|
||||
echo "::warning::Missing hash for ${SYSTEM}"
|
||||
fi
|
||||
|
||||
if [ -z "$CORRECT_HASH" ]; then
|
||||
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
|
||||
cat "$BUILD_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ${SYSTEM}: ${CORRECT_HASH}"
|
||||
jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \
|
||||
'.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
|
||||
mv "${HASH_FILE}.tmp" "$HASH_FILE"
|
||||
done
|
||||
|
||||
echo "All hashes computed:"
|
||||
cat "$HASH_FILE"
|
||||
|
||||
- name: Commit ${{ env.TITLE }} changes
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
- name: Commit changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HASH_FILE="nix/hashes.json"
|
||||
echo "Checking for changes..."
|
||||
|
||||
summarize() {
|
||||
local status="$1"
|
||||
{
|
||||
echo "### Nix $TITLE"
|
||||
echo ""
|
||||
echo "- ref: ${GITHUB_REF_NAME}"
|
||||
echo "- status: ${status}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
|
||||
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
FILES=("$HASH_FILE")
|
||||
STATUS="$(git status --short -- "${FILES[@]}" || true)"
|
||||
if [ -z "$STATUS" ]; then
|
||||
echo "No changes detected."
|
||||
summarize "no changes"
|
||||
if [ -z "$(git status --short -- "$HASH_FILE")" ]; then
|
||||
echo "No changes to commit"
|
||||
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changes detected:"
|
||||
echo "$STATUS"
|
||||
git add "${FILES[@]}"
|
||||
git add "$HASH_FILE"
|
||||
git commit -m "chore: update nix node_modules hashes"
|
||||
|
||||
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
||||
git pull --rebase --autostash origin "$BRANCH"
|
||||
git push origin HEAD:"$BRANCH"
|
||||
echo "Changes pushed successfully"
|
||||
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
|
||||
git push origin HEAD:"$GITHUB_REF_NAME"
|
||||
|
||||
summarize "committed $(git rev-parse --short HEAD)"
|
||||
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -103,7 +103,7 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-ubuntu-2404
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- host: blacksmith-8vcpu-ubuntu-2404-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
|
||||
138
.github/workflows/test.yml
vendored
138
.github/workflows/test.yml
vendored
@@ -1,11 +1,38 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.settings.name }})
|
||||
unit:
|
||||
name: unit (linux)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun turbo test
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: unit
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -13,17 +40,12 @@ jobs:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
playwright: bunx playwright install --with-deps
|
||||
workdir: .
|
||||
command: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
bun turbo test
|
||||
- name: windows
|
||||
host: windows-latest
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
playwright: bunx playwright install
|
||||
workdir: packages/app
|
||||
command: bun test:e2e:local
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -40,87 +62,10 @@ jobs:
|
||||
working-directory: packages/app
|
||||
run: ${{ matrix.settings.playwright }}
|
||||
|
||||
- name: Set OS-specific paths
|
||||
run: |
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
|
||||
else
|
||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Seed opencode data
|
||||
if: matrix.settings.name != 'windows'
|
||||
working-directory: packages/opencode
|
||||
run: bun script/seed-e2e.ts
|
||||
env:
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
|
||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
|
||||
|
||||
- name: Run opencode server
|
||||
if: matrix.settings.name != 'windows'
|
||||
working-directory: packages/opencode
|
||||
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 &
|
||||
env:
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
OPENCODE_CLIENT: "app"
|
||||
|
||||
- name: Wait for opencode server
|
||||
if: matrix.settings.name != 'windows'
|
||||
run: |
|
||||
for i in {1..120}; do
|
||||
curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0
|
||||
sleep 1
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: run
|
||||
working-directory: ${{ matrix.settings.workdir }}
|
||||
run: ${{ matrix.settings.command }}
|
||||
- name: Run app e2e tests
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
env:
|
||||
CI: true
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
PLAYWRIGHT_SERVER_HOST: "127.0.0.1"
|
||||
PLAYWRIGHT_SERVER_PORT: "4096"
|
||||
VITE_OPENCODE_SERVER_HOST: "127.0.0.1"
|
||||
VITE_OPENCODE_SERVER_PORT: "4096"
|
||||
OPENCODE_CLIENT: "app"
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
@@ -133,3 +78,18 @@ jobs:
|
||||
path: |
|
||||
packages/app/e2e/test-results
|
||||
packages/app/e2e/playwright-report
|
||||
|
||||
required:
|
||||
name: test (linux)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
needs:
|
||||
- unit
|
||||
- e2e
|
||||
if: always()
|
||||
steps:
|
||||
- name: Verify upstream test jobs passed
|
||||
run: |
|
||||
echo "unit=${{ needs.unit.result }}"
|
||||
echo "e2e=${{ needs.e2e.result }}"
|
||||
test "${{ needs.unit.result }}" = "success"
|
||||
test "${{ needs.e2e.result }}" = "success"
|
||||
|
||||
2
.github/workflows/typecheck.yml
vendored
2
.github/workflows/typecheck.yml
vendored
@@ -1,6 +1,8 @@
|
||||
name: typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
.codex
|
||||
*~
|
||||
playground
|
||||
tmp
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: git commit and push
|
||||
model: opencode/glm-4.7
|
||||
model: opencode/kimi-k2.5
|
||||
subtask: true
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
// "plugin": ["opencode-openai-codex-auth"],
|
||||
// "enterprise": {
|
||||
// "url": "https://enterprise.dev.opencode.ai",
|
||||
// },
|
||||
@@ -9,12 +8,7 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"mcp": {
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
"github-pr-search": false,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
sst-env.d.ts
|
||||
sst-env.d.ts
|
||||
packages/desktop/src/bindings.ts
|
||||
|
||||
98
AGENTS.md
98
AGENTS.md
@@ -1,82 +1,112 @@
|
||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
||||
|
||||
## Style Guide
|
||||
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Prefer single word variable names where possible
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||
|
||||
### Avoid let statements
|
||||
### Naming
|
||||
|
||||
We don't like `let` statements, especially combined with if/else statements.
|
||||
Prefer `const`.
|
||||
|
||||
Good:
|
||||
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
||||
|
||||
```ts
|
||||
const foo = condition ? 1 : 2
|
||||
// Good
|
||||
const foo = 1
|
||||
function journal(dir: string) {}
|
||||
|
||||
// Bad
|
||||
const fooBar = 1
|
||||
function prepareJournal(dir: string) {}
|
||||
```
|
||||
|
||||
Bad:
|
||||
Reduce total variable count by inlining when a value is only used once.
|
||||
|
||||
```ts
|
||||
let foo
|
||||
// Good
|
||||
const journal = await Bun.file(path.join(dir, "journal.json")).json()
|
||||
|
||||
// Bad
|
||||
const journalPath = path.join(dir, "journal.json")
|
||||
const journal = await Bun.file(journalPath).json()
|
||||
```
|
||||
|
||||
### Destructuring
|
||||
|
||||
Avoid unnecessary destructuring. Use dot notation to preserve context.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
obj.a
|
||||
obj.b
|
||||
|
||||
// Bad
|
||||
const { a, b } = obj
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
const foo = condition ? 1 : 2
|
||||
|
||||
// Bad
|
||||
let foo
|
||||
if (condition) foo = 1
|
||||
else foo = 2
|
||||
```
|
||||
|
||||
### Avoid else statements
|
||||
### Control Flow
|
||||
|
||||
Prefer early returns or using an `iife` to avoid else statements.
|
||||
|
||||
Good:
|
||||
Avoid `else` statements. Prefer early returns.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
return 2
|
||||
}
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
// Bad
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
else return 2
|
||||
}
|
||||
```
|
||||
|
||||
### Prefer single word naming
|
||||
### Schema Definitions (Drizzle)
|
||||
|
||||
Try your best to find a single word name for your variables, functions, etc.
|
||||
Only use multiple words if you cannot.
|
||||
|
||||
Good:
|
||||
Use snake_case for field names so column names don't need to be redefined as strings.
|
||||
|
||||
```ts
|
||||
const foo = 1
|
||||
const bar = 2
|
||||
const baz = 3
|
||||
```
|
||||
// Good
|
||||
const table = sqliteTable("session", {
|
||||
id: text().primaryKey(),
|
||||
project_id: text().notNull(),
|
||||
created_at: integer().notNull(),
|
||||
})
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const fooBar = 1
|
||||
const barBaz = 2
|
||||
const bazFoo = 3
|
||||
// Bad
|
||||
const table = sqliteTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
projectID: text("project_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
})
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
You MUST avoid using `mocks` as much as possible.
|
||||
Tests MUST test actual implementation, do not duplicate logic into a test.
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
136
README.bs.md
Normal file
136
README.bs.md
Normal file
@@ -0,0 +1,136 @@
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">OpenCode je open source AI agent za programiranje.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh.md">简体中文</a> |
|
||||
<a href="README.zht.md">繁體中文</a> |
|
||||
<a href="README.ko.md">한국어</a> |
|
||||
<a href="README.de.md">Deutsch</a> |
|
||||
<a href="README.es.md">Español</a> |
|
||||
<a href="README.fr.md">Français</a> |
|
||||
<a href="README.it.md">Italiano</a> |
|
||||
<a href="README.da.md">Dansk</a> |
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
---
|
||||
|
||||
### Instalacija
|
||||
|
||||
```bash
|
||||
# YOLO
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Package manageri
|
||||
npm i -g opencode-ai@latest # ili bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
|
||||
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
|
||||
paru -S opencode-bin # Arch Linux
|
||||
mise use -g opencode # Bilo koji OS
|
||||
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Ukloni verzije starije od 0.1.x prije instalacije.
|
||||
|
||||
### Desktop aplikacija (BETA)
|
||||
|
||||
OpenCode je dostupan i kao desktop aplikacija. Preuzmi je direktno sa [stranice izdanja](https://github.com/anomalyco/opencode/releases) ili sa [opencode.ai/download](https://opencode.ai/download).
|
||||
|
||||
| Platforma | Preuzimanje |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||
| Linux | `.deb`, `.rpm`, ili AppImage |
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask opencode-desktop
|
||||
# Windows (Scoop)
|
||||
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||
```
|
||||
|
||||
#### Instalacijski direktorij
|
||||
|
||||
Instalacijska skripta koristi sljedeći redoslijed prioriteta za putanju instalacije:
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Prilagođeni instalacijski direktorij
|
||||
2. `$XDG_BIN_DIR` - Putanja usklađena sa XDG Base Directory specifikacijom
|
||||
3. `$HOME/bin` - Standardni korisnički bin direktorij (ako postoji ili se može kreirati)
|
||||
4. `$HOME/.opencode/bin` - Podrazumijevana rezervna lokacija
|
||||
|
||||
```bash
|
||||
# Primjeri
|
||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Agenti
|
||||
|
||||
OpenCode uključuje dva ugrađena agenta između kojih možeš prebacivati tasterom `Tab`.
|
||||
|
||||
- **build** - Podrazumijevani agent sa punim pristupom za razvoj
|
||||
- **plan** - Agent samo za čitanje za analizu i istraživanje koda
|
||||
- Podrazumijevano zabranjuje izmjene datoteka
|
||||
- Traži dozvolu prije pokretanja bash komandi
|
||||
- Idealan za istraživanje nepoznatih codebase-ova ili planiranje izmjena
|
||||
|
||||
Uključen je i **general** pod-agent za složene pretrage i višekoračne zadatke.
|
||||
Koristi se interno i može se pozvati pomoću `@general` u porukama.
|
||||
|
||||
Saznaj više o [agentima](https://opencode.ai/docs/agents).
|
||||
|
||||
### Dokumentacija
|
||||
|
||||
Za više informacija o konfiguraciji OpenCode-a, [**pogledaj dokumentaciju**](https://opencode.ai/docs).
|
||||
|
||||
### Doprinosi
|
||||
|
||||
Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIBUTING.md) prije slanja pull requesta.
|
||||
|
||||
### Gradnja na OpenCode-u
|
||||
|
||||
Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Po čemu se razlikuje od Claude Code-a?
|
||||
|
||||
Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su:
|
||||
|
||||
- 100% open source
|
||||
- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna.
|
||||
- LSP podrška odmah po instalaciji
|
||||
- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu.
|
||||
- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata.
|
||||
|
||||
---
|
||||
|
||||
**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
20
README.md
20
README.md
@@ -27,10 +27,12 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a>
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
@@ -81,7 +83,7 @@ The install script respects the following priority order for the installation pa
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
|
||||
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
|
||||
3. `$HOME/bin` - Standard user binary directory (if exists or can be created)
|
||||
3. `$HOME/bin` - Standard user binary directory (if it exists or can be created)
|
||||
4. `$HOME/.opencode/bin` - Default fallback
|
||||
|
||||
```bash
|
||||
@@ -94,20 +96,20 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
OpenCode includes two built-in agents you can switch between with the `Tab` key.
|
||||
|
||||
- **build** - Default, full access agent for development work
|
||||
- **build** - Default, full-access agent for development work
|
||||
- **plan** - Read-only agent for analysis and code exploration
|
||||
- Denies file edits by default
|
||||
- Asks permission before running bash commands
|
||||
- Ideal for exploring unfamiliar codebases or planning changes
|
||||
|
||||
Also, included is a **general** subagent for complex searches and multistep tasks.
|
||||
Also included is a **general** subagent for complex searches and multistep tasks.
|
||||
This is used internally and can be invoked using `@general` in messages.
|
||||
|
||||
Learn more about [agents](https://opencode.ai/docs/agents).
|
||||
|
||||
### Documentation
|
||||
|
||||
For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).
|
||||
For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs).
|
||||
|
||||
### Contributing
|
||||
|
||||
@@ -115,7 +117,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
|
||||
|
||||
### Building on OpenCode
|
||||
|
||||
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
|
||||
If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
|
||||
|
||||
### FAQ
|
||||
|
||||
@@ -124,10 +126,10 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
|
||||
- Out of the box LSP support
|
||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||
- Out-of-the-box LSP support
|
||||
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||
- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a>
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
135
README.tr.md
Normal file
135
README.tr.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">Açık kaynaklı yapay zeka kodlama asistanı.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh.md">简体中文</a> |
|
||||
<a href="README.zht.md">繁體中文</a> |
|
||||
<a href="README.ko.md">한국어</a> |
|
||||
<a href="README.de.md">Deutsch</a> |
|
||||
<a href="README.es.md">Español</a> |
|
||||
<a href="README.fr.md">Français</a> |
|
||||
<a href="README.it.md">Italiano</a> |
|
||||
<a href="README.da.md">Dansk</a> |
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
---
|
||||
|
||||
### Kurulum
|
||||
|
||||
```bash
|
||||
# YOLO
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Paket yöneticileri
|
||||
npm i -g opencode-ai@latest # veya bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
||||
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
|
||||
paru -S opencode-bin # Arch Linux
|
||||
mise use -g opencode # Tüm işletim sistemleri
|
||||
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın.
|
||||
|
||||
### Masaüstü Uygulaması (BETA)
|
||||
|
||||
OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz.
|
||||
|
||||
| Platform | İndirme |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||
| Linux | `.deb`, `.rpm` veya AppImage |
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask opencode-desktop
|
||||
# Windows (Scoop)
|
||||
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||
```
|
||||
|
||||
#### Kurulum Dizini (Installation Directory)
|
||||
|
||||
Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder:
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini
|
||||
2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol
|
||||
3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa)
|
||||
4. `$HOME/.opencode/bin` - Varsayılan yedek konum
|
||||
|
||||
```bash
|
||||
# Örnekler
|
||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Ajanlar
|
||||
|
||||
OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir.
|
||||
|
||||
- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan
|
||||
- **plan** - Analiz ve kod keşfi için salt okunur ajan
|
||||
- Varsayılan olarak dosya düzenlemelerini reddeder
|
||||
- Bash komutlarını çalıştırmadan önce izin ister
|
||||
- Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal
|
||||
|
||||
Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır.
|
||||
Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir.
|
||||
|
||||
[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin.
|
||||
|
||||
### Dokümantasyon
|
||||
|
||||
OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs).
|
||||
|
||||
### Katkıda Bulunma
|
||||
|
||||
OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun.
|
||||
|
||||
### OpenCode Üzerine Geliştirme
|
||||
|
||||
OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
|
||||
|
||||
### SSS
|
||||
|
||||
#### Bu Claude Code'dan nasıl farklı?
|
||||
|
||||
Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
|
||||
|
||||
- %100 açık kaynak
|
||||
- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
|
||||
- Kurulum gerektirmeyen hazır LSP desteği
|
||||
- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
|
||||
- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
|
||||
|
||||
---
|
||||
|
||||
**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
@@ -124,7 +126,7 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
- 100% 開源。
|
||||
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
|
||||
- 內建 LSP (語言伺服器協定) 支援。
|
||||
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造;我們將不斷挑戰終端機介面的極限。
|
||||
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。
|
||||
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
|
||||
|
||||
---
|
||||
|
||||
248
bun.lock
248
bun.lock
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -44,7 +44,7 @@
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "0.3.0",
|
||||
"ghostty-web": "0.4.0",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,13 +182,15 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "~2",
|
||||
"@tauri-apps/plugin-deep-link": "~2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
@@ -213,7 +215,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -242,7 +244,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -258,35 +260,36 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.13.0",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.73",
|
||||
"@ai-sdk/anthropic": "2.0.57",
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.74",
|
||||
"@ai-sdk/anthropic": "2.0.58",
|
||||
"@ai-sdk/azure": "2.0.91",
|
||||
"@ai-sdk/cerebras": "1.0.34",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@ai-sdk/cohere": "2.0.22",
|
||||
"@ai-sdk/deepinfra": "1.0.31",
|
||||
"@ai-sdk/gateway": "2.0.25",
|
||||
"@ai-sdk/deepinfra": "1.0.33",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.52",
|
||||
"@ai-sdk/google-vertex": "3.0.97",
|
||||
"@ai-sdk/google-vertex": "3.0.98",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@ai-sdk/openai": "2.0.89",
|
||||
"@ai-sdk/openai-compatible": "1.0.30",
|
||||
"@ai-sdk/openai-compatible": "1.0.32",
|
||||
"@ai-sdk/perplexity": "2.0.23",
|
||||
"@ai-sdk/provider": "2.0.1",
|
||||
"@ai-sdk/provider-utils": "3.0.20",
|
||||
"@ai-sdk/togetherai": "1.0.31",
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/togetherai": "1.0.34",
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.5.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.2",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -297,9 +300,9 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.75",
|
||||
"@opentui/solid": "0.1.75",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.77",
|
||||
"@opentui/solid": "0.1.77",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -307,8 +310,9 @@
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "2.3.1",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.4",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
@@ -362,7 +366,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -382,7 +386,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -393,7 +397,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -406,7 +410,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -448,7 +452,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -459,7 +463,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -495,7 +499,7 @@
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@types/bun": "catalog:",
|
||||
@@ -516,12 +520,12 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.5",
|
||||
"@types/bun": "1.3.8",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "5.0.119",
|
||||
"ai": "5.0.124",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "3.1.0",
|
||||
@@ -557,25 +561,33 @@
|
||||
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="],
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
|
||||
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
|
||||
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XOK0dJsAGoPYi/lfR4KFBi8xhvJ46oCpAxUD6FmJAuJ4eh0qlj5zDt+myvzM8gvN7S6K7zHD+mdWlOPKGQT8Vg=="],
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
|
||||
|
||||
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
|
||||
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-87qFcYNvDF/89hB//MQjYTb3tlsAfmgeZrZ34RESeBTZpSgs0EzYOMqPMwFTHUNp4wteoifikDJbaS/9Da8cfw=="],
|
||||
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rq+FX55ne7lMiqai7NcvvDZj4HLsr+hg77WayqmySqc6zhw3tIOLxd4Ty6OpwNj0C0bVMi3iCl2zvJIEirh9XA=="],
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
|
||||
|
||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
|
||||
|
||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4d5EKu0OW7Gf5WFpGo4ixn0iWEwA+GpteqUjEznWGmi7qdLE5zdkbRik5B1HrDDiw5P90yO51xBex/Fp50JcVA=="],
|
||||
|
||||
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WWOz5Kj+5fVe94h7WeReqjUOVtAquDE2kM575FUc8CsVxH2tRfA5cLa8nu3bknSezsKt3i67YM6mvCRxiXCkWA=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.97", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s4tI7Z15i6FlbtCvS4SBRal8wRfkOXJzKxlS6cU4mJW/QfUfoVy4b22836NVNJwDvkG/HkDSfzwm/X8mn46MhA=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
|
||||
|
||||
@@ -591,9 +603,9 @@
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RlYubjStoZQxna4Ng91Vvo8YskvL7lW9zj68IwZfCnaDBSAp1u6Nhc5BR4ZtKnY6PA3XEtu4bATIQl7yiiQ+Lw=="],
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
|
||||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
|
||||
|
||||
@@ -911,8 +923,22 @@
|
||||
|
||||
"@expressive-code/plugin-text-markers": ["@expressive-code/plugin-text-markers@0.41.6", "", { "dependencies": { "@expressive-code/core": "^0.41.6" } }, "sha512-PBFa1wGyYzRExMDzBmAWC6/kdfG1oLn4pLpBeTfIRrALPjcGA/59HP3e7q9J0Smk4pC7U+lWkA2LHR8FYV8U7Q=="],
|
||||
|
||||
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
|
||||
|
||||
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
|
||||
|
||||
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
|
||||
|
||||
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
|
||||
|
||||
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
|
||||
|
||||
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
|
||||
@@ -925,7 +951,9 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "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-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="],
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.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-OoAwCz4fOci3h/2l+PRHMclclh3IaFq8w1es2wvBJ8ca7vtglKsBYT7dvmYpsXlu7pg9mopbjcexvmVCQEUTAQ=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1119,6 +1147,8 @@
|
||||
|
||||
"@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
|
||||
|
||||
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
||||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
@@ -1221,27 +1251,27 @@
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="],
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
|
||||
|
||||
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.75", "", { "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.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.77", "", { "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.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -1359,6 +1389,8 @@
|
||||
|
||||
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
|
||||
@@ -1753,6 +1785,8 @@
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
|
||||
|
||||
"@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="],
|
||||
|
||||
"@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="],
|
||||
@@ -1797,7 +1831,7 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
@@ -1909,7 +1943,7 @@
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
@@ -1935,6 +1969,8 @@
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
@@ -1947,7 +1983,9 @@
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@5.0.119", "", { "dependencies": { "@ai-sdk/gateway": "2.0.25", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HUOwhc17fl2SZTJGZyA/99aNu706qKfXaUBCy9vgZiXBwrxg2eTzn2BCz7kmYDsfx6Fg2ACBy2icm41bsDXCTw=="],
|
||||
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
|
||||
|
||||
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
@@ -2009,10 +2047,14 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"avvio": ["avvio@9.1.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw=="],
|
||||
|
||||
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
|
||||
|
||||
"aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="],
|
||||
@@ -2097,9 +2139,9 @@
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
|
||||
|
||||
@@ -2455,16 +2497,26 @@
|
||||
|
||||
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-stringify": ["fast-json-stringify@6.2.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw=="],
|
||||
|
||||
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="],
|
||||
|
||||
"fastify": ["fastify@5.7.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA=="],
|
||||
|
||||
"fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
@@ -2479,6 +2531,8 @@
|
||||
|
||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||
|
||||
"find-my-way": ["find-my-way@9.4.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w=="],
|
||||
|
||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
|
||||
@@ -2555,7 +2609,7 @@
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||
|
||||
"ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="],
|
||||
"ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
|
||||
@@ -2845,6 +2899,8 @@
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
@@ -2881,6 +2937,8 @@
|
||||
|
||||
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||
|
||||
"light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
@@ -3183,6 +3241,8 @@
|
||||
|
||||
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
@@ -3289,6 +3349,12 @@
|
||||
|
||||
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||
|
||||
"pino": ["pino@10.3.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
|
||||
@@ -3341,6 +3407,8 @@
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"promise.allsettled": ["promise.allsettled@1.0.7", "", { "dependencies": { "array.prototype.map": "^1.0.5", "call-bind": "^1.0.2", "define-properties": "^1.2.0", "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1", "iterate-value": "^1.0.2" } }, "sha512-hezvKvQQmsFkOdrZfYxUxkyxl8mgFQeT259Ajj9PXdbg9VzBCWrItOev72JyWxkCD5VSSqAeHmlN3tWx4DlmsA=="],
|
||||
|
||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||
@@ -3363,6 +3431,8 @@
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
@@ -3397,6 +3467,8 @@
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="],
|
||||
|
||||
"recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="],
|
||||
@@ -3463,6 +3535,8 @@
|
||||
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
|
||||
|
||||
"retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],
|
||||
|
||||
"retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="],
|
||||
@@ -3475,6 +3549,8 @@
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
|
||||
|
||||
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
|
||||
@@ -3497,6 +3573,10 @@
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="],
|
||||
@@ -3505,6 +3585,8 @@
|
||||
|
||||
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||
|
||||
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
@@ -3519,6 +3601,8 @@
|
||||
|
||||
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||
@@ -3581,6 +3665,8 @@
|
||||
|
||||
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -3589,6 +3675,8 @@
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
||||
@@ -3691,6 +3779,8 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
||||
"three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="],
|
||||
|
||||
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
|
||||
@@ -3975,7 +4065,9 @@
|
||||
|
||||
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
@@ -3983,11 +4075,13 @@
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/cerebras/@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=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/deepinfra/@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=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
||||
"@ai-sdk/fireworks/@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=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
@@ -3997,9 +4091,9 @@
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/togetherai/@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=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/vercel/@ai-sdk/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=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
|
||||
@@ -4069,6 +4163,8 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.17.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
@@ -4271,6 +4367,16 @@
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"ai-gateway-provider/@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=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@2.0.56", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FGlqwWc3tAYqDHE8r8hQGQLcMiPUwgz90oU2QygUH930OWtCLapFkSu114DgVaIN/qoM1DUX+inv0Ee74Fgp5g=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
@@ -4359,6 +4465,8 @@
|
||||
|
||||
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
||||
|
||||
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
@@ -4381,11 +4489,11 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -4803,6 +4911,14 @@
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768393167,
|
||||
"narHash": "sha256-n2063BRjHde6DqAz2zavhOOiLUwA3qXt7jQYHyETjX8=",
|
||||
"lastModified": 1770073757,
|
||||
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2f594d5af95d4fdac67fba60376ec11e482041cb",
|
||||
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
43
flake.nix
43
flake.nix
@@ -30,6 +30,26 @@
|
||||
};
|
||||
});
|
||||
|
||||
overlays = {
|
||||
default =
|
||||
final: _prev:
|
||||
let
|
||||
node_modules = final.callPackage ./nix/node_modules.nix {
|
||||
inherit rev;
|
||||
};
|
||||
opencode = final.callPackage ./nix/opencode.nix {
|
||||
inherit node_modules;
|
||||
};
|
||||
desktop = final.callPackage ./nix/desktop.nix {
|
||||
inherit opencode;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit opencode;
|
||||
opencode-desktop = desktop;
|
||||
};
|
||||
};
|
||||
|
||||
packages = forEachSystem (
|
||||
pkgs:
|
||||
let
|
||||
@@ -42,28 +62,15 @@
|
||||
desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||
inherit opencode;
|
||||
};
|
||||
# nixpkgs cpu naming to bun cpu naming
|
||||
cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; };
|
||||
# matrix of node_modules builds - these will always fail due to fakeHash usage
|
||||
# but allow computation of the correct hash from any build machine for any cpu/os
|
||||
# see the update-nix-hashes workflow for usage
|
||||
moduleUpdaters = pkgs.lib.listToAttrs (
|
||||
pkgs.lib.concatMap (cpu:
|
||||
map (os: {
|
||||
name = "${cpu}-${os}_node_modules";
|
||||
value = node_modules.override {
|
||||
bunCpu = cpuMap.${cpu};
|
||||
bunOs = os;
|
||||
hash = pkgs.lib.fakeHash;
|
||||
};
|
||||
}) [ "linux" "darwin" ]
|
||||
) [ "x86_64" "aarch64" ]
|
||||
);
|
||||
in
|
||||
{
|
||||
default = opencode;
|
||||
inherit opencode desktop;
|
||||
} // moduleUpdaters
|
||||
# Updater derivation with fakeHash - build fails and reveals correct hash
|
||||
node_modules_updater = node_modules.override {
|
||||
hash = pkgs.lib.fakeHash;
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,6 +135,16 @@ const ZEN_MODELS = [
|
||||
new sst.Secret("ZEN_MODELS8"),
|
||||
new sst.Secret("ZEN_MODELS9"),
|
||||
new sst.Secret("ZEN_MODELS10"),
|
||||
new sst.Secret("ZEN_MODELS11"),
|
||||
new sst.Secret("ZEN_MODELS12"),
|
||||
new sst.Secret("ZEN_MODELS13"),
|
||||
new sst.Secret("ZEN_MODELS14"),
|
||||
new sst.Secret("ZEN_MODELS15"),
|
||||
new sst.Secret("ZEN_MODELS16"),
|
||||
new sst.Secret("ZEN_MODELS17"),
|
||||
new sst.Secret("ZEN_MODELS18"),
|
||||
new sst.Secret("ZEN_MODELS19"),
|
||||
new sst.Secret("ZEN_MODELS20"),
|
||||
]
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||
|
||||
@@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||
rustc
|
||||
jq
|
||||
makeWrapper
|
||||
]
|
||||
++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
||||
|
||||
buildInputs = lib.optionals stdenv.isLinux [
|
||||
dbus
|
||||
@@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||
gst_all_1.gstreamer
|
||||
gst_all_1.gst-plugins-base
|
||||
gst_all_1.gst-plugins-good
|
||||
gst_all_1.gst-plugins-bad
|
||||
];
|
||||
|
||||
strictDeps = true;
|
||||
@@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||
mainProgram = "opencode-desktop";
|
||||
inherit (opencode.meta) platforms;
|
||||
};
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-gUWzUsk81miIrjg0fZQmsIQG4pZYmEHgzN6BaXI+lfc=",
|
||||
"aarch64-linux": "sha256-gwEG75ha/ojTO2iAObTmLTtEkXIXJ7BThzfI5CqlJh8=",
|
||||
"aarch64-darwin": "sha256-20RGG2GkUItCzD67gDdoSLfexttM8abS//FKO9bfjoM=",
|
||||
"x86_64-darwin": "sha256-i2VawFuR1UbjPVYoybU6aJDJfFo0tcvtl1aM31Y2mTQ="
|
||||
"x86_64-linux": "sha256-1IpZnnN6+acCcV0AgO4OVdvgf4TFBFId5dms5W5ecA0=",
|
||||
"aarch64-linux": "sha256-TKmPhXokOav46ucP9AFwHGgKmB9CdGCcUtwqUtLlzG4=",
|
||||
"aarch64-darwin": "sha256-xJQuw3+QHYnlClDrafQKPQyR+aqyAEofvYYjCowHDps=",
|
||||
"x86_64-darwin": "sha256-ywU3Oka2QNGKu/HI+//3bdYJ9qo1N7K5Wr2vpTgSM/g="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
bun,
|
||||
bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64",
|
||||
bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin",
|
||||
rev ? "dirty",
|
||||
hash ?
|
||||
(lib.pipe ./hashes.json [
|
||||
@@ -16,6 +14,9 @@ let
|
||||
builtins.readFile
|
||||
builtins.fromJSON
|
||||
];
|
||||
platform = stdenvNoCC.hostPlatform;
|
||||
bunCpu = if platform.isAarch64 then "arm64" else "x64";
|
||||
bunOs = if platform.isLinux then "linux" else "darwin";
|
||||
in
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "opencode-node_modules";
|
||||
@@ -39,23 +40,22 @@ stdenvNoCC.mkDerivation {
|
||||
"SOCKS_SERVER"
|
||||
];
|
||||
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
];
|
||||
nativeBuildInputs = [ bun ];
|
||||
|
||||
dontConfigure = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$(mktemp -d)
|
||||
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
|
||||
bun install \
|
||||
--cpu="${bunCpu}" \
|
||||
--os="${bunOs}" \
|
||||
--filter '!./' \
|
||||
--filter './packages/opencode' \
|
||||
--filter './packages/desktop' \
|
||||
--frozen-lockfile \
|
||||
--ignore-scripts \
|
||||
--no-progress \
|
||||
--linker=isolated
|
||||
--no-progress
|
||||
bun --bun ${./scripts/canonicalize-node-modules.ts}
|
||||
bun --bun ${./scripts/normalize-bun-binaries.ts}
|
||||
runHook postBuild
|
||||
@@ -63,10 +63,8 @@ stdenvNoCC.mkDerivation {
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out
|
||||
find . -type d -name node_modules -exec cp -R --parents {} $out \;
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
'';
|
||||
|
||||
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
|
||||
env.OPENCODE_DISABLE_MODELS_FETCH = true;
|
||||
env.OPENCODE_VERSION = finalAttrs.version;
|
||||
env.OPENCODE_CHANNEL = "local";
|
||||
|
||||
@@ -79,7 +80,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
writableTmpDirAsHomeHook
|
||||
];
|
||||
doInstallCheck = true;
|
||||
versionCheckKeepEnvironment = [ "HOME" ];
|
||||
versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ];
|
||||
versionCheckProgramArg = "--version";
|
||||
|
||||
passthru = {
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
|
||||
import { join, relative } from "path"
|
||||
|
||||
type SemverLike = {
|
||||
valid: (value: string) => string | null
|
||||
rcompare: (left: string, right: string) => number
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
dir: string
|
||||
version: string
|
||||
label: string
|
||||
}
|
||||
|
||||
async function isDirectory(path: string) {
|
||||
try {
|
||||
const info = await lstat(path)
|
||||
return info.isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isValidSemver = (v: string) => Bun.semver.satisfies(v, "x.x.x")
|
||||
|
||||
const root = process.cwd()
|
||||
const bunRoot = join(root, "node_modules/.bun")
|
||||
const linkRoot = join(bunRoot, "node_modules")
|
||||
const directories = (await readdir(bunRoot)).sort()
|
||||
|
||||
const versions = new Map<string, Entry[]>()
|
||||
|
||||
for (const entry of directories) {
|
||||
const full = join(bunRoot, entry)
|
||||
const info = await lstat(full)
|
||||
if (!info.isDirectory()) {
|
||||
if (!(await isDirectory(full))) {
|
||||
continue
|
||||
}
|
||||
const parsed = parseEntry(entry)
|
||||
@@ -29,37 +34,23 @@ for (const entry of directories) {
|
||||
continue
|
||||
}
|
||||
const list = versions.get(parsed.name) ?? []
|
||||
list.push({ dir: full, version: parsed.version, label: entry })
|
||||
list.push({ dir: full, version: parsed.version })
|
||||
versions.set(parsed.name, list)
|
||||
}
|
||||
|
||||
const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
|
||||
| SemverLike
|
||||
| {
|
||||
default: SemverLike
|
||||
}
|
||||
const semver = "default" in semverModule ? semverModule.default : semverModule
|
||||
const selections = new Map<string, Entry>()
|
||||
|
||||
for (const [slug, list] of versions) {
|
||||
list.sort((a, b) => {
|
||||
const left = semver.valid(a.version)
|
||||
const right = semver.valid(b.version)
|
||||
if (left && right) {
|
||||
const delta = semver.rcompare(left, right)
|
||||
if (delta !== 0) {
|
||||
return delta
|
||||
}
|
||||
}
|
||||
if (left && !right) {
|
||||
return -1
|
||||
}
|
||||
if (!left && right) {
|
||||
return 1
|
||||
}
|
||||
const aValid = isValidSemver(a.version)
|
||||
const bValid = isValidSemver(b.version)
|
||||
if (aValid && bValid) return -Bun.semver.order(a.version, b.version)
|
||||
if (aValid) return -1
|
||||
if (bValid) return 1
|
||||
return b.version.localeCompare(a.version)
|
||||
})
|
||||
selections.set(slug, list[0])
|
||||
const first = list[0]
|
||||
if (first) selections.set(slug, first)
|
||||
}
|
||||
|
||||
await rm(linkRoot, { recursive: true, force: true })
|
||||
@@ -77,10 +68,7 @@ for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0]
|
||||
await mkdir(parent, { recursive: true })
|
||||
const linkPath = join(parent, leaf)
|
||||
const desired = join(entry.dir, "node_modules", slug)
|
||||
const exists = await lstat(desired)
|
||||
.then((info) => info.isDirectory())
|
||||
.catch(() => false)
|
||||
if (!exists) {
|
||||
if (!(await isDirectory(desired))) {
|
||||
continue
|
||||
}
|
||||
const relativeTarget = relative(parent, desired)
|
||||
|
||||
@@ -8,7 +8,7 @@ type PackageManifest = {
|
||||
|
||||
const root = process.cwd()
|
||||
const bunRoot = join(root, "node_modules/.bun")
|
||||
const bunEntries = (await safeReadDir(bunRoot)).sort()
|
||||
const bunEntries = (await readdir(bunRoot)).sort()
|
||||
let rewritten = 0
|
||||
|
||||
for (const entry of bunEntries) {
|
||||
@@ -45,11 +45,11 @@ for (const entry of bunEntries) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[normalize-bun-binaries] rewrote ${rewritten} links`)
|
||||
console.log(`[normalize-bun-binaries] rebuilt ${rewritten} links`)
|
||||
|
||||
async function collectPackages(modulesRoot: string) {
|
||||
const found: string[] = []
|
||||
const topLevel = (await safeReadDir(modulesRoot)).sort()
|
||||
const topLevel = (await readdir(modulesRoot)).sort()
|
||||
for (const name of topLevel) {
|
||||
if (name === ".bin" || name === ".bun") {
|
||||
continue
|
||||
@@ -59,7 +59,7 @@ async function collectPackages(modulesRoot: string) {
|
||||
continue
|
||||
}
|
||||
if (name.startsWith("@")) {
|
||||
const scoped = (await safeReadDir(full)).sort()
|
||||
const scoped = (await readdir(full)).sort()
|
||||
for (const child of scoped) {
|
||||
const scopedDir = join(full, child)
|
||||
if (await isDirectory(scopedDir)) {
|
||||
@@ -121,14 +121,6 @@ async function isDirectory(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function safeReadDir(path: string) {
|
||||
try {
|
||||
return await readdir(path)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBinName(name: string) {
|
||||
const slash = name.lastIndexOf("/")
|
||||
if (slash >= 0) {
|
||||
|
||||
10
package.json
10
package.json
@@ -4,9 +4,11 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"packageManager": "bun@1.3.8",
|
||||
"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",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
@@ -21,7 +23,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.5",
|
||||
"@types/bun": "1.3.8",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
@@ -38,7 +40,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"ai": "5.0.119",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
@@ -99,6 +101,6 @@
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
|
||||
}
|
||||
}
|
||||
|
||||
176
packages/app/e2e/AGENTS.md
Normal file
176
packages/app/e2e/AGENTS.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# E2E Testing Guide
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
```bash
|
||||
# Run all e2e tests
|
||||
bun test:e2e
|
||||
|
||||
# Run specific test file
|
||||
bun test:e2e -- app/home.spec.ts
|
||||
|
||||
# Run single test by title
|
||||
bun test:e2e -- -g "home renders and shows core entrypoints"
|
||||
|
||||
# Run tests with UI mode (for debugging)
|
||||
bun test:e2e:ui
|
||||
|
||||
# Run tests locally with full server setup
|
||||
bun test:e2e:local
|
||||
|
||||
# View test report
|
||||
bun test:e2e:report
|
||||
|
||||
# Typecheck
|
||||
bun typecheck
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
All tests live in `packages/app/e2e/`:
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
|
||||
├── actions.ts # Reusable action helpers
|
||||
├── selectors.ts # DOM selectors
|
||||
├── utils.ts # Utilities (serverUrl, modKey, path helpers)
|
||||
└── [feature]/
|
||||
└── *.spec.ts # Test files
|
||||
```
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
test("test description", async ({ page, sdk, gotoSession }) => {
|
||||
await gotoSession() // or gotoSession(sessionID)
|
||||
|
||||
// Your test code
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
### Using Fixtures
|
||||
|
||||
- `page` - Playwright page
|
||||
- `sdk` - OpenCode SDK client for API calls
|
||||
- `gotoSession(sessionID?)` - Navigate to session
|
||||
|
||||
### Helper Functions
|
||||
|
||||
**Actions** (`actions.ts`):
|
||||
|
||||
- `openPalette(page)` - Open command palette
|
||||
- `openSettings(page)` - Open settings dialog
|
||||
- `closeDialog(page, dialog)` - Close any dialog
|
||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||
- `withSession(sdk, title, callback)` - Create temp session
|
||||
- `clickListItem(container, filter)` - Click list item by key/text
|
||||
|
||||
**Selectors** (`selectors.ts`):
|
||||
|
||||
- `promptSelector` - Prompt input
|
||||
- `terminalSelector` - Terminal panel
|
||||
- `sessionItemSelector(id)` - Session in sidebar
|
||||
- `listItemSelector` - Generic list items
|
||||
|
||||
**Utils** (`utils.ts`):
|
||||
|
||||
- `modKey` - Meta (Mac) or Control (Linux/Win)
|
||||
- `serverUrl` - Backend server URL
|
||||
- `sessionPath(dir, id?)` - Build session URL
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports
|
||||
|
||||
Always import from `../fixtures`, not `@playwright/test`:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
import { test, expect } from "../fixtures"
|
||||
|
||||
// ❌ Bad
|
||||
import { test, expect } from "@playwright/test"
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Test files: `feature-name.spec.ts`
|
||||
- Test names: lowercase, descriptive: `"sidebar can be toggled"`
|
||||
- Variables: camelCase
|
||||
- Constants: SCREAMING_SNAKE_CASE
|
||||
|
||||
### Error Handling
|
||||
|
||||
Tests should clean up after themselves:
|
||||
|
||||
```typescript
|
||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, "test session", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
// Test code...
|
||||
}) // Auto-deletes session
|
||||
})
|
||||
```
|
||||
|
||||
### Timeouts
|
||||
|
||||
Default: 60s per test, 10s per assertion. Override when needed:
|
||||
|
||||
```typescript
|
||||
test.setTimeout(120_000) // For long LLM operations
|
||||
test("slow test", async () => {
|
||||
await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
### Selectors
|
||||
|
||||
Use `data-component`, `data-action`, or semantic roles:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
await page.locator('[data-component="prompt-input"]').click()
|
||||
await page.getByRole("button", { name: "Open settings" }).click()
|
||||
|
||||
// ❌ Bad
|
||||
await page.locator(".css-class-name").click()
|
||||
await page.locator("#id-name").click()
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
Use `modKey` for cross-platform compatibility:
|
||||
|
||||
```typescript
|
||||
import { modKey } from "../utils"
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
||||
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
```
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
1. Choose appropriate folder or create new one
|
||||
2. Import from `../fixtures`
|
||||
3. Use helper functions from `../actions` and `../selectors`
|
||||
4. Clean up any created resources
|
||||
5. Use specific selectors (avoid CSS classes)
|
||||
6. Test one feature per test file
|
||||
|
||||
## Local Development
|
||||
|
||||
For UI debugging, use:
|
||||
|
||||
```bash
|
||||
bun test:e2e:ui
|
||||
```
|
||||
|
||||
This opens Playwright's interactive UI for step-through debugging.
|
||||
421
packages/app/e2e/actions.ts
Normal file
421
packages/app/e2e/actions.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { modKey, serverUrl } from "./utils"
|
||||
import {
|
||||
sessionItemSelector,
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
titlebarRightSelector,
|
||||
popoverBodySelector,
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
import type { createSdk } from "./utils"
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page
|
||||
.evaluate(() => {
|
||||
const el = document.activeElement
|
||||
if (el instanceof HTMLElement) el.blur()
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||
return dialog
|
||||
}
|
||||
|
||||
export async function closeDialog(page: Page, dialog: Locator) {
|
||||
await page.keyboard.press("Escape")
|
||||
const closed = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closed) return
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closedSecond) return
|
||||
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(dialog).toHaveCount(0)
|
||||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const main = page.locator("main")
|
||||
const classes = (await main.getAttribute("class")) ?? ""
|
||||
return classes.includes("xl:border-l")
|
||||
}
|
||||
|
||||
export async function toggleSidebar(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
}
|
||||
|
||||
export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (visible) await button.click()
|
||||
if (!visible) await toggleSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const opened = await expect(main)
|
||||
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||
}
|
||||
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (visible) await button.click()
|
||||
if (!visible) await toggleSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const closed = await expect(main)
|
||||
.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closed) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(main).toHaveClass(/xl:border-l/)
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
await defocus(page)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
|
||||
const opened = await dialog
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) return dialog
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
return dialog
|
||||
}
|
||||
|
||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await page.addInitScript(
|
||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||
const key = "opencode.global.dat:server"
|
||||
const raw = localStorage.getItem(key)
|
||||
const parsed = (() => {
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||
const list = Array.isArray(store.list) ? store.list : []
|
||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||
|
||||
const add = (origin: string, directory: string) => {
|
||||
const current = nextProjects[origin]
|
||||
const items = Array.isArray(current) ? current : []
|
||||
const existing = items.filter(
|
||||
(p): p is { worktree: string; expanded?: boolean } =>
|
||||
!!p &&
|
||||
typeof p === "object" &&
|
||||
"worktree" in p &&
|
||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||
)
|
||||
|
||||
if (existing.some((p) => p.worktree === directory)) return
|
||||
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||
}
|
||||
|
||||
const directories = [args.directory, ...args.extra]
|
||||
for (const directory of directories) {
|
||||
add("local", directory)
|
||||
add(args.serverUrl, directory)
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
list,
|
||||
projects: nextProjects,
|
||||
lastProject,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
||||
)
|
||||
}
|
||||
|
||||
export async function createTestProject() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||
|
||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||
|
||||
execSync("git init", { 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', {
|
||||
cwd: root,
|
||||
stdio: "ignore",
|
||||
})
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
||||
await expect(sessionEl).toBeVisible()
|
||||
await sessionEl.hover()
|
||||
return sessionEl
|
||||
}
|
||||
|
||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||
|
||||
const scroller = page.locator(".session-scroller").first()
|
||||
await expect(scroller).toBeVisible()
|
||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
.filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
|
||||
.filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
|
||||
.filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
|
||||
.first()
|
||||
|
||||
const opened = await menu
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) return menu
|
||||
|
||||
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click()
|
||||
|
||||
await expect(menu).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
|
||||
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
|
||||
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.click({ force: options?.force })
|
||||
}
|
||||
|
||||
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
||||
const dialog = page.getByRole("dialog").first()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click()
|
||||
}
|
||||
|
||||
export async function openSharePopover(page: Page) {
|
||||
const rightSection = page.locator(titlebarRightSelector)
|
||||
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
||||
await expect(shareButton).toBeVisible()
|
||||
|
||||
const popoverBody = page
|
||||
.locator(popoverBodySelector)
|
||||
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||
.first()
|
||||
|
||||
const opened = await popoverBody
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await shareButton.click()
|
||||
await expect(popoverBody).toBeVisible()
|
||||
}
|
||||
return { rightSection, popoverBody }
|
||||
}
|
||||
|
||||
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
||||
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click()
|
||||
}
|
||||
|
||||
export async function clickListItem(
|
||||
container: Locator | Page,
|
||||
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
|
||||
): Promise<Locator> {
|
||||
let item: Locator
|
||||
|
||||
if (typeof filter === "string" || filter instanceof RegExp) {
|
||||
item = container.locator(listItemSelector).filter({ hasText: filter }).first()
|
||||
} else if (filter.keyStartsWith) {
|
||||
item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
|
||||
} else if (filter.key) {
|
||||
item = container.locator(listItemKeySelector(filter.key)).first()
|
||||
} else if (filter.text) {
|
||||
item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
|
||||
} else {
|
||||
throw new Error("Invalid filter provided to clickListItem")
|
||||
}
|
||||
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
return item
|
||||
}
|
||||
|
||||
export async function withSession<T>(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
title: string,
|
||||
callback: (session: { id: string; title: string }) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||
if (!session?.id) throw new Error("Session create did not return an id")
|
||||
|
||||
try {
|
||||
return await callback(session)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openStatusPopover(page: Page) {
|
||||
await defocus(page)
|
||||
|
||||
const rightSection = page.locator(titlebarRightSelector)
|
||||
const trigger = rightSection.getByRole("button", { name: /status/i }).first()
|
||||
|
||||
const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
|
||||
|
||||
const opened = await popoverBody
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(popoverBody).toBeVisible()
|
||||
}
|
||||
|
||||
return { rightSection, popoverBody }
|
||||
}
|
||||
|
||||
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
const opened = await menu
|
||||
.waitFor({ state: "visible", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) {
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
return menu
|
||||
}
|
||||
|
||||
await trigger.click({ force: true })
|
||||
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
return menu
|
||||
}
|
||||
|
||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||
const current = await page
|
||||
.getByRole("button", { name: "New workspace" })
|
||||
.first()
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (current === enabled) return
|
||||
|
||||
await openProjectMenu(page, projectSlug)
|
||||
|
||||
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click({ force: true })
|
||||
|
||||
const expected = enabled ? "New workspace" : "New session"
|
||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||
}
|
||||
|
||||
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
|
||||
const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { dirPath, promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { dirPath } from "../utils"
|
||||
|
||||
test("project route redirects to /session", async ({ page, directory, slug }) => {
|
||||
await page.goto(dirPath(directory))
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { openPalette } from "../actions"
|
||||
|
||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||
const dialog = await openPalette(page)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { serverName, serverUrl } from "../utils"
|
||||
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
||||
|
||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||
|
||||
@@ -33,31 +34,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
||||
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
const menu = row.locator('[data-component="icon-button"]').last()
|
||||
await menu.click()
|
||||
await page.getByRole("menuitem", { name: "Set as default" }).click()
|
||||
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click({ force: true })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
await clickMenuItem(menu, /set as default/i)
|
||||
|
||||
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
||||
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closed = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!closed) {
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!closedSecond) {
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(dialog).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await ensurePopoverOpen()
|
||||
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
||||
const title = `e2e smoke ${Date.now()}`
|
||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
||||
|
||||
if (!created?.id) throw new Error("Session create did not return an id")
|
||||
const sessionID = created.id
|
||||
|
||||
try {
|
||||
await gotoSession(sessionID)
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("hello from e2e")
|
||||
await expect(prompt).toContainText("hello from e2e")
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +1,42 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, promptSelector } from "../utils"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const stamp = Date.now()
|
||||
const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data)
|
||||
const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data)
|
||||
|
||||
if (!one?.id) throw new Error("Session create did not return an id")
|
||||
if (!two?.id) throw new Error("Session create did not return an id")
|
||||
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
|
||||
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
|
||||
await gotoSession(one.id)
|
||||
|
||||
try {
|
||||
await gotoSession(one.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
|
||||
if (collapsed) {
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||
}
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
const back = page.getByRole("button", { name: "Back" })
|
||||
const forward = page.getByRole("button", { name: "Forward" })
|
||||
|
||||
const back = page.getByRole("button", { name: "Back" })
|
||||
const forward = page.getByRole("button", { name: "Forward" })
|
||||
await expect(back).toBeVisible()
|
||||
await expect(back).toBeEnabled()
|
||||
await back.click()
|
||||
|
||||
await expect(back).toBeVisible()
|
||||
await expect(back).toBeEnabled()
|
||||
await back.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(forward).toBeVisible()
|
||||
await expect(forward).toBeEnabled()
|
||||
await forward.click()
|
||||
|
||||
await expect(forward).toBeVisible()
|
||||
await expect(forward).toBeEnabled()
|
||||
await forward.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
}
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { openPalette, clickListItem } from "../actions"
|
||||
|
||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
const dialog = await openPalette(page)
|
||||
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill("package.json")
|
||||
|
||||
const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||
await expect(fileItem).toBeVisible()
|
||||
await fileItem.click()
|
||||
await clickListItem(dialog, { keyStartsWith: "file:" })
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { openPalette, clickListItem } from "../actions"
|
||||
|
||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -7,21 +7,12 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
||||
const sep = process.platform === "win32" ? "\\" : "/"
|
||||
const file = ["packages", "app", "package.json"].join(sep)
|
||||
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
const dialog = await openPalette(page)
|
||||
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill(file)
|
||||
|
||||
const fileItem = dialog
|
||||
.locator(
|
||||
'[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]',
|
||||
)
|
||||
.first()
|
||||
await expect(fileItem).toBeVisible()
|
||||
await fileItem.click()
|
||||
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { test as base, expect } from "@playwright/test"
|
||||
import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils"
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
export const settingsKey = "settings.v3"
|
||||
|
||||
type TestFixtures = {
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
withProject: <T>(
|
||||
callback: (project: {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
}) => Promise<T>,
|
||||
options?: { extra?: string[] },
|
||||
) => Promise<T>
|
||||
}
|
||||
|
||||
type WorkerFixtures = {
|
||||
@@ -29,54 +41,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use(createSdk(directory))
|
||||
},
|
||||
gotoSession: async ({ page, directory }, use) => {
|
||||
await page.addInitScript(
|
||||
(input: { directory: string; serverUrl: string }) => {
|
||||
const key = "opencode.global.dat:server"
|
||||
const raw = localStorage.getItem(key)
|
||||
const parsed = (() => {
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||
const list = Array.isArray(store.list) ? store.list : []
|
||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||
|
||||
const add = (origin: string) => {
|
||||
const current = nextProjects[origin]
|
||||
const items = Array.isArray(current) ? current : []
|
||||
const existing = items.filter(
|
||||
(p): p is { worktree: string; expanded?: boolean } =>
|
||||
!!p &&
|
||||
typeof p === "object" &&
|
||||
"worktree" in p &&
|
||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||
)
|
||||
|
||||
if (existing.some((p) => p.worktree === input.directory)) return
|
||||
nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing]
|
||||
}
|
||||
|
||||
add("local")
|
||||
add(input.serverUrl)
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
list,
|
||||
projects: nextProjects,
|
||||
lastProject,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, serverUrl },
|
||||
)
|
||||
await seedStorage(page, { directory })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
@@ -84,6 +49,39 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
}
|
||||
await use(gotoSession)
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const directory = await createTestProject()
|
||||
const slug = dirSlug(directory)
|
||||
await seedStorage(page, { directory, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
return await callback({ directory, slug, gotoSession })
|
||||
} finally {
|
||||
await cleanupTestProject(directory)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||
user: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export { expect }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { clickListItem } from "../actions"
|
||||
|
||||
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -32,9 +33,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
||||
|
||||
await input.fill(model)
|
||||
|
||||
const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`)
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
await clickListItem(dialog, { key })
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { closeDialog, openSettings, clickListItem } from "../actions"
|
||||
|
||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -27,18 +28,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(picker).toHaveCount(0)
|
||||
|
||||
const settings = page.getByRole("dialog")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
const opened = await settings
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(settings).toBeVisible()
|
||||
}
|
||||
const settings = await openSettings(page)
|
||||
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
const search = settings.getByPlaceholder("Search models")
|
||||
@@ -52,22 +42,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closed = await settings
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!closed) {
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await settings
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!closedSecond) {
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
await closeDialog(page, settings)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
|
||||
53
packages/app/e2e/projects/project-edit.spec.ts
Normal file
53
packages/app/e2e/projects/project-edit.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar } from "../actions"
|
||||
|
||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const open = async () => {
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await header.hover()
|
||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||
await expect(editItem).toBeVisible()
|
||||
await editItem.click({ force: true })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||
return dialog
|
||||
}
|
||||
|
||||
const name = `e2e project ${Date.now()}`
|
||||
const startup = `echo e2e_${Date.now()}`
|
||||
|
||||
const dialog = await open()
|
||||
|
||||
const nameInput = dialog.getByLabel("Name")
|
||||
await nameInput.fill(name)
|
||||
|
||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||
await startupInput.fill(startup)
|
||||
|
||||
await dialog.getByRole("button", { name: "Save" }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await expect(header).toContainText(name)
|
||||
|
||||
const reopened = await open()
|
||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||
await expect(reopened).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
74
packages/app/e2e/projects/projects-close.spec.ts
Normal file
74
packages/app/e2e/projects/projects-close.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.hover()
|
||||
|
||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||
await expect(close).toBeVisible()
|
||||
await close.click()
|
||||
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("can close a project via project header more options menu", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherName = other.split("/").pop() ?? other
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
const header = page
|
||||
.locator(".group\\/project")
|
||||
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
||||
.first()
|
||||
await expect(header).toContainText(otherName)
|
||||
|
||||
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
35
packages/app/e2e/projects/projects-switch.spec.ts
Normal file
35
packages/app/e2e/projects/projects-switch.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async ({ directory }) => {
|
||||
await defocus(page)
|
||||
|
||||
const currentSlug = dirSlug(directory)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||
await expect(currentButton).toBeVisible()
|
||||
await currentButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
333
packages/app/e2e/projects/workspaces.spec.ts
Normal file
333
packages/app/e2e/projects/workspaces.spec.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
import { test, expect } from "../fixtures"
|
||||
|
||||
test.describe.configure({ mode: "serial" })
|
||||
import {
|
||||
cleanupTestProject,
|
||||
clickMenuItem,
|
||||
confirmDialog,
|
||||
openSidebar,
|
||||
openWorkspaceMenu,
|
||||
setWorkspacesEnabled,
|
||||
} from "../actions"
|
||||
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
const rootSlug = project.slug
|
||||
await openSidebar(page)
|
||||
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const dir = base64Decode(slug)
|
||||
|
||||
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)
|
||||
|
||||
return { rootSlug, slug, directory: dir }
|
||||
}
|
||||
|
||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
||||
|
||||
await setWorkspacesEnabled(page, slug, false)
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test("can create a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const currentSlug = slugFromUrl(page.url())
|
||||
return currentSlug.length > 0 && currentSlug !== slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const workspaceSlug = slugFromUrl(page.url())
|
||||
const workspaceDir = base64Decode(workspaceSlug)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
||||
|
||||
await cleanupTestProject(workspaceDir)
|
||||
})
|
||||
})
|
||||
|
||||
test("can rename a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const { slug } = await setupWorkspaceTest(page, project)
|
||||
|
||||
const rename = `e2e workspace ${Date.now()}`
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||
|
||||
await expect(menu).toHaveCount(0)
|
||||
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
await expect(item).toBeVisible()
|
||||
const input = item.locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(rename)
|
||||
await input.press("Enter")
|
||||
await expect(item).toContainText(rename)
|
||||
})
|
||||
})
|
||||
|
||||
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||
|
||||
const readme = path.join(createdDir, "README.md")
|
||||
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||
const original = await fs.readFile(readme, "utf8")
|
||||
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
||||
await fs.writeFile(readme, dirty, "utf8")
|
||||
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await fs
|
||||
.stat(extra)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const files = await sdk.file
|
||||
.status({ directory: createdDir })
|
||||
.then((r) => r.data ?? [])
|
||||
.catch(() => [])
|
||||
return files.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
||||
await confirmDialog(page, /^Reset workspace$/i)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const files = await sdk.file
|
||||
.status({ directory: createdDir })
|
||||
.then((r) => r.data ?? [])
|
||||
.catch(() => [])
|
||||
return files.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await fs
|
||||
.stat(extra)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
})
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("can delete a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
||||
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
await confirmDialog(page, /^Delete workspace$/i)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
await withProject(async ({ slug: rootSlug }) => {
|
||||
const workspaces = [] as { directory: string; slug: string }[]
|
||||
|
||||
const listSlugs = async () => {
|
||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||
const slugs = await nodes.evaluateAll((els) => {
|
||||
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
||||
})
|
||||
return slugs
|
||||
}
|
||||
|
||||
const waitReady = async (slug: string) => {
|
||||
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)
|
||||
}
|
||||
|
||||
const drag = async (from: string, to: string) => {
|
||||
const src = page.locator(workspaceItemSelector(from)).first()
|
||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||
|
||||
await src.scrollIntoViewIfNeeded()
|
||||
await dst.scrollIntoViewIfNeeded()
|
||||
|
||||
const a = await src.boundingBox()
|
||||
const b = await dst.boundingBox()
|
||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||
|
||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
try {
|
||||
await openSidebar(page)
|
||||
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const dir = base64Decode(slug)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
|
||||
await openSidebar(page)
|
||||
}
|
||||
|
||||
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
||||
|
||||
const a = workspaces[0].slug
|
||||
const b = workspaces[1].slug
|
||||
|
||||
await waitReady(a)
|
||||
await waitReady(b)
|
||||
|
||||
const list = async () => {
|
||||
const slugs = await listSlugs()
|
||||
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const slugs = await list()
|
||||
return slugs.length === 2
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const before = await list()
|
||||
const from = before[1]
|
||||
const to = before[0]
|
||||
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
||||
|
||||
await drag(from, to)
|
||||
|
||||
await expect.poll(async () => await list()).toEqual([from, to])
|
||||
} finally {
|
||||
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,13 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||
const title = `e2e smoke context ${Date.now()}`
|
||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
||||
|
||||
if (!created?.id) throw new Error("Session create did not return an id")
|
||||
const sessionID = created.id
|
||||
|
||||
try {
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
sessionID: session.id,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
@@ -22,12 +19,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await gotoSession(sessionID)
|
||||
await gotoSession(session.id)
|
||||
|
||||
const contextButton = page
|
||||
.locator('[data-component="button"]')
|
||||
@@ -39,7 +36,5 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
||||
|
||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
|
||||
function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
}
|
||||
import { promptSelector } from "../selectors"
|
||||
import { sessionIDFromUrl, withSession } from "../actions"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
57
packages/app/e2e/selectors.ts
Normal file
57
packages/app/e2e/selectors.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
|
||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||
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 settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
||||
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
||||
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||
|
||||
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||
|
||||
export const projectSwitchSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||
|
||||
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
|
||||
|
||||
export const projectMenuTriggerSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||
|
||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||
|
||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||
|
||||
export const titlebarRightSelector = "#opencode-titlebar-right"
|
||||
|
||||
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||
|
||||
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
||||
|
||||
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||
|
||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||
|
||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||
|
||||
export const workspaceItemSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
|
||||
|
||||
export const workspaceMenuTriggerSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
|
||||
|
||||
export const listItemSelector = '[data-slot="list-item"]'
|
||||
|
||||
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
||||
|
||||
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
|
||||
|
||||
export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`
|
||||
157
packages/app/e2e/session/session.spec.ts
Normal file
157
packages/app/e2e/session/session.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
openSidebar,
|
||||
openSessionMoreMenu,
|
||||
clickMenuItem,
|
||||
confirmDialog,
|
||||
openSharePopover,
|
||||
withSession,
|
||||
} from "../actions"
|
||||
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
type Sdk = Parameters<typeof withSession>[0]
|
||||
|
||||
async function seedMessage(sdk: Sdk, sessionID: string) {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "e2e seed" }],
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const originalTitle = `e2e rename test ${stamp}`
|
||||
const newTitle = `e2e renamed ${stamp}`
|
||||
|
||||
await withSession(sdk, originalTitle, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(newTitle)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e archive test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.time?.archived
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e delete test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session
|
||||
.get({ sessionID: session.id })
|
||||
.then((r) => r.data)
|
||||
.catch(() => undefined)
|
||||
return data?.id
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
const stamp = Date.now()
|
||||
const title = `e2e share test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
|
||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
||||
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const sharedPopover = await openSharePopover(page)
|
||||
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||
await unpublish.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const unsharedPopover = await openSharePopover(page)
|
||||
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
320
packages/app/e2e/settings/settings-keybinds.spec.ts
Normal file
320
packages/app/e2e/settings/settings-keybinds.spec.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, withSession } from "../actions"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("B")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyH`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("H")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const main = page.locator("main")
|
||||
const initialClasses = (await main.getAttribute("class")) ?? ""
|
||||
const initiallyClosed = initialClasses.includes("xl:border-l")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
||||
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
|
||||
expect(afterToggleClosed).toBe(!initiallyClosed)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const finalClasses = (await main.getAttribute("class")) ?? ""
|
||||
const finalClosed = finalClasses.includes("xl:border-l")
|
||||
expect(finalClosed).toBe(initiallyClosed)
|
||||
})
|
||||
|
||||
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
|
||||
})
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const customKeybind = await keybindButton.textContent()
|
||||
expect(customKeybind).toContain("X")
|
||||
|
||||
const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
|
||||
await expect(resetButton).toBeVisible()
|
||||
await expect(resetButton).toBeEnabled()
|
||||
await resetButton.click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const restoredKeybind = await keybindButton.textContent()
|
||||
expect(restoredKeybind).toContain("B")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
})
|
||||
|
||||
test("clearing a keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("B")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press("Delete")
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const clearedKeybind = await keybindButton.textContent()
|
||||
expect(clearedKeybind).toMatch(/unassigned|press/i)
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const stillOnSession = page.url().includes("/session")
|
||||
expect(stillOnSession).toBe(true)
|
||||
})
|
||||
|
||||
test("changing settings open keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain(",")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Slash`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("/")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const settingsDialog = page.getByRole("dialog")
|
||||
await expect(settingsDialog).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Slash`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await closeDialog(page, settingsDialog)
|
||||
})
|
||||
|
||||
test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, "test session for keybind", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const initialUrl = page.url()
|
||||
expect(initialUrl).toContain(`/session/${session.id}`)
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyN`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("N")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+N`)
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const newUrl = page.url()
|
||||
expect(newUrl).toMatch(/\/session\/?$/)
|
||||
expect(newUrl).not.toContain(session.id)
|
||||
})
|
||||
})
|
||||
|
||||
test("changing file open keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("P")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyF`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("F")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
|
||||
await expect(filePickerDialog).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+F`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(filePickerDialog).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(filePickerDialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+KeyY`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("Y")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const terminal = page.locator(terminalSelector)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("changing command palette keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("P")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyK`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("K")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
|
||||
await expect(palette).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+K`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(palette).toBeVisible()
|
||||
await expect(palette.getByRole("textbox").first()).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(palette).toHaveCount(0)
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, settingsLanguageSelectSelector } from "../utils"
|
||||
|
||||
test("smoke changing language updates settings labels", async ({ page, gotoSession }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
|
||||
})
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
|
||||
const opened = await dialog
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
}
|
||||
|
||||
const heading = dialog.getByRole("heading", { level: 2 })
|
||||
await expect(heading).toHaveText("General")
|
||||
|
||||
const select = dialog.locator(settingsLanguageSelectSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
|
||||
|
||||
await expect(heading).toHaveText("Allgemein")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
|
||||
await expect(heading).toHaveText("General")
|
||||
})
|
||||
122
packages/app/e2e/settings/settings-models.spec.ts
Normal file
122
packages/app/e2e/settings/settings-models.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
|
||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
|
||||
const command = page.locator('[data-slash-id="model.choose"]')
|
||||
await expect(command).toBeVisible()
|
||||
await command.hover()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const picker = page.getByRole("dialog")
|
||||
await expect(picker).toBeVisible()
|
||||
|
||||
const target = picker.locator('[data-slot="list-item"]').first()
|
||||
await expect(target).toBeVisible()
|
||||
|
||||
const key = await target.getAttribute("data-key")
|
||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||
|
||||
const name = (await target.locator("span").first().innerText()).trim()
|
||||
if (!name) throw new Error("Failed to resolve model name from list item")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(picker).toHaveCount(0)
|
||||
|
||||
const settings = await openSettings(page)
|
||||
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
const search = settings.getByPlaceholder("Search models")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill(name)
|
||||
|
||||
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
||||
const input = toggle.locator('[data-slot="switch-input"]')
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||
|
||||
await closeDialog(page, settings)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
await expect(command).toBeVisible()
|
||||
await command.hover()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const pickerAgain = page.getByRole("dialog")
|
||||
await expect(pickerAgain).toBeVisible()
|
||||
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
|
||||
|
||||
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(pickerAgain).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
|
||||
const command = page.locator('[data-slash-id="model.choose"]')
|
||||
await expect(command).toBeVisible()
|
||||
await command.hover()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const picker = page.getByRole("dialog")
|
||||
await expect(picker).toBeVisible()
|
||||
|
||||
const target = picker.locator('[data-slot="list-item"]').first()
|
||||
await expect(target).toBeVisible()
|
||||
|
||||
const key = await target.getAttribute("data-key")
|
||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||
|
||||
const name = (await target.locator("span").first().innerText()).trim()
|
||||
if (!name) throw new Error("Failed to resolve model name from list item")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(picker).toHaveCount(0)
|
||||
|
||||
const settings = await openSettings(page)
|
||||
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
const search = settings.getByPlaceholder("Search models")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill(name)
|
||||
|
||||
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
||||
const input = toggle.locator('[data-slot="switch-input"]')
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||
|
||||
await closeDialog(page, settings)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
await expect(command).toBeVisible()
|
||||
await command.hover()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const pickerAgain = page.getByRole("dialog")
|
||||
await expect(pickerAgain).toBeVisible()
|
||||
|
||||
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(pickerAgain).toHaveCount(0)
|
||||
})
|
||||
@@ -1,56 +1,136 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, promptSelector } from "../utils"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
|
||||
test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
|
||||
test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
const settings = await openSettings(page)
|
||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||
await expect(customProviderSection).toBeVisible()
|
||||
|
||||
const opened = await dialog
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
}
|
||||
|
||||
await dialog.getByRole("tab", { name: "Providers" }).click()
|
||||
await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByText("Popular providers", { exact: true })).toBeVisible()
|
||||
|
||||
await dialog.getByRole("button", { name: "Show more providers" }).click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search providers") })
|
||||
const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
|
||||
await connectButton.click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||
await expect(providerDialog).toBeVisible()
|
||||
await expect(providerDialog.getByPlaceholder("Search providers")).toBeVisible()
|
||||
await expect(providerDialog.locator('[data-slot="list-item"]').first()).toBeVisible()
|
||||
|
||||
await providerDialog.getByLabel("Provider ID").fill("test-provider")
|
||||
await providerDialog.getByLabel("Display name").fill("Test Provider")
|
||||
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
|
||||
await providerDialog.getByLabel("API key").fill("fake-key")
|
||||
|
||||
await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
|
||||
await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
|
||||
|
||||
await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
|
||||
await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
|
||||
await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
|
||||
await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
|
||||
await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
|
||||
await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(providerDialog).toHaveCount(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
const stillOpen = await dialog.isVisible().catch(() => false)
|
||||
if (!stillOpen) return
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closed = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (closed) return
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (closedSecond) return
|
||||
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await closeDialog(page, settings)
|
||||
})
|
||||
|
||||
test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const settings = await openSettings(page)
|
||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||
|
||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||
await expect(providerDialog).toBeVisible()
|
||||
|
||||
await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
|
||||
await providerDialog.getByLabel("Base URL").fill("not-a-url")
|
||||
|
||||
await providerDialog.getByRole("button", { name: /submit|save/i }).click()
|
||||
|
||||
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
|
||||
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(providerDialog).toHaveCount(0)
|
||||
|
||||
await closeDialog(page, settings)
|
||||
})
|
||||
|
||||
test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const settings = await openSettings(page)
|
||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||
|
||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||
await expect(providerDialog).toBeVisible()
|
||||
|
||||
await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
|
||||
await providerDialog.getByLabel("Display name").fill("Multi Model Test")
|
||||
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
|
||||
|
||||
await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
|
||||
await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
|
||||
|
||||
const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
|
||||
await providerDialog.getByRole("button", { name: "Add model" }).click()
|
||||
const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
|
||||
expect(idInputsAfter).toBe(idInputsBefore + 1)
|
||||
|
||||
await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
|
||||
await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
|
||||
|
||||
await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
|
||||
await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(providerDialog).toHaveCount(0)
|
||||
|
||||
await closeDialog(page, settings)
|
||||
})
|
||||
|
||||
test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const settings = await openSettings(page)
|
||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||
|
||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||
await expect(providerDialog).toBeVisible()
|
||||
|
||||
await providerDialog.getByLabel("Provider ID").fill("header-test")
|
||||
await providerDialog.getByLabel("Display name").fill("Header Test")
|
||||
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
|
||||
|
||||
await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
|
||||
await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
|
||||
|
||||
const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
|
||||
await providerDialog.getByRole("button", { name: "Add header" }).click()
|
||||
const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
|
||||
expect(headerInputsAfter).toBe(headerInputsBefore + 1)
|
||||
|
||||
await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
|
||||
await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
|
||||
|
||||
await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
|
||||
await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(providerDialog).toHaveCount(0)
|
||||
|
||||
await closeDialog(page, settings)
|
||||
})
|
||||
|
||||
@@ -1,44 +1,292 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { test, expect, settingsKey } from "../fixtures"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
import {
|
||||
settingsColorSchemeSelector,
|
||||
settingsFontSelector,
|
||||
settingsLanguageSelectSelector,
|
||||
settingsNotificationsAgentSelector,
|
||||
settingsNotificationsErrorsSelector,
|
||||
settingsNotificationsPermissionsSelector,
|
||||
settingsReleaseNotesSelector,
|
||||
settingsSoundsAgentSelector,
|
||||
settingsThemeSelector,
|
||||
settingsUpdatesStartupSelector,
|
||||
} from "../selectors"
|
||||
|
||||
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
|
||||
const opened = await dialog
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
}
|
||||
const dialog = await openSettings(page)
|
||||
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
|
||||
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
const closed = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closed) return
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closedSecond) return
|
||||
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await closeDialog(page, dialog)
|
||||
})
|
||||
|
||||
test("changing language updates settings labels", async ({ page, gotoSession }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
|
||||
})
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
|
||||
const heading = dialog.getByRole("heading", { level: 2 })
|
||||
await expect(heading).toHaveText("General")
|
||||
|
||||
const select = dialog.locator(settingsLanguageSelectSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
|
||||
|
||||
await expect(heading).toHaveText("Allgemein")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
|
||||
await expect(heading).toHaveText("General")
|
||||
})
|
||||
|
||||
test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsColorSchemeSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||
|
||||
const colorScheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute("data-color-scheme")
|
||||
})
|
||||
expect(colorScheme).toBe("dark")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
|
||||
|
||||
const lightColorScheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute("data-color-scheme")
|
||||
})
|
||||
expect(lightColorScheme).toBe("light")
|
||||
})
|
||||
|
||||
test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsThemeSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
const count = await items.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
|
||||
expect(firstTheme).toBeTruthy()
|
||||
|
||||
await items.nth(1).click()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
const storedThemeId = await page.evaluate(() => {
|
||||
return localStorage.getItem("opencode-theme-id")
|
||||
})
|
||||
|
||||
expect(storedThemeId).not.toBeNull()
|
||||
expect(storedThemeId).not.toBe("oc-1")
|
||||
|
||||
const dataTheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute("data-theme")
|
||||
})
|
||||
expect(dataTheme).toBe(storedThemeId)
|
||||
})
|
||||
|
||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsFontSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
const initialFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||
})
|
||||
expect(initialFontFamily).toContain("IBM Plex Mono")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
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("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(true)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(false)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.notifications?.agent).toBe(false)
|
||||
})
|
||||
|
||||
test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(true)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(false)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.notifications?.permissions).toBe(false)
|
||||
})
|
||||
|
||||
test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(false)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(true)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.notifications?.errors).toBe(true)
|
||||
})
|
||||
|
||||
test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsSoundsAgentSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
await items.nth(2).click()
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
||||
})
|
||||
|
||||
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
|
||||
const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
|
||||
if (isDisabled) {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(true)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(false)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.updates?.startup).toBe(false)
|
||||
})
|
||||
|
||||
test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsReleaseNotesSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(true)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(false)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.general?.releaseNotes).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, promptSelector } from "../utils"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
@@ -13,12 +14,7 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
||||
try {
|
||||
await gotoSession(one.id)
|
||||
|
||||
const main = page.locator("main")
|
||||
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
|
||||
if (collapsed) {
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||
}
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(target).toBeVisible()
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { openSidebar, toggleSidebar } from "../actions"
|
||||
|
||||
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const main = page.locator("main")
|
||||
const closedClass = /xl:border-l/
|
||||
const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
|
||||
await openSidebar(page)
|
||||
|
||||
if (isClosed) {
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(closedClass)
|
||||
}
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).toHaveClass(closedClass)
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(closedClass)
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||
})
|
||||
|
||||
94
packages/app/e2e/status/status-popover.spec.ts
Normal file
94
packages/app/e2e/status/status-popover.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openStatusPopover } from "../actions"
|
||||
|
||||
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
|
||||
await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
|
||||
await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
|
||||
await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(popoverBody).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
|
||||
await expect(serversTab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const serverList = popoverBody.locator('[role="tabpanel"]').first()
|
||||
await expect(serverList.locator("button").first()).toBeVisible()
|
||||
})
|
||||
|
||||
test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
|
||||
await mcpTab.click()
|
||||
|
||||
const ariaSelected = await mcpTab.getAttribute("aria-selected")
|
||||
expect(ariaSelected).toBe("true")
|
||||
|
||||
const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
||||
await expect(mcpContent).toBeVisible()
|
||||
})
|
||||
|
||||
test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
|
||||
await lspTab.click()
|
||||
|
||||
const ariaSelected = await lspTab.getAttribute("aria-selected")
|
||||
expect(ariaSelected).toBe("true")
|
||||
|
||||
const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
||||
await expect(lspContent).toBeVisible()
|
||||
})
|
||||
|
||||
test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
|
||||
await pluginsTab.click()
|
||||
|
||||
const ariaSelected = await pluginsTab.getAttribute("aria-selected")
|
||||
expect(ariaSelected).toBe("true")
|
||||
|
||||
const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
||||
await expect(pluginsContent).toBeVisible()
|
||||
})
|
||||
|
||||
test("status popover closes on escape", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
await expect(popoverBody).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(popoverBody).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
await expect(popoverBody).toBeVisible()
|
||||
|
||||
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
|
||||
|
||||
await expect(popoverBody).toHaveCount(0)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector, terminalSelector, terminalToggleKey } from "../utils"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector, terminalToggleKey } from "../utils"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "./fixtures"
|
||||
import { modelVariantCycleSelector } from "./utils"
|
||||
import { modelVariantCycleSelector } from "./selectors"
|
||||
|
||||
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -10,12 +10,6 @@ export const serverName = `${serverHost}:${serverPort}`
|
||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||
export const terminalToggleKey = "Control+Backquote"
|
||||
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||
|
||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||
|
||||
export function createSdk(directory?: string) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.53",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./vite": "./vite.js"
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
@@ -13,7 +14,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test": "bun run test:unit",
|
||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "bun script/e2e-local.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
@@ -54,7 +57,7 @@
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "0.3.0",
|
||||
"ghostty-web": "0.4.0",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
|
||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
|
||||
@@ -55,10 +55,11 @@ const extraArgs = (() => {
|
||||
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
|
||||
|
||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
||||
const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1"
|
||||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
OPENCODE_DISABLE_SHARE: "true",
|
||||
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||
@@ -83,58 +84,99 @@ const runnerEnv = {
|
||||
PLAYWRIGHT_PORT: String(webPort),
|
||||
} satisfies Record<string, string>
|
||||
|
||||
const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
|
||||
cwd: opencodeDir,
|
||||
env: serverEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
let seed: ReturnType<typeof Bun.spawn> | undefined
|
||||
let runner: ReturnType<typeof Bun.spawn> | undefined
|
||||
let server: { stop: () => Promise<void> | void } | undefined
|
||||
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
||||
let cleaned = false
|
||||
let internalError = false
|
||||
|
||||
const seedExit = await seed.exited
|
||||
if (seedExit !== 0) {
|
||||
process.exit(seedExit)
|
||||
const cleanup = async () => {
|
||||
if (cleaned) return
|
||||
cleaned = true
|
||||
|
||||
if (seed && seed.exitCode === null) seed.kill("SIGTERM")
|
||||
if (runner && runner.exitCode === null) runner.kill("SIGTERM")
|
||||
|
||||
const jobs = [
|
||||
inst?.Instance.disposeAll(),
|
||||
server?.stop(),
|
||||
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
||||
].filter(Boolean)
|
||||
await Promise.allSettled(jobs)
|
||||
}
|
||||
|
||||
Object.assign(process.env, serverEnv)
|
||||
process.env.AGENT = "1"
|
||||
process.env.OPENCODE = "1"
|
||||
const shutdown = (code: number, reason: string) => {
|
||||
process.exitCode = code
|
||||
void cleanup().finally(() => {
|
||||
console.error(`e2e-local shutdown: ${reason}`)
|
||||
process.exit(code)
|
||||
})
|
||||
}
|
||||
|
||||
const log = await import("../../opencode/src/util/log")
|
||||
const install = await import("../../opencode/src/installation")
|
||||
await log.Log.init({
|
||||
print: true,
|
||||
dev: install.Installation.isLocal(),
|
||||
level: "WARN",
|
||||
const reportInternalError = (reason: string, error: unknown) => {
|
||||
internalError = true
|
||||
console.error(`e2e-local internal error: ${reason}`)
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
process.once("SIGINT", () => shutdown(130, "SIGINT"))
|
||||
process.once("SIGTERM", () => shutdown(143, "SIGTERM"))
|
||||
process.once("SIGHUP", () => shutdown(129, "SIGHUP"))
|
||||
process.once("uncaughtException", (error) => {
|
||||
reportInternalError("uncaughtException", error)
|
||||
})
|
||||
process.once("unhandledRejection", (error) => {
|
||||
reportInternalError("unhandledRejection", error)
|
||||
})
|
||||
|
||||
const servermod = await import("../../opencode/src/server/server")
|
||||
const inst = await import("../../opencode/src/project/instance")
|
||||
const server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
||||
let code = 1
|
||||
|
||||
try {
|
||||
seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
|
||||
cwd: opencodeDir,
|
||||
env: serverEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
const seedExit = await seed.exited
|
||||
if (seedExit !== 0) {
|
||||
code = seedExit
|
||||
} else {
|
||||
Object.assign(process.env, serverEnv)
|
||||
process.env.AGENT = "1"
|
||||
process.env.OPENCODE = "1"
|
||||
|
||||
const log = await import("../../opencode/src/util/log")
|
||||
const install = await import("../../opencode/src/installation")
|
||||
await log.Log.init({
|
||||
print: true,
|
||||
dev: install.Installation.isLocal(),
|
||||
level: "WARN",
|
||||
})
|
||||
|
||||
const servermod = await import("../../opencode/src/server/server")
|
||||
inst = await import("../../opencode/src/project/instance")
|
||||
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
||||
|
||||
const result = await (async () => {
|
||||
try {
|
||||
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
||||
|
||||
const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
|
||||
runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
|
||||
cwd: appDir,
|
||||
env: runnerEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
return { code: await runner.exited }
|
||||
} catch (error) {
|
||||
return { error }
|
||||
} finally {
|
||||
await inst.Instance.disposeAll()
|
||||
await server.stop()
|
||||
code = await runner.exited
|
||||
}
|
||||
})()
|
||||
|
||||
if ("error" in result) {
|
||||
console.error(result.error)
|
||||
process.exit(1)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
code = 1
|
||||
} finally {
|
||||
await cleanup()
|
||||
}
|
||||
|
||||
process.exit(result.code)
|
||||
if (code === 0 && internalError) code = 1
|
||||
|
||||
process.exit(code)
|
||||
|
||||
@@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
describe.skip("SerializeAddon", () => {
|
||||
describe("SerializeAddon", () => {
|
||||
describe("ANSI color preservation", () => {
|
||||
test("should preserve text attributes (bold, italic, underline)", async () => {
|
||||
const { term, addon } = createTerminal()
|
||||
|
||||
@@ -56,6 +56,39 @@ interface IBufferCell {
|
||||
isDim(): boolean
|
||||
}
|
||||
|
||||
type TerminalBuffers = {
|
||||
active?: IBuffer
|
||||
normal?: IBuffer
|
||||
alternate?: IBuffer
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
const isBuffer = (value: unknown): value is IBuffer => {
|
||||
if (!isRecord(value)) return false
|
||||
if (typeof value.length !== "number") return false
|
||||
if (typeof value.cursorX !== "number") return false
|
||||
if (typeof value.cursorY !== "number") return false
|
||||
if (typeof value.baseY !== "number") return false
|
||||
if (typeof value.viewportY !== "number") return false
|
||||
if (typeof value.getLine !== "function") return false
|
||||
if (typeof value.getNullCell !== "function") return false
|
||||
return true
|
||||
}
|
||||
|
||||
const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => {
|
||||
if (!isRecord(value)) return
|
||||
const raw = value.buffer
|
||||
if (!isRecord(raw)) return
|
||||
const active = isBuffer(raw.active) ? raw.active : undefined
|
||||
const normal = isBuffer(raw.normal) ? raw.normal : undefined
|
||||
const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined
|
||||
if (!active && !normal) return
|
||||
return { active, normal, alternate }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
@@ -241,19 +274,19 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
protected _rowEnd(row: number, isLastRow: boolean): void {
|
||||
let rowSeparator = ""
|
||||
|
||||
if (this._nullCellCount > 0) {
|
||||
const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1)
|
||||
const wrapped = !!nextLine?.isWrapped
|
||||
|
||||
if (this._nullCellCount > 0 && wrapped) {
|
||||
this._currentRow += " ".repeat(this._nullCellCount)
|
||||
this._nullCellCount = 0
|
||||
}
|
||||
|
||||
if (!isLastRow) {
|
||||
const nextLine = this._buffer.getLine(row + 1)
|
||||
this._nullCellCount = 0
|
||||
|
||||
if (!nextLine?.isWrapped) {
|
||||
rowSeparator = "\r\n"
|
||||
this._lastCursorRow = row + 1
|
||||
this._lastCursorCol = 0
|
||||
}
|
||||
if (!isLastRow && !wrapped) {
|
||||
rowSeparator = "\r\n"
|
||||
this._lastCursorRow = row + 1
|
||||
this._lastCursorCol = 0
|
||||
}
|
||||
|
||||
this._allRows[this._rowIndex] = this._currentRow
|
||||
@@ -389,7 +422,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
|
||||
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
|
||||
|
||||
const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
|
||||
const styleChanged = sgrSeq.length > 0
|
||||
|
||||
if (styleChanged) {
|
||||
if (this._nullCellCount > 0) {
|
||||
@@ -442,12 +475,24 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (!excludeFinalCursorPosition) {
|
||||
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
|
||||
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
|
||||
const cursorCol = this._buffer.cursorX + 1
|
||||
content += `\u001b[${cursorRow};${cursorCol}H`
|
||||
}
|
||||
if (excludeFinalCursorPosition) return content
|
||||
|
||||
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
|
||||
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
|
||||
const cursorCol = this._buffer.cursorX + 1
|
||||
content += `\u001b[${cursorRow};${cursorCol}H`
|
||||
|
||||
const line = this._buffer.getLine(absoluteCursorRow)
|
||||
const cell = line?.getCell(this._buffer.cursorX)
|
||||
const style = (() => {
|
||||
if (!cell) return this._buffer.getNullCell()
|
||||
if (cell.getWidth() !== 0) return cell
|
||||
if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell
|
||||
return cell
|
||||
})()
|
||||
|
||||
const sgrSeq = this._diffStyle(style, this._cursorStyle)
|
||||
if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m`
|
||||
|
||||
return content
|
||||
}
|
||||
@@ -486,14 +531,13 @@ export class SerializeAddon implements ITerminalAddon {
|
||||
throw new Error("Cannot use addon until it has been loaded")
|
||||
}
|
||||
|
||||
const terminal = this._terminal as any
|
||||
const buffer = terminal.buffer
|
||||
const buffer = getTerminalBuffers(this._terminal)
|
||||
|
||||
if (!buffer) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const normalBuffer = buffer.normal || buffer.active
|
||||
const normalBuffer = buffer.normal ?? buffer.active
|
||||
const altBuffer = buffer.alternate
|
||||
|
||||
if (!normalBuffer) {
|
||||
@@ -521,14 +565,13 @@ export class SerializeAddon implements ITerminalAddon {
|
||||
throw new Error("Cannot use addon until it has been loaded")
|
||||
}
|
||||
|
||||
const terminal = this._terminal as any
|
||||
const buffer = terminal.buffer
|
||||
const buffer = getTerminalBuffers(this._terminal)
|
||||
|
||||
if (!buffer) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const activeBuffer = buffer.active || buffer.normal
|
||||
const activeBuffer = buffer.active ?? buffer.normal
|
||||
if (!activeBuffer) {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights"
|
||||
import Layout from "@/pages/layout"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { Suspense } from "solid-js"
|
||||
import { Suspense, JSX } from "solid-js"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
@@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AppInterface(props: { defaultUrl?: string }) {
|
||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
||||
const platform = usePlatform()
|
||||
|
||||
const stored = (() => {
|
||||
@@ -106,12 +106,12 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerProvider defaultUrl={defaultServerUrl()}>
|
||||
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
root={(routerProps) => (
|
||||
<SettingsProvider>
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
@@ -119,7 +119,10 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
||||
<ModelsProvider>
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
<Layout>
|
||||
{props.children}
|
||||
{routerProps.children}
|
||||
</Layout>
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</ModelsProvider>
|
||||
|
||||
@@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) {
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? "Provider ID is required"
|
||||
? language.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? "Use lowercase letters, numbers, hyphens, or underscores"
|
||||
? language.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? "Display name is required" : undefined
|
||||
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? "Base URL is required"
|
||||
? language.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? "Must start with http:// or https://"
|
||||
? language.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
|
||||
@@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) {
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: existingProvider && !disabled
|
||||
? "That provider ID already exists"
|
||||
? language.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const modelErrors = form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const modelIdError = !id
|
||||
? "Required"
|
||||
? language.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? "Duplicate"
|
||||
? language.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const modelNameError = !m.name.trim() ? "Required" : undefined
|
||||
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
|
||||
return { id: modelIdError, name: modelNameError }
|
||||
})
|
||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||
@@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) {
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? "Required"
|
||||
? language.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? "Duplicate"
|
||||
? language.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? "Required" : undefined
|
||||
const valueError = !value ? language.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||
@@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">Custom provider</div>
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||
<p class="text-14-regular text-text-base">
|
||||
Configure an OpenAI-compatible provider. See the{" "}
|
||||
{language.t("provider.custom.description.prefix")}
|
||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||
provider config docs
|
||||
{language.t("provider.custom.description.link")}
|
||||
</Link>
|
||||
.
|
||||
{language.t("provider.custom.description.suffix")}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
label="Provider ID"
|
||||
placeholder="myprovider"
|
||||
description="Lowercase letters, numbers, hyphens, or underscores"
|
||||
label={language.t("provider.custom.field.providerID.label")}
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={setForm.bind(null, "providerID")}
|
||||
validationState={errors.providerID ? "invalid" : undefined}
|
||||
error={errors.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label="Display name"
|
||||
placeholder="My AI Provider"
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={setForm.bind(null, "name")}
|
||||
validationState={errors.name ? "invalid" : undefined}
|
||||
error={errors.name}
|
||||
/>
|
||||
<TextField
|
||||
label="Base URL"
|
||||
placeholder="https://api.myprovider.com/v1"
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={setForm.bind(null, "baseURL")}
|
||||
validationState={errors.baseURL ? "invalid" : undefined}
|
||||
error={errors.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
label="API key"
|
||||
placeholder="API key"
|
||||
description="Optional. Leave empty if you manage auth via headers."
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={setForm.bind(null, "apiKey")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">Models</label>
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label="ID"
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder="model-id"
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setForm("models", i(), "id", v)}
|
||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
||||
@@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label="Name"
|
||||
label={language.t("provider.custom.models.name.label")}
|
||||
hideLabel
|
||||
placeholder="Display Name"
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setForm("models", i(), "name", v)}
|
||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
||||
@@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) {
|
||||
class="mt-1.5"
|
||||
onClick={() => removeModel(i())}
|
||||
disabled={form.models.length <= 1}
|
||||
aria-label="Remove model"
|
||||
aria-label={language.t("provider.custom.models.remove")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
||||
Add model
|
||||
{language.t("provider.custom.models.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">Headers (optional)</label>
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label="Header"
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder="Header-Name"
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
||||
@@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label="Value"
|
||||
label={language.t("provider.custom.headers.value.label")}
|
||||
hideLabel
|
||||
placeholder="value"
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
||||
@@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) {
|
||||
class="mt-1.5"
|
||||
onClick={() => removeHeader(i())}
|
||||
disabled={form.headers.length <= 1}
|
||||
aria-label="Remove header"
|
||||
aria-label={language.t("provider.custom.headers.remove")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
||||
Add header
|
||||
{language.t("provider.custom.headers.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||
{form.saving ? "Saving..." : language.t("common.submit")}
|
||||
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -158,22 +158,22 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": store.iconHover && !store.iconUrl,
|
||||
"opacity-0": !(store.iconHover && !store.iconUrl),
|
||||
}}
|
||||
>
|
||||
<Icon name="cloud-upload" size="large" class="text-icon-invert-base" />
|
||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": store.iconHover && !!store.iconUrl,
|
||||
"opacity-0": !(store.iconHover && !!store.iconUrl),
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size="large" class="text-icon-invert-base" />
|
||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
||||
@@ -223,7 +223,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
value={store.startup}
|
||||
onChange={(v) => setStore("startup", v)}
|
||||
spellcheck={false}
|
||||
class="max-h-40 w-full font-mono text-xs no-scrollbar"
|
||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { ListRef } from "@opencode-ai/ui/list"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
@@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps {
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
}
|
||||
|
||||
type Row = {
|
||||
absolute: string
|
||||
search: string
|
||||
}
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const sync = useGlobalSync()
|
||||
const sdk = useGlobalSDK()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const home = createMemo(() => sync.data.path.home)
|
||||
const [filter, setFilter] = createSignal("")
|
||||
|
||||
const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
|
||||
let list: ListRef | undefined
|
||||
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async () => {
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
|
||||
const start = createMemo(
|
||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||
)
|
||||
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
|
||||
const clean = (value: string) => {
|
||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||
}
|
||||
|
||||
function normalize(input: string) {
|
||||
const v = input.replaceAll("\\", "/")
|
||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||
@@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
return ""
|
||||
}
|
||||
|
||||
function display(path: string) {
|
||||
function parentOf(input: string) {
|
||||
const v = trimTrailing(input)
|
||||
if (v === "/") return v
|
||||
if (v === "//") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
|
||||
const i = v.lastIndexOf("/")
|
||||
if (i <= 0) return "/"
|
||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||
return v.slice(0, i)
|
||||
}
|
||||
|
||||
function modeOf(input: string) {
|
||||
const raw = normalizeDriveRoot(input.trim())
|
||||
if (!raw) return "relative" as const
|
||||
if (raw.startsWith("~")) return "tilde" as const
|
||||
if (rootOf(raw)) return "absolute" as const
|
||||
return "relative" as const
|
||||
}
|
||||
|
||||
function display(path: string, input: string) {
|
||||
const full = trimTrailing(path)
|
||||
if (modeOf(input) === "absolute") return full
|
||||
|
||||
return tildeOf(full) || full
|
||||
}
|
||||
|
||||
function tildeOf(absolute: string) {
|
||||
const full = trimTrailing(absolute)
|
||||
const h = home()
|
||||
if (!h) return full
|
||||
if (!h) return ""
|
||||
|
||||
const hn = trimTrailing(h)
|
||||
const lc = full.toLowerCase()
|
||||
const hc = hn.toLowerCase()
|
||||
if (lc === hc) return "~"
|
||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||
return full
|
||||
return ""
|
||||
}
|
||||
|
||||
function scoped(filter: string) {
|
||||
function row(absolute: string): Row {
|
||||
const full = trimTrailing(absolute)
|
||||
const tilde = tildeOf(full)
|
||||
|
||||
const withSlash = (value: string) => {
|
||||
if (!value) return ""
|
||||
if (value.endsWith("/")) return value
|
||||
return value + "/"
|
||||
}
|
||||
|
||||
const search = Array.from(
|
||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||
).join("\n")
|
||||
return { absolute: full, search }
|
||||
}
|
||||
|
||||
function scoped(value: string) {
|
||||
const base = start()
|
||||
if (!base) return
|
||||
|
||||
const raw = normalizeDriveRoot(filter.trim())
|
||||
const raw = normalizeDriveRoot(value)
|
||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||
|
||||
const h = home()
|
||||
@@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
}
|
||||
|
||||
const directories = async (filter: string) => {
|
||||
const input = scoped(filter)
|
||||
if (!input) return [] as string[]
|
||||
const value = clean(filter)
|
||||
const scopedInput = scoped(value)
|
||||
if (!scopedInput) return [] as string[]
|
||||
|
||||
const raw = normalizeDriveRoot(filter.trim())
|
||||
const raw = normalizeDriveRoot(value)
|
||||
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||
|
||||
const query = normalizeDriveRoot(input.path)
|
||||
const query = normalizeDriveRoot(scopedInput.path)
|
||||
|
||||
if (!isPath) {
|
||||
const results = await sdk.client.find
|
||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
||||
const find = () =>
|
||||
sdk.client.find
|
||||
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
return results.map((rel) => join(input.directory, rel)).slice(0, 50)
|
||||
if (!isPath) {
|
||||
const results = await find()
|
||||
|
||||
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
|
||||
}
|
||||
|
||||
const segments = query.replace(/^\/+/, "").split("/")
|
||||
@@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
|
||||
const cap = 12
|
||||
const branch = 4
|
||||
let paths = [input.directory]
|
||||
let paths = [scopedInput.directory]
|
||||
for (const part of head) {
|
||||
if (part === "..") {
|
||||
paths = paths.map((p) => {
|
||||
const v = trimTrailing(p)
|
||||
if (v === "/") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
const i = v.lastIndexOf("/")
|
||||
if (i <= 0) return "/"
|
||||
return v.slice(0, i)
|
||||
})
|
||||
paths = paths.map(parentOf)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
}
|
||||
|
||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
||||
return Array.from(new Set(out)).slice(0, 50)
|
||||
const deduped = Array.from(new Set(out))
|
||||
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
||||
const expand = !raw.endsWith("/")
|
||||
if (!expand || !tail) {
|
||||
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
|
||||
return items.slice(0, 50)
|
||||
}
|
||||
|
||||
const needle = tail.toLowerCase()
|
||||
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
|
||||
const target = exact[0]
|
||||
if (!target) return deduped.slice(0, 50)
|
||||
|
||||
const children = await match(target, "", 30)
|
||||
const items = Array.from(new Set([...deduped, ...children]))
|
||||
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
||||
}
|
||||
|
||||
const items = async (value: string) => {
|
||||
const results = await directories(value)
|
||||
return results.map(row)
|
||||
}
|
||||
|
||||
function resolve(absolute: string) {
|
||||
@@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
|
||||
emptyMessage={language.t("dialog.directory.empty")}
|
||||
loadingMessage={language.t("common.loading")}
|
||||
items={directories}
|
||||
key={(x) => x}
|
||||
items={items}
|
||||
key={(x) => x.absolute}
|
||||
filterKeys={["search"]}
|
||||
ref={(r) => (list = r)}
|
||||
onFilter={(value) => setFilter(clean(value))}
|
||||
onKeyEvent={(e, item) => {
|
||||
if (e.key !== "Tab") return
|
||||
if (e.shiftKey) return
|
||||
if (!item) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const value = display(item.absolute, filter())
|
||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||
}}
|
||||
onSelect={(path) => {
|
||||
if (!path) return
|
||||
resolve(path)
|
||||
resolve(path.absolute)
|
||||
}}
|
||||
>
|
||||
{(absolute) => {
|
||||
const path = display(absolute)
|
||||
{(item) => {
|
||||
const path = display(item.absolute, filter())
|
||||
if (path === "~") {
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-strong whitespace-nowrap">~</span>
|
||||
<span class="text-text-weak whitespace-nowrap">/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
|
||||
<span class="text-text-weak whitespace-nowrap">/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
type EntryType = "command" | "file"
|
||||
type EntryType = "command" | "file" | "session"
|
||||
|
||||
type Entry = {
|
||||
id: string
|
||||
@@ -22,6 +28,10 @@ type Entry = {
|
||||
category: string
|
||||
option?: CommandOption
|
||||
path?: string
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}
|
||||
|
||||
type DialogSelectFileMode = "all" | "files"
|
||||
@@ -33,9 +43,13 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const filesOnly = () => props.mode === "files"
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const [grouped, setGrouped] = createSignal(false)
|
||||
const common = [
|
||||
@@ -73,6 +87,54 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
path,
|
||||
})
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return
|
||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||
})
|
||||
const workspaces = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
const current = project()
|
||||
if (!current) return directory ? [directory] : []
|
||||
|
||||
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||
return dirs
|
||||
})
|
||||
const homedir = createMemo(() => globalSync.data.path.home)
|
||||
const label = (directory: string) => {
|
||||
const current = project()
|
||||
const kind =
|
||||
current && directory === current.worktree
|
||||
? language.t("workspace.type.local")
|
||||
: language.t("workspace.type.sandbox")
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const home = homedir()
|
||||
const path = home ? directory.replace(home, "~") : directory
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
}
|
||||
|
||||
const sessionItem = (input: {
|
||||
directory: string
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}): Entry => ({
|
||||
id: `session:${input.directory}:${input.id}`,
|
||||
type: "session",
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
category: language.t("command.category.session"),
|
||||
directory: input.directory,
|
||||
sessionID: input.id,
|
||||
archived: input.archived,
|
||||
updated: input.updated,
|
||||
})
|
||||
|
||||
const list = createMemo(() => allowed().map(commandItem))
|
||||
|
||||
const picks = createMemo(() => {
|
||||
@@ -122,6 +184,69 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
return out
|
||||
}
|
||||
|
||||
const sessionToken = { value: 0 }
|
||||
let sessionInflight: Promise<Entry[]> | undefined
|
||||
let sessionAll: Entry[] | undefined
|
||||
|
||||
const sessions = (text: string) => {
|
||||
const query = text.trim()
|
||||
if (!query) {
|
||||
sessionToken.value += 1
|
||||
sessionInflight = undefined
|
||||
sessionAll = undefined
|
||||
return [] as Entry[]
|
||||
}
|
||||
|
||||
if (sessionAll) return sessionAll
|
||||
if (sessionInflight) return sessionInflight
|
||||
|
||||
const current = sessionToken.value
|
||||
const dirs = workspaces()
|
||||
if (dirs.length === 0) return [] as Entry[]
|
||||
|
||||
sessionInflight = Promise.all(
|
||||
dirs.map((directory) => {
|
||||
const description = label(directory)
|
||||
return globalSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
.then((x) =>
|
||||
(x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title ?? language.t("command.session.new"),
|
||||
description,
|
||||
directory,
|
||||
archived: s.time?.archived,
|
||||
updated: s.time?.updated,
|
||||
})),
|
||||
)
|
||||
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
if (sessionToken.value !== current) return [] as Entry[]
|
||||
const seen = new Set<string>()
|
||||
const next = results
|
||||
.flat()
|
||||
.filter((item) => {
|
||||
const key = `${item.directory}:${item.id}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map(sessionItem)
|
||||
sessionAll = next
|
||||
return next
|
||||
})
|
||||
.catch(() => [] as Entry[])
|
||||
.finally(() => {
|
||||
sessionInflight = undefined
|
||||
})
|
||||
|
||||
return sessionInflight
|
||||
}
|
||||
|
||||
const items = async (text: string) => {
|
||||
const query = text.trim()
|
||||
setGrouped(query.length > 0)
|
||||
@@ -146,9 +271,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const files = await file.searchFiles(query)
|
||||
return files.map(fileItem)
|
||||
}
|
||||
const files = await file.searchFiles(query)
|
||||
|
||||
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
||||
const entries = files.map(fileItem)
|
||||
return [...list(), ...entries]
|
||||
return [...list(), ...nextSessions, ...entries]
|
||||
}
|
||||
|
||||
const handleMove = (item: Entry | undefined) => {
|
||||
@@ -162,6 +288,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.open()
|
||||
layout.fileTree.setTab("all")
|
||||
props.onOpenFile?.(path)
|
||||
@@ -178,6 +305,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === "session") {
|
||||
if (!item.directory || !item.sessionID) return
|
||||
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!item.path) return
|
||||
open(item.path)
|
||||
}
|
||||
@@ -202,13 +335,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
items={items}
|
||||
key={(item) => item.id}
|
||||
filterKeys={["title", "description", "category"]}
|
||||
groupBy={(item) => item.category}
|
||||
groupBy={grouped() ? (item) => item.category : () => ""}
|
||||
onMove={handleMove}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{(item) => (
|
||||
<Show
|
||||
when={item.type === "command"}
|
||||
<Switch
|
||||
fallback={
|
||||
<div class="w-full flex items-center justify-between rounded-md pl-1">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
@@ -223,18 +355,48 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
||||
<Show when={item.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
|
||||
<Match when={item.type === "command"}>
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
||||
<Show when={item.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.keybind}>
|
||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.keybind}>
|
||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={item.type === "session"}>
|
||||
<div class="w-full flex items-center justify-between rounded-md pl-1">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="text-14-regular text-text-strong truncate"
|
||||
classList={{ "opacity-70": !!item.archived }}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<Show when={item.description}>
|
||||
<span
|
||||
class="text-14-regular text-text-weak truncate"
|
||||
classList={{ "opacity-70": !!item.archived }}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={item.updated}>
|
||||
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
||||
{getRelativeTime(new Date(item.updated!).toISOString())}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
|
||||
@@ -54,7 +54,6 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
forceMount={false}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
@@ -88,11 +87,13 @@ const ModelList: Component<{
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||
|
||||
export function ModelSelectorPopover(props: {
|
||||
provider?: string
|
||||
children?: JSX.Element | ((open: boolean) => JSX.Element)
|
||||
triggerAs?: T
|
||||
triggerProps?: ComponentProps<T>
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
}) {
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
@@ -177,18 +178,13 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
placement="top-start"
|
||||
gutter={8}
|
||||
>
|
||||
<Kobalte.Trigger
|
||||
ref={(el) => setStore("trigger", el)}
|
||||
as={props.triggerAs ?? "div"}
|
||||
{...(props.triggerProps as any)}
|
||||
>
|
||||
{typeof props.children === "function" ? props.children(store.open) : props.children}
|
||||
<Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
{props.children}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
data-component="model-popover-content"
|
||||
ref={(el) => setStore("content", el)}
|
||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
onEscapeKeyDown={(event) => {
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
@@ -215,7 +211,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
class="p-1"
|
||||
action={
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip placement="top" forceMount={false} value={language.t("command.provider.connect")}>
|
||||
<Tooltip placement="top" value={language.t("command.provider.connect")}>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
@@ -225,7 +221,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
onClick={handleConnectProvider}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="top" forceMount={false} value={language.t("dialog.model.manage")}>
|
||||
<Tooltip placement="top" value={language.t("dialog.model.manage")}>
|
||||
<IconButton
|
||||
icon="sliders"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
|
||||
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||
import { normalizeServerUrl, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
|
||||
type ServerStatus = { healthy: boolean; version?: string }
|
||||
import { ServerRow } from "@/components/server/server-row"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
interface AddRowProps {
|
||||
value: string
|
||||
@@ -40,19 +38,6 @@ interface EditRowProps {
|
||||
onBlur: () => void
|
||||
}
|
||||
|
||||
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
||||
const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
fetch: platform.fetch,
|
||||
signal,
|
||||
})
|
||||
return sdk.global
|
||||
.health()
|
||||
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
|
||||
function AddRow(props: AddRowProps) {
|
||||
return (
|
||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||
@@ -131,7 +116,7 @@ export function DialogSelectServer() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<string, ServerStatus | undefined>,
|
||||
status: {} as Record<string, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
url: "",
|
||||
adding: false,
|
||||
@@ -165,6 +150,7 @@ export function DialogSelectServer() {
|
||||
{ initialValue: null },
|
||||
)
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
@@ -180,7 +166,7 @@ export function DialogSelectServer() {
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const result = await checkHealth(normalized, platform)
|
||||
const result = await checkServerHealth(normalized, fetcher)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
@@ -227,7 +213,7 @@ export function DialogSelectServer() {
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerStatus) => {
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
@@ -242,10 +228,10 @@ export function DialogSelectServer() {
|
||||
})
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<string, ServerStatus> = {}
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
items().map(async (url) => {
|
||||
results[url] = await checkHealth(url, platform)
|
||||
results[url] = await checkServerHealth(url, fetcher)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
@@ -300,7 +286,7 @@ export function DialogSelectServer() {
|
||||
|
||||
setStore("addServer", { adding: true, error: "" })
|
||||
|
||||
const result = await checkHealth(normalized, platform)
|
||||
const result = await checkServerHealth(normalized, fetcher)
|
||||
setStore("addServer", { adding: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
@@ -327,7 +313,7 @@ export function DialogSelectServer() {
|
||||
|
||||
setStore("editServer", { busy: true, error: "" })
|
||||
|
||||
const result = await checkHealth(normalized, platform)
|
||||
const result = await checkServerHealth(normalized, fetcher)
|
||||
setStore("editServer", { busy: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
@@ -369,6 +355,9 @@ export function DialogSelectServer() {
|
||||
|
||||
async function handleRemove(url: string) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||
platform.setDefaultServerUrl?.(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -410,35 +399,6 @@ export function DialogSelectServer() {
|
||||
}
|
||||
>
|
||||
{(i) => {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
||||
setTruncated(nameTruncated || versionTruncated)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
check()
|
||||
window.addEventListener("resize", check)
|
||||
onCleanup(() => window.removeEventListener("resize", check))
|
||||
})
|
||||
|
||||
const tooltipValue = () => {
|
||||
const name = serverDisplayName(i)
|
||||
const version = store.status[i]?.version
|
||||
return (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{name}</span>
|
||||
<Show when={version}>
|
||||
<span class="text-text-invert-base">{version}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||
<Show
|
||||
@@ -456,34 +416,19 @@ export function DialogSelectServer() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||
"bg-border-weak-base": store.status[i] === undefined,
|
||||
}}
|
||||
/>
|
||||
<span ref={nameRef} class="truncate">
|
||||
{serverDisplayName(i)}
|
||||
</span>
|
||||
<Show when={store.status[i]?.version}>
|
||||
<span ref={versionRef} class="text-text-weak text-14-regular truncate">
|
||||
{store.status[i]?.version}
|
||||
</span>
|
||||
</Show>
|
||||
<ServerRow
|
||||
url={i}
|
||||
status={store.status[i]}
|
||||
dimmed={store.status[i]?.healthy === false}
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i}>
|
||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={store.editServer.id !== i}>
|
||||
<div class="flex items-center justify-center gap-5 pl-4">
|
||||
|
||||
77
packages/app/src/components/file-tree.test.ts
Normal file
77
packages/app/src/components/file-tree.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let shouldListRoot: typeof import("./file-tree").shouldListRoot
|
||||
let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
|
||||
let dirsToExpand: typeof import("./file-tree").dirsToExpand
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@/context/file", () => ({
|
||||
useFile: () => ({
|
||||
tree: {
|
||||
state: () => undefined,
|
||||
list: () => Promise.resolve(),
|
||||
children: () => [],
|
||||
expand: () => {},
|
||||
collapse: () => {},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/collapsible", () => ({
|
||||
Collapsible: {
|
||||
Trigger: (props: { children?: unknown }) => props.children,
|
||||
Content: (props: { children?: unknown }) => props.children,
|
||||
},
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
|
||||
mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
|
||||
mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
|
||||
const mod = await import("./file-tree")
|
||||
shouldListRoot = mod.shouldListRoot
|
||||
shouldListExpanded = mod.shouldListExpanded
|
||||
dirsToExpand = mod.dirsToExpand
|
||||
})
|
||||
|
||||
describe("file tree fetch discipline", () => {
|
||||
test("root lists on mount unless already loaded or loading", () => {
|
||||
expect(shouldListRoot({ level: 0 })).toBe(true)
|
||||
expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
|
||||
expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
|
||||
expect(shouldListRoot({ level: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
test("nested dirs list only when expanded and stale", () => {
|
||||
expect(shouldListExpanded({ level: 1 })).toBe(false)
|
||||
expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false)
|
||||
expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true)
|
||||
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false)
|
||||
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false)
|
||||
expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false)
|
||||
})
|
||||
|
||||
test("allowed auto-expand picks only collapsed dirs", () => {
|
||||
const expanded = new Set<string>()
|
||||
const filter = { dirs: new Set(["src", "src/components"]) }
|
||||
|
||||
const first = dirsToExpand({
|
||||
level: 0,
|
||||
filter,
|
||||
expanded: (dir) => expanded.has(dir),
|
||||
})
|
||||
|
||||
expect(first).toEqual(["src", "src/components"])
|
||||
|
||||
for (const dir of first) expanded.add(dir)
|
||||
|
||||
const second = dirsToExpand({
|
||||
level: 0,
|
||||
filter,
|
||||
expanded: (dir) => expanded.has(dir),
|
||||
})
|
||||
|
||||
expect(second).toEqual([])
|
||||
expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createMemo,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
@@ -18,6 +19,14 @@ import {
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function pathToFileUrl(filepath: string): string {
|
||||
const encodedPath = filepath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/")
|
||||
return `file://${encodedPath}`
|
||||
}
|
||||
|
||||
type Kind = "add" | "del" | "mix"
|
||||
|
||||
type Filter = {
|
||||
@@ -25,6 +34,34 @@ type Filter = {
|
||||
dirs: Set<string>
|
||||
}
|
||||
|
||||
export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
|
||||
if (input.level !== 0) return false
|
||||
if (input.dir?.loaded) return false
|
||||
if (input.dir?.loading) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function shouldListExpanded(input: {
|
||||
level: number
|
||||
dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
|
||||
}) {
|
||||
if (input.level === 0) return false
|
||||
if (!input.dir?.expanded) return false
|
||||
if (input.dir.loaded) return false
|
||||
if (input.dir.loading) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function dirsToExpand(input: {
|
||||
level: number
|
||||
filter?: { dirs: Set<string> }
|
||||
expanded: (dir: string) => boolean
|
||||
}) {
|
||||
if (input.level !== 0) return []
|
||||
if (!input.filter) return []
|
||||
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
||||
}
|
||||
|
||||
export default function FileTree(props: {
|
||||
path: string
|
||||
class?: string
|
||||
@@ -111,29 +148,87 @@ export default function FileTree(props: {
|
||||
|
||||
createEffect(() => {
|
||||
const current = filter()
|
||||
if (!current) return
|
||||
if (level !== 0) return
|
||||
|
||||
for (const dir of current.dirs) {
|
||||
const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
|
||||
if (expanded) continue
|
||||
file.tree.expand(dir)
|
||||
}
|
||||
const dirs = dirsToExpand({
|
||||
level,
|
||||
filter: current,
|
||||
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
||||
})
|
||||
for (const dir of dirs) file.tree.expand(dir)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.path,
|
||||
(path) => {
|
||||
const dir = untrack(() => file.tree.state(path))
|
||||
if (!shouldListRoot({ level, dir })) return
|
||||
void file.tree.list(path)
|
||||
},
|
||||
{ defer: false },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const path = props.path
|
||||
untrack(() => void file.tree.list(path))
|
||||
const dir = file.tree.state(props.path)
|
||||
if (!shouldListExpanded({ level, dir })) return
|
||||
void file.tree.list(props.path)
|
||||
})
|
||||
|
||||
const nodes = createMemo(() => {
|
||||
const nodes = file.tree.children(props.path)
|
||||
const current = filter()
|
||||
if (!current) return nodes
|
||||
return nodes.filter((node) => {
|
||||
|
||||
const parent = (path: string) => {
|
||||
const idx = path.lastIndexOf("/")
|
||||
if (idx === -1) return ""
|
||||
return path.slice(0, idx)
|
||||
}
|
||||
|
||||
const leaf = (path: string) => {
|
||||
const idx = path.lastIndexOf("/")
|
||||
return idx === -1 ? path : path.slice(idx + 1)
|
||||
}
|
||||
|
||||
const out = nodes.filter((node) => {
|
||||
if (node.type === "file") return current.files.has(node.path)
|
||||
return current.dirs.has(node.path)
|
||||
})
|
||||
|
||||
const seen = new Set(out.map((node) => node.path))
|
||||
|
||||
for (const dir of current.dirs) {
|
||||
if (parent(dir) !== props.path) continue
|
||||
if (seen.has(dir)) continue
|
||||
out.push({
|
||||
name: leaf(dir),
|
||||
path: dir,
|
||||
absolute: dir,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(dir)
|
||||
}
|
||||
|
||||
for (const item of current.files) {
|
||||
if (parent(item) !== props.path) continue
|
||||
if (seen.has(item)) continue
|
||||
out.push({
|
||||
name: leaf(item),
|
||||
path: item,
|
||||
absolute: item,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(item)
|
||||
}
|
||||
|
||||
return out.toSorted((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
const Node = (
|
||||
@@ -160,7 +255,7 @@ export default function FileTree(props: {
|
||||
onDragStart={(e: DragEvent) => {
|
||||
if (!draggable()) return
|
||||
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||
e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
|
||||
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
|
||||
|
||||
const dragImage = document.createElement("div")
|
||||
@@ -194,7 +289,7 @@ export default function FileTree(props: {
|
||||
: kind === "del"
|
||||
? "color: var(--icon-diff-delete-base)"
|
||||
: kind === "mix"
|
||||
? "color: var(--icon-diff-modified-base)"
|
||||
? "color: var(--icon-warning-active)"
|
||||
: undefined
|
||||
return (
|
||||
<span
|
||||
@@ -221,7 +316,7 @@ export default function FileTree(props: {
|
||||
? "color: var(--icon-diff-add-base)"
|
||||
: kind === "del"
|
||||
? "color: var(--icon-diff-delete-base)"
|
||||
: "color: var(--icon-diff-modified-base)"
|
||||
: "color: var(--icon-warning-active)"
|
||||
|
||||
return (
|
||||
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
|
||||
@@ -236,7 +331,7 @@ export default function FileTree(props: {
|
||||
? "background-color: var(--icon-diff-add-base)"
|
||||
: kind === "del"
|
||||
? "background-color: var(--icon-diff-delete-base)"
|
||||
: "background-color: var(--icon-diff-modified-base)"
|
||||
: "background-color: var(--icon-warning-active)"
|
||||
|
||||
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
|
||||
}
|
||||
@@ -274,7 +369,6 @@ export default function FileTree(props: {
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
forceMount={false}
|
||||
openDelay={2000}
|
||||
placement="bottom-start"
|
||||
class="w-full"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
156
packages/app/src/components/prompt-input/attachments.ts
Normal file
156
packages/app/src/components/prompt-input/attachments.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getCursorPosition } from "./editor-dom"
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isFocused: () => boolean
|
||||
isDialogActive: () => boolean
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
focusEditor: () => void
|
||||
addPart: (part: ContentPart) => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
}
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const prompt = usePrompt()
|
||||
const language = useLanguage()
|
||||
|
||||
const addImageAttachment = async (file: File) => {
|
||||
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
const dataUrl = reader.result as string
|
||||
const attachment: ImageAttachmentPart = {
|
||||
type: "image",
|
||||
id: crypto.randomUUID(),
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
dataUrl,
|
||||
}
|
||||
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
|
||||
prompt.set([...prompt.current(), attachment], cursorPosition)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const removeImageAttachment = (id: string) => {
|
||||
const current = prompt.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
prompt.set(next, prompt.cursor())
|
||||
}
|
||||
|
||||
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 imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
for (const item of imageItems) {
|
||||
const file = item.getAsFile()
|
||||
if (file) await addImageAttachment(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (fileItems.length > 0) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||
|
||||
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
||||
if (input.readClipboardImage && !plainText) {
|
||||
const file = await input.readClipboardImage()
|
||||
if (file) {
|
||||
await addImageAttachment(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!plainText) return
|
||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
const handleGlobalDragOver = (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
|
||||
event.preventDefault()
|
||||
const hasFiles = event.dataTransfer?.types.includes("Files")
|
||||
const hasText = event.dataTransfer?.types.includes("text/plain")
|
||||
if (hasFiles) {
|
||||
input.setDraggingType("image")
|
||||
} else if (hasText) {
|
||||
input.setDraggingType("@mention")
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalDragLeave = (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
if (!event.relatedTarget) {
|
||||
input.setDraggingType(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalDrop = async (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
|
||||
event.preventDefault()
|
||||
input.setDraggingType(null)
|
||||
|
||||
const plainText = event.dataTransfer?.getData("text/plain")
|
||||
const filePrefix = "file:"
|
||||
if (plainText?.startsWith(filePrefix)) {
|
||||
const filePath = plainText.slice(filePrefix.length)
|
||||
input.focusEditor()
|
||||
input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 })
|
||||
return
|
||||
}
|
||||
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
for (const file of Array.from(dropped)) {
|
||||
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
|
||||
await addImageAttachment(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("dragover", handleGlobalDragOver)
|
||||
document.addEventListener("dragleave", handleGlobalDragLeave)
|
||||
document.addEventListener("drop", handleGlobalDrop)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("dragover", handleGlobalDragOver)
|
||||
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
||||
document.removeEventListener("drop", handleGlobalDrop)
|
||||
})
|
||||
|
||||
return {
|
||||
addImageAttachment,
|
||||
removeImageAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
|
||||
describe("buildRequestParts", () => {
|
||||
test("builds typed request and optimistic parts without cast path", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/foo.ts",
|
||||
content: "@src/foo.ts",
|
||||
start: 5,
|
||||
end: 16,
|
||||
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
||||
},
|
||||
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.requestParts[0]?.type).toBe("text")
|
||||
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
|
||||
expect(
|
||||
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
||||
).toBe(true)
|
||||
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
||||
|
||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
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 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||
],
|
||||
images: [],
|
||||
text: "@src/foo.ts",
|
||||
messageID: "msg_2",
|
||||
sessionID: "ses_2",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const fooFiles = result.requestParts.filter(
|
||||
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
|
||||
)
|
||||
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
|
||||
|
||||
expect(fooFiles).toHaveLength(2)
|
||||
expect(synthetic).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\foo.ts",
|
||||
messageID: "msg_win_1",
|
||||
sessionID: "ses_win_1",
|
||||
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
||||
})
|
||||
|
||||
// Should create valid file URLs
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should not have encoded backslashes in wrong place
|
||||
expect(filePart.url).not.toContain("%5C")
|
||||
// Should have normalized to forward slashes
|
||||
expect(filePart.url).toContain("/src/foo.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles Windows absolute path with special characters", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@file#name.txt",
|
||||
messageID: "msg_win_2",
|
||||
sessionID: "ses_win_2",
|
||||
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Special chars should be encoded
|
||||
expect(filePart.url).toContain("file%23name.txt")
|
||||
// Should have Windows drive letter properly encoded
|
||||
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]%3A/)
|
||||
}
|
||||
})
|
||||
|
||||
test("handles Linux absolute paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src/app.ts",
|
||||
messageID: "msg_linux_1",
|
||||
sessionID: "ses_linux_1",
|
||||
sessionDirectory: "/home/user/project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should be a normal Unix path
|
||||
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles macOS paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@README.md",
|
||||
messageID: "msg_mac_1",
|
||||
sessionID: "ses_mac_1",
|
||||
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should be a normal Unix path
|
||||
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles context files with Windows paths", () => {
|
||||
const prompt: Prompt = []
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
||||
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
||||
],
|
||||
images: [],
|
||||
text: "test",
|
||||
messageID: "msg_win_ctx",
|
||||
sessionID: "ses_win_ctx",
|
||||
sessionDirectory: "D:\\workspace\\app",
|
||||
})
|
||||
|
||||
const fileParts = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(fileParts).toHaveLength(2)
|
||||
|
||||
// All file URLs should be valid
|
||||
fileParts.forEach((part) => {
|
||||
if (part.type === "file") {
|
||||
expect(() => new URL(part.url)).not.toThrow()
|
||||
expect(part.url).not.toContain("%5C") // No encoded backslashes
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("handles absolute Windows paths (user manually specifies full path)", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@D:\\other\\project\\file.ts",
|
||||
messageID: "msg_abs",
|
||||
sessionID: "ses_abs",
|
||||
sessionDirectory: "C:\\current\\project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should handle absolute path that differs from sessionDirectory
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
expect(filePart.url).toContain("/D%3A/other/project/file.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles selection with query parameters on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
type: "file",
|
||||
path: "src\\App.tsx",
|
||||
content: "@src\\App.tsx",
|
||||
start: 0,
|
||||
end: 11,
|
||||
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\App.tsx",
|
||||
messageID: "msg_sel",
|
||||
sessionID: "ses_sel",
|
||||
sessionDirectory: "C:\\project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should have query parameters
|
||||
expect(filePart.url).toContain("?start=10&end=20")
|
||||
// Should be valid URL
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Query params should parse correctly
|
||||
const url = new URL(filePart.url)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles file paths with dots and special segments on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@..\\..\\shared\\util.ts",
|
||||
messageID: "msg_dots",
|
||||
sessionID: "ses_dots",
|
||||
sessionDirectory: "C:\\projects\\myapp\\src",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should be valid URL
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should preserve .. segments (backend normalizes)
|
||||
expect(filePart.url).toContain("/..")
|
||||
}
|
||||
})
|
||||
})
|
||||
190
packages/app/src/components/prompt-input/build-request-parts.ts
Normal file
190
packages/app/src/components/prompt-input/build-request-parts.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
|
||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
type BuildRequestPartsInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
images: ImageAttachmentPart[]
|
||||
text: string
|
||||
messageID: string
|
||||
sessionID: string
|
||||
sessionDirectory: string
|
||||
}
|
||||
|
||||
const absolute = (directory: string, path: string) =>
|
||||
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
|
||||
|
||||
const encodeFilePath = (filepath: string): string => {
|
||||
// Normalize Windows paths: convert backslashes to forward slashes
|
||||
let normalized = filepath.replace(/\\/g, "/")
|
||||
|
||||
// Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
normalized = "/" + normalized
|
||||
}
|
||||
|
||||
// Encode each path segment (preserving forward slashes as path separators)
|
||||
return normalized
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/")
|
||||
}
|
||||
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
|
||||
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
|
||||
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
|
||||
const range =
|
||||
start === undefined || end === undefined
|
||||
? "this file"
|
||||
: start === end
|
||||
? `line ${start}`
|
||||
: `lines ${start} through ${end}`
|
||||
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
|
||||
}
|
||||
|
||||
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
|
||||
if (part.type === "text") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
text: part.text,
|
||||
synthetic: part.synthetic,
|
||||
ignored: part.ignored,
|
||||
time: part.time,
|
||||
metadata: part.metadata,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
if (part.type === "file") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: part.source,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: part.id,
|
||||
type: "agent",
|
||||
name: part.name,
|
||||
source: part.source,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
const requestParts: PromptRequestPart[] = [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input.text,
|
||||
},
|
||||
]
|
||||
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
filename: getFilename(attachment.path),
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path,
|
||||
},
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "agent",
|
||||
name: attachment.name,
|
||||
source: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
const used = new Set(files.map((part) => part.url))
|
||||
const context = input.context.flatMap((item) => {
|
||||
const path = absolute(input.sessionDirectory, item.path)
|
||||
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment && used.has(url)) return []
|
||||
used.add(url)
|
||||
|
||||
const filePart = {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(item.path),
|
||||
} satisfies PromptRequestPart
|
||||
|
||||
if (!comment) return [filePart]
|
||||
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: commentNote(item.path, item.selection, comment),
|
||||
synthetic: true,
|
||||
} satisfies PromptRequestPart,
|
||||
filePart,
|
||||
]
|
||||
})
|
||||
|
||||
const images = input.images.map((attachment) => {
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
requestParts.push(...files, ...context, ...agents, ...images)
|
||||
|
||||
return {
|
||||
requestParts,
|
||||
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
|
||||
}
|
||||
}
|
||||
82
packages/app/src/components/prompt-input/context-items.tsx
Normal file
82
packages/app/src/components/prompt-input/context-items.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import type { ContextItem } from "@/context/prompt"
|
||||
|
||||
type PromptContextItem = ContextItem & { key: string }
|
||||
|
||||
type ContextItemsProps = {
|
||||
items: PromptContextItem[]
|
||||
active: (item: PromptContextItem) => boolean
|
||||
openComment: (item: PromptContextItem) => void
|
||||
remove: (item: PromptContextItem) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.items.length > 0}>
|
||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<Tooltip
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||
{getDirectory(item.path)}
|
||||
</span>
|
||||
<span class="shrink-0">{getFilename(item.path)}</span>
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
|
||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||
props.active(item),
|
||||
"bg-background-stronger": !props.active(item),
|
||||
}}
|
||||
onClick={() => props.openComment(item)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.remove(item)
|
||||
}}
|
||||
aria-label={props.t("prompt.context.removeFile")}
|
||||
/>
|
||||
</div>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
20
packages/app/src/components/prompt-input/drag-overlay.tsx
Normal file
20
packages/app/src/components/prompt-input/drag-overlay.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Component, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
||||
type PromptDragOverlayProps = {
|
||||
type: "image" | "@mention" | null
|
||||
label: string
|
||||
}
|
||||
|
||||
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.type !== null}>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||
<Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
|
||||
<span class="text-14-regular">{props.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
51
packages/app/src/components/prompt-input/editor-dom.test.ts
Normal file
51
packages/app/src/components/prompt-input/editor-dom.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
||||
|
||||
describe("prompt-input editor dom", () => {
|
||||
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
|
||||
const fragment = createTextFragment("foo\n\nbar")
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(fragment)
|
||||
|
||||
expect(container.childNodes.length).toBe(5)
|
||||
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
expect(container.childNodes[2]?.textContent).toBe("\u200B")
|
||||
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
|
||||
expect(container.childNodes[4]?.textContent).toBe("bar")
|
||||
})
|
||||
|
||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(document.createTextNode("ab\u200B"))
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createTextNode("cd"))
|
||||
|
||||
expect(getNodeLength(container.childNodes[0]!)).toBe(2)
|
||||
expect(getNodeLength(container.childNodes[1]!)).toBe(1)
|
||||
expect(getTextLength(container)).toBe(5)
|
||||
})
|
||||
|
||||
test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
|
||||
const container = document.createElement("div")
|
||||
const pill = document.createElement("span")
|
||||
pill.dataset.type = "file"
|
||||
pill.textContent = "@file"
|
||||
container.appendChild(document.createTextNode("ab"))
|
||||
container.appendChild(pill)
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createTextNode("cd"))
|
||||
document.body.appendChild(container)
|
||||
|
||||
setCursorPosition(container, 2)
|
||||
expect(getCursorPosition(container)).toBe(2)
|
||||
|
||||
setCursorPosition(container, 7)
|
||||
expect(getCursorPosition(container)).toBe(7)
|
||||
|
||||
setCursorPosition(container, 8)
|
||||
expect(getCursorPosition(container)).toBe(8)
|
||||
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user