mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-10 19:04:17 +00:00
Compare commits
431 Commits
redesign-r
...
sqlite2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4666daa581 | ||
|
|
caf1316116 | ||
|
|
1e2f664410 | ||
|
|
a3aad9c9bf | ||
|
|
eb2587844b | ||
|
|
d863a9cf4e | ||
|
|
7d5be1556a | ||
|
|
659f15aa9b | ||
|
|
d1f5b9e911 | ||
|
|
284b00ff23 | ||
|
|
2c5760742b | ||
|
|
70c794e913 | ||
|
|
3929f0b5bd | ||
|
|
6f5dfe125a | ||
|
|
27fa9dc843 | ||
|
|
1e03a55acd | ||
|
|
65c9669283 | ||
|
|
18b6257119 | ||
|
|
c607c01fb9 | ||
|
|
4c4e30cd71 | ||
|
|
19ad7ad809 | ||
|
|
87795384de | ||
|
|
0732ab3393 | ||
|
|
2bccfd7462 | ||
|
|
83853cc5e6 | ||
|
|
4a73d51acd | ||
|
|
63cd763418 | ||
|
|
32394b699e | ||
|
|
12262862cd | ||
|
|
56a752092e | ||
|
|
439e7ec1fd | ||
|
|
20cf3fc679 | ||
|
|
949f61075f | ||
|
|
705200e199 | ||
|
|
85fa8abd50 | ||
|
|
3118cab2d8 | ||
|
|
31f893f8cb | ||
|
|
056d0c1197 | ||
|
|
1de66812bf | ||
|
|
832902c8e3 | ||
|
|
3d6fb29f0c | ||
|
|
9824370f82 | ||
|
|
371e106faa | ||
|
|
19809e7680 | ||
|
|
389afef336 | ||
|
|
274bb948e7 | ||
|
|
d9b4535d64 | ||
|
|
3dc720ff9c | ||
|
|
56b340b5d5 | ||
|
|
ba740eaefd | ||
|
|
39c5da4405 | ||
|
|
83708c295c | ||
|
|
a84bdd7cd7 | ||
|
|
110f6804fb | ||
|
|
e5ec2f9991 | ||
|
|
7bca3fbf18 | ||
|
|
d578f80f00 | ||
|
|
dc53086c1e | ||
|
|
f74c0339cc | ||
|
|
fb94b4f8e8 | ||
|
|
8ad4768ecd | ||
|
|
24fd8c166d | ||
|
|
a7c5d5ac4c | ||
|
|
5be1202eea | ||
|
|
373b2270e7 | ||
|
|
94d0c9940a | ||
|
|
05355a6b5c | ||
|
|
7ff51183ce | ||
|
|
bda0cbdec7 | ||
|
|
acc53d9f61 | ||
|
|
30f0d3b394 | ||
|
|
03f3029dc6 | ||
|
|
aed7bb8c09 | ||
|
|
dd2d232a9d | ||
|
|
993ac55e39 | ||
|
|
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 | ||
|
|
5952891b1e | ||
|
|
d7c8a3f50d | ||
|
|
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 | ||
|
|
ce353819e8 | ||
|
|
2dae94e5a3 | ||
|
|
8bf97ef9e5 | ||
|
|
683d234d80 | ||
|
|
229cdafcc4 | ||
|
|
154d0ebf53 | ||
|
|
2e9a63fe4f | ||
|
|
579902ace6 | ||
|
|
c6adc19e41 | ||
|
|
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 | ||
|
|
ce56166510 | ||
|
|
fba5a79c45 | ||
|
|
5911e4c06a | ||
|
|
42fb840f22 | ||
|
|
dbde377ab0 | ||
|
|
9adcf524e2 | ||
|
|
531b1941a0 | ||
|
|
a1c46e05ee | ||
|
|
1fe1457cfa | ||
|
|
aedd85d885 | ||
|
|
5b3d94ebaa | ||
|
|
1a6a3f4b54 | ||
|
|
3116cfc167 | ||
|
|
05529f66d7 | ||
|
|
ef09dddaa5 | ||
|
|
bf7af99a3f | ||
|
|
7555742bf0 | ||
|
|
fa20bc296b | ||
|
|
195731f347 | ||
|
|
72de9fe7a6 | ||
|
|
bec1148192 | ||
|
|
8c8d888140 | ||
|
|
d3a247bfff | ||
|
|
9ef319f25f | ||
|
|
64e2bf8bf0 | ||
|
|
4dcfdf6572 | ||
|
|
25f3d6d5a9 | ||
|
|
e19a9e9614 | ||
|
|
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 | ||
|
|
fcc903489b | ||
|
|
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 | ||
|
|
949e69a9bf | ||
|
|
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 | ||
|
|
8c30f551e2 | ||
|
|
cb721497c1 | ||
|
|
4ec6293054 | ||
|
|
b7a323355c | ||
|
|
d4f053042c | ||
|
|
5f552534c7 | ||
|
|
ad5b790bb3 | ||
|
|
ed87341c4f | ||
|
|
794ecab028 | ||
|
|
eeb235724b | ||
|
|
61084e7f6f | ||
|
|
200aef2eb3 | ||
|
|
f6e375a555 | ||
|
|
db908deee5 | ||
|
|
7b72cc3a48 | ||
|
|
b8cbfd48ec | ||
|
|
498cbb2c26 | ||
|
|
d6fbd255b6 | ||
|
|
2de1c82bf7 | ||
|
|
34ebb3d051 | ||
|
|
9c3e3c1ab5 | ||
|
|
3ea499f04e | ||
|
|
ab13c1d1c4 | ||
|
|
53b610c331 | ||
|
|
e3519356f2 | ||
|
|
2619acc0ff | ||
|
|
1bc45dc266 | ||
|
|
2e8feb1c78 | ||
|
|
00e60899cc | ||
|
|
30a918e9d4 | ||
|
|
ac16068140 | ||
|
|
19a41ab297 | ||
|
|
cd174d8cba | ||
|
|
246e901e42 | ||
|
|
0ccef1b31f | ||
|
|
7706f5b6a8 | ||
|
|
63e38555c9 | ||
|
|
f40685ab13 | ||
|
|
a48a5a3462 | ||
|
|
5e1639de2b | ||
|
|
2b05833c32 | ||
|
|
acdcf7fa88 | ||
|
|
bf0754caeb | ||
|
|
4d50a32979 | ||
|
|
57edb0ddc5 | ||
|
|
a614b78c6d | ||
|
|
b9f5a34247 | ||
|
|
81b47a44e2 | ||
|
|
0c1c07467e | ||
|
|
105688bf90 | ||
|
|
1e7b4768b1 |
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/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: true
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Discord Community
|
||||
url: https://discord.gg/opencode
|
||||
|
||||
18
.github/VOUCHED.td
vendored
Normal file
18
.github/VOUCHED.td
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Vouched contributors for this project.
|
||||
#
|
||||
# See https://github.com/mitchellh/vouch for details.
|
||||
#
|
||||
# Syntax:
|
||||
# - One handle per line (without @), sorted alphabetically.
|
||||
# - Optional platform prefix: platform:username (e.g., github:user).
|
||||
# - Denounce with minus prefix: -username or -platform:username.
|
||||
# - Optional details after a space following the handle.
|
||||
adamdotdevin
|
||||
fwang
|
||||
iamdavidhill
|
||||
jayair
|
||||
kitlangton
|
||||
kommander
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
thdxr
|
||||
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
|
||||
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -1,6 +1,6 @@
|
||||
### What does this PR do?
|
||||
|
||||
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the pr.
|
||||
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
|
||||
|
||||
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
|
||||
|
||||
|
||||
132
.github/workflows/close-stale-prs.yml
vendored
132
.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,6 +26,15 @@ 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"
|
||||
@@ -32,6 +42,42 @@ jobs:
|
||||
core.info(`Dry run mode: ${dryRun}`)
|
||||
core.info(`Cutoff date: ${cutoff.toISOString()}`)
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -73,17 +119,27 @@ jobs:
|
||||
const allPrs = []
|
||||
let cursor = null
|
||||
let hasNextPage = true
|
||||
let pageCount = 0
|
||||
|
||||
while (hasNextPage) {
|
||||
const result = await github.graphql(query, {
|
||||
owner,
|
||||
repo,
|
||||
cursor,
|
||||
})
|
||||
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`)
|
||||
@@ -114,28 +170,66 @@ jobs:
|
||||
|
||||
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.author.login}: ${pr.title}`)
|
||||
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.author.login}: ${pr.title}`)
|
||||
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(`=============================`)
|
||||
|
||||
86
.github/workflows/compliance-close.yml
vendored
Normal file
86
.github/workflows/compliance-close.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: compliance-close
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every 30 minutes to check for expired compliance windows
|
||||
- cron: "*/30 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-non-compliant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close non-compliant issues and PRs after 2 hours
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: items } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'needs:compliance',
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
core.info('No open issues/PRs with needs:compliance label');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const twoHours = 2 * 60 * 60 * 1000;
|
||||
|
||||
for (const item of items) {
|
||||
const isPR = !!item.pull_request;
|
||||
const kind = isPR ? 'PR' : 'issue';
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
});
|
||||
|
||||
const complianceComment = comments.find(c => c.body.includes('<!-- issue-compliance -->'));
|
||||
if (!complianceComment) continue;
|
||||
|
||||
const commentAge = now - new Date(complianceComment.created_at).getTime();
|
||||
if (commentAge < twoHours) {
|
||||
core.info(`${kind} #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeMessage = isPR
|
||||
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
|
||||
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
body: closeMessage,
|
||||
});
|
||||
|
||||
if (isPR) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: item.number,
|
||||
state: 'closed',
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Closed non-compliant ${kind} #${item.number} after 2-hour window`);
|
||||
}
|
||||
8
.github/workflows/daily-issues-recap.yml
vendored
8
.github/workflows/daily-issues-recap.yml
vendored
@@ -48,8 +48,12 @@ jobs:
|
||||
TODAY'S DATE: ${TODAY}
|
||||
|
||||
STEP 1: Gather today's issues
|
||||
Search for all issues created today (${TODAY}) using:
|
||||
gh issue list --repo ${{ github.repository }} --state all --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
|
||||
Search for all OPEN issues created today (${TODAY}) using:
|
||||
gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
|
||||
|
||||
IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these:
|
||||
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
|
||||
This recap is specifically for COMMUNITY (external) issues only.
|
||||
|
||||
STEP 2: Analyze and categorize
|
||||
For each issue created today, categorize it:
|
||||
|
||||
12
.github/workflows/daily-pr-recap.yml
vendored
12
.github/workflows/daily-pr-recap.yml
vendored
@@ -47,14 +47,18 @@ jobs:
|
||||
TODAY'S DATE: ${TODAY}
|
||||
|
||||
STEP 1: Gather PR data
|
||||
Run these commands to gather PR information. ONLY include PRs created or updated TODAY (${TODAY}):
|
||||
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
|
||||
|
||||
# PRs created today
|
||||
gh pr list --repo ${{ github.repository }} --state all --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||
# Open PRs created today
|
||||
gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||
|
||||
# PRs with activity today (updated today)
|
||||
# Open PRs with activity today (updated today)
|
||||
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||
|
||||
IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these:
|
||||
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
|
||||
This recap is specifically for COMMUNITY (external) contributions only.
|
||||
|
||||
|
||||
|
||||
STEP 2: For high-activity PRs, check comment counts
|
||||
|
||||
82
.github/workflows/docs-locale-sync.yml
vendored
Normal file
82
.github/workflows/docs-locale-sync.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: docs-locale-sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- packages/web/src/content/docs/*.mdx
|
||||
|
||||
jobs:
|
||||
sync-locales:
|
||||
if: github.actor != 'opencode-agent[bot]'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- 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: Compute changed English docs
|
||||
id: changes
|
||||
run: |
|
||||
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No English docs changed in push range"
|
||||
exit 0
|
||||
fi
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "files<<EOF"
|
||||
echo "$FILES"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Sync locale docs with OpenCode
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
uses: sst/opencode/github@latest
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
with:
|
||||
model: opencode/gpt-5.2
|
||||
agent: docs
|
||||
prompt: |
|
||||
Update localized docs to match the latest English docs changes.
|
||||
|
||||
Changed English doc files:
|
||||
<changed_english_docs>
|
||||
${{ steps.changes.outputs.files }}
|
||||
</changed_english_docs>
|
||||
|
||||
Requirements:
|
||||
1. Update all relevant locale docs under packages/web/src/content/docs/<locale>/ so they reflect these English page changes.
|
||||
2. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
|
||||
3. Keep locale docs structure aligned with their corresponding English pages.
|
||||
4. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
|
||||
5. If no locale updates are needed, make no changes.
|
||||
|
||||
- name: Commit and push locale docs updates
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "No locale docs changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git add -A
|
||||
git commit -m "docs(i18n): sync locale docs from english changes"
|
||||
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
|
||||
git push origin HEAD:"$GITHUB_REF_NAME"
|
||||
82
.github/workflows/duplicate-issues.yml
vendored
82
.github/workflows/duplicate-issues.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Check for duplicate issues
|
||||
- name: Check duplicates and compliance
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -34,30 +34,84 @@ jobs:
|
||||
"webfetch": "deny"
|
||||
}
|
||||
run: |
|
||||
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:'
|
||||
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
|
||||
|
||||
Issue number:
|
||||
${{ github.event.issue.number }}
|
||||
Issue number: ${{ github.event.issue.number }}
|
||||
|
||||
Lookup this issue and search through existing issues (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue.
|
||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||
|
||||
You have TWO tasks. Perform both, then post a SINGLE comment (if needed).
|
||||
|
||||
---
|
||||
|
||||
TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK
|
||||
|
||||
Check whether the issue follows our contributing guidelines and issue templates.
|
||||
|
||||
This project has three issue templates that every issue MUST use one of:
|
||||
|
||||
1. Bug Report - requires a Description field with real content
|
||||
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
|
||||
3. Question - requires the Question field with real content
|
||||
|
||||
Additionally check:
|
||||
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
|
||||
- The issue has real content, not just template placeholder text left unchanged
|
||||
- Bug reports should include some context about how to reproduce
|
||||
- Feature requests should explain the problem or need
|
||||
- We want to push for having the user provide system description & information
|
||||
|
||||
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
|
||||
|
||||
---
|
||||
|
||||
TASK 2: DUPLICATE CHECK
|
||||
|
||||
Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
|
||||
Consider:
|
||||
1. Similar titles or descriptions
|
||||
2. Same error messages or symptoms
|
||||
3. Related functionality or components
|
||||
4. Similar feature requests
|
||||
|
||||
If you find any potential duplicates, please comment on the new issue with:
|
||||
- A brief explanation of why it might be a duplicate
|
||||
- Links to the potentially duplicate issues
|
||||
- A suggestion to check those issues first
|
||||
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
|
||||
|
||||
---
|
||||
|
||||
POSTING YOUR COMMENT:
|
||||
|
||||
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
|
||||
|
||||
If the issue is NOT compliant, start the comment with:
|
||||
<!-- issue-compliance -->
|
||||
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||
|
||||
If duplicates were found, include a section about potential duplicates with links.
|
||||
|
||||
If the issue mentions keybinds/keyboard shortcuts, include a note about #4997.
|
||||
|
||||
If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all.
|
||||
|
||||
Use this format for the comment:
|
||||
'This issue might be a duplicate of existing issues. Please check:
|
||||
|
||||
[If not compliant:]
|
||||
<!-- issue-compliance -->
|
||||
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
|
||||
|
||||
**What needs to be fixed:**
|
||||
- [specific reasons]
|
||||
|
||||
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
|
||||
|
||||
[If duplicates found, add:]
|
||||
---
|
||||
This issue might be a duplicate of existing issues. Please check:
|
||||
- #[issue_number]: [brief description of similarity]
|
||||
|
||||
Feel free to ignore if none of these address your specific case.'
|
||||
[If keybind-related, add:]
|
||||
For keybind-related issues, please also check our pinned keybinds documentation: #4997
|
||||
|
||||
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, please add a comment mentioning the pinned keybinds issue #4997:
|
||||
'For keybind-related issues, please also check our pinned keybinds documentation: #4997'
|
||||
[End with if not compliant:]
|
||||
If you believe this was flagged incorrectly, please let a maintainer know.
|
||||
|
||||
If no clear duplicates are found, do not comment."
|
||||
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
||||
|
||||
3
.github/workflows/nix-hashes.yml
vendored
3
.github/workflows/nix-hashes.yml
vendored
@@ -12,6 +12,9 @@ on:
|
||||
- "package.json"
|
||||
- "packages/*/package.json"
|
||||
- "flake.lock"
|
||||
- "nix/node_modules.nix"
|
||||
- "nix/scripts/**"
|
||||
- "patches/**"
|
||||
- ".github/workflows/nix-hashes.yml"
|
||||
|
||||
jobs:
|
||||
|
||||
135
.github/workflows/test.yml
vendored
135
.github/workflows/test.yml
vendored
@@ -7,8 +7,32 @@ on:
|
||||
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:
|
||||
@@ -16,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
|
||||
@@ -43,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
|
||||
@@ -136,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"
|
||||
|
||||
96
.github/workflows/vouch-check-issue.yml
vendored
Normal file
96
.github/workflows/vouch-check-issue.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: vouch-check-issue
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if issue author is denounced
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const author = context.payload.issue.user.login;
|
||||
const issueNumber = context.payload.issue.number;
|
||||
|
||||
// Skip bots
|
||||
if (author.endsWith('[bot]')) {
|
||||
core.info(`Skipping bot: ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the VOUCHED.td file via API (no checkout needed)
|
||||
let content;
|
||||
try {
|
||||
const response = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/VOUCHED.td',
|
||||
});
|
||||
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info('No .github/VOUCHED.td file found, skipping check.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse the .td file for denounced users
|
||||
const denounced = new Map();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
if (!trimmed.startsWith('-')) continue;
|
||||
|
||||
const rest = trimmed.slice(1).trim();
|
||||
if (!rest) continue;
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||
|
||||
// Handle platform:username or bare username
|
||||
// Only match bare usernames or github: prefix (skip other platforms)
|
||||
const colonIdx = handle.indexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const platform = handle.slice(0, colonIdx).toLowerCase();
|
||||
if (platform !== 'github') continue;
|
||||
}
|
||||
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||
if (!username) continue;
|
||||
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
}
|
||||
|
||||
// Check if the author is denounced
|
||||
const reason = denounced.get(author.toLowerCase());
|
||||
if (reason === undefined) {
|
||||
core.info(`User ${author} is not denounced. Allowing issue.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Author is denounced — close the issue
|
||||
const body = 'This issue has been automatically closed.';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
|
||||
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
|
||||
93
.github/workflows/vouch-check-pr.yml
vendored
Normal file
93
.github/workflows/vouch-check-pr.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: vouch-check-pr
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if PR author is denounced
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const author = context.payload.pull_request.user.login;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
// Skip bots
|
||||
if (author.endsWith('[bot]')) {
|
||||
core.info(`Skipping bot: ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the VOUCHED.td file via API (no checkout needed)
|
||||
let content;
|
||||
try {
|
||||
const response = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/VOUCHED.td',
|
||||
});
|
||||
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info('No .github/VOUCHED.td file found, skipping check.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse the .td file for denounced users
|
||||
const denounced = new Map();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
if (!trimmed.startsWith('-')) continue;
|
||||
|
||||
const rest = trimmed.slice(1).trim();
|
||||
if (!rest) continue;
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||
|
||||
// Handle platform:username or bare username
|
||||
// Only match bare usernames or github: prefix (skip other platforms)
|
||||
const colonIdx = handle.indexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const platform = handle.slice(0, colonIdx).toLowerCase();
|
||||
if (platform !== 'github') continue;
|
||||
}
|
||||
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||
if (!username) continue;
|
||||
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
}
|
||||
|
||||
// Check if the author is denounced
|
||||
const reason = denounced.get(author.toLowerCase());
|
||||
if (reason === undefined) {
|
||||
core.info(`User ${author} is not denounced. Allowing PR.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Author is denounced — close the PR
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: 'This pull request has been automatically closed.',
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
|
||||
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
|
||||
37
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
37
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: vouch-manage-by-issue
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: vouch-manage
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
manage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- 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 }}
|
||||
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@main
|
||||
with:
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
.codex
|
||||
*~
|
||||
playground
|
||||
tmp
|
||||
|
||||
883
.opencode/agent/translator.md
Normal file
883
.opencode/agent/translator.md
Normal file
@@ -0,0 +1,883 @@
|
||||
---
|
||||
description: Translate content for a specified locale while preserving technical terms
|
||||
mode: subagent
|
||||
model: opencode/gemini-3-pro
|
||||
---
|
||||
|
||||
You are a professional translator and localization specialist.
|
||||
|
||||
Translate the user's content into the requested target locale (language + region, e.g. fr-FR, de-DE).
|
||||
|
||||
Requirements:
|
||||
|
||||
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
|
||||
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
|
||||
- Also preserve every term listed in the Do-Not-Translate glossary below.
|
||||
- Do not modify fenced code blocks.
|
||||
- Output ONLY the translation (no commentary).
|
||||
|
||||
If the target locale is missing, ask the user to provide it.
|
||||
|
||||
---
|
||||
|
||||
# Do-Not-Translate Terms (OpenCode Docs)
|
||||
|
||||
Generated from: `packages/web/src/content/docs/*.mdx` (default English docs)
|
||||
Generated on: 2026-02-10
|
||||
|
||||
Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation).
|
||||
|
||||
General rules (verbatim, even if not listed below):
|
||||
|
||||
- Anything inside inline code (single backticks) or fenced code blocks (triple backticks)
|
||||
- MDX/JS code in docs: `import ... from "..."`, component tags, identifiers
|
||||
- CLI commands, flags, config keys/values, file paths, URLs/domains, and env vars
|
||||
|
||||
## Proper nouns and product names
|
||||
|
||||
Additional (not reliably captured via link text):
|
||||
|
||||
```text
|
||||
Astro
|
||||
Bun
|
||||
Chocolatey
|
||||
Cursor
|
||||
Docker
|
||||
Git
|
||||
GitHub Actions
|
||||
GitLab CI
|
||||
GNOME Terminal
|
||||
Homebrew
|
||||
Mise
|
||||
Neovim
|
||||
Node.js
|
||||
npm
|
||||
Obsidian
|
||||
opencode
|
||||
opencode-ai
|
||||
Paru
|
||||
pnpm
|
||||
ripgrep
|
||||
Scoop
|
||||
SST
|
||||
Starlight
|
||||
Visual Studio Code
|
||||
VS Code
|
||||
VSCodium
|
||||
Windsurf
|
||||
Windows Terminal
|
||||
Yarn
|
||||
Zellij
|
||||
Zed
|
||||
anomalyco
|
||||
```
|
||||
|
||||
Extracted from link labels in the English docs (review and prune as desired):
|
||||
|
||||
```text
|
||||
@openspoon/subtask2
|
||||
302.AI console
|
||||
ACP progress report
|
||||
Agent Client Protocol
|
||||
Agent Skills
|
||||
Agentic
|
||||
AGENTS.md
|
||||
AI SDK
|
||||
Alacritty
|
||||
Anthropic
|
||||
Anthropic's Data Policies
|
||||
Atom One
|
||||
Avante.nvim
|
||||
Ayu
|
||||
Azure AI Foundry
|
||||
Azure portal
|
||||
Baseten
|
||||
built-in GITHUB_TOKEN
|
||||
Bun.$
|
||||
Catppuccin
|
||||
Cerebras console
|
||||
ChatGPT Plus or Pro
|
||||
Cloudflare dashboard
|
||||
CodeCompanion.nvim
|
||||
CodeNomad
|
||||
Configuring Adapters: Environment Variables
|
||||
Context7 MCP server
|
||||
Cortecs console
|
||||
Deep Infra dashboard
|
||||
DeepSeek console
|
||||
Duo Agent Platform
|
||||
Everforest
|
||||
Fireworks AI console
|
||||
Firmware dashboard
|
||||
Ghostty
|
||||
GitLab CLI agents docs
|
||||
GitLab docs
|
||||
GitLab User Settings > Access Tokens
|
||||
Granular Rules (Object Syntax)
|
||||
Grep by Vercel
|
||||
Groq console
|
||||
Gruvbox
|
||||
Helicone
|
||||
Helicone documentation
|
||||
Helicone Header Directory
|
||||
Helicone's Model Directory
|
||||
Hugging Face Inference Providers
|
||||
Hugging Face settings
|
||||
install WSL
|
||||
IO.NET console
|
||||
JetBrains IDE
|
||||
Kanagawa
|
||||
Kitty
|
||||
MiniMax API Console
|
||||
Models.dev
|
||||
Moonshot AI console
|
||||
Nebius Token Factory console
|
||||
Nord
|
||||
OAuth
|
||||
Ollama integration docs
|
||||
OpenAI's Data Policies
|
||||
OpenChamber
|
||||
OpenCode
|
||||
OpenCode config
|
||||
OpenCode Config
|
||||
OpenCode TUI with the opencode theme
|
||||
OpenCode Web - Active Session
|
||||
OpenCode Web - New Session
|
||||
OpenCode Web - See Servers
|
||||
OpenCode Zen
|
||||
OpenCode-Obsidian
|
||||
OpenRouter dashboard
|
||||
OpenWork
|
||||
OVHcloud panel
|
||||
Pro+ subscription
|
||||
SAP BTP Cockpit
|
||||
Scaleway Console IAM settings
|
||||
Scaleway Generative APIs
|
||||
SDK documentation
|
||||
Sentry MCP server
|
||||
shell API
|
||||
Together AI console
|
||||
Tokyonight
|
||||
Unified Billing
|
||||
Venice AI console
|
||||
Vercel dashboard
|
||||
WezTerm
|
||||
Windows Subsystem for Linux (WSL)
|
||||
WSL
|
||||
WSL (Windows Subsystem for Linux)
|
||||
WSL extension
|
||||
xAI console
|
||||
Z.AI API console
|
||||
Zed
|
||||
ZenMux dashboard
|
||||
Zod
|
||||
```
|
||||
|
||||
## Acronyms and initialisms
|
||||
|
||||
```text
|
||||
ACP
|
||||
AGENTS
|
||||
AI
|
||||
AI21
|
||||
ANSI
|
||||
API
|
||||
AST
|
||||
AWS
|
||||
BTP
|
||||
CD
|
||||
CDN
|
||||
CI
|
||||
CLI
|
||||
CMD
|
||||
CORS
|
||||
DEBUG
|
||||
EKS
|
||||
ERROR
|
||||
FAQ
|
||||
GLM
|
||||
GNOME
|
||||
GPT
|
||||
HTML
|
||||
HTTP
|
||||
HTTPS
|
||||
IAM
|
||||
ID
|
||||
IDE
|
||||
INFO
|
||||
IO
|
||||
IP
|
||||
IRSA
|
||||
JS
|
||||
JSON
|
||||
JSONC
|
||||
K2
|
||||
LLM
|
||||
LM
|
||||
LSP
|
||||
M2
|
||||
MCP
|
||||
MR
|
||||
NET
|
||||
NPM
|
||||
NTLM
|
||||
OIDC
|
||||
OS
|
||||
PAT
|
||||
PATH
|
||||
PHP
|
||||
PR
|
||||
PTY
|
||||
README
|
||||
RFC
|
||||
RPC
|
||||
SAP
|
||||
SDK
|
||||
SKILL
|
||||
SSE
|
||||
SSO
|
||||
TS
|
||||
TTY
|
||||
TUI
|
||||
UI
|
||||
URL
|
||||
US
|
||||
UX
|
||||
VCS
|
||||
VPC
|
||||
VPN
|
||||
VS
|
||||
WARN
|
||||
WSL
|
||||
X11
|
||||
YAML
|
||||
```
|
||||
|
||||
## Code identifiers used in prose (CamelCase, mixedCase)
|
||||
|
||||
```text
|
||||
apiKey
|
||||
AppleScript
|
||||
AssistantMessage
|
||||
baseURL
|
||||
BurntSushi
|
||||
ChatGPT
|
||||
ClangFormat
|
||||
CodeCompanion
|
||||
CodeNomad
|
||||
DeepSeek
|
||||
DefaultV2
|
||||
FileContent
|
||||
FileDiff
|
||||
FileNode
|
||||
fineGrained
|
||||
FormatterStatus
|
||||
GitHub
|
||||
GitLab
|
||||
iTerm2
|
||||
JavaScript
|
||||
JetBrains
|
||||
macOS
|
||||
mDNS
|
||||
MiniMax
|
||||
NeuralNomadsAI
|
||||
NickvanDyke
|
||||
NoeFabris
|
||||
OpenAI
|
||||
OpenAPI
|
||||
OpenChamber
|
||||
OpenCode
|
||||
OpenRouter
|
||||
OpenTUI
|
||||
OpenWork
|
||||
ownUserPermissions
|
||||
PowerShell
|
||||
ProviderAuthAuthorization
|
||||
ProviderAuthMethod
|
||||
ProviderInitError
|
||||
SessionStatus
|
||||
TabItem
|
||||
tokenType
|
||||
ToolIDs
|
||||
ToolList
|
||||
TypeScript
|
||||
typesUrl
|
||||
UserMessage
|
||||
VcsInfo
|
||||
WebView2
|
||||
WezTerm
|
||||
xAI
|
||||
ZenMux
|
||||
```
|
||||
|
||||
## OpenCode CLI commands (as shown in docs)
|
||||
|
||||
```text
|
||||
opencode
|
||||
opencode [project]
|
||||
opencode /path/to/project
|
||||
opencode acp
|
||||
opencode agent [command]
|
||||
opencode agent create
|
||||
opencode agent list
|
||||
opencode attach [url]
|
||||
opencode attach http://10.20.30.40:4096
|
||||
opencode attach http://localhost:4096
|
||||
opencode auth [command]
|
||||
opencode auth list
|
||||
opencode auth login
|
||||
opencode auth logout
|
||||
opencode auth ls
|
||||
opencode export [sessionID]
|
||||
opencode github [command]
|
||||
opencode github install
|
||||
opencode github run
|
||||
opencode import <file>
|
||||
opencode import https://opncd.ai/s/abc123
|
||||
opencode import session.json
|
||||
opencode mcp [command]
|
||||
opencode mcp add
|
||||
opencode mcp auth [name]
|
||||
opencode mcp auth list
|
||||
opencode mcp auth ls
|
||||
opencode mcp auth my-oauth-server
|
||||
opencode mcp auth sentry
|
||||
opencode mcp debug <name>
|
||||
opencode mcp debug my-oauth-server
|
||||
opencode mcp list
|
||||
opencode mcp logout [name]
|
||||
opencode mcp logout my-oauth-server
|
||||
opencode mcp ls
|
||||
opencode models --refresh
|
||||
opencode models [provider]
|
||||
opencode models anthropic
|
||||
opencode run [message..]
|
||||
opencode run Explain the use of context in Go
|
||||
opencode serve
|
||||
opencode serve --cors http://localhost:5173 --cors https://app.example.com
|
||||
opencode serve --hostname 0.0.0.0 --port 4096
|
||||
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
||||
opencode session [command]
|
||||
opencode session list
|
||||
opencode stats
|
||||
opencode uninstall
|
||||
opencode upgrade
|
||||
opencode upgrade [target]
|
||||
opencode upgrade v0.1.48
|
||||
opencode web
|
||||
opencode web --cors https://example.com
|
||||
opencode web --hostname 0.0.0.0
|
||||
opencode web --mdns
|
||||
opencode web --mdns --mdns-domain myproject.local
|
||||
opencode web --port 4096
|
||||
opencode web --port 4096 --hostname 0.0.0.0
|
||||
opencode.server.close()
|
||||
```
|
||||
|
||||
## Slash commands and routes
|
||||
|
||||
```text
|
||||
/agent
|
||||
/auth/:id
|
||||
/clear
|
||||
/command
|
||||
/config
|
||||
/config/providers
|
||||
/connect
|
||||
/continue
|
||||
/doc
|
||||
/editor
|
||||
/event
|
||||
/experimental/tool?provider=<p>&model=<m>
|
||||
/experimental/tool/ids
|
||||
/export
|
||||
/file?path=<path>
|
||||
/file/content?path=<p>
|
||||
/file/status
|
||||
/find?pattern=<pat>
|
||||
/find/file
|
||||
/find/file?query=<q>
|
||||
/find/symbol?query=<q>
|
||||
/formatter
|
||||
/global/event
|
||||
/global/health
|
||||
/help
|
||||
/init
|
||||
/instance/dispose
|
||||
/log
|
||||
/lsp
|
||||
/mcp
|
||||
/mnt/
|
||||
/mnt/c/
|
||||
/mnt/d/
|
||||
/models
|
||||
/oc
|
||||
/opencode
|
||||
/path
|
||||
/project
|
||||
/project/current
|
||||
/provider
|
||||
/provider/{id}/oauth/authorize
|
||||
/provider/{id}/oauth/callback
|
||||
/provider/auth
|
||||
/q
|
||||
/quit
|
||||
/redo
|
||||
/resume
|
||||
/session
|
||||
/session/:id
|
||||
/session/:id/abort
|
||||
/session/:id/children
|
||||
/session/:id/command
|
||||
/session/:id/diff
|
||||
/session/:id/fork
|
||||
/session/:id/init
|
||||
/session/:id/message
|
||||
/session/:id/message/:messageID
|
||||
/session/:id/permissions/:permissionID
|
||||
/session/:id/prompt_async
|
||||
/session/:id/revert
|
||||
/session/:id/share
|
||||
/session/:id/shell
|
||||
/session/:id/summarize
|
||||
/session/:id/todo
|
||||
/session/:id/unrevert
|
||||
/session/status
|
||||
/share
|
||||
/summarize
|
||||
/theme
|
||||
/tui
|
||||
/tui/append-prompt
|
||||
/tui/clear-prompt
|
||||
/tui/control/next
|
||||
/tui/control/response
|
||||
/tui/execute-command
|
||||
/tui/open-help
|
||||
/tui/open-models
|
||||
/tui/open-sessions
|
||||
/tui/open-themes
|
||||
/tui/show-toast
|
||||
/tui/submit-prompt
|
||||
/undo
|
||||
/Users/username
|
||||
/Users/username/projects/*
|
||||
/vcs
|
||||
```
|
||||
|
||||
## CLI flags and short options
|
||||
|
||||
```text
|
||||
--agent
|
||||
--attach
|
||||
--command
|
||||
--continue
|
||||
--cors
|
||||
--cwd
|
||||
--days
|
||||
--dir
|
||||
--dry-run
|
||||
--event
|
||||
--file
|
||||
--force
|
||||
--fork
|
||||
--format
|
||||
--help
|
||||
--hostname
|
||||
--hostname 0.0.0.0
|
||||
--keep-config
|
||||
--keep-data
|
||||
--log-level
|
||||
--max-count
|
||||
--mdns
|
||||
--mdns-domain
|
||||
--method
|
||||
--model
|
||||
--models
|
||||
--port
|
||||
--print-logs
|
||||
--project
|
||||
--prompt
|
||||
--refresh
|
||||
--session
|
||||
--share
|
||||
--title
|
||||
--token
|
||||
--tools
|
||||
--verbose
|
||||
--version
|
||||
--wait
|
||||
|
||||
-c
|
||||
-d
|
||||
-f
|
||||
-h
|
||||
-m
|
||||
-n
|
||||
-s
|
||||
-v
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
```text
|
||||
AI_API_URL
|
||||
AI_FLOW_CONTEXT
|
||||
AI_FLOW_EVENT
|
||||
AI_FLOW_INPUT
|
||||
AICORE_DEPLOYMENT_ID
|
||||
AICORE_RESOURCE_GROUP
|
||||
AICORE_SERVICE_KEY
|
||||
ANTHROPIC_API_KEY
|
||||
AWS_ACCESS_KEY_ID
|
||||
AWS_BEARER_TOKEN_BEDROCK
|
||||
AWS_PROFILE
|
||||
AWS_REGION
|
||||
AWS_ROLE_ARN
|
||||
AWS_SECRET_ACCESS_KEY
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE
|
||||
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
AZURE_RESOURCE_NAME
|
||||
CI_PROJECT_DIR
|
||||
CI_SERVER_FQDN
|
||||
CI_WORKLOAD_REF
|
||||
CLOUDFLARE_ACCOUNT_ID
|
||||
CLOUDFLARE_API_TOKEN
|
||||
CLOUDFLARE_GATEWAY_ID
|
||||
CONTEXT7_API_KEY
|
||||
GITHUB_TOKEN
|
||||
GITLAB_AI_GATEWAY_URL
|
||||
GITLAB_HOST
|
||||
GITLAB_INSTANCE_URL
|
||||
GITLAB_OAUTH_CLIENT_ID
|
||||
GITLAB_TOKEN
|
||||
GITLAB_TOKEN_OPENCODE
|
||||
GOOGLE_APPLICATION_CREDENTIALS
|
||||
GOOGLE_CLOUD_PROJECT
|
||||
HTTP_PROXY
|
||||
HTTPS_PROXY
|
||||
K2_
|
||||
MY_API_KEY
|
||||
MY_ENV_VAR
|
||||
MY_MCP_CLIENT_ID
|
||||
MY_MCP_CLIENT_SECRET
|
||||
NO_PROXY
|
||||
NODE_ENV
|
||||
NODE_EXTRA_CA_CERTS
|
||||
NPM_AUTH_TOKEN
|
||||
OC_ALLOW_WAYLAND
|
||||
OPENCODE_API_KEY
|
||||
OPENCODE_AUTH_JSON
|
||||
OPENCODE_AUTO_SHARE
|
||||
OPENCODE_CLIENT
|
||||
OPENCODE_CONFIG
|
||||
OPENCODE_CONFIG_CONTENT
|
||||
OPENCODE_CONFIG_DIR
|
||||
OPENCODE_DISABLE_AUTOCOMPACT
|
||||
OPENCODE_DISABLE_AUTOUPDATE
|
||||
OPENCODE_DISABLE_CLAUDE_CODE
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
OPENCODE_DISABLE_FILETIME_CHECK
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD
|
||||
OPENCODE_DISABLE_MODELS_FETCH
|
||||
OPENCODE_DISABLE_PRUNE
|
||||
OPENCODE_DISABLE_TERMINAL_TITLE
|
||||
OPENCODE_ENABLE_EXA
|
||||
OPENCODE_ENABLE_EXPERIMENTAL_MODELS
|
||||
OPENCODE_EXPERIMENTAL
|
||||
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||
OPENCODE_EXPERIMENTAL_EXA
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY
|
||||
OPENCODE_EXPERIMENTAL_LSP_TOOL
|
||||
OPENCODE_EXPERIMENTAL_LSP_TY
|
||||
OPENCODE_EXPERIMENTAL_MARKDOWN
|
||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
||||
OPENCODE_EXPERIMENTAL_OXFMT
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
||||
OPENCODE_FAKE_VCS
|
||||
OPENCODE_GIT_BASH_PATH
|
||||
OPENCODE_MODEL
|
||||
OPENCODE_MODELS_URL
|
||||
OPENCODE_PERMISSION
|
||||
OPENCODE_PORT
|
||||
OPENCODE_SERVER_PASSWORD
|
||||
OPENCODE_SERVER_USERNAME
|
||||
PROJECT_ROOT
|
||||
RESOURCE_NAME
|
||||
RUST_LOG
|
||||
VARIABLE_NAME
|
||||
VERTEX_LOCATION
|
||||
XDG_CONFIG_HOME
|
||||
```
|
||||
|
||||
## Package/module identifiers
|
||||
|
||||
```text
|
||||
../../../config.mjs
|
||||
@astrojs/starlight/components
|
||||
@opencode-ai/plugin
|
||||
@opencode-ai/sdk
|
||||
path
|
||||
shescape
|
||||
zod
|
||||
|
||||
@
|
||||
@ai-sdk/anthropic
|
||||
@ai-sdk/cerebras
|
||||
@ai-sdk/google
|
||||
@ai-sdk/openai
|
||||
@ai-sdk/openai-compatible
|
||||
@File#L37-42
|
||||
@modelcontextprotocol/server-everything
|
||||
@opencode
|
||||
```
|
||||
|
||||
## GitHub owner/repo slugs referenced in docs
|
||||
|
||||
```text
|
||||
24601/opencode-zellij-namer
|
||||
angristan/opencode-wakatime
|
||||
anomalyco/opencode
|
||||
apps/opencode-agent
|
||||
athal7/opencode-devcontainers
|
||||
awesome-opencode/awesome-opencode
|
||||
backnotprop/plannotator
|
||||
ben-vargas/ai-sdk-provider-opencode-sdk
|
||||
btriapitsyn/openchamber
|
||||
BurntSushi/ripgrep
|
||||
Cluster444/agentic
|
||||
code-yeongyu/oh-my-opencode
|
||||
darrenhinde/opencode-agents
|
||||
different-ai/opencode-scheduler
|
||||
different-ai/openwork
|
||||
features/copilot
|
||||
folke/tokyonight.nvim
|
||||
franlol/opencode-md-table-formatter
|
||||
ggml-org/llama.cpp
|
||||
ghoulr/opencode-websearch-cited.git
|
||||
H2Shami/opencode-helicone-session
|
||||
hosenur/portal
|
||||
jamesmurdza/daytona
|
||||
jenslys/opencode-gemini-auth
|
||||
JRedeker/opencode-morph-fast-apply
|
||||
JRedeker/opencode-shell-strategy
|
||||
kdcokenny/ocx
|
||||
kdcokenny/opencode-background-agents
|
||||
kdcokenny/opencode-notify
|
||||
kdcokenny/opencode-workspace
|
||||
kdcokenny/opencode-worktree
|
||||
login/device
|
||||
mohak34/opencode-notifier
|
||||
morhetz/gruvbox
|
||||
mtymek/opencode-obsidian
|
||||
NeuralNomadsAI/CodeNomad
|
||||
nick-vi/opencode-type-inject
|
||||
NickvanDyke/opencode.nvim
|
||||
NoeFabris/opencode-antigravity-auth
|
||||
nordtheme/nord
|
||||
numman-ali/opencode-openai-codex-auth
|
||||
olimorris/codecompanion.nvim
|
||||
panta82/opencode-notificator
|
||||
rebelot/kanagawa.nvim
|
||||
remorses/kimaki
|
||||
sainnhe/everforest
|
||||
shekohex/opencode-google-antigravity-auth
|
||||
shekohex/opencode-pty.git
|
||||
spoons-and-mirrors/subtask2
|
||||
sudo-tee/opencode.nvim
|
||||
supermemoryai/opencode-supermemory
|
||||
Tarquinen/opencode-dynamic-context-pruning
|
||||
Th3Whit3Wolf/one-nvim
|
||||
upstash/context7
|
||||
vtemian/micode
|
||||
vtemian/octto
|
||||
yetone/avante.nvim
|
||||
zenobi-us/opencode-plugin-template
|
||||
zenobi-us/opencode-skillful
|
||||
```
|
||||
|
||||
## Paths, filenames, globs, and URLs
|
||||
|
||||
```text
|
||||
./.opencode/themes/*.json
|
||||
./<project-slug>/storage/
|
||||
./config/#custom-directory
|
||||
./global/storage/
|
||||
.agents/skills/*/SKILL.md
|
||||
.agents/skills/<name>/SKILL.md
|
||||
.clang-format
|
||||
.claude
|
||||
.claude/skills
|
||||
.claude/skills/*/SKILL.md
|
||||
.claude/skills/<name>/SKILL.md
|
||||
.env
|
||||
.github/workflows/opencode.yml
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
.ignore
|
||||
.NET SDK
|
||||
.npmrc
|
||||
.ocamlformat
|
||||
.opencode
|
||||
.opencode/
|
||||
.opencode/agents/
|
||||
.opencode/commands/
|
||||
.opencode/commands/test.md
|
||||
.opencode/modes/
|
||||
.opencode/plans/*.md
|
||||
.opencode/plugins/
|
||||
.opencode/skills/<name>/SKILL.md
|
||||
.opencode/skills/git-release/SKILL.md
|
||||
.opencode/tools/
|
||||
.well-known/opencode
|
||||
{ type: "raw" \| "patch", content: string }
|
||||
{file:path/to/file}
|
||||
**/*.js
|
||||
%USERPROFILE%/intelephense/license.txt
|
||||
%USERPROFILE%\.cache\opencode
|
||||
%USERPROFILE%\.config\opencode\opencode.jsonc
|
||||
%USERPROFILE%\.config\opencode\plugins
|
||||
%USERPROFILE%\.local\share\opencode
|
||||
%USERPROFILE%\.local\share\opencode\log
|
||||
<project-root>/.opencode/themes/*.json
|
||||
<providerId>/<modelId>
|
||||
<your-project>/.opencode/plugins/
|
||||
~
|
||||
~/...
|
||||
~/.agents/skills/*/SKILL.md
|
||||
~/.agents/skills/<name>/SKILL.md
|
||||
~/.aws/credentials
|
||||
~/.bashrc
|
||||
~/.cache/opencode
|
||||
~/.cache/opencode/node_modules/
|
||||
~/.claude/CLAUDE.md
|
||||
~/.claude/skills/
|
||||
~/.claude/skills/*/SKILL.md
|
||||
~/.claude/skills/<name>/SKILL.md
|
||||
~/.config/opencode
|
||||
~/.config/opencode/AGENTS.md
|
||||
~/.config/opencode/agents/
|
||||
~/.config/opencode/commands/
|
||||
~/.config/opencode/modes/
|
||||
~/.config/opencode/opencode.json
|
||||
~/.config/opencode/opencode.jsonc
|
||||
~/.config/opencode/plugins/
|
||||
~/.config/opencode/skills/*/SKILL.md
|
||||
~/.config/opencode/skills/<name>/SKILL.md
|
||||
~/.config/opencode/themes/*.json
|
||||
~/.config/opencode/tools/
|
||||
~/.config/zed/settings.json
|
||||
~/.local/share
|
||||
~/.local/share/opencode/
|
||||
~/.local/share/opencode/auth.json
|
||||
~/.local/share/opencode/log/
|
||||
~/.local/share/opencode/mcp-auth.json
|
||||
~/.local/share/opencode/opencode.jsonc
|
||||
~/.npmrc
|
||||
~/.zshrc
|
||||
~/code/
|
||||
~/Library/Application Support
|
||||
~/projects/*
|
||||
~/projects/personal/
|
||||
${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts
|
||||
$HOME/intelephense/license.txt
|
||||
$HOME/projects/*
|
||||
$XDG_CONFIG_HOME/opencode/themes/*.json
|
||||
agent/
|
||||
agents/
|
||||
build/
|
||||
commands/
|
||||
dist/
|
||||
http://<wsl-ip>:4096
|
||||
http://127.0.0.1:8080/callback
|
||||
http://localhost:<port>
|
||||
http://localhost:4096
|
||||
http://localhost:4096/doc
|
||||
https://app.example.com
|
||||
https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/
|
||||
https://opencode.ai/zen/v1/chat/completions
|
||||
https://opencode.ai/zen/v1/messages
|
||||
https://opencode.ai/zen/v1/models/gemini-3-flash
|
||||
https://opencode.ai/zen/v1/models/gemini-3-pro
|
||||
https://opencode.ai/zen/v1/responses
|
||||
https://RESOURCE_NAME.openai.azure.com/
|
||||
laravel/pint
|
||||
log/
|
||||
model: "anthropic/claude-sonnet-4-5"
|
||||
modes/
|
||||
node_modules/
|
||||
openai/gpt-4.1
|
||||
opencode.ai/config.json
|
||||
opencode/<model-id>
|
||||
opencode/gpt-5.1-codex
|
||||
opencode/gpt-5.2-codex
|
||||
opencode/kimi-k2
|
||||
openrouter/google/gemini-2.5-flash
|
||||
opncd.ai/s/<share-id>
|
||||
packages/*/AGENTS.md
|
||||
plugins/
|
||||
project/
|
||||
provider_id/model_id
|
||||
provider/model
|
||||
provider/model-id
|
||||
rm -rf ~/.cache/opencode
|
||||
skills/
|
||||
skills/*/SKILL.md
|
||||
src/**/*.ts
|
||||
themes/
|
||||
tools/
|
||||
```
|
||||
|
||||
## Keybind strings
|
||||
|
||||
```text
|
||||
alt+b
|
||||
Alt+Ctrl+K
|
||||
alt+d
|
||||
alt+f
|
||||
Cmd+Esc
|
||||
Cmd+Option+K
|
||||
Cmd+Shift+Esc
|
||||
Cmd+Shift+G
|
||||
Cmd+Shift+P
|
||||
ctrl+a
|
||||
ctrl+b
|
||||
ctrl+d
|
||||
ctrl+e
|
||||
Ctrl+Esc
|
||||
ctrl+f
|
||||
ctrl+g
|
||||
ctrl+k
|
||||
Ctrl+Shift+Esc
|
||||
Ctrl+Shift+P
|
||||
ctrl+t
|
||||
ctrl+u
|
||||
ctrl+w
|
||||
ctrl+x
|
||||
DELETE
|
||||
Shift+Enter
|
||||
WIN+R
|
||||
```
|
||||
|
||||
## Model ID strings referenced
|
||||
|
||||
```text
|
||||
{env:OPENCODE_MODEL}
|
||||
anthropic/claude-3-5-sonnet-20241022
|
||||
anthropic/claude-haiku-4-20250514
|
||||
anthropic/claude-haiku-4-5
|
||||
anthropic/claude-sonnet-4-20250514
|
||||
anthropic/claude-sonnet-4-5
|
||||
gitlab/duo-chat-haiku-4-5
|
||||
lmstudio/google/gemma-3n-e4b
|
||||
openai/gpt-4.1
|
||||
openai/gpt-5
|
||||
opencode/gpt-5.1-codex
|
||||
opencode/gpt-5.2-codex
|
||||
opencode/kimi-k2
|
||||
openrouter/google/gemini-2.5-flash
|
||||
```
|
||||
@@ -16,15 +16,12 @@ wip:
|
||||
|
||||
For anything in the packages/web use the docs: prefix.
|
||||
|
||||
For anything in the packages/app use the ignore: prefix.
|
||||
|
||||
prefer to explain WHY something was done from an end user perspective instead of
|
||||
WHAT was done.
|
||||
|
||||
do not do generic messages like "improved agent experience" be very specific
|
||||
about what user facing changes were made
|
||||
|
||||
if there are changes do a git pull --rebase
|
||||
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
|
||||
|
||||
## GIT DIFF
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
// "plugin": ["opencode-openai-codex-auth"],
|
||||
// "enterprise": {
|
||||
// "url": "https://enterprise.dev.opencode.ai",
|
||||
// },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Use this tool to assign and/or label a Github issue.
|
||||
Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
You can assign the following users:
|
||||
- thdxr
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sst-env.d.ts
|
||||
desktop/src/bindings.ts
|
||||
packages/desktop/src/bindings.ts
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
- 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
|
||||
@@ -109,3 +110,4 @@ const table = sqliteTable("session", {
|
||||
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
@@ -258,3 +258,49 @@ These are not strictly enforced, they are just general guidelines:
|
||||
## Feature Requests
|
||||
|
||||
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
||||
|
||||
## Trust & Vouch System
|
||||
|
||||
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
|
||||
|
||||
### How it works
|
||||
|
||||
- **Vouched users** are explicitly trusted contributors.
|
||||
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
|
||||
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
|
||||
|
||||
### For maintainers
|
||||
|
||||
Collaborators with write access can manage the vouch list by commenting on any issue:
|
||||
|
||||
- `vouch` — vouch for the issue author
|
||||
- `vouch @username` — vouch for a specific user
|
||||
- `denounce` — denounce the issue author
|
||||
- `denounce @username` — denounce a specific user
|
||||
- `denounce @username <reason>` — denounce with a reason
|
||||
- `unvouch` / `unvouch @username` — remove someone from the list
|
||||
|
||||
Changes are committed automatically to `.github/VOUCHED.td`.
|
||||
|
||||
### Denouncement policy
|
||||
|
||||
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
|
||||
|
||||
## Issue Requirements
|
||||
|
||||
All issues **must** use one of our issue templates:
|
||||
|
||||
- **Bug report** — for reporting bugs (requires a description)
|
||||
- **Feature request** — for suggesting enhancements (requires verification checkbox and description)
|
||||
- **Question** — for asking questions (requires the question)
|
||||
|
||||
Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed.
|
||||
|
||||
Issues may be flagged for:
|
||||
|
||||
- Not using a template
|
||||
- Required fields left empty or filled with placeholder text
|
||||
- AI-generated walls of text
|
||||
- Missing meaningful content
|
||||
|
||||
If you believe your issue was incorrectly flagged, let a maintainer know.
|
||||
|
||||
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)
|
||||
17
README.md
17
README.md
@@ -27,6 +27,7 @@
|
||||
<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> |
|
||||
@@ -82,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
|
||||
@@ -95,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
|
||||
|
||||
@@ -116,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
|
||||
|
||||
@@ -125,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
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": {
|
||||
|
||||
20
flake.nix
20
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
|
||||
|
||||
@@ -275,7 +275,7 @@ async function assertOpencodeConnected() {
|
||||
body: {
|
||||
service: "github-workflow",
|
||||
level: "info",
|
||||
message: "Prepare to react to Github Workflow event",
|
||||
message: "Prepare to react to GitHub Workflow event",
|
||||
},
|
||||
})
|
||||
connected = true
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-4I0lpBnbAi7IZMURTMLysjrqdsNvXJf8802NrJnpdks=",
|
||||
"aarch64-linux": "sha256-WOGKsPlcQVSbL8TDr1JYO/2ucPTV2Hy0TXJKWv8EoVw=",
|
||||
"aarch64-darwin": "sha256-LuvjwGm1QsHoLxuvSSp4VsDIv02Z/rTONsU32arQMuw=",
|
||||
"x86_64-darwin": "sha256-AbglfgCWj/r+wHfle+e+D3b/xPcwwg4IK7j5iwn9nzw="
|
||||
"x86_64-linux": "sha256-cvRBvHRuunNjF07c4GVHl5rRgoTn1qfI/HdJWtOV63M=",
|
||||
"aarch64-linux": "sha256-DJUI4pMZ7wQTnyOiuDHALmZz7FZtrTbzRzCuNOShmWE=",
|
||||
"aarch64-darwin": "sha256-JnkqDwuC7lNsjafV+jOGfvs8K1xC8rk5CTOW+spjiCA=",
|
||||
"x86_64-darwin": "sha256-GBeTqq2vDn/mXplYNglrAT2xajjFVzB4ATHnMS0j7z4="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ stdenvNoCC.mkDerivation {
|
||||
../bun.lock
|
||||
../package.json
|
||||
../patches
|
||||
../install
|
||||
../install # required by desktop build (cli.rs include_str!)
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,6 +40,8 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -99,6 +103,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
[test]
|
||||
root = "./src"
|
||||
preload = ["./happydom.ts"]
|
||||
|
||||
@@ -21,7 +21,12 @@ import {
|
||||
import type { createSdk } from "./utils"
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page.mouse.click(5, 5)
|
||||
await page
|
||||
.evaluate(() => {
|
||||
const el = document.activeElement
|
||||
if (el instanceof HTMLElement) el.blur()
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
@@ -68,14 +73,50 @@ export async function toggleSidebar(page: Page) {
|
||||
|
||||
export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const 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(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||
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(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(main).toHaveClass(/xl:border-l/)
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
@@ -182,13 +223,30 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
}
|
||||
|
||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
const sessionEl = await hoverSessionItem(page, sessionID)
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||
|
||||
const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
|
||||
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()
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as base, expect } from "@playwright/test"
|
||||
import { seedProjects } from "./actions"
|
||||
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"
|
||||
|
||||
@@ -8,6 +8,14 @@ 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 = {
|
||||
@@ -33,17 +41,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use(createSdk(directory))
|
||||
},
|
||||
gotoSession: async ({ page, directory }, use) => {
|
||||
await seedProjects(page, { directory })
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||
user: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
await seedStorage(page, { directory })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
@@ -51,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,52 +1,53 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar } from "../actions"
|
||||
|
||||
test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await openSidebar(page)
|
||||
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 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 })
|
||||
await expect(header).toContainText(name)
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,69 +1,73 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||
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, directory, gotoSession }) => {
|
||||
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)
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
await openSidebar(page)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.hover()
|
||||
|
||||
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()
|
||||
|
||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||
await expect(close).toBeVisible()
|
||||
await close.click()
|
||||
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => {
|
||||
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)
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
await openSidebar(page)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
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 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 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 })
|
||||
|
||||
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)
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
|
||||
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => {
|
||||
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)
|
||||
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await withProject(
|
||||
async ({ directory }) => {
|
||||
await defocus(page)
|
||||
|
||||
await defocus(page)
|
||||
const currentSlug = dirSlug(directory)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
|
||||
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`))
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||
await expect(currentButton).toBeVisible()
|
||||
await currentButton.click()
|
||||
|
||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||
await expect(currentButton).toBeVisible()
|
||||
await currentButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
|
||||
140
packages/app/e2e/projects/workspace-new-session.spec.ts
Normal file
140
packages/app/e2e/projects/workspace-new-session.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||
await openSidebar(page)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await openSidebar(page)
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (slug === root) return ""
|
||||
if (seen.includes(slug)) return ""
|
||||
return slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
return { slug, directory }
|
||||
}
|
||||
|
||||
async function openWorkspaceNewSession(page: Page, slug: string) {
|
||||
await waitWorkspaceReady(page, slug)
|
||||
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
await item.hover()
|
||||
|
||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click({ force: true })
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||
await openWorkspaceNewSession(page, slug)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.click()
|
||||
await page.keyboard.type(text)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
|
||||
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
return sessionID
|
||||
}
|
||||
|
||||
async function sessionDirectory(directory: string, sessionID: string) {
|
||||
const info = await createSdk(directory)
|
||||
.session.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!info) return ""
|
||||
return info.directory
|
||||
}
|
||||
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ directory, slug: root }) => {
|
||||
const workspaces = [] as { slug: string; directory: string }[]
|
||||
const sessions = [] as string[]
|
||||
|
||||
try {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
|
||||
const first = await createWorkspace(page, root, [])
|
||||
workspaces.push(first)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
workspaces.push(second)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
sessions.push(firstSession)
|
||||
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
sessions.push(secondSession)
|
||||
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
sessions.push(thirdSession)
|
||||
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
|
||||
} finally {
|
||||
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
|
||||
await Promise.all(
|
||||
sessions.map((sessionID) =>
|
||||
Promise.all(
|
||||
dirs.map((dir) =>
|
||||
createSdk(dir)
|
||||
.session.delete({ sessionID })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -10,33 +10,20 @@ import {
|
||||
cleanupTestProject,
|
||||
clickMenuItem,
|
||||
confirmDialog,
|
||||
createTestProject,
|
||||
openSidebar,
|
||||
openWorkspaceMenu,
|
||||
seedProjects,
|
||||
setWorkspacesEnabled,
|
||||
} from "../actions"
|
||||
import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) {
|
||||
const project = await createTestProject()
|
||||
const rootSlug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
await gotoSession()
|
||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
const rootSlug = project.slug
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(rootSlug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
@@ -70,25 +57,13 @@ async function setupWorkspaceTest(page: Page, directory: string, gotoSession: ()
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
return { project, rootSlug, slug, directory: dir }
|
||||
return { rootSlug, slug, directory: dir }
|
||||
}
|
||||
|
||||
test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => {
|
||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const project = await createTestProject()
|
||||
const slug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
|
||||
|
||||
await withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
@@ -101,27 +76,13 @@ test("can enable and disable workspaces from project menu", async ({ page, direc
|
||||
await setWorkspacesEnabled(page, slug, false)
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can create a workspace", async ({ page, directory, gotoSession }) => {
|
||||
test("can create a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const project = await createTestProject()
|
||||
const slug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
|
||||
|
||||
await withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
|
||||
@@ -162,17 +123,15 @@ test("can create a workspace", async ({ page, directory, gotoSession }) => {
|
||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
||||
|
||||
await cleanupTestProject(workspaceDir)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can rename a workspace", async ({ page, directory, gotoSession }) => {
|
||||
test("can rename a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession)
|
||||
await withProject(async (project) => {
|
||||
const { slug } = await setupWorkspaceTest(page, project)
|
||||
|
||||
try {
|
||||
const rename = `e2e workspace ${Date.now()}`
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||
@@ -186,17 +145,15 @@ test("can rename a workspace", async ({ page, directory, gotoSession }) => {
|
||||
await input.fill(rename)
|
||||
await input.press("Enter")
|
||||
await expect(item).toContainText(rename)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
|
||||
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession)
|
||||
await withProject(async (project) => {
|
||||
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||
|
||||
try {
|
||||
const readme = path.join(createdDir, "README.md")
|
||||
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||
const original = await fs.readFile(readme, "utf8")
|
||||
@@ -250,17 +207,15 @@ test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
|
||||
.catch(() => false)
|
||||
})
|
||||
.toBe(false)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can delete a workspace", async ({ page, directory, gotoSession }) => {
|
||||
test("can delete a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession)
|
||||
await withProject(async (project) => {
|
||||
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
||||
|
||||
try {
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
await confirmDialog(page, /^Delete workspace$/i)
|
||||
@@ -268,124 +223,111 @@ test("can delete a workspace", async ({ page, directory, gotoSession }) => {
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => {
|
||||
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 project = await createTestProject()
|
||||
const rootSlug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
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 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 gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(rootSlug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
|
||||
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()
|
||||
const waitReady = async (slug: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const dir = base64Decode(slug)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
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)))
|
||||
}
|
||||
|
||||
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)))
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,6 +48,9 @@ export const workspaceItemSelector = (slug: string) =>
|
||||
export const workspaceMenuTriggerSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
|
||||
|
||||
export const workspaceNewSessionSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
|
||||
|
||||
export const listItemSelector = '[data-slot="list-item"]'
|
||||
|
||||
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
||||
|
||||
126
packages/app/e2e/session/session-undo-redo.spec.ts
Normal file
126
packages/app/e2e/session/session-undo-redo.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { withSession } from "../actions"
|
||||
import { createSdk, modKey } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
async function seedConversation(input: {
|
||||
page: Page
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
sessionID: string
|
||||
token: string
|
||||
}) {
|
||||
const prompt = input.page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.click()
|
||||
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
|
||||
await input.page.keyboard.press("Enter")
|
||||
|
||||
let userMessageID: string | undefined
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await input.sdk.session
|
||||
.messages({ sessionID: input.sessionID, limit: 50 })
|
||||
.then((r) => r.data ?? [])
|
||||
const users = messages.filter((m) => m.info.role === "user")
|
||||
if (users.length === 0) return false
|
||||
|
||||
const user = users.reduce((acc, item) => (item.info.id > acc.info.id ? item : acc))
|
||||
userMessageID = user.info.id
|
||||
|
||||
const assistantText = messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.flatMap((m) => m.parts)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
|
||||
return assistantText.includes(input.token)
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
if (!userMessageID) throw new Error("Expected a user message id")
|
||||
return { prompt, userMessageID }
|
||||
}
|
||||
|
||||
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `undo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||
|
||||
await seeded.prompt.click()
|
||||
await page.keyboard.type("/undo")
|
||||
|
||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||
await expect(undo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(seeded.userMessageID)
|
||||
|
||||
await expect(seeded.prompt).toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `redo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||
|
||||
await seeded.prompt.click()
|
||||
await page.keyboard.type("/undo")
|
||||
|
||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||
await expect(undo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(seeded.userMessageID)
|
||||
|
||||
await seeded.prompt.click()
|
||||
await page.keyboard.press(`${modKey}+A`)
|
||||
await page.keyboard.press("Backspace")
|
||||
await page.keyboard.type("/redo")
|
||||
|
||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||
await expect(redo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(seeded.prompt).not.toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,57 +11,98 @@ import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => {
|
||||
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)
|
||||
await openSidebar(page)
|
||||
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first()
|
||||
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(newTitle)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
|
||||
})
|
||||
})
|
||||
|
||||
test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => {
|
||||
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)
|
||||
await openSidebar(page)
|
||||
|
||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
|
||||
await expect(sessionEl).not.toBeVisible()
|
||||
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("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => {
|
||||
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)
|
||||
await openSidebar(page)
|
||||
|
||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
|
||||
await expect(sessionEl).not.toBeVisible()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,6 +113,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, withSession } from "../actions"
|
||||
import { keybindButtonSelector } from "../selectors"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||
@@ -267,11 +267,14 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await page.waitForTimeout(100)
|
||||
const terminal = page.locator(terminalSelector)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
const pageStable = await page.evaluate(() => document.readyState === "complete")
|
||||
expect(pageStable).toBe(true)
|
||||
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 }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openStatusPopover, defocus } from "../actions"
|
||||
import { openStatusPopover } from "../actions"
|
||||
|
||||
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -88,7 +88,7 @@ test("status popover closes when clicking outside", async ({ page, gotoSession }
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
await expect(popoverBody).toBeVisible()
|
||||
|
||||
await defocus(page)
|
||||
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
|
||||
|
||||
await expect(popoverBody).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.48",
|
||||
"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:",
|
||||
|
||||
@@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const win = process.platform === "win32"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -15,8 +14,7 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: !win,
|
||||
workers: win ? 1 : undefined,
|
||||
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,6 +55,7 @@ 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,
|
||||
@@ -83,58 +84,95 @@ 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
|
||||
|
||||
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) => {
|
||||
console.warn(`e2e-local ignored server error: ${reason}`)
|
||||
console.warn(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)
|
||||
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,12 +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>
|
||||
gutter?: number
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
}) {
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
@@ -176,14 +176,10 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
}}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
gutter={props.gutter ?? 8}
|
||||
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
|
||||
@@ -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">
|
||||
|
||||
78
packages/app/src/components/file-tree.test.ts
Normal file
78
packages/app/src/components/file-tree.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useFile } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
createMemo,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
@@ -18,6 +20,10 @@ import {
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function pathToFileUrl(filepath: string): string {
|
||||
return `file://${encodeFilePath(filepath)}`
|
||||
}
|
||||
|
||||
type Kind = "add" | "del" | "mix"
|
||||
|
||||
type Filter = {
|
||||
@@ -25,6 +31,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 +145,89 @@ 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)
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
const Node = (
|
||||
@@ -160,7 +254,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 +288,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 +315,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 +330,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 +368,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?.() ?? Math.random().toString(16).slice(2),
|
||||
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]:/)
|
||||
}
|
||||
})
|
||||
|
||||
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:/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("/..")
|
||||
}
|
||||
})
|
||||
})
|
||||
179
packages/app/src/components/prompt-input/build-request-parts.ts
Normal file
179
packages/app/src/components/prompt-input/build-request-parts.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
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 { encodeFilePath } from "@/context/file/path"
|
||||
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) => {
|
||||
if (path.startsWith("/")) return path
|
||||
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
||||
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
||||
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
135
packages/app/src/components/prompt-input/editor-dom.ts
Normal file
135
packages/app/src/components/prompt-input/editor-dom.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
export function createTextFragment(content: string): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment()
|
||||
const segments = content.split("\n")
|
||||
segments.forEach((segment, index) => {
|
||||
if (segment) {
|
||||
fragment.appendChild(document.createTextNode(segment))
|
||||
} else if (segments.length > 1) {
|
||||
fragment.appendChild(document.createTextNode("\u200B"))
|
||||
}
|
||||
if (index < segments.length - 1) {
|
||||
fragment.appendChild(document.createElement("br"))
|
||||
}
|
||||
})
|
||||
return fragment
|
||||
}
|
||||
|
||||
export function getNodeLength(node: Node): number {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
||||
return (node.textContent ?? "").replace(/\u200B/g, "").length
|
||||
}
|
||||
|
||||
export function getTextLength(node: Node): number {
|
||||
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
|
||||
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
||||
let length = 0
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
length += getTextLength(child)
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
export function getCursorPosition(parent: HTMLElement): number {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return 0
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!parent.contains(range.startContainer)) return 0
|
||||
const preCaretRange = range.cloneRange()
|
||||
preCaretRange.selectNodeContents(parent)
|
||||
preCaretRange.setEnd(range.startContainer, range.startOffset)
|
||||
return getTextLength(preCaretRange.cloneContents())
|
||||
}
|
||||
|
||||
export function setCursorPosition(parent: HTMLElement, position: number) {
|
||||
let remaining = position
|
||||
let node = parent.firstChild
|
||||
while (node) {
|
||||
const length = getNodeLength(node)
|
||||
const isText = node.nodeType === Node.TEXT_NODE
|
||||
const isPill =
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||
|
||||
if (isText && remaining <= length) {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
range.setStart(node, remaining)
|
||||
range.collapse(true)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
return
|
||||
}
|
||||
|
||||
if ((isPill || isBreak) && remaining <= length) {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
if (remaining === 0) {
|
||||
range.setStartBefore(node)
|
||||
}
|
||||
if (remaining > 0 && isPill) {
|
||||
range.setStartAfter(node)
|
||||
}
|
||||
if (remaining > 0 && isBreak) {
|
||||
const next = node.nextSibling
|
||||
if (next && next.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(next, 0)
|
||||
}
|
||||
if (!next || next.nodeType !== Node.TEXT_NODE) {
|
||||
range.setStartAfter(node)
|
||||
}
|
||||
}
|
||||
range.collapse(true)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
return
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
node = node.nextSibling
|
||||
}
|
||||
|
||||
const fallbackRange = document.createRange()
|
||||
const fallbackSelection = window.getSelection()
|
||||
const last = parent.lastChild
|
||||
if (last && last.nodeType === Node.TEXT_NODE) {
|
||||
const len = last.textContent ? last.textContent.length : 0
|
||||
fallbackRange.setStart(last, len)
|
||||
}
|
||||
if (!last || last.nodeType !== Node.TEXT_NODE) {
|
||||
fallbackRange.selectNodeContents(parent)
|
||||
}
|
||||
fallbackRange.collapse(false)
|
||||
fallbackSelection?.removeAllRanges()
|
||||
fallbackSelection?.addRange(fallbackRange)
|
||||
}
|
||||
|
||||
export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
|
||||
let remaining = offset
|
||||
const nodes = Array.from(parent.childNodes)
|
||||
|
||||
for (const node of nodes) {
|
||||
const length = getNodeLength(node)
|
||||
const isText = node.nodeType === Node.TEXT_NODE
|
||||
const isPill =
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||
|
||||
if (isText && remaining <= length) {
|
||||
if (edge === "start") range.setStart(node, remaining)
|
||||
if (edge === "end") range.setEnd(node, remaining)
|
||||
return
|
||||
}
|
||||
|
||||
if ((isPill || isBreak) && remaining <= length) {
|
||||
if (edge === "start" && remaining === 0) range.setStartBefore(node)
|
||||
if (edge === "start" && remaining > 0) range.setStartAfter(node)
|
||||
if (edge === "end" && remaining === 0) range.setEndBefore(node)
|
||||
if (edge === "end" && remaining > 0) range.setEndAfter(node)
|
||||
return
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
}
|
||||
}
|
||||
69
packages/app/src/components/prompt-input/history.test.ts
Normal file
69
packages/app/src/components/prompt-input/history.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||
|
||||
describe("prompt-input history", () => {
|
||||
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
|
||||
const first = prependHistoryEntry([], DEFAULT_PROMPT)
|
||||
expect(first).toEqual([])
|
||||
|
||||
const withOne = prependHistoryEntry([], text("hello"))
|
||||
expect(withOne).toHaveLength(1)
|
||||
|
||||
const deduped = prependHistoryEntry(withOne, text("hello"))
|
||||
expect(deduped).toBe(withOne)
|
||||
})
|
||||
|
||||
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
||||
const entries = [text("third"), text("second"), text("first")]
|
||||
const up = navigatePromptHistory({
|
||||
direction: "up",
|
||||
entries,
|
||||
historyIndex: -1,
|
||||
currentPrompt: text("draft"),
|
||||
savedPrompt: null,
|
||||
})
|
||||
expect(up.handled).toBe(true)
|
||||
if (!up.handled) throw new Error("expected handled")
|
||||
expect(up.historyIndex).toBe(0)
|
||||
expect(up.cursor).toBe("start")
|
||||
|
||||
const down = navigatePromptHistory({
|
||||
direction: "down",
|
||||
entries,
|
||||
historyIndex: up.historyIndex,
|
||||
currentPrompt: text("ignored"),
|
||||
savedPrompt: up.savedPrompt,
|
||||
})
|
||||
expect(down.handled).toBe(true)
|
||||
if (!down.handled) throw new Error("expected handled")
|
||||
expect(down.historyIndex).toBe(-1)
|
||||
expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
|
||||
})
|
||||
|
||||
test("helpers clone prompt and count text content length", () => {
|
||||
const original: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/a.ts",
|
||||
content: "@src/a.ts",
|
||||
start: 3,
|
||||
end: 12,
|
||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||
},
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
|
||||
]
|
||||
const copy = clonePromptParts(original)
|
||||
expect(copy).not.toBe(original)
|
||||
expect(promptLength(copy)).toBe(12)
|
||||
if (copy[1]?.type !== "file") throw new Error("expected file")
|
||||
copy[1].selection!.startLine = 9
|
||||
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||
expect(original[1].selection?.startLine).toBe(1)
|
||||
})
|
||||
})
|
||||
160
packages/app/src/components/prompt-input/history.ts
Normal file
160
packages/app/src/components/prompt-input/history.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export const MAX_HISTORY = 100
|
||||
|
||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) => {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: part.selection ? { ...part.selection } : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
|
||||
const text = prompt
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
.trim()
|
||||
const hasImages = prompt.some((part) => part.type === "image")
|
||||
if (!text && !hasImages) return entries
|
||||
|
||||
const entry = clonePromptParts(prompt)
|
||||
const last = entries[0]
|
||||
if (last && isPromptEqual(last, entry)) return entries
|
||||
return [entry, ...entries].slice(0, max)
|
||||
}
|
||||
|
||||
function isPromptEqual(promptA: Prompt, promptB: Prompt) {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
const partA = promptA[i]
|
||||
const partB = promptB[i]
|
||||
if (partA.type !== partB.type) return false
|
||||
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
|
||||
if (partA.type === "file") {
|
||||
if (partA.path !== (partB.type === "file" ? partB.path : "")) return false
|
||||
const a = partA.selection
|
||||
const b = partB.type === "file" ? partB.selection : undefined
|
||||
const sameSelection =
|
||||
(!a && !b) ||
|
||||
(!!a &&
|
||||
!!b &&
|
||||
a.startLine === b.startLine &&
|
||||
a.startChar === b.startChar &&
|
||||
a.endLine === b.endLine &&
|
||||
a.endChar === b.endChar)
|
||||
if (!sameSelection) return false
|
||||
}
|
||||
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
|
||||
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type HistoryNavInput = {
|
||||
direction: "up" | "down"
|
||||
entries: Prompt[]
|
||||
historyIndex: number
|
||||
currentPrompt: Prompt
|
||||
savedPrompt: Prompt | null
|
||||
}
|
||||
|
||||
type HistoryNavResult =
|
||||
| {
|
||||
handled: false
|
||||
historyIndex: number
|
||||
savedPrompt: Prompt | null
|
||||
}
|
||||
| {
|
||||
handled: true
|
||||
historyIndex: number
|
||||
savedPrompt: Prompt | null
|
||||
prompt: Prompt
|
||||
cursor: "start" | "end"
|
||||
}
|
||||
|
||||
export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
|
||||
if (input.direction === "up") {
|
||||
if (input.entries.length === 0) {
|
||||
return {
|
||||
handled: false,
|
||||
historyIndex: input.historyIndex,
|
||||
savedPrompt: input.savedPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex === -1) {
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: 0,
|
||||
savedPrompt: clonePromptParts(input.currentPrompt),
|
||||
prompt: input.entries[0],
|
||||
cursor: "start",
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex < input.entries.length - 1) {
|
||||
const next = input.historyIndex + 1
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
savedPrompt: input.savedPrompt,
|
||||
prompt: input.entries[next],
|
||||
cursor: "start",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: false,
|
||||
historyIndex: input.historyIndex,
|
||||
savedPrompt: input.savedPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex > 0) {
|
||||
const next = input.historyIndex - 1
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
savedPrompt: input.savedPrompt,
|
||||
prompt: input.entries[next],
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex === 0) {
|
||||
if (input.savedPrompt) {
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
prompt: input.savedPrompt,
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
prompt: DEFAULT_PROMPT,
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: false,
|
||||
historyIndex: input.historyIndex,
|
||||
savedPrompt: input.savedPrompt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||
|
||||
type PromptImageAttachmentsProps = {
|
||||
attachments: ImageAttachmentPart[]
|
||||
onOpen: (attachment: ImageAttachmentPart) => void
|
||||
onRemove: (id: string) => void
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.attachments.length > 0}>
|
||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group">
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.filename}
|
||||
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||
onClick={() => props.onOpen(attachment)}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemove(attachment.id)}
|
||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-text-weak" />
|
||||
</button>
|
||||
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
35
packages/app/src/components/prompt-input/placeholder.test.ts
Normal file
35
packages/app/src/components/prompt-input/placeholder.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promptPlaceholder } from "./placeholder"
|
||||
|
||||
describe("promptPlaceholder", () => {
|
||||
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
|
||||
|
||||
test("returns shell placeholder in shell mode", () => {
|
||||
const value = promptPlaceholder({
|
||||
mode: "shell",
|
||||
commentCount: 0,
|
||||
example: "example",
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.shell")
|
||||
})
|
||||
|
||||
test("returns summarize placeholders for comment context", () => {
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
|
||||
"prompt.placeholder.summarizeComment",
|
||||
)
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
|
||||
"prompt.placeholder.summarizeComments",
|
||||
)
|
||||
})
|
||||
|
||||
test("returns default placeholder with example", () => {
|
||||
const value = promptPlaceholder({
|
||||
mode: "normal",
|
||||
commentCount: 0,
|
||||
example: "translated-example",
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.normal:translated-example")
|
||||
})
|
||||
})
|
||||
13
packages/app/src/components/prompt-input/placeholder.ts
Normal file
13
packages/app/src/components/prompt-input/placeholder.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
type PromptPlaceholderInput = {
|
||||
mode: "normal" | "shell"
|
||||
commentCount: number
|
||||
example: string
|
||||
t: (key: string, params?: Record<string, string>) => string
|
||||
}
|
||||
|
||||
export function promptPlaceholder(input: PromptPlaceholderInput) {
|
||||
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
|
||||
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
|
||||
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
|
||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||
}
|
||||
144
packages/app/src/components/prompt-input/slash-popover.tsx
Normal file
144
packages/app/src/components/prompt-input/slash-popover.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Component, For, Match, Show, Switch } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
export type AtOption =
|
||||
| { type: "agent"; name: string; display: string }
|
||||
| { type: "file"; path: string; display: string; recent?: boolean }
|
||||
|
||||
export interface SlashCommand {
|
||||
id: string
|
||||
trigger: string
|
||||
title: string
|
||||
description?: string
|
||||
keybind?: string
|
||||
type: "builtin" | "custom"
|
||||
source?: "command" | "mcp" | "skill"
|
||||
}
|
||||
|
||||
type PromptPopoverProps = {
|
||||
popover: "at" | "slash" | null
|
||||
setSlashPopoverRef: (el: HTMLDivElement) => void
|
||||
atFlat: AtOption[]
|
||||
atActive?: string
|
||||
atKey: (item: AtOption) => string
|
||||
setAtActive: (id: string) => void
|
||||
onAtSelect: (item: AtOption) => void
|
||||
slashFlat: SlashCommand[]
|
||||
slashActive?: string
|
||||
setSlashActive: (id: string) => void
|
||||
onSlashSelect: (item: SlashCommand) => void
|
||||
commandKeybind: (id: string) => string | undefined
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.popover}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||
}}
|
||||
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
|
||||
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.popover === "at"}>
|
||||
<Show
|
||||
when={props.atFlat.length > 0}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
||||
>
|
||||
<For each={props.atFlat.slice(0, 10)}>
|
||||
{(item) => (
|
||||
<button
|
||||
classList={{
|
||||
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
||||
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
|
||||
}}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onMouseEnter={() => props.setAtActive(props.atKey(item))}
|
||||
>
|
||||
<Show
|
||||
when={item.type === "agent"}
|
||||
fallback={
|
||||
<>
|
||||
<FileIcon
|
||||
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
|
||||
{item.type === "file"
|
||||
? item.path.endsWith("/")
|
||||
? item.path
|
||||
: getDirectory(item.path)
|
||||
: ""}
|
||||
</span>
|
||||
<Show when={item.type === "file" && !item.path.endsWith("/")}>
|
||||
<span class="text-text-strong whitespace-nowrap">
|
||||
{item.type === "file" ? getFilename(item.path) : ""}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
||||
@{item.type === "agent" ? item.name : ""}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.popover === "slash"}>
|
||||
<Show
|
||||
when={props.slashFlat.length > 0}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
|
||||
>
|
||||
<For each={props.slashFlat}>
|
||||
{(cmd) => (
|
||||
<button
|
||||
data-slash-id={cmd.id}
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
||||
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
|
||||
}}
|
||||
onClick={() => props.onSlashSelect(cmd)}
|
||||
onMouseEnter={() => props.setSlashActive(cmd.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
||||
<Show when={cmd.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
||||
{cmd.source === "skill"
|
||||
? props.t("prompt.slash.badge.skill")
|
||||
: cmd.source === "mcp"
|
||||
? props.t("prompt.slash.badge.mcp")
|
||||
: props.t("prompt.slash.badge.custom")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.commandKeybind(cmd.id)}>
|
||||
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
175
packages/app/src/components/prompt-input/submit.test.ts
Normal file
175
packages/app/src/components/prompt-input/submit.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
|
||||
let selected = "/repo/worktree-a"
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
|
||||
const clientFor = (directory: string) => ({
|
||||
session: {
|
||||
create: async () => {
|
||||
createdSessions.push(directory)
|
||||
return { data: { id: `session-${createdSessions.length}` } }
|
||||
},
|
||||
shell: async () => {
|
||||
sentShell.push(directory)
|
||||
return { data: undefined }
|
||||
},
|
||||
prompt: async () => ({ data: undefined }),
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
},
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: (input: { directory: string }) => {
|
||||
createdClients.push(input.directory)
|
||||
return clientFor(input.directory)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/util/encode", () => ({
|
||||
base64Encode: (value: string) => value,
|
||||
}))
|
||||
|
||||
mock.module("@/context/local", () => ({
|
||||
useLocal: () => ({
|
||||
model: {
|
||||
current: () => ({ id: "model", provider: { id: "provider" } }),
|
||||
variant: { current: () => undefined },
|
||||
},
|
||||
agent: {
|
||||
current: () => ({ name: "agent" }),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => ({
|
||||
current: () => promptValue,
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/layout", () => ({
|
||||
useLayout: () => ({
|
||||
handoff: {
|
||||
setTabs: () => undefined,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => ({
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
url: "http://localhost:4096",
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => ({
|
||||
data: { command: [] },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/global-sync", () => ({
|
||||
useGlobalSync: () => ({
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
return [{}, () => undefined]
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
usePlatform: () => ({
|
||||
fetch: fetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/language", () => ({
|
||||
useLanguage: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mod = await import("./submit")
|
||||
createPromptSubmit = mod.createPromptSubmit
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
})
|
||||
})
|
||||
417
packages/app/src/components/prompt-input/submit.ts
Normal file
417
packages/app/src/components/prompt-input/submit.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { Accessor } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
cleanup: VoidFunction
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingPrompt>()
|
||||
|
||||
type PromptSubmitInput = {
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
commentCount: Accessor<number>
|
||||
mode: Accessor<"normal" | "shell">
|
||||
working: Accessor<boolean>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
promptLength: (prompt: Prompt) => number
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
resetHistoryNavigation: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
setPopover: (popover: "at" | "slash" | null) => void
|
||||
newSessionWorktree?: Accessor<string | undefined>
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
type CommentItem = {
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const platform = usePlatform()
|
||||
const local = useLocal()
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
const queued = pending.get(sessionID)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
pending.delete(sessionID)
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk.client.session
|
||||
.abort({
|
||||
sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const restoreCommentItems = (items: CommentItem[]) => {
|
||||
for (const item of items) {
|
||||
prompt.context.add({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const removeCommentItems = (items: { key: string }[]) => {
|
||||
for (const item of items) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
const currentPrompt = prompt.current()
|
||||
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const images = input.imageAttachments().slice()
|
||||
const mode = input.mode()
|
||||
|
||||
if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
|
||||
if (input.working()) abort()
|
||||
return
|
||||
}
|
||||
|
||||
const currentModel = local.model.current()
|
||||
const currentAgent = local.agent.current()
|
||||
if (!currentModel || !currentAgent) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||
description: language.t("prompt.toast.modelAgentRequired.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk.directory
|
||||
const isNewSession = !params.id
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk.client
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await client.worktree
|
||||
.create({ directory: projectDirectory })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree?.directory) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: language.t("common.requestFailed"),
|
||||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: platform.fetch,
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
globalSync.child(sessionDirectory)
|
||||
}
|
||||
|
||||
input.onNewSessionWorktreeReset?.()
|
||||
}
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
session = await client.session
|
||||
.create()
|
||||
.then((x) => x.data ?? undefined)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (session) {
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const variant = local.model.variant.current()
|
||||
|
||||
const clearInput = () => {
|
||||
prompt.reset()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
prompt.set(currentPrompt, input.promptLength(currentPrompt))
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
}
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
client.session
|
||||
.shell({
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = sync.data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
client.session
|
||||
.command({
|
||||
sessionID: session.id,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const context = prompt.context.items().slice()
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
images,
|
||||
text,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
sessionDirectory,
|
||||
})
|
||||
|
||||
const optimisticMessage: Message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent,
|
||||
model,
|
||||
}
|
||||
|
||||
const addOptimisticMessage = () =>
|
||||
sync.session.optimistic.add({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
message: optimisticMessage,
|
||||
parts: optimisticParts,
|
||||
})
|
||||
|
||||
const removeOptimisticMessage = () =>
|
||||
sync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
addOptimisticMessage()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
restoreInput()
|
||||
}
|
||||
|
||||
pending.set(session.id, { abort: controller, cleanup })
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(session.id)
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
return true
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const ok = await waitForWorktree()
|
||||
if (!ok) return
|
||||
await client.session.prompt({
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
void send().catch((err) => {
|
||||
pending.delete(session.id)
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
restoreInput()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
abort,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
295
packages/app/src/components/question-dock.tsx
Normal file
295
packages/app/src/components/question-dock.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { For, Show, createMemo, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as QuestionAnswer[],
|
||||
custom: [] as string[],
|
||||
editing: false,
|
||||
sending: false,
|
||||
})
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const customPicked = createMemo(() => {
|
||||
const value = input()
|
||||
if (!value) return false
|
||||
return store.answers[store.tab]?.includes(value) ?? false
|
||||
})
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
}
|
||||
|
||||
const reply = (answers: QuestionAnswer[]) => {
|
||||
if (store.sending) return
|
||||
|
||||
setStore("sending", true)
|
||||
sdk.client.question
|
||||
.reply({ requestID: props.request.id, answers })
|
||||
.catch(fail)
|
||||
.finally(() => setStore("sending", false))
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
if (store.sending) return
|
||||
|
||||
setStore("sending", true)
|
||||
sdk.client.question
|
||||
.reject({ requestID: props.request.id })
|
||||
.catch(fail)
|
||||
.finally(() => setStore("sending", false))
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
}
|
||||
|
||||
const pick = (answer: string, custom: boolean = false) => {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = [answer]
|
||||
setStore("answers", answers)
|
||||
|
||||
if (custom) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = answer
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
|
||||
if (single()) {
|
||||
reply([[answer]])
|
||||
return
|
||||
}
|
||||
|
||||
setStore("tab", store.tab + 1)
|
||||
}
|
||||
|
||||
const toggle = (answer: string) => {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
const index = next.indexOf(answer)
|
||||
if (index === -1) next.push(answer)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
}
|
||||
|
||||
const selectTab = (index: number) => {
|
||||
setStore("tab", index)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const selectOption = (optIndex: number) => {
|
||||
if (store.sending) return
|
||||
|
||||
if (optIndex === options().length) {
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
|
||||
const opt = options()[optIndex]
|
||||
if (!opt) return
|
||||
if (multi()) {
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
pick(opt.label)
|
||||
}
|
||||
|
||||
const handleCustomSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
if (store.sending) return
|
||||
|
||||
const value = input().trim()
|
||||
if (!value) {
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
if (multi()) {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
if (!next.includes(value)) next.push(value)
|
||||
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
pick(value, true)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="question-prompt">
|
||||
<Show when={!single()}>
|
||||
<div data-slot="question-tabs">
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const active = () => index() === store.tab
|
||||
const answered = () => (store.answers[index()]?.length ?? 0) > 0
|
||||
return (
|
||||
<button
|
||||
data-slot="question-tab"
|
||||
data-active={active()}
|
||||
data-answered={answered()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectTab(index())}
|
||||
>
|
||||
{q.header}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<button
|
||||
data-slot="question-tab"
|
||||
data-active={confirm()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectTab(questions().length)}
|
||||
>
|
||||
{language.t("ui.common.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!confirm()}>
|
||||
<div data-slot="question-content">
|
||||
<div data-slot="question-text">
|
||||
{question()?.question}
|
||||
{multi() ? " " + language.t("ui.question.multiHint") : ""}
|
||||
</div>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
<Show when={picked()}>
|
||||
<Icon name="check-small" size="normal" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={customPicked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(options().length)}
|
||||
>
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<Show when={!store.editing && input()}>
|
||||
<span data-slot="option-description">{input()}</span>
|
||||
</Show>
|
||||
<Show when={customPicked()}>
|
||||
<Icon name="check-small" size="normal" />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={store.editing}>
|
||||
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
|
||||
<input
|
||||
ref={(el) => setTimeout(() => el.focus(), 0)}
|
||||
type="text"
|
||||
data-slot="custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
disabled={store.sending}
|
||||
onInput={(e) => {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = e.currentTarget.value
|
||||
setStore("custom", inputs)
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
|
||||
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={store.sending}
|
||||
onClick={() => setStore("editing", false)}
|
||||
>
|
||||
{language.t("ui.common.cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={confirm()}>
|
||||
<div data-slot="question-review">
|
||||
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<div data-slot="review-item">
|
||||
<span data-slot="review-label">{q.question}</span>
|
||||
<span data-slot="review-value" data-answered={answered()}>
|
||||
{answered() ? value() : language.t("ui.question.review.notAnswered")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div data-slot="question-actions">
|
||||
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<Show when={!single()}>
|
||||
<Show when={confirm()}>
|
||||
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
|
||||
{language.t("ui.common.submit")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!confirm() && multi()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => selectTab(store.tab + 1)}
|
||||
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
|
||||
>
|
||||
{language.t("ui.common.next")}
|
||||
</Button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
packages/app/src/components/server/server-row.tsx
Normal file
77
packages/app/src/components/server/server-row.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { serverDisplayName } from "@/context/server"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
|
||||
interface ServerRowProps extends ParentProps {
|
||||
url: string
|
||||
status?: ServerHealth
|
||||
class?: string
|
||||
nameClass?: string
|
||||
versionClass?: string
|
||||
dimmed?: boolean
|
||||
badge?: JSXElement
|
||||
}
|
||||
|
||||
export function ServerRow(props: ServerRowProps) {
|
||||
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(() => {
|
||||
props.url
|
||||
props.status?.version
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(check)
|
||||
return
|
||||
}
|
||||
check()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
check()
|
||||
if (typeof window === "undefined") return
|
||||
window.addEventListener("resize", check)
|
||||
onCleanup(() => window.removeEventListener("resize", check))
|
||||
})
|
||||
|
||||
const tooltipValue = () => (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{serverDisplayName(props.url)}</span>
|
||||
<Show when={props.status?.version}>
|
||||
<span class="text-text-invert-base">{props.status?.version}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": props.status?.healthy === true,
|
||||
"bg-icon-critical-base": props.status?.healthy === false,
|
||||
"bg-border-weak-base": props.status === undefined,
|
||||
}}
|
||||
/>
|
||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||
{serverDisplayName(props.url)}
|
||||
</span>
|
||||
<Show when={props.status?.version}>
|
||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||
{props.status?.version}
|
||||
</span>
|
||||
</Show>
|
||||
{props.badge}
|
||||
{props.children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
@@ -23,6 +22,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
|
||||
const usd = createMemo(
|
||||
@@ -33,30 +33,15 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||
const context = createMemo(() => metrics().context)
|
||||
const cost = createMemo(() => {
|
||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return usd().format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const locale = language.locale()
|
||||
const last = findLast(messages(), (x) => {
|
||||
if (x.role !== "assistant") return false
|
||||
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
||||
return total > 0
|
||||
}) as AssistantMessage
|
||||
if (!last) return
|
||||
const total =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
return {
|
||||
tokens: total.toLocaleString(locale),
|
||||
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
return usd().format(metrics().totalCost)
|
||||
})
|
||||
|
||||
const openContext = () => {
|
||||
if (!params.id) return
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.open()
|
||||
layout.fileTree.setTab("all")
|
||||
tabs().open("context")
|
||||
@@ -64,8 +49,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}
|
||||
|
||||
const circle = () => (
|
||||
<div class="p-1">
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
|
||||
<div class="flex items-center justify-center">
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -75,11 +60,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
{(ctx) => (
|
||||
<>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().tokens}</span>
|
||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
|
||||
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
tokens: { input: number; output: number; reasoning: number; read: number; write: number },
|
||||
cost: number,
|
||||
providerID = "openai",
|
||||
modelID = "gpt-4.1",
|
||||
) => {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
providerID,
|
||||
modelID,
|
||||
cost,
|
||||
tokens: {
|
||||
input: tokens.input,
|
||||
output: tokens.output,
|
||||
reasoning: tokens.reasoning,
|
||||
cache: {
|
||||
read: tokens.read,
|
||||
write: tokens.write,
|
||||
},
|
||||
},
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
const user = (id: string) => {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
cost: 0,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("getSessionContextMetrics", () => {
|
||||
test("computes totals and usage from latest assistant with tokens", () => {
|
||||
const messages = [
|
||||
user("u1"),
|
||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
|
||||
]
|
||||
const providers = [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
models: {
|
||||
"gpt-4.1": {
|
||||
name: "GPT-4.1",
|
||||
limit: { context: 1000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const metrics = getSessionContextMetrics(messages, providers)
|
||||
|
||||
expect(metrics.totalCost).toBe(1.75)
|
||||
expect(metrics.context?.message.id).toBe("a2")
|
||||
expect(metrics.context?.total).toBe(500)
|
||||
expect(metrics.context?.usage).toBe(50)
|
||||
expect(metrics.context?.providerLabel).toBe("OpenAI")
|
||||
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
|
||||
})
|
||||
|
||||
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
||||
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
||||
const providers = [{ id: "p-1", models: {} }]
|
||||
|
||||
const metrics = getSessionContextMetrics(messages, providers)
|
||||
|
||||
expect(metrics.context?.providerLabel).toBe("p-1")
|
||||
expect(metrics.context?.modelLabel).toBe("m-1")
|
||||
expect(metrics.context?.limit).toBeUndefined()
|
||||
expect(metrics.context?.usage).toBeNull()
|
||||
})
|
||||
|
||||
test("recomputes when message array is mutated in place", () => {
|
||||
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
||||
const providers = [{ id: "openai", models: {} }]
|
||||
|
||||
const one = getSessionContextMetrics(messages, providers)
|
||||
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
||||
const two = getSessionContextMetrics(messages, providers)
|
||||
|
||||
expect(one.context?.message.id).toBe("a1")
|
||||
expect(two.context?.message.id).toBe("a2")
|
||||
expect(two.totalCost).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
name?: string
|
||||
models: Record<string, Model | undefined>
|
||||
}
|
||||
|
||||
type Model = {
|
||||
name?: string
|
||||
limit: {
|
||||
context: number
|
||||
}
|
||||
}
|
||||
|
||||
type Context = {
|
||||
message: AssistantMessage
|
||||
provider?: Provider
|
||||
model?: Model
|
||||
providerLabel: string
|
||||
modelLabel: string
|
||||
limit: number | undefined
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
total: number
|
||||
usage: number | null
|
||||
}
|
||||
|
||||
type Metrics = {
|
||||
totalCost: number
|
||||
context: Context | undefined
|
||||
}
|
||||
|
||||
const tokenTotal = (msg: AssistantMessage) => {
|
||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||
}
|
||||
|
||||
const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
if (tokenTotal(msg) <= 0) continue
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
const build = (messages: Message[], providers: Provider[]): Metrics => {
|
||||
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
||||
const message = lastAssistantWithTokens(messages)
|
||||
if (!message) return { totalCost, context: undefined }
|
||||
|
||||
const provider = providers.find((item) => item.id === message.providerID)
|
||||
const model = provider?.models[message.modelID]
|
||||
const limit = model?.limit.context
|
||||
const total = tokenTotal(message)
|
||||
|
||||
return {
|
||||
totalCost,
|
||||
context: {
|
||||
message,
|
||||
provider,
|
||||
model,
|
||||
providerLabel: provider?.name ?? message.providerID,
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
output: message.tokens.output,
|
||||
reasoning: message.tokens.reasoning,
|
||||
cacheRead: message.tokens.cache.read,
|
||||
cacheWrite: message.tokens.cache.write,
|
||||
total,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
|
||||
return build(messages, providers)
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { Code } from "@opencode-ai/ui/code"
|
||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
|
||||
interface SessionContextTabProps {
|
||||
messages: () => Message[]
|
||||
@@ -34,44 +35,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const ctx = createMemo(() => {
|
||||
const last = findLast(props.messages(), (x) => {
|
||||
if (x.role !== "assistant") return false
|
||||
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
||||
return total > 0
|
||||
}) as AssistantMessage
|
||||
if (!last) return
|
||||
|
||||
const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
|
||||
const model = provider?.models[last.modelID]
|
||||
const limit = model?.limit.context
|
||||
|
||||
const input = last.tokens.input
|
||||
const output = last.tokens.output
|
||||
const reasoning = last.tokens.reasoning
|
||||
const cacheRead = last.tokens.cache.read
|
||||
const cacheWrite = last.tokens.cache.write
|
||||
const total = input + output + reasoning + cacheRead + cacheWrite
|
||||
const usage = limit ? Math.round((total / limit) * 100) : null
|
||||
|
||||
return {
|
||||
message: last,
|
||||
provider,
|
||||
model,
|
||||
limit,
|
||||
input,
|
||||
output,
|
||||
reasoning,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
total,
|
||||
usage,
|
||||
}
|
||||
})
|
||||
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return usd().format(total)
|
||||
return usd().format(metrics().totalCost)
|
||||
})
|
||||
|
||||
const counts = createMemo(() => {
|
||||
@@ -114,14 +82,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
const providerLabel = createMemo(() => {
|
||||
const c = ctx()
|
||||
if (!c) return "—"
|
||||
return c.provider?.name ?? c.message.providerID
|
||||
return c.providerLabel
|
||||
})
|
||||
|
||||
const modelLabel = createMemo(() => {
|
||||
const c = ctx()
|
||||
if (!c) return "—"
|
||||
if (c.model?.name) return c.model.name
|
||||
return c.message.modelID
|
||||
return c.modelLabel
|
||||
})
|
||||
|
||||
const breakdown = createMemo(
|
||||
|
||||
@@ -6,18 +6,23 @@ import { useLayout } from "@/context/layout"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
|
||||
export function SessionHeader() {
|
||||
@@ -25,6 +30,7 @@ export function SessionHeader() {
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const command = useCommand()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
@@ -48,6 +54,168 @@ export function SessionHeader() {
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
"cursor",
|
||||
"zed",
|
||||
"textmate",
|
||||
"antigravity",
|
||||
"finder",
|
||||
"terminal",
|
||||
"iterm2",
|
||||
"ghostty",
|
||||
"xcode",
|
||||
"android-studio",
|
||||
"powershell",
|
||||
"sublime-text",
|
||||
] as const
|
||||
type OpenApp = (typeof OPEN_APPS)[number]
|
||||
|
||||
const MAC_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
||||
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
||||
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
] as const
|
||||
|
||||
const WINDOWS_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
] as const
|
||||
|
||||
const LINUX_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
] as const
|
||||
|
||||
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
|
||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||
if (typeof navigator !== "object") return "unknown"
|
||||
const value = navigator.platform || navigator.userAgent
|
||||
if (/Mac/i.test(value)) return "macos"
|
||||
if (/Win/i.test(value)) return "windows"
|
||||
if (/Linux/i.test(value)) return "linux"
|
||||
return "unknown"
|
||||
})
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
|
||||
|
||||
const apps = createMemo(() => {
|
||||
if (os() === "macos") return MAC_APPS
|
||||
if (os() === "windows") return WINDOWS_APPS
|
||||
return LINUX_APPS
|
||||
})
|
||||
|
||||
const fileManager = createMemo(() => {
|
||||
if (os() === "macos") return { label: "Finder", icon: "finder" as const }
|
||||
if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const }
|
||||
return { label: "File Manager", icon: "finder" as const }
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (platform.platform !== "desktop") return
|
||||
if (!platform.checkAppExists) return
|
||||
|
||||
const list = apps()
|
||||
|
||||
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
|
||||
|
||||
void Promise.all(
|
||||
list.map((app) =>
|
||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||
.then((value) => Boolean(value))
|
||||
.catch(() => false)
|
||||
.then((ok) => {
|
||||
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
||||
return [app.id, ok] as const
|
||||
}),
|
||||
),
|
||||
).then((entries) => {
|
||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||
})
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
{ id: "finder", label: fileManager().label, icon: fileManager().icon },
|
||||
...apps().filter((app) => exists[app.id]),
|
||||
] as const
|
||||
})
|
||||
|
||||
type OpenIcon = OpenApp | "file-explorer"
|
||||
const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
|
||||
const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
|
||||
|
||||
const checksReady = createMemo(() => {
|
||||
if (platform.platform !== "desktop") return true
|
||||
if (!platform.checkAppExists) return true
|
||||
const list = apps()
|
||||
return list.every((app) => exists[app.id] !== undefined)
|
||||
})
|
||||
|
||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||
|
||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||
|
||||
createEffect(() => {
|
||||
if (platform.platform !== "desktop") return
|
||||
if (!checksReady()) return
|
||||
const value = prefs.app
|
||||
if (options().some((o) => o.id === value)) return
|
||||
setPrefs("app", options()[0]?.id ?? "finder")
|
||||
})
|
||||
|
||||
const openDir = (app: OpenApp) => {
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return
|
||||
if (!canOpen()) return
|
||||
|
||||
const item = options().find((o) => o.id === app)
|
||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const copyPath = () => {
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return
|
||||
navigator.clipboard
|
||||
.writeText(directory)
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("session.share.copy.copied"),
|
||||
description: directory,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const [state, setState] = createStore({
|
||||
share: false,
|
||||
unshare: false,
|
||||
@@ -130,7 +298,7 @@ export function SessionHeader() {
|
||||
<Portal mount={mount()}>
|
||||
<button
|
||||
type="button"
|
||||
class="hidden md:flex w-[320px] max-w-full min-w-0 p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||
class="hidden md:flex w-[320px] max-w-full min-w-0 h-[24px] px-2 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-base bg-surface-panel transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
aria-label={language.t("session.header.searchFiles")}
|
||||
>
|
||||
@@ -141,7 +309,11 @@ export function SessionHeader() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={hotkey()}>{(keybind) => <Keybind class="shrink-0">{keybind()}</Keybind>}</Show>
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -151,6 +323,87 @@ export function SessionHeader() {
|
||||
<Portal mount={mount()}>
|
||||
<div class="flex items-center gap-3">
|
||||
<StatusPopover />
|
||||
<Show when={projectDirectory()}>
|
||||
<div class="hidden xl:flex items-center">
|
||||
<Show
|
||||
when={canOpen()}
|
||||
fallback={
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none"
|
||||
onClick={copyPath}
|
||||
aria-label={language.t("session.header.open.copyPath")}
|
||||
>
|
||||
<Icon name="copy" size="small" class="text-icon-base" />
|
||||
<span class="text-12-regular text-text-strong">
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
|
||||
onClick={() => openDir(current().id)}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<AppIcon id={current().icon} class="size-4" />
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">Open</span>
|
||||
</Button>
|
||||
<div class="self-stretch w-px bg-border-base/70" />
|
||||
<DropdownMenu gutter={6} placement="bottom-end">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="chevron-down"
|
||||
variant="ghost"
|
||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
|
||||
<DropdownMenu.RadioGroup
|
||||
value={prefs.app}
|
||||
onChange={(value) => {
|
||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||
setPrefs("app", value as OpenApp)
|
||||
}}
|
||||
>
|
||||
{options().map((o) => (
|
||||
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<AppIcon id={o.icon} class={size(o.icon)} />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
))}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={copyPath}>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showShare()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
@@ -166,8 +419,9 @@ export function SessionHeader() {
|
||||
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "secondary",
|
||||
class: "rounded-sm h-[24px] px-3",
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
@@ -193,7 +447,14 @@ export function SessionHeader() {
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable tabIndex={-1} class="w-full" />
|
||||
<TextField
|
||||
value={shareUrl() ?? ""}
|
||||
readOnly
|
||||
copyable
|
||||
copyKind="link"
|
||||
tabIndex={-1}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
@@ -232,8 +493,8 @@ export function SessionHeader() {
|
||||
>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "link"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
variant="ghost"
|
||||
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
aria-label={
|
||||
@@ -283,27 +544,53 @@ export function SessionHeader() {
|
||||
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/file-tree-toggle size-6 p-0"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
class="group/review-toggle size-6 p-0"
|
||||
onClick={() => view().reviewPanel.toggle()}
|
||||
aria-label={language.t("command.review.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
aria-controls="review-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.fileTree.opened() ? "layout-right-full" : "layout-right"}
|
||||
class="group-hover/file-tree-toggle:hidden"
|
||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-right-partial"
|
||||
class="hidden group-hover/file-tree-toggle:inline-block"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.fileTree.opened() ? "layout-right" : "layout-right-full"}
|
||||
class="hidden group-active/file-tree-toggle:inline-block"
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="hidden md:block shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/file-tree-toggle size-6 p-0"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
aria-label={language.t("command.fileTree.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-controls="file-tree-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name="bullet-list"
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
@@ -15,6 +16,7 @@ interface NewSessionViewProps {
|
||||
|
||||
export function NewSessionView(props: NewSessionViewProps) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
|
||||
@@ -24,11 +26,11 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
if (options().includes(selection)) return selection
|
||||
return MAIN_WORKTREE
|
||||
})
|
||||
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
|
||||
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
|
||||
const isWorktree = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (!project) return false
|
||||
return sync.data.path.directory !== project.worktree
|
||||
return sdk.directory !== project.worktree
|
||||
})
|
||||
|
||||
const label = (value: string) => {
|
||||
@@ -45,7 +47,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
|
||||
<div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
|
||||
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="folder" size="small" />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Component, createMemo, type JSX } from "solid-js"
|
||||
import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
@@ -41,6 +42,8 @@ export const SettingsGeneral: Component = () => {
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
@@ -131,12 +134,7 @@ export const SettingsGeneral: Component = () => {
|
||||
const soundOptions = [...SOUND_OPTIONS]
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
||||
@@ -232,7 +230,7 @@ export const SettingsGeneral: Component = () => {
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||
>
|
||||
{(option) => (
|
||||
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||
@@ -416,13 +414,49 @@ export const SettingsGeneral: Component = () => {
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={linux()}>
|
||||
{(_) => {
|
||||
const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
|
||||
const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
|
||||
|
||||
const onChange = (checked: boolean) =>
|
||||
platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||
<span class="text-text-weak">
|
||||
<Icon name="help" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
description={language.t("settings.general.row.wayland.description")}
|
||||
>
|
||||
<div data-action="settings-wayland">
|
||||
<Switch checked={value() === "wayland"} onChange={onChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string
|
||||
title: string | JSX.Element
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -45,7 +44,7 @@ function groupFor(id: string): KeybindGroup {
|
||||
if (id === PALETTE_ID) return "General"
|
||||
if (id.startsWith("terminal.")) return "Terminal"
|
||||
if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
|
||||
if (id.startsWith("file.")) return "Navigation"
|
||||
if (id.startsWith("file.") || id.startsWith("fileTree.")) return "Navigation"
|
||||
if (id.startsWith("prompt.")) return "Prompt"
|
||||
if (
|
||||
id.startsWith("session.") ||
|
||||
@@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
@@ -436,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
@@ -40,12 +39,7 @@ export const SettingsModels: Component = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||
@@ -131,6 +125,6 @@ export const SettingsModels: Component = () => {
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderMeta = { source?: ProviderSource }
|
||||
@@ -116,12 +115,7 @@ export const SettingsProviders: Component = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
||||
@@ -232,11 +226,11 @@ export const SettingsProviders: Component = () => {
|
||||
</For>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none"
|
||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||
data-component="custom-provider-section"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">Custom provider</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
@@ -267,6 +261,6 @@ export const SettingsProviders: Component = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||
import { normalizeServerUrl, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
|
||||
type ServerStatus = { healthy: boolean; version?: string }
|
||||
|
||||
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 }))
|
||||
}
|
||||
import { ServerRow } from "@/components/server/server-row"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
@@ -42,10 +27,11 @@ export function StatusPopover() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<string, ServerStatus | undefined>,
|
||||
status: {} as Record<string, ServerHealth | undefined>,
|
||||
loading: null as string | null,
|
||||
defaultServerUrl: undefined as string | undefined,
|
||||
})
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
|
||||
const servers = createMemo(() => {
|
||||
const current = server.url
|
||||
@@ -60,7 +46,7 @@ export function StatusPopover() {
|
||||
if (!list.length) return list
|
||||
const active = server.url
|
||||
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
|
||||
@@ -75,10 +61,10 @@ export function StatusPopover() {
|
||||
})
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<string, ServerStatus> = {}
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
servers().map(async (url) => {
|
||||
results[url] = await checkHealth(url, platform)
|
||||
results[url] = await checkServerHealth(url, fetcher)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
@@ -155,7 +141,7 @@ export function StatusPopover() {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
"rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
@@ -213,78 +199,43 @@ export function StatusPopover() {
|
||||
const isDefault = () => url === store.defaultServerUrl
|
||||
const status = () => store.status[url]
|
||||
const isBlocked = () => status()?.healthy === false
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
||||
setTruncated(nameTruncated || versionTruncated)
|
||||
}
|
||||
check()
|
||||
window.addEventListener("resize", check)
|
||||
onCleanup(() => window.removeEventListener("resize", check))
|
||||
})
|
||||
|
||||
const tooltipValue = () => {
|
||||
const name = serverDisplayName(url)
|
||||
const version = status()?.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 (
|
||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"opacity-50": isBlocked(),
|
||||
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
||||
"cursor-not-allowed": isBlocked(),
|
||||
}}
|
||||
aria-disabled={isBlocked()}
|
||||
onClick={() => {
|
||||
if (isBlocked()) return
|
||||
server.setActive(url)
|
||||
navigate("/")
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
||||
"cursor-not-allowed": isBlocked(),
|
||||
}}
|
||||
aria-disabled={isBlocked()}
|
||||
onClick={() => {
|
||||
if (isBlocked()) return
|
||||
server.setActive(url)
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<ServerRow
|
||||
url={url}
|
||||
status={status()}
|
||||
dimmed={isBlocked()}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={isDefault()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status()?.healthy === true,
|
||||
"bg-icon-critical-base": status()?.healthy === false,
|
||||
"bg-border-weak-base": status() === undefined,
|
||||
}}
|
||||
/>
|
||||
<span ref={nameRef} class="text-14-regular text-text-base truncate">
|
||||
{serverDisplayName(url)}
|
||||
</span>
|
||||
<Show when={status()?.version}>
|
||||
<span ref={versionRef} class="text-12-regular text-text-weak truncate">
|
||||
{status()?.version}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={isDefault()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
<div class="flex-1" />
|
||||
<Show when={isActive()}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import { parseKeybind, matchKeybind } from "@/context/command"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { LocalPTY } from "@/context/terminal"
|
||||
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
onSubmit?: () => void
|
||||
@@ -52,6 +57,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||
}
|
||||
|
||||
export const Terminal = (props: TerminalProps) => {
|
||||
const platform = usePlatform()
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
@@ -68,6 +74,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let handleTextareaBlur: () => void
|
||||
let disposed = false
|
||||
const cleanups: VoidFunction[] = []
|
||||
const start =
|
||||
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
|
||||
let cursor = start ?? 0
|
||||
|
||||
const cleanup = () => {
|
||||
if (!cleanups.length) return
|
||||
@@ -108,17 +117,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const colors = getTerminalColors()
|
||||
setTerminalColors(colors)
|
||||
if (!term) return
|
||||
const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
|
||||
if (!setOption) return
|
||||
setOption("theme", colors)
|
||||
setOptionIfSupported(term, "theme", colors)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const font = monoFontFamily(settings.appearance.font())
|
||||
if (!term) return
|
||||
const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
|
||||
if (!setOption) return
|
||||
setOption("fontFamily", font)
|
||||
setOptionIfSupported(term, "fontFamily", font)
|
||||
})
|
||||
|
||||
const focusTerminal = () => {
|
||||
@@ -135,6 +140,22 @@ export const Terminal = (props: TerminalProps) => {
|
||||
focusTerminal()
|
||||
}
|
||||
|
||||
const handleLinkClick = (event: MouseEvent) => {
|
||||
if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return
|
||||
if (event.altKey) return
|
||||
if (event.button !== 0) return
|
||||
|
||||
const t = term
|
||||
if (!t) return
|
||||
|
||||
const text = getHoveredLinkText(t)
|
||||
if (!text) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
platform.openLink(text)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const run = async () => {
|
||||
const loaded = await loadGhostty()
|
||||
@@ -145,12 +166,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const once = { value: false }
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
url.password = window.__OPENCODE__?.serverPassword
|
||||
}
|
||||
const socket = new WebSocket(url)
|
||||
socket.binaryType = "arraybuffer"
|
||||
cleanups.push(() => {
|
||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
|
||||
})
|
||||
@@ -166,6 +191,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
fontSize: 14,
|
||||
fontFamily: monoFontFamily(settings.appearance.font()),
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
theme: terminalColors(),
|
||||
scrollback: 10_000,
|
||||
ghostty: g,
|
||||
@@ -219,26 +245,29 @@ export const Terminal = (props: TerminalProps) => {
|
||||
return true
|
||||
}
|
||||
|
||||
// allow for ctrl-` to toggle terminal in parent
|
||||
if (event.ctrlKey && key === "`") {
|
||||
return true
|
||||
}
|
||||
// allow for toggle terminal keybinds in parent
|
||||
const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND
|
||||
const keybinds = parseKeybind(config)
|
||||
|
||||
return false
|
||||
return matchKeybind(keybinds, event)
|
||||
})
|
||||
|
||||
const fit = new mod.FitAddon()
|
||||
const serializer = new SerializeAddon()
|
||||
cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.())
|
||||
cleanups.push(() => disposeIfDisposable(fit))
|
||||
t.loadAddon(serializer)
|
||||
t.loadAddon(fit)
|
||||
fitAddon = fit
|
||||
serializeAddon = serializer
|
||||
|
||||
t.open(container)
|
||||
|
||||
container.addEventListener("pointerdown", handlePointerDown)
|
||||
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
||||
|
||||
container.addEventListener("click", handleLinkClick, { capture: true })
|
||||
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
|
||||
|
||||
handleTextareaFocus = () => {
|
||||
t.options.cursorBlink = true
|
||||
}
|
||||
@@ -253,15 +282,11 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
focusTerminal()
|
||||
|
||||
fit.fit()
|
||||
|
||||
if (local.pty.buffer) {
|
||||
if (local.pty.rows && local.pty.cols) {
|
||||
t.resize(local.pty.cols, local.pty.rows)
|
||||
}
|
||||
t.write(local.pty.buffer, () => {
|
||||
if (local.pty.scrollY) {
|
||||
t.scrollToLine(local.pty.scrollY)
|
||||
}
|
||||
fitAddon.fit()
|
||||
if (local.pty.scrollY) t.scrollToLine(local.pty.scrollY)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -269,6 +294,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
handleResize = () => fit.fit()
|
||||
window.addEventListener("resize", handleResize)
|
||||
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
||||
|
||||
const onResize = t.onResize(async (size) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
await sdk.client.pty
|
||||
@@ -282,19 +308,19 @@ export const Terminal = (props: TerminalProps) => {
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.())
|
||||
cleanups.push(() => disposeIfDisposable(onResize))
|
||||
const onData = t.onData((data) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data)
|
||||
}
|
||||
})
|
||||
cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.())
|
||||
cleanups.push(() => disposeIfDisposable(onData))
|
||||
const onKey = t.onKey((key) => {
|
||||
if (key.key == "Enter") {
|
||||
props.onSubmit?.()
|
||||
}
|
||||
})
|
||||
cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.())
|
||||
cleanups.push(() => disposeIfDisposable(onKey))
|
||||
// t.onScroll((ydisp) => {
|
||||
// console.log("Scroll position:", ydisp)
|
||||
// })
|
||||
@@ -314,8 +340,31 @@ export const Terminal = (props: TerminalProps) => {
|
||||
socket.addEventListener("open", handleOpen)
|
||||
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
t.write(event.data)
|
||||
if (disposed) return
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
|
||||
const bytes = new Uint8Array(event.data)
|
||||
if (bytes[0] !== 0) return
|
||||
const json = decoder.decode(bytes.subarray(1))
|
||||
try {
|
||||
const meta = JSON.parse(json) as { cursor?: unknown }
|
||||
const next = meta?.cursor
|
||||
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
||||
cursor = next
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = typeof event.data === "string" ? event.data : ""
|
||||
if (!data) return
|
||||
t.write(data)
|
||||
cursor += data.length
|
||||
}
|
||||
socket.addEventListener("message", handleMessage)
|
||||
cleanups.push(() => socket.removeEventListener("message", handleMessage))
|
||||
@@ -369,6 +418,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
props.onCleanup({
|
||||
...local.pty,
|
||||
buffer,
|
||||
cursor,
|
||||
rows: t.rows,
|
||||
cols: t.cols,
|
||||
scrollY: t.getViewportY(),
|
||||
|
||||
63
packages/app/src/components/titlebar-history.test.ts
Normal file
63
packages/app/src/components/titlebar-history.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history"
|
||||
|
||||
function history(): TitlebarHistory {
|
||||
return { stack: [], index: 0, action: undefined }
|
||||
}
|
||||
|
||||
describe("titlebar history", () => {
|
||||
test("append and trim keeps max bounded", () => {
|
||||
let state = history()
|
||||
state = applyPath(state, "/", 3)
|
||||
state = applyPath(state, "/a", 3)
|
||||
state = applyPath(state, "/b", 3)
|
||||
state = applyPath(state, "/c", 3)
|
||||
|
||||
expect(state.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(state.stack.length).toBe(3)
|
||||
expect(state.index).toBe(2)
|
||||
})
|
||||
|
||||
test("back and forward indexes stay correct after trimming", () => {
|
||||
let state = history()
|
||||
state = applyPath(state, "/", 3)
|
||||
state = applyPath(state, "/a", 3)
|
||||
state = applyPath(state, "/b", 3)
|
||||
state = applyPath(state, "/c", 3)
|
||||
|
||||
expect(state.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(state.index).toBe(2)
|
||||
|
||||
const back = backPath(state)
|
||||
expect(back?.to).toBe("/b")
|
||||
expect(back?.state.index).toBe(1)
|
||||
|
||||
const afterBack = applyPath(back!.state, back!.to, 3)
|
||||
expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(afterBack.index).toBe(1)
|
||||
|
||||
const forward = forwardPath(afterBack)
|
||||
expect(forward?.to).toBe("/c")
|
||||
expect(forward?.state.index).toBe(2)
|
||||
|
||||
const afterForward = applyPath(forward!.state, forward!.to, 3)
|
||||
expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(afterForward.index).toBe(2)
|
||||
})
|
||||
|
||||
test("action-driven navigation does not push duplicate history entries", () => {
|
||||
const state: TitlebarHistory = {
|
||||
stack: ["/", "/a", "/b"],
|
||||
index: 2,
|
||||
action: undefined,
|
||||
}
|
||||
|
||||
const back = backPath(state)
|
||||
expect(back?.to).toBe("/a")
|
||||
|
||||
const next = applyPath(back!.state, back!.to, 10)
|
||||
expect(next.stack).toEqual(["/", "/a", "/b"])
|
||||
expect(next.index).toBe(1)
|
||||
expect(next.action).toBeUndefined()
|
||||
})
|
||||
})
|
||||
57
packages/app/src/components/titlebar-history.ts
Normal file
57
packages/app/src/components/titlebar-history.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export const MAX_TITLEBAR_HISTORY = 100
|
||||
|
||||
export type TitlebarAction = "back" | "forward" | undefined
|
||||
|
||||
export type TitlebarHistory = {
|
||||
stack: string[]
|
||||
index: number
|
||||
action: TitlebarAction
|
||||
}
|
||||
|
||||
export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||
if (!state.stack.length) {
|
||||
const stack = current === "/" ? ["/"] : ["/", current]
|
||||
return { stack, index: stack.length - 1, action: undefined }
|
||||
}
|
||||
|
||||
const active = state.stack[state.index]
|
||||
if (current === active) {
|
||||
if (!state.action) return state
|
||||
return { ...state, action: undefined }
|
||||
}
|
||||
|
||||
if (state.action) return { ...state, action: undefined }
|
||||
|
||||
return pushPath(state, current, max)
|
||||
}
|
||||
|
||||
export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||
const stack = state.stack.slice(0, state.index + 1).concat(path)
|
||||
const next = trimHistory(stack, stack.length - 1, max)
|
||||
return { ...state, ...next, action: undefined }
|
||||
}
|
||||
|
||||
export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
|
||||
if (stack.length <= max) return { stack, index }
|
||||
const cut = stack.length - max
|
||||
return {
|
||||
stack: stack.slice(cut),
|
||||
index: Math.max(0, index - cut),
|
||||
}
|
||||
}
|
||||
|
||||
export function backPath(state: TitlebarHistory) {
|
||||
if (state.index <= 0) return
|
||||
const index = state.index - 1
|
||||
const to = state.stack[index]
|
||||
if (!to) return
|
||||
return { state: { ...state, index, action: "back" as const }, to }
|
||||
}
|
||||
|
||||
export function forwardPath(state: TitlebarHistory) {
|
||||
if (state.index >= state.stack.length - 1) return
|
||||
const index = state.index + 1
|
||||
const to = state.stack[index]
|
||||
if (!to) return
|
||||
return { state: { ...state, index, action: "forward" as const }, to }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
|
||||
export function Titlebar() {
|
||||
const layout = useLayout()
|
||||
@@ -39,25 +40,9 @@ export function Titlebar() {
|
||||
const current = path()
|
||||
|
||||
untrack(() => {
|
||||
if (!history.stack.length) {
|
||||
const stack = current === "/" ? ["/"] : ["/", current]
|
||||
setHistory({ stack, index: stack.length - 1 })
|
||||
return
|
||||
}
|
||||
|
||||
const active = history.stack[history.index]
|
||||
if (current === active) {
|
||||
if (history.action) setHistory("action", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (history.action) {
|
||||
setHistory("action", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const next = history.stack.slice(0, history.index + 1).concat(current)
|
||||
setHistory({ stack: next, index: next.length - 1 })
|
||||
const next = applyPath(history, current)
|
||||
if (next === history) return
|
||||
setHistory(next)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,29 +50,49 @@ export function Titlebar() {
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
|
||||
const back = () => {
|
||||
if (!canBack()) return
|
||||
const index = history.index - 1
|
||||
const to = history.stack[index]
|
||||
if (!to) return
|
||||
setHistory({ index, action: "back" })
|
||||
navigate(to)
|
||||
const next = backPath(history)
|
||||
if (!next) return
|
||||
setHistory(next.state)
|
||||
navigate(next.to)
|
||||
}
|
||||
|
||||
const forward = () => {
|
||||
if (!canForward()) return
|
||||
const index = history.index + 1
|
||||
const to = history.stack[index]
|
||||
if (!to) return
|
||||
setHistory({ index, action: "forward" })
|
||||
navigate(to)
|
||||
const next = forwardPath(history)
|
||||
if (!next) return
|
||||
setHistory(next.state)
|
||||
navigate(next.to)
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
id: "common.goBack",
|
||||
title: language.t("common.goBack"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+[",
|
||||
onSelect: back,
|
||||
},
|
||||
{
|
||||
id: "common.goForward",
|
||||
title: language.t("common.goForward"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+]",
|
||||
onSelect: forward,
|
||||
},
|
||||
])
|
||||
|
||||
const getWin = () => {
|
||||
if (platform.platform !== "desktop") return
|
||||
|
||||
const tauri = (
|
||||
window as unknown as {
|
||||
__TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise<void> } } }
|
||||
__TAURI__?: {
|
||||
window?: {
|
||||
getCurrentWindow?: () => {
|
||||
startDragging?: () => Promise<void>
|
||||
toggleMaximize?: () => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
).__TAURI__
|
||||
if (!tauri?.window?.getCurrentWindow) return
|
||||
@@ -133,22 +138,33 @@ export function Titlebar() {
|
||||
void win.startDragging().catch(() => undefined)
|
||||
}
|
||||
|
||||
const maximize = (e: MouseEvent) => {
|
||||
if (platform.platform !== "desktop") return
|
||||
if (interactive(e.target)) return
|
||||
if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
|
||||
|
||||
const win = getWin()
|
||||
if (!win?.toggleMaximize) return
|
||||
|
||||
e.preventDefault()
|
||||
void win.toggleMaximize().catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
style={{ "min-height": minHeight() }}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
onDblClick={maximize}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center min-w-0": true,
|
||||
"pl-2": !mac(),
|
||||
}}
|
||||
onMouseDown={drag}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<Show when={mac()}>
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} data-tauri-drag-region />
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
@@ -222,13 +238,10 @@ export function Titlebar() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center">
|
||||
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
||||
</div>
|
||||
|
||||
@@ -238,9 +251,8 @@ export function Titlebar() {
|
||||
"pr-6": !windows(),
|
||||
}}
|
||||
onMouseDown={drag}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" data-tauri-drag-region />
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
|
||||
<Show when={windows()}>
|
||||
<div class="w-6 shrink-0" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
|
||||
43
packages/app/src/context/command-keybind.test.ts
Normal file
43
packages/app/src/context/command-keybind.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { formatKeybind, matchKeybind, parseKeybind } from "./command"
|
||||
|
||||
describe("command keybind helpers", () => {
|
||||
test("parseKeybind handles aliases and multiple combos", () => {
|
||||
const keybinds = parseKeybind("control+option+k, mod+shift+comma")
|
||||
|
||||
expect(keybinds).toHaveLength(2)
|
||||
expect(keybinds[0]).toEqual({
|
||||
key: "k",
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: true,
|
||||
})
|
||||
expect(keybinds[1]?.shift).toBe(true)
|
||||
expect(keybinds[1]?.key).toBe("comma")
|
||||
expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true)
|
||||
})
|
||||
|
||||
test("parseKeybind treats none and empty as disabled", () => {
|
||||
expect(parseKeybind("none")).toEqual([])
|
||||
expect(parseKeybind("")).toEqual([])
|
||||
})
|
||||
|
||||
test("matchKeybind normalizes punctuation keys", () => {
|
||||
const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space")
|
||||
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true)
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true)
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true)
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("formatKeybind returns human readable output", () => {
|
||||
const display = formatKeybind("ctrl+alt+arrowup")
|
||||
|
||||
expect(display).toContain("↑")
|
||||
expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true)
|
||||
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||
expect(formatKeybind("none")).toBe("")
|
||||
})
|
||||
})
|
||||
25
packages/app/src/context/command.test.ts
Normal file
25
packages/app/src/context/command.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { upsertCommandRegistration } from "./command"
|
||||
|
||||
describe("upsertCommandRegistration", () => {
|
||||
test("replaces keyed registrations", () => {
|
||||
const one = () => [{ id: "one", title: "One" }]
|
||||
const two = () => [{ id: "two", title: "Two" }]
|
||||
|
||||
const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
|
||||
|
||||
expect(next).toHaveLength(1)
|
||||
expect(next[0]?.options).toBe(two)
|
||||
})
|
||||
|
||||
test("keeps unkeyed registrations additive", () => {
|
||||
const one = () => [{ id: "one", title: "One" }]
|
||||
const two = () => [{ id: "two", title: "Two" }]
|
||||
|
||||
const next = upsertCommandRegistration([{ options: one }], { options: two })
|
||||
|
||||
expect(next).toHaveLength(2)
|
||||
expect(next[0]?.options).toBe(two)
|
||||
expect(next[1]?.options).toBe(one)
|
||||
})
|
||||
})
|
||||
@@ -64,6 +64,16 @@ export type CommandCatalogItem = {
|
||||
slash?: string
|
||||
}
|
||||
|
||||
export type CommandRegistration = {
|
||||
key?: string
|
||||
options: Accessor<CommandOption[]>
|
||||
}
|
||||
|
||||
export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
|
||||
if (entry.key === undefined) return [entry, ...registrations]
|
||||
return [entry, ...registrations.filter((x) => x.key !== entry.key)]
|
||||
}
|
||||
|
||||
export function parseKeybind(config: string): Keybind[] {
|
||||
if (!config || config === "none") return []
|
||||
|
||||
@@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
registrations: [] as Accessor<CommandOption[]>[],
|
||||
registrations: [] as CommandRegistration[],
|
||||
suspendCount: 0,
|
||||
})
|
||||
const warnedDuplicates = new Set<string>()
|
||||
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
@@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const all: CommandOption[] = []
|
||||
|
||||
for (const reg of store.registrations) {
|
||||
for (const opt of reg()) {
|
||||
if (seen.has(opt.id)) continue
|
||||
for (const opt of reg.options()) {
|
||||
if (seen.has(opt.id)) {
|
||||
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||
warnedDuplicates.add(opt.id)
|
||||
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen.add(opt.id)
|
||||
all.push(opt)
|
||||
}
|
||||
@@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
function register(cb: () => CommandOption[]): void
|
||||
function register(key: string, cb: () => CommandOption[]): void
|
||||
function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
|
||||
const id = typeof key === "string" ? key : undefined
|
||||
const next = typeof key === "function" ? key : cb
|
||||
if (!next) return
|
||||
const options = createMemo(next)
|
||||
const entry: CommandRegistration = {
|
||||
key: id,
|
||||
options,
|
||||
}
|
||||
setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
|
||||
onCleanup(() => {
|
||||
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setStore("registrations", (arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setStore("registrations", (arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
},
|
||||
register,
|
||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
||||
run(id, source)
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user