Compare commits
241
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2264c93b6b | ||
|
|
891875402c | ||
|
|
154cbf6996 | ||
|
|
64bafce665 | ||
|
|
5588453cbe | ||
|
|
5aaf8f8247 | ||
|
|
8c1f1f13dc | ||
|
|
b942e0b4dc | ||
|
|
f282613746 | ||
|
|
7c440ae82c | ||
|
|
b7bd561eaa | ||
|
|
6daa962aaa | ||
|
|
93a07e5a2a | ||
|
|
93e060272a | ||
|
|
acac05f22e | ||
|
|
b5a4671c64 | ||
|
|
a68fedd4a6 | ||
|
|
015cd404e4 | ||
|
|
9921809565 | ||
|
|
137336f373 | ||
|
|
d940d17918 | ||
|
|
0a5d5bc524 | ||
|
|
a30696f9bf | ||
|
|
25bdd77b1d | ||
|
|
2f12e8ee92 | ||
|
|
95211a8854 | ||
|
|
6b5cf936a2 | ||
|
|
17e62b050f | ||
|
|
ee84eb44ee | ||
|
|
82dd4b6908 | ||
|
|
185858749b | ||
|
|
39a504773c | ||
|
|
b7b734f51f | ||
|
|
dcff5b6596 | ||
|
|
4f7da2b757 | ||
|
|
60e616ec81 | ||
|
|
416964acd0 | ||
|
|
017ad2c7f7 | ||
|
|
e33eb1b058 | ||
|
|
857b8a4b56 | ||
|
|
3975329629 | ||
|
|
54e14c1a17 | ||
|
|
3741516fe3 | ||
|
|
76381f33d5 | ||
|
|
95d0d476e3 | ||
|
|
7508839b70 | ||
|
|
e88cbefabe | ||
|
|
a38bae684f | ||
|
|
08671e3155 | ||
|
|
0d557721cf | ||
|
|
e709808b32 | ||
|
|
0d22068c90 | ||
|
|
d116c227e0 | ||
|
|
3f07dffbb0 | ||
|
|
801e4a8a9d | ||
|
|
3adeed8f97 | ||
|
|
1275c71a63 | ||
|
|
ca8c23dd71 | ||
|
|
acc2bf5db9 | ||
|
|
96fbc30945 | ||
|
|
ba545ba9b3 | ||
|
|
188cc24bfc | ||
|
|
5e3162b7f4 | ||
|
|
f9aa209131 | ||
|
|
f86f654cda | ||
|
|
aadd2e13d7 | ||
|
|
531357b40c | ||
|
|
aa6b552c39 | ||
|
|
a3f1918489 | ||
|
|
a9fca05d8b | ||
|
|
824165eb79 | ||
|
|
562c9d76d9 | ||
|
|
c002ca03ba | ||
|
|
befb5d54fb | ||
|
|
69f5f657f2 | ||
|
|
0405b425f5 | ||
|
|
70cf609ce9 | ||
|
|
2f76b49df3 | ||
|
|
dfd5f38408 | ||
|
|
3b93e8d95c | ||
|
|
23631a9393 | ||
|
|
f1e0c31b8f | ||
|
|
30a25e4edc | ||
|
|
ea1aba4192 | ||
|
|
b9aad20be6 | ||
|
|
965f32ad63 | ||
|
|
cf828fff85 | ||
|
|
cf8b033be1 | ||
|
|
1bd5dc5382 | ||
|
|
06d63ca54c | ||
|
|
423778c93a | ||
|
|
8de9e47a5b | ||
|
|
d63ed3bbe3 | ||
|
|
4369d79636 | ||
|
|
3408f1a6ae | ||
|
|
34c58af796 | ||
|
|
37979ea44f | ||
|
|
50b5168c16 | ||
|
|
6b17645f2e | ||
|
|
52006c2fd9 | ||
|
|
26197ec95b | ||
|
|
43bb389e35 | ||
|
|
985090ef3c | ||
|
|
52eb8a7a8c | ||
|
|
1cabeb00d0 | ||
|
|
9564c1d6be | ||
|
|
1832eeffc9 | ||
|
|
e6d8315e29 | ||
|
|
784a17f7b3 | ||
|
|
04aef44fc3 | ||
|
|
c02dd067b2 | ||
|
|
141fdef588 | ||
|
|
3982c7d99a | ||
|
|
76745d0594 | ||
|
|
4850ecc419 | ||
|
|
43354eeabd | ||
|
|
7a9290dc9b | ||
|
|
cfbe9d329f | ||
|
|
f02499fa44 | ||
|
|
bd9d7b3221 | ||
|
|
c69474846f | ||
|
|
0dc80df6fd | ||
|
|
8e985e0a75 | ||
|
|
a4d31b6f95 | ||
|
|
c5dc075a88 | ||
|
|
eade8ee07b | ||
|
|
8fbba8de73 | ||
|
|
826664b559 | ||
|
|
d1f884033f | ||
|
|
0f3630d936 | ||
|
|
83d0e48e38 | ||
|
|
6c9b2c37a5 | ||
|
|
3ab41d548f | ||
|
|
d3d783e23d | ||
|
|
7aad2ee9ae | ||
|
|
f390ac251d | ||
|
|
7837bbc639 | ||
|
|
372dcc033c | ||
|
|
4158d7cda8 | ||
|
|
425abe2fbf | ||
|
|
744fb6aed0 | ||
|
|
e9f8e6aeec | ||
|
|
5dee3328d4 | ||
|
|
2f63152af3 | ||
|
|
c9891fe071 | ||
|
|
d35956fd92 | ||
|
|
7417e6eb38 | ||
|
|
5db089070a | ||
|
|
612b656d36 | ||
|
|
cb6ec0a564 | ||
|
|
12b8c42387 | ||
|
|
fa75d922ed | ||
|
|
e445dc0746 | ||
|
|
e84d441b82 | ||
|
|
377bf7ff21 | ||
|
|
b39c1f158f | ||
|
|
f23d8d343b | ||
|
|
91f2ac3cb2 | ||
|
|
ec720145fa | ||
|
|
f6948d0ffa | ||
|
|
d52ee41b3a | ||
|
|
ca5e85d6ea | ||
|
|
01cec84789 | ||
|
|
e62a15d421 | ||
|
|
d29dfe31e4 | ||
|
|
f15755684f | ||
|
|
16145af480 | ||
|
|
eace76e525 | ||
|
|
cc1d3732bc | ||
|
|
1798af72b0 | ||
|
|
2c82e6c6ae | ||
|
|
3577d829c2 | ||
|
|
29d02d643b | ||
|
|
23c803707d | ||
|
|
b51005ec4a | ||
|
|
dfbe553626 | ||
|
|
2af1ca7290 | ||
|
|
3e67104257 | ||
|
|
d1d7447493 | ||
|
|
c3faeae9d0 | ||
|
|
94baf1f721 | ||
|
|
9b8b9e28e2 | ||
|
|
2a56a1d6ef | ||
|
|
9e45313b0a | ||
|
|
d4c90b2dfb | ||
|
|
5b784871f0 | ||
|
|
e5f677dfb5 | ||
|
|
6a96810249 | ||
|
|
8b7fe7c09f | ||
|
|
0961632a9c | ||
|
|
abbf60080d | ||
|
|
33252a65b4 | ||
|
|
e70d984320 | ||
|
|
da7c874808 | ||
|
|
a19ef17bcb | ||
|
|
121d6a72c0 | ||
|
|
35f64b80fa | ||
|
|
feca42b025 | ||
|
|
53f118c57a | ||
|
|
786ae0a584 | ||
|
|
f73f88fb56 | ||
|
|
ac254fb442 | ||
|
|
597ae57bb1 | ||
|
|
a552652fcc | ||
|
|
511c7abaca | ||
|
|
f834915d3f | ||
|
|
65c21f8fe4 | ||
|
|
6b972329fd | ||
|
|
6ecd011e51 | ||
|
|
8e5db3083c | ||
|
|
d005e70f50 | ||
|
|
46122d9a0a | ||
|
|
81ac41e089 | ||
|
|
c0e71c4261 | ||
|
|
507f13a30c | ||
|
|
90f39bf672 | ||
|
|
95bf01a757 | ||
|
|
b6bbb95704 | ||
|
|
d713026a6a | ||
|
|
73c4d3644c | ||
|
|
571f5b31c9 | ||
|
|
644f0d4e92 | ||
|
|
d9f18e4006 | ||
|
|
2f4374c829 | ||
|
|
3542f3e406 | ||
|
|
f1caf84064 | ||
|
|
85126556b8 | ||
|
|
252b2c450d | ||
|
|
0c32afbc35 | ||
|
|
aef0e58ad7 | ||
|
|
1a6461e8bc | ||
|
|
e834a2e6c9 | ||
|
|
9d3f32065b | ||
|
|
e7ff7143b6 | ||
|
|
2c36cbb87c | ||
|
|
77fa8ddc88 | ||
|
|
4a56491e42 | ||
|
|
f51bd28ed8 | ||
|
|
6cd2a68851 | ||
|
|
9259d2bf52 | ||
|
|
e94ae550ea |
@@ -1,4 +1,5 @@
|
|||||||
# web + desktop packages
|
# web + desktop packages
|
||||||
packages/app/ @adamdotdevin
|
packages/app/ @adamdotdevin
|
||||||
packages/tauri/ @adamdotdevin
|
packages/tauri/ @adamdotdevin
|
||||||
|
packages/desktop/src-tauri/ @brendonovich
|
||||||
packages/desktop/ @adamdotdevin
|
packages/desktop/ @adamdotdevin
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ runs:
|
|||||||
with:
|
with:
|
||||||
app-id: ${{ inputs.opencode-app-id }}
|
app-id: ${{ inputs.opencode-app-id }}
|
||||||
private-key: ${{ inputs.opencode-app-secret }}
|
private-key: ${{ inputs.opencode-app-secret }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
|
||||||
- name: Configure git user
|
- name: Configure git user
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
name: beta
|
name: beta
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches: [dev]
|
schedule:
|
||||||
pull_request:
|
- cron: "0 * * * *"
|
||||||
types: [opened, synchronize, labeled, unlabeled]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
if: |
|
|
||||||
github.event_name == 'push' ||
|
|
||||||
(github.event_name == 'pull_request' &&
|
|
||||||
contains(github.event.pull_request.labels.*.name, 'contributor'))
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
close-stale-prs:
|
close-stale-prs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Close inactive PRs
|
- name: Close inactive PRs
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v8
|
||||||
@@ -25,59 +26,210 @@ jobs:
|
|||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
const DAYS_INACTIVE = 60
|
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 cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
|
||||||
const { owner, repo } = context.repo
|
const { owner, repo } = context.repo
|
||||||
const dryRun = context.payload.inputs?.dryRun === "true"
|
const dryRun = context.payload.inputs?.dryRun === "true"
|
||||||
const stalePrs = []
|
|
||||||
|
|
||||||
core.info(`Dry run mode: ${dryRun}`)
|
core.info(`Dry run mode: ${dryRun}`)
|
||||||
|
core.info(`Cutoff date: ${cutoff.toISOString()}`)
|
||||||
|
|
||||||
const prs = await github.paginate(github.rest.pulls.list, {
|
function sleep(ms) {
|
||||||
owner,
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
repo,
|
}
|
||||||
state: "open",
|
|
||||||
per_page: 100,
|
|
||||||
sort: "updated",
|
|
||||||
direction: "asc",
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const pr of prs) {
|
async function withRetry(fn, description = 'API call') {
|
||||||
const lastUpdated = new Date(pr.updated_at)
|
let lastError
|
||||||
if (lastUpdated > cutoff) {
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
core.info(`PR ${pr.number} is fresh`)
|
try {
|
||||||
continue
|
const result = await fn()
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
const isRateLimited = error.status === 403 &&
|
||||||
|
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
|
||||||
|
|
||||||
|
if (!isRateLimited) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse retry-after header, default to 60 seconds
|
||||||
|
const retryAfter = error.response?.headers?.['retry-after']
|
||||||
|
? parseInt(error.response.headers['retry-after'])
|
||||||
|
: 60
|
||||||
|
|
||||||
|
// Exponential backoff: retryAfter * 2^attempt
|
||||||
|
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
|
||||||
|
|
||||||
|
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
|
||||||
|
|
||||||
|
await sleep(backoffMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query($owner: String!, $repo: String!, $cursor: String) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
pullRequests(first: 100, states: OPEN, after: $cursor) {
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
author {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
commits(last: 1) {
|
||||||
|
nodes {
|
||||||
|
commit {
|
||||||
|
committedDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comments(last: 1) {
|
||||||
|
nodes {
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reviews(last: 1) {
|
||||||
|
nodes {
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const allPrs = []
|
||||||
|
let cursor = null
|
||||||
|
let hasNextPage = true
|
||||||
|
let pageCount = 0
|
||||||
|
|
||||||
|
while (hasNextPage) {
|
||||||
|
pageCount++
|
||||||
|
core.info(`Fetching page ${pageCount} of open PRs...`)
|
||||||
|
|
||||||
|
const result = await withRetry(
|
||||||
|
() => github.graphql(query, { owner, repo, cursor }),
|
||||||
|
`GraphQL page ${pageCount}`
|
||||||
|
)
|
||||||
|
|
||||||
|
allPrs.push(...result.repository.pullRequests.nodes)
|
||||||
|
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
|
||||||
|
cursor = result.repository.pullRequests.pageInfo.endCursor
|
||||||
|
|
||||||
|
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
|
||||||
|
|
||||||
|
// Delay between pagination requests (use small batch delay for reads)
|
||||||
|
if (hasNextPage) {
|
||||||
|
await sleep(SMALL_BATCH_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`Found ${allPrs.length} open pull requests`)
|
||||||
|
|
||||||
|
const stalePrs = allPrs.filter((pr) => {
|
||||||
|
const dates = [
|
||||||
|
new Date(pr.createdAt),
|
||||||
|
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
|
||||||
|
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
|
||||||
|
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
|
||||||
|
].filter((d) => d !== null)
|
||||||
|
|
||||||
|
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||||
|
|
||||||
|
if (!lastActivity || lastActivity > cutoff) {
|
||||||
|
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
stalePrs.push(pr)
|
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
|
||||||
}
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
if (!stalePrs.length) {
|
if (!stalePrs.length) {
|
||||||
core.info("No stale pull requests found.")
|
core.info("No stale pull requests found.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
core.info(`Found ${stalePrs.length} stale pull requests`)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Close stale PRs
|
||||||
|
// ============================================
|
||||||
|
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
|
||||||
|
? LARGE_BATCH_DELAY_MS
|
||||||
|
: SMALL_BATCH_DELAY_MS
|
||||||
|
|
||||||
|
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
|
||||||
|
|
||||||
|
let closedCount = 0
|
||||||
|
let skippedCount = 0
|
||||||
|
|
||||||
for (const pr of stalePrs) {
|
for (const pr of stalePrs) {
|
||||||
const issue_number = pr.number
|
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.`
|
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) {
|
if (dryRun) {
|
||||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`)
|
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
await github.rest.issues.createComment({
|
try {
|
||||||
owner,
|
// Add comment
|
||||||
repo,
|
await withRetry(
|
||||||
issue_number,
|
() => github.rest.issues.createComment({
|
||||||
body: closeComment,
|
owner,
|
||||||
})
|
repo,
|
||||||
|
issue_number,
|
||||||
|
body: closeComment,
|
||||||
|
}),
|
||||||
|
`Comment on PR #${issue_number}`
|
||||||
|
)
|
||||||
|
|
||||||
await github.rest.pulls.update({
|
// Close PR
|
||||||
owner,
|
await withRetry(
|
||||||
repo,
|
() => github.rest.pulls.update({
|
||||||
pull_number: issue_number,
|
owner,
|
||||||
state: "closed",
|
repo,
|
||||||
})
|
pull_number: issue_number,
|
||||||
|
state: "closed",
|
||||||
|
}),
|
||||||
|
`Close PR #${issue_number}`
|
||||||
|
)
|
||||||
|
|
||||||
core.info(`Closed PR #${issue_number} from ${pr.user.login}`)
|
closedCount++
|
||||||
|
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||||
|
|
||||||
|
// Delay before processing next PR
|
||||||
|
await sleep(requestDelayMs)
|
||||||
|
} catch (error) {
|
||||||
|
skippedCount++
|
||||||
|
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
||||||
|
core.info(`\n========== Summary ==========`)
|
||||||
|
core.info(`Total open PRs found: ${allPrs.length}`)
|
||||||
|
core.info(`Stale PRs identified: ${stalePrs.length}`)
|
||||||
|
core.info(`PRs closed: ${closedCount}`)
|
||||||
|
core.info(`PRs skipped (errors): ${skippedCount}`)
|
||||||
|
core.info(`Elapsed time: ${elapsed}s`)
|
||||||
|
core.info(`=============================`)
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
||||||
|
# Workaround for Pulumi version conflict:
|
||||||
|
# GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
|
||||||
|
# from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
|
||||||
|
# SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
|
||||||
|
# Removing the system language plugin forces SST to use its bundled compatible version.
|
||||||
|
# TODO: Remove when sst supports Pulumi >3.210.0
|
||||||
|
- name: Fix Pulumi version conflict
|
||||||
|
run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
|
||||||
|
|
||||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
|||||||
@@ -6,13 +6,7 @@ permissions:
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
paths:
|
branches: [dev]
|
||||||
- "bun.lock"
|
|
||||||
- "package.json"
|
|
||||||
- "packages/*/package.json"
|
|
||||||
- "flake.lock"
|
|
||||||
- ".github/workflows/nix-hashes.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
paths:
|
||||||
- "bun.lock"
|
- "bun.lock"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
@@ -21,120 +15,131 @@ on:
|
|||||||
- ".github/workflows/nix-hashes.yml"
|
- ".github/workflows/nix-hashes.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nix-hashes:
|
# Native runners required: bun install cross-compilation flags (--os/--cpu)
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
# do not produce byte-identical node_modules as native installs.
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
compute-hash:
|
||||||
env:
|
strategy:
|
||||||
TITLE: node_modules hashes
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- system: x86_64-linux
|
||||||
|
runner: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
- system: aarch64-linux
|
||||||
|
runner: blacksmith-4vcpu-ubuntu-2404-arm
|
||||||
|
- system: x86_64-darwin
|
||||||
|
runner: macos-15-intel
|
||||||
|
- system: aarch64-darwin
|
||||||
|
runner: macos-latest
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Nix
|
||||||
|
uses: nixbuild/nix-quick-install-action@v34
|
||||||
|
|
||||||
|
- name: Compute node_modules hash
|
||||||
|
id: hash
|
||||||
|
env:
|
||||||
|
SYSTEM: ${{ matrix.system }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUILD_LOG=$(mktemp)
|
||||||
|
trap 'rm -f "$BUILD_LOG"' EXIT
|
||||||
|
|
||||||
|
# Build with fakeHash to trigger hash mismatch and reveal correct hash
|
||||||
|
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||||
|
|
||||||
|
# Extract hash from build log with portability
|
||||||
|
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||||
|
|
||||||
|
if [ -z "$HASH" ]; then
|
||||||
|
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||||
|
cat "$BUILD_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$HASH" > hash.txt
|
||||||
|
echo "Computed hash for ${SYSTEM}: $HASH"
|
||||||
|
|
||||||
|
- name: Upload hash
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
name: hash-${{ matrix.system }}
|
||||||
|
path: hash.txt
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
update-hashes:
|
||||||
|
needs: compute-hash
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.head_ref || github.ref_name }}
|
ref: ${{ github.ref_name }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
|
||||||
|
|
||||||
- name: Setup git committer
|
- name: Setup git committer
|
||||||
id: committer
|
|
||||||
uses: ./.github/actions/setup-git-committer
|
uses: ./.github/actions/setup-git-committer
|
||||||
with:
|
with:
|
||||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
- name: Setup Nix
|
|
||||||
uses: nixbuild/nix-quick-install-action@v34
|
|
||||||
|
|
||||||
- name: Pull latest changes
|
- name: Pull latest changes
|
||||||
env:
|
|
||||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
|
||||||
run: |
|
run: |
|
||||||
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
|
||||||
git pull --rebase --autostash origin "$BRANCH"
|
|
||||||
|
|
||||||
- name: Compute all node_modules hashes
|
- name: Download hash artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: hashes
|
||||||
|
pattern: hash-*
|
||||||
|
|
||||||
|
- name: Update hashes.json
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HASH_FILE="nix/hashes.json"
|
HASH_FILE="nix/hashes.json"
|
||||||
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
|
||||||
|
|
||||||
if [ ! -f "$HASH_FILE" ]; then
|
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||||
mkdir -p "$(dirname "$HASH_FILE")"
|
|
||||||
echo '{"nodeModules":{}}' > "$HASH_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for SYSTEM in $SYSTEMS; do
|
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
|
||||||
echo "Computing hash for ${SYSTEM}..."
|
FILE="hashes/hash-${SYSTEM}/hash.txt"
|
||||||
BUILD_LOG=$(mktemp)
|
if [ -f "$FILE" ]; then
|
||||||
trap 'rm -f "$BUILD_LOG"' EXIT
|
HASH="$(tr -d '[:space:]' < "$FILE")"
|
||||||
|
echo "${SYSTEM}: ${HASH}"
|
||||||
# The updater derivations use fakeHash, so they will fail and reveal the correct hash
|
jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json
|
||||||
UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules"
|
mv tmp.json "$HASH_FILE"
|
||||||
|
else
|
||||||
nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
echo "::warning::Missing hash for ${SYSTEM}"
|
||||||
|
|
||||||
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
|
|
||||||
|
|
||||||
if [ -z "$CORRECT_HASH" ]; then
|
|
||||||
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$CORRECT_HASH" ]; then
|
|
||||||
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
|
|
||||||
cat "$BUILD_LOG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " ${SYSTEM}: ${CORRECT_HASH}"
|
|
||||||
jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \
|
|
||||||
'.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
|
|
||||||
mv "${HASH_FILE}.tmp" "$HASH_FILE"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "All hashes computed:"
|
|
||||||
cat "$HASH_FILE"
|
cat "$HASH_FILE"
|
||||||
|
|
||||||
- name: Commit ${{ env.TITLE }} changes
|
- name: Commit changes
|
||||||
env:
|
|
||||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HASH_FILE="nix/hashes.json"
|
HASH_FILE="nix/hashes.json"
|
||||||
echo "Checking for changes..."
|
|
||||||
|
|
||||||
summarize() {
|
if [ -z "$(git status --short -- "$HASH_FILE")" ]; then
|
||||||
local status="$1"
|
echo "No changes to commit"
|
||||||
{
|
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "### Nix $TITLE"
|
echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo ""
|
|
||||||
echo "- ref: ${GITHUB_REF_NAME}"
|
|
||||||
echo "- status: ${status}"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
|
|
||||||
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
fi
|
|
||||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
}
|
|
||||||
|
|
||||||
FILES=("$HASH_FILE")
|
|
||||||
STATUS="$(git status --short -- "${FILES[@]}" || true)"
|
|
||||||
if [ -z "$STATUS" ]; then
|
|
||||||
echo "No changes detected."
|
|
||||||
summarize "no changes"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Changes detected:"
|
git add "$HASH_FILE"
|
||||||
echo "$STATUS"
|
|
||||||
git add "${FILES[@]}"
|
|
||||||
git commit -m "chore: update nix node_modules hashes"
|
git commit -m "chore: update nix node_modules hashes"
|
||||||
|
|
||||||
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
|
||||||
git pull --rebase --autostash origin "$BRANCH"
|
git push origin HEAD:"$GITHUB_REF_NAME"
|
||||||
git push origin HEAD:"$BRANCH"
|
|
||||||
echo "Changes pushed successfully"
|
|
||||||
|
|
||||||
summarize "committed $(git rev-parse --short HEAD)"
|
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ jobs:
|
|||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404
|
- host: blacksmith-4vcpu-ubuntu-2404
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404-arm
|
- host: blacksmith-8vcpu-ubuntu-2404-arm
|
||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: test
|
name: test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
name: typecheck
|
name: typecheck
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [dev]
|
branches: [dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
.codex
|
||||||
*~
|
*~
|
||||||
playground
|
playground
|
||||||
tmp
|
tmp
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: git commit and push
|
description: git commit and push
|
||||||
model: opencode/glm-4.7
|
model: opencode/kimi-k2.5
|
||||||
subtask: true
|
subtask: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
// "plugin": ["opencode-openai-codex-auth"],
|
|
||||||
// "enterprise": {
|
// "enterprise": {
|
||||||
// "url": "https://enterprise.dev.opencode.ai",
|
// "url": "https://enterprise.dev.opencode.ai",
|
||||||
// },
|
// },
|
||||||
@@ -9,12 +8,7 @@
|
|||||||
"options": {},
|
"options": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {},
|
||||||
"context7": {
|
|
||||||
"type": "remote",
|
|
||||||
"url": "https://mcp.context7.com/mcp",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
"github-pr-search": false,
|
"github-pr-search": false,
|
||||||
|
|||||||
+2
-1
@@ -1 +1,2 @@
|
|||||||
sst-env.d.ts
|
sst-env.d.ts
|
||||||
|
desktop/src/bindings.ts
|
||||||
|
|||||||
@@ -5,78 +5,107 @@
|
|||||||
|
|
||||||
## Style Guide
|
## Style Guide
|
||||||
|
|
||||||
|
### General Principles
|
||||||
|
|
||||||
- Keep things in one function unless composable or reusable
|
- Keep things in one function unless composable or reusable
|
||||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
|
||||||
- Avoid `try`/`catch` where possible
|
- Avoid `try`/`catch` where possible
|
||||||
- Avoid using the `any` type
|
- Avoid using the `any` type
|
||||||
- Prefer single word variable names where possible
|
- Prefer single word variable names where possible
|
||||||
- Use Bun APIs when possible, like `Bun.file()`
|
- Use Bun APIs when possible, like `Bun.file()`
|
||||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||||
|
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||||
|
|
||||||
### Avoid let statements
|
### Naming
|
||||||
|
|
||||||
We don't like `let` statements, especially combined with if/else statements.
|
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
||||||
Prefer `const`.
|
|
||||||
|
|
||||||
Good:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const foo = condition ? 1 : 2
|
// Good
|
||||||
|
const foo = 1
|
||||||
|
function journal(dir: string) {}
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
const fooBar = 1
|
||||||
|
function prepareJournal(dir: string) {}
|
||||||
```
|
```
|
||||||
|
|
||||||
Bad:
|
Reduce total variable count by inlining when a value is only used once.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
let foo
|
// Good
|
||||||
|
const journal = await Bun.file(path.join(dir, "journal.json")).json()
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
const journalPath = path.join(dir, "journal.json")
|
||||||
|
const journal = await Bun.file(journalPath).json()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Destructuring
|
||||||
|
|
||||||
|
Avoid unnecessary destructuring. Use dot notation to preserve context.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good
|
||||||
|
obj.a
|
||||||
|
obj.b
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
const { a, b } = obj
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
|
||||||
|
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good
|
||||||
|
const foo = condition ? 1 : 2
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
let foo
|
||||||
if (condition) foo = 1
|
if (condition) foo = 1
|
||||||
else foo = 2
|
else foo = 2
|
||||||
```
|
```
|
||||||
|
|
||||||
### Avoid else statements
|
### Control Flow
|
||||||
|
|
||||||
Prefer early returns or using an `iife` to avoid else statements.
|
Avoid `else` statements. Prefer early returns.
|
||||||
|
|
||||||
Good:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
// Good
|
||||||
function foo() {
|
function foo() {
|
||||||
if (condition) return 1
|
if (condition) return 1
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
Bad:
|
// Bad
|
||||||
|
|
||||||
```ts
|
|
||||||
function foo() {
|
function foo() {
|
||||||
if (condition) return 1
|
if (condition) return 1
|
||||||
else return 2
|
else return 2
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Prefer single word naming
|
### Schema Definitions (Drizzle)
|
||||||
|
|
||||||
Try your best to find a single word name for your variables, functions, etc.
|
Use snake_case for field names so column names don't need to be redefined as strings.
|
||||||
Only use multiple words if you cannot.
|
|
||||||
|
|
||||||
Good:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const foo = 1
|
// Good
|
||||||
const bar = 2
|
const table = sqliteTable("session", {
|
||||||
const baz = 3
|
id: text().primaryKey(),
|
||||||
```
|
project_id: text().notNull(),
|
||||||
|
created_at: integer().notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
Bad:
|
// Bad
|
||||||
|
const table = sqliteTable("session", {
|
||||||
```ts
|
id: text("id").primaryKey(),
|
||||||
const fooBar = 1
|
projectID: text("project_id").notNull(),
|
||||||
const barBaz = 2
|
createdAt: integer("created_at").notNull(),
|
||||||
const bazFoo = 3
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
You MUST avoid using `mocks` as much as possible.
|
- Avoid mocks as much as possible
|
||||||
Tests MUST test actual implementation, do not duplicate logic into a test.
|
- Test actual implementation, do not duplicate logic into tests
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a>
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -81,7 +82,7 @@ The install script respects the following priority order for the installation pa
|
|||||||
|
|
||||||
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
|
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
|
||||||
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
|
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
|
4. `$HOME/.opencode/bin` - Default fallback
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -94,20 +95,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.
|
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
|
- **plan** - Read-only agent for analysis and code exploration
|
||||||
- Denies file edits by default
|
- Denies file edits by default
|
||||||
- Asks permission before running bash commands
|
- Asks permission before running bash commands
|
||||||
- Ideal for exploring unfamiliar codebases or planning changes
|
- 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.
|
This is used internally and can be invoked using `@general` in messages.
|
||||||
|
|
||||||
Learn more about [agents](https://opencode.ai/docs/agents).
|
Learn more about [agents](https://opencode.ai/docs/agents).
|
||||||
|
|
||||||
### Documentation
|
### 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
|
### Contributing
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
|
|||||||
|
|
||||||
### Building on OpenCode
|
### 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
|
### FAQ
|
||||||
|
|
||||||
@@ -124,10 +125,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:
|
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||||
|
|
||||||
- 100% open source
|
- 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.
|
- 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
|
- 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 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -30,7 +30,8 @@
|
|||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a>
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai">
|
||||||
|
<picture>
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||||
|
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">Açık kaynaklı yapay zeka kodlama asistanı.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||||
|
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||||
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">English</a> |
|
||||||
|
<a href="README.zh.md">简体中文</a> |
|
||||||
|
<a href="README.zht.md">繁體中文</a> |
|
||||||
|
<a href="README.ko.md">한국어</a> |
|
||||||
|
<a href="README.de.md">Deutsch</a> |
|
||||||
|
<a href="README.es.md">Español</a> |
|
||||||
|
<a href="README.fr.md">Français</a> |
|
||||||
|
<a href="README.it.md">Italiano</a> |
|
||||||
|
<a href="README.da.md">Dansk</a> |
|
||||||
|
<a href="README.ja.md">日本語</a> |
|
||||||
|
<a href="README.pl.md">Polski</a> |
|
||||||
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.ar.md">العربية</a> |
|
||||||
|
<a href="README.no.md">Norsk</a> |
|
||||||
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Kurulum
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# YOLO
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# Paket yöneticileri
|
||||||
|
npm i -g opencode-ai@latest # veya bun/pnpm/yarn
|
||||||
|
scoop install opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
||||||
|
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
|
||||||
|
paru -S opencode-bin # Arch Linux
|
||||||
|
mise use -g opencode # Tüm işletim sistemleri
|
||||||
|
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın.
|
||||||
|
|
||||||
|
### Masaüstü Uygulaması (BETA)
|
||||||
|
|
||||||
|
OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz.
|
||||||
|
|
||||||
|
| Platform | İndirme |
|
||||||
|
| --------------------- | ------------------------------------- |
|
||||||
|
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||||
|
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||||
|
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||||
|
| Linux | `.deb`, `.rpm` veya AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
# Windows (Scoop)
|
||||||
|
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Kurulum Dizini (Installation Directory)
|
||||||
|
|
||||||
|
Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini
|
||||||
|
2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol
|
||||||
|
3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa)
|
||||||
|
4. `$HOME/.opencode/bin` - Varsayılan yedek konum
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Örnekler
|
||||||
|
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ajanlar
|
||||||
|
|
||||||
|
OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir.
|
||||||
|
|
||||||
|
- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan
|
||||||
|
- **plan** - Analiz ve kod keşfi için salt okunur ajan
|
||||||
|
- Varsayılan olarak dosya düzenlemelerini reddeder
|
||||||
|
- Bash komutlarını çalıştırmadan önce izin ister
|
||||||
|
- Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal
|
||||||
|
|
||||||
|
Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır.
|
||||||
|
Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir.
|
||||||
|
|
||||||
|
[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin.
|
||||||
|
|
||||||
|
### Dokümantasyon
|
||||||
|
|
||||||
|
OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs).
|
||||||
|
|
||||||
|
### Katkıda Bulunma
|
||||||
|
|
||||||
|
OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun.
|
||||||
|
|
||||||
|
### OpenCode Üzerine Geliştirme
|
||||||
|
|
||||||
|
OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
|
||||||
|
|
||||||
|
### SSS
|
||||||
|
|
||||||
|
#### Bu Claude Code'dan nasıl farklı?
|
||||||
|
|
||||||
|
Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
|
||||||
|
|
||||||
|
- %100 açık kaynak
|
||||||
|
- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
|
||||||
|
- Kurulum gerektirmeyen hazır LSP desteği
|
||||||
|
- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
|
||||||
|
- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
+3
-1
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-2
@@ -29,7 +29,9 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -124,7 +126,7 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
|||||||
- 100% 開源。
|
- 100% 開源。
|
||||||
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
|
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
|
||||||
- 內建 LSP (語言伺服器協定) 支援。
|
- 內建 LSP (語言伺服器協定) 支援。
|
||||||
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造;我們將不斷挑戰終端機介面的極限。
|
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。
|
||||||
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
|
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -266,27 +266,27 @@
|
|||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.13.0",
|
"@agentclientprotocol/sdk": "0.13.0",
|
||||||
"@ai-sdk/amazon-bedrock": "3.0.73",
|
"@ai-sdk/amazon-bedrock": "3.0.74",
|
||||||
"@ai-sdk/anthropic": "2.0.57",
|
"@ai-sdk/anthropic": "2.0.58",
|
||||||
"@ai-sdk/azure": "2.0.91",
|
"@ai-sdk/azure": "2.0.91",
|
||||||
"@ai-sdk/cerebras": "1.0.34",
|
"@ai-sdk/cerebras": "1.0.36",
|
||||||
"@ai-sdk/cohere": "2.0.22",
|
"@ai-sdk/cohere": "2.0.22",
|
||||||
"@ai-sdk/deepinfra": "1.0.31",
|
"@ai-sdk/deepinfra": "1.0.33",
|
||||||
"@ai-sdk/gateway": "2.0.25",
|
"@ai-sdk/gateway": "2.0.30",
|
||||||
"@ai-sdk/google": "2.0.52",
|
"@ai-sdk/google": "2.0.52",
|
||||||
"@ai-sdk/google-vertex": "3.0.97",
|
"@ai-sdk/google-vertex": "3.0.98",
|
||||||
"@ai-sdk/groq": "2.0.34",
|
"@ai-sdk/groq": "2.0.34",
|
||||||
"@ai-sdk/mistral": "2.0.27",
|
"@ai-sdk/mistral": "2.0.27",
|
||||||
"@ai-sdk/openai": "2.0.89",
|
"@ai-sdk/openai": "2.0.89",
|
||||||
"@ai-sdk/openai-compatible": "1.0.30",
|
"@ai-sdk/openai-compatible": "1.0.32",
|
||||||
"@ai-sdk/perplexity": "2.0.23",
|
"@ai-sdk/perplexity": "2.0.23",
|
||||||
"@ai-sdk/provider": "2.0.1",
|
"@ai-sdk/provider": "2.0.1",
|
||||||
"@ai-sdk/provider-utils": "3.0.20",
|
"@ai-sdk/provider-utils": "3.0.20",
|
||||||
"@ai-sdk/togetherai": "1.0.31",
|
"@ai-sdk/togetherai": "1.0.34",
|
||||||
"@ai-sdk/vercel": "1.0.31",
|
"@ai-sdk/vercel": "1.0.33",
|
||||||
"@ai-sdk/xai": "2.0.51",
|
"@ai-sdk/xai": "2.0.56",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
"@gitlab/gitlab-ai-provider": "3.4.0",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.25.2",
|
||||||
@@ -297,9 +297,9 @@
|
|||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||||
"@opentui/core": "0.1.75",
|
"@opentui/core": "0.1.77",
|
||||||
"@opentui/solid": "0.1.75",
|
"@opentui/solid": "0.1.77",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -307,8 +307,9 @@
|
|||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
|
"ai-gateway-provider": "2.3.1",
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"bun-pty": "0.4.4",
|
"bun-pty": "0.4.8",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
@@ -362,7 +363,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -382,7 +383,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -393,7 +394,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -406,7 +407,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -448,7 +449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -459,7 +460,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -521,7 +522,7 @@
|
|||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"ai": "5.0.119",
|
"ai": "5.0.124",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
@@ -559,23 +560,31 @@
|
|||||||
|
|
||||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
|
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="],
|
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
|
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
|
||||||
|
|
||||||
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
|
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
|
||||||
|
|
||||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XOK0dJsAGoPYi/lfR4KFBi8xhvJ46oCpAxUD6FmJAuJ4eh0qlj5zDt+myvzM8gvN7S6K7zHD+mdWlOPKGQT8Vg=="],
|
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
|
||||||
|
|
||||||
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
|
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
|
||||||
|
|
||||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-87qFcYNvDF/89hB//MQjYTb3tlsAfmgeZrZ34RESeBTZpSgs0EzYOMqPMwFTHUNp4wteoifikDJbaS/9Da8cfw=="],
|
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
|
||||||
|
|
||||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rq+FX55ne7lMiqai7NcvvDZj4HLsr+hg77WayqmySqc6zhw3tIOLxd4Ty6OpwNj0C0bVMi3iCl2zvJIEirh9XA=="],
|
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
|
||||||
|
|
||||||
|
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
|
||||||
|
|
||||||
|
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4d5EKu0OW7Gf5WFpGo4ixn0iWEwA+GpteqUjEznWGmi7qdLE5zdkbRik5B1HrDDiw5P90yO51xBex/Fp50JcVA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WWOz5Kj+5fVe94h7WeReqjUOVtAquDE2kM575FUc8CsVxH2tRfA5cLa8nu3bknSezsKt3i67YM6mvCRxiXCkWA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
|
||||||
|
|
||||||
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
|
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
|
||||||
|
|
||||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.97", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s4tI7Z15i6FlbtCvS4SBRal8wRfkOXJzKxlS6cU4mJW/QfUfoVy4b22836NVNJwDvkG/HkDSfzwm/X8mn46MhA=="],
|
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
|
||||||
|
|
||||||
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
|
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
|
||||||
|
|
||||||
@@ -591,11 +600,11 @@
|
|||||||
|
|
||||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||||
|
|
||||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RlYubjStoZQxna4Ng91Vvo8YskvL7lW9zj68IwZfCnaDBSAp1u6Nhc5BR4ZtKnY6PA3XEtu4bATIQl7yiiQ+Lw=="],
|
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
|
||||||
|
|
||||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
|
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
|
||||||
|
|
||||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
|
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.56", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FGlqwWc3tAYqDHE8r8hQGQLcMiPUwgz90oU2QygUH930OWtCLapFkSu114DgVaIN/qoM1DUX+inv0Ee74Fgp5g=="],
|
||||||
|
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
@@ -925,7 +934,7 @@
|
|||||||
|
|
||||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||||
|
|
||||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="],
|
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="],
|
||||||
|
|
||||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||||
|
|
||||||
@@ -1221,27 +1230,27 @@
|
|||||||
|
|
||||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||||
|
|
||||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="],
|
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
|
||||||
|
|
||||||
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
|
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="],
|
"@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="],
|
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
@@ -1909,7 +1918,7 @@
|
|||||||
|
|
||||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||||
|
|
||||||
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
|
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||||
|
|
||||||
@@ -1947,7 +1956,9 @@
|
|||||||
|
|
||||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||||
|
|
||||||
"ai": ["ai@5.0.119", "", { "dependencies": { "@ai-sdk/gateway": "2.0.25", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HUOwhc17fl2SZTJGZyA/99aNu706qKfXaUBCy9vgZiXBwrxg2eTzn2BCz7kmYDsfx6Fg2ACBy2icm41bsDXCTw=="],
|
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
|
||||||
|
|
||||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||||
|
|
||||||
@@ -2097,7 +2108,7 @@
|
|||||||
|
|
||||||
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
||||||
|
|
||||||
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
|
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||||
|
|
||||||
@@ -3975,7 +3986,9 @@
|
|||||||
|
|
||||||
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||||
|
|
||||||
|
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||||
|
|
||||||
@@ -3983,11 +3996,13 @@
|
|||||||
|
|
||||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||||
|
|
||||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||||
|
|
||||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||||
|
|
||||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||||
|
|
||||||
|
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||||
|
|
||||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||||
|
|
||||||
@@ -3997,11 +4012,11 @@
|
|||||||
|
|
||||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||||
|
|
||||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||||
|
|
||||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||||
|
|
||||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||||
|
|
||||||
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||||
|
|
||||||
@@ -4271,6 +4286,14 @@
|
|||||||
|
|
||||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||||
|
|
||||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
@@ -4381,11 +4404,11 @@
|
|||||||
|
|
||||||
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||||
|
|
||||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||||
|
|
||||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||||
|
|
||||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||||
|
|
||||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||||
|
|
||||||
@@ -4803,6 +4826,14 @@
|
|||||||
|
|
||||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||||
|
|
||||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|||||||
@@ -42,28 +42,15 @@
|
|||||||
desktop = pkgs.callPackage ./nix/desktop.nix {
|
desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||||
inherit opencode;
|
inherit opencode;
|
||||||
};
|
};
|
||||||
# nixpkgs cpu naming to bun cpu naming
|
|
||||||
cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; };
|
|
||||||
# matrix of node_modules builds - these will always fail due to fakeHash usage
|
|
||||||
# but allow computation of the correct hash from any build machine for any cpu/os
|
|
||||||
# see the update-nix-hashes workflow for usage
|
|
||||||
moduleUpdaters = pkgs.lib.listToAttrs (
|
|
||||||
pkgs.lib.concatMap (cpu:
|
|
||||||
map (os: {
|
|
||||||
name = "${cpu}-${os}_node_modules";
|
|
||||||
value = node_modules.override {
|
|
||||||
bunCpu = cpuMap.${cpu};
|
|
||||||
bunOs = os;
|
|
||||||
hash = pkgs.lib.fakeHash;
|
|
||||||
};
|
|
||||||
}) [ "linux" "darwin" ]
|
|
||||||
) [ "x86_64" "aarch64" ]
|
|
||||||
);
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = opencode;
|
default = opencode;
|
||||||
inherit opencode desktop;
|
inherit opencode desktop;
|
||||||
} // moduleUpdaters
|
# Updater derivation with fakeHash - build fails and reveals correct hash
|
||||||
|
node_modules_updater = node_modules.override {
|
||||||
|
hash = pkgs.lib.fakeHash;
|
||||||
|
};
|
||||||
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
|||||||
rustc
|
rustc
|
||||||
jq
|
jq
|
||||||
makeWrapper
|
makeWrapper
|
||||||
]
|
] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
||||||
++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
|
||||||
|
|
||||||
buildInputs = lib.optionals stdenv.isLinux [
|
buildInputs = lib.optionals stdenv.isLinux [
|
||||||
dbus
|
dbus
|
||||||
@@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
|||||||
gst_all_1.gstreamer
|
gst_all_1.gstreamer
|
||||||
gst_all_1.gst-plugins-base
|
gst_all_1.gst-plugins-base
|
||||||
gst_all_1.gst-plugins-good
|
gst_all_1.gst-plugins-good
|
||||||
|
gst_all_1.gst-plugins-bad
|
||||||
];
|
];
|
||||||
|
|
||||||
strictDeps = true;
|
strictDeps = true;
|
||||||
@@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
|||||||
mainProgram = "opencode-desktop";
|
mainProgram = "opencode-desktop";
|
||||||
inherit (opencode.meta) platforms;
|
inherit (opencode.meta) platforms;
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-gUWzUsk81miIrjg0fZQmsIQG4pZYmEHgzN6BaXI+lfc=",
|
"x86_64-linux": "sha256-Uc9UFWrG9bVROt+DmXduXoY409wBBLtBe0G7R41NF8Q=",
|
||||||
"aarch64-linux": "sha256-gwEG75ha/ojTO2iAObTmLTtEkXIXJ7BThzfI5CqlJh8=",
|
"aarch64-linux": "sha256-KTUsuPfWaw2qb26GmEa5tcSeF3+Kx2X5ZP5DE8jJuvQ=",
|
||||||
"aarch64-darwin": "sha256-20RGG2GkUItCzD67gDdoSLfexttM8abS//FKO9bfjoM=",
|
"aarch64-darwin": "sha256-C650/LVIoeymKnRw9lVO3f5ve9xYZPrO0vOM5pqY2nE=",
|
||||||
"x86_64-darwin": "sha256-i2VawFuR1UbjPVYoybU6aJDJfFo0tcvtl1aM31Y2mTQ="
|
"x86_64-darwin": "sha256-xLLI2mNn222ktx6s8rwej3rMzQGl1S1jV/NXmLFg2DU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-10
@@ -2,8 +2,6 @@
|
|||||||
lib,
|
lib,
|
||||||
stdenvNoCC,
|
stdenvNoCC,
|
||||||
bun,
|
bun,
|
||||||
bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64",
|
|
||||||
bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin",
|
|
||||||
rev ? "dirty",
|
rev ? "dirty",
|
||||||
hash ?
|
hash ?
|
||||||
(lib.pipe ./hashes.json [
|
(lib.pipe ./hashes.json [
|
||||||
@@ -16,6 +14,9 @@ let
|
|||||||
builtins.readFile
|
builtins.readFile
|
||||||
builtins.fromJSON
|
builtins.fromJSON
|
||||||
];
|
];
|
||||||
|
platform = stdenvNoCC.hostPlatform;
|
||||||
|
bunCpu = if platform.isAarch64 then "arm64" else "x64";
|
||||||
|
bunOs = if platform.isLinux then "linux" else "darwin";
|
||||||
in
|
in
|
||||||
stdenvNoCC.mkDerivation {
|
stdenvNoCC.mkDerivation {
|
||||||
pname = "opencode-node_modules";
|
pname = "opencode-node_modules";
|
||||||
@@ -39,23 +40,22 @@ stdenvNoCC.mkDerivation {
|
|||||||
"SOCKS_SERVER"
|
"SOCKS_SERVER"
|
||||||
];
|
];
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [ bun ];
|
||||||
bun
|
|
||||||
];
|
|
||||||
|
|
||||||
dontConfigure = true;
|
dontConfigure = true;
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
export HOME=$(mktemp -d)
|
|
||||||
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
|
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
|
||||||
bun install \
|
bun install \
|
||||||
--cpu="${bunCpu}" \
|
--cpu="${bunCpu}" \
|
||||||
--os="${bunOs}" \
|
--os="${bunOs}" \
|
||||||
|
--filter '!./' \
|
||||||
|
--filter './packages/opencode' \
|
||||||
|
--filter './packages/desktop' \
|
||||||
--frozen-lockfile \
|
--frozen-lockfile \
|
||||||
--ignore-scripts \
|
--ignore-scripts \
|
||||||
--no-progress \
|
--no-progress
|
||||||
--linker=isolated
|
|
||||||
bun --bun ${./scripts/canonicalize-node-modules.ts}
|
bun --bun ${./scripts/canonicalize-node-modules.ts}
|
||||||
bun --bun ${./scripts/normalize-bun-binaries.ts}
|
bun --bun ${./scripts/normalize-bun-binaries.ts}
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
@@ -63,10 +63,8 @@ stdenvNoCC.mkDerivation {
|
|||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
|
|
||||||
mkdir -p $out
|
mkdir -p $out
|
||||||
find . -type d -name node_modules -exec cp -R --parents {} $out \;
|
find . -type d -name node_modules -exec cp -R --parents {} $out \;
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"ai": "5.0.119",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# E2E Testing Guide
|
||||||
|
|
||||||
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all e2e tests
|
||||||
|
bun test:e2e
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
bun test:e2e -- app/home.spec.ts
|
||||||
|
|
||||||
|
# Run single test by title
|
||||||
|
bun test:e2e -- -g "home renders and shows core entrypoints"
|
||||||
|
|
||||||
|
# Run tests with UI mode (for debugging)
|
||||||
|
bun test:e2e:ui
|
||||||
|
|
||||||
|
# Run tests locally with full server setup
|
||||||
|
bun test:e2e:local
|
||||||
|
|
||||||
|
# View test report
|
||||||
|
bun test:e2e:report
|
||||||
|
|
||||||
|
# Typecheck
|
||||||
|
bun typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
All tests live in `packages/app/e2e/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/
|
||||||
|
├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
|
||||||
|
├── actions.ts # Reusable action helpers
|
||||||
|
├── selectors.ts # DOM selectors
|
||||||
|
├── utils.ts # Utilities (serverUrl, modKey, path helpers)
|
||||||
|
└── [feature]/
|
||||||
|
└── *.spec.ts # Test files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Patterns
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
test("test description", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await gotoSession() // or gotoSession(sessionID)
|
||||||
|
|
||||||
|
// Your test code
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Fixtures
|
||||||
|
|
||||||
|
- `page` - Playwright page
|
||||||
|
- `sdk` - OpenCode SDK client for API calls
|
||||||
|
- `gotoSession(sessionID?)` - Navigate to session
|
||||||
|
|
||||||
|
### Helper Functions
|
||||||
|
|
||||||
|
**Actions** (`actions.ts`):
|
||||||
|
|
||||||
|
- `openPalette(page)` - Open command palette
|
||||||
|
- `openSettings(page)` - Open settings dialog
|
||||||
|
- `closeDialog(page, dialog)` - Close any dialog
|
||||||
|
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||||
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
|
**Selectors** (`selectors.ts`):
|
||||||
|
|
||||||
|
- `promptSelector` - Prompt input
|
||||||
|
- `terminalSelector` - Terminal panel
|
||||||
|
- `sessionItemSelector(id)` - Session in sidebar
|
||||||
|
- `listItemSelector` - Generic list items
|
||||||
|
|
||||||
|
**Utils** (`utils.ts`):
|
||||||
|
|
||||||
|
- `modKey` - Meta (Mac) or Control (Linux/Win)
|
||||||
|
- `serverUrl` - Backend server URL
|
||||||
|
- `sessionPath(dir, id?)` - Build session URL
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
Always import from `../fixtures`, not `@playwright/test`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- Test files: `feature-name.spec.ts`
|
||||||
|
- Test names: lowercase, descriptive: `"sidebar can be toggled"`
|
||||||
|
- Variables: camelCase
|
||||||
|
- Constants: SCREAMING_SNAKE_CASE
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Tests should clean up after themselves:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, "test session", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
// Test code...
|
||||||
|
}) // Auto-deletes session
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
Default: 60s per test, 10s per assertion. Override when needed:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test.setTimeout(120_000) // For long LLM operations
|
||||||
|
test("slow test", async () => {
|
||||||
|
await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selectors
|
||||||
|
|
||||||
|
Use `data-component`, `data-action`, or semantic roles:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
await page.locator('[data-component="prompt-input"]').click()
|
||||||
|
await page.getByRole("button", { name: "Open settings" }).click()
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
await page.locator(".css-class-name").click()
|
||||||
|
await page.locator("#id-name").click()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
Use `modKey` for cross-platform compatibility:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
||||||
|
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
1. Choose appropriate folder or create new one
|
||||||
|
2. Import from `../fixtures`
|
||||||
|
3. Use helper functions from `../actions` and `../selectors`
|
||||||
|
4. Clean up any created resources
|
||||||
|
5. Use specific selectors (avoid CSS classes)
|
||||||
|
6. Test one feature per test file
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
For UI debugging, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test:e2e:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
This opens Playwright's interactive UI for step-through debugging.
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { execSync } from "node:child_process"
|
||||||
|
import { modKey, serverUrl } from "./utils"
|
||||||
|
import {
|
||||||
|
sessionItemSelector,
|
||||||
|
dropdownMenuTriggerSelector,
|
||||||
|
dropdownMenuContentSelector,
|
||||||
|
projectMenuTriggerSelector,
|
||||||
|
projectWorkspacesToggleSelector,
|
||||||
|
titlebarRightSelector,
|
||||||
|
popoverBodySelector,
|
||||||
|
listItemSelector,
|
||||||
|
listItemKeySelector,
|
||||||
|
listItemKeyStartsWithSelector,
|
||||||
|
workspaceItemSelector,
|
||||||
|
workspaceMenuTriggerSelector,
|
||||||
|
} from "./selectors"
|
||||||
|
import type { createSdk } from "./utils"
|
||||||
|
|
||||||
|
export async function defocus(page: Page) {
|
||||||
|
await page.mouse.click(5, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPalette(page: Page) {
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+P`)
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDialog(page: Page, dialog: Locator) {
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
const closed = await dialog
|
||||||
|
.waitFor({ state: "detached", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (closed) return
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
const closedSecond = await dialog
|
||||||
|
.waitFor({ state: "detached", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (closedSecond) return
|
||||||
|
|
||||||
|
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isSidebarClosed(page: Page) {
|
||||||
|
const main = page.locator("main")
|
||||||
|
const classes = (await main.getAttribute("class")) ?? ""
|
||||||
|
return classes.includes("xl:border-l")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleSidebar(page: Page) {
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+B`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSidebar(page: Page) {
|
||||||
|
if (!(await isSidebarClosed(page))) return
|
||||||
|
await toggleSidebar(page)
|
||||||
|
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSidebar(page: Page) {
|
||||||
|
if (await isSidebarClosed(page)) return
|
||||||
|
await toggleSidebar(page)
|
||||||
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSettings(page: Page) {
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||||
|
|
||||||
|
const opened = await dialog
|
||||||
|
.waitFor({ state: "visible", timeout: 3000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (opened) return dialog
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
||||||
|
await page.addInitScript(
|
||||||
|
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||||
|
const key = "opencode.global.dat:server"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const parsed = (() => {
|
||||||
|
if (!raw) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as unknown
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||||
|
const list = Array.isArray(store.list) ? store.list : []
|
||||||
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
|
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||||
|
|
||||||
|
const add = (origin: string, directory: string) => {
|
||||||
|
const current = nextProjects[origin]
|
||||||
|
const items = Array.isArray(current) ? current : []
|
||||||
|
const existing = items.filter(
|
||||||
|
(p): p is { worktree: string; expanded?: boolean } =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === "object" &&
|
||||||
|
"worktree" in p &&
|
||||||
|
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.some((p) => p.worktree === directory)) return
|
||||||
|
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = [args.directory, ...args.extra]
|
||||||
|
for (const directory of directories) {
|
||||||
|
add("local", directory)
|
||||||
|
add(args.serverUrl, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
list,
|
||||||
|
projects: nextProjects,
|
||||||
|
lastProject,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestProject() {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||||
|
|
||||||
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
|
cwd: root,
|
||||||
|
stdio: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupTestProject(directory: string) {
|
||||||
|
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionIDFromUrl(url: string) {
|
||||||
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
|
return match?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||||
|
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
||||||
|
await expect(sessionEl).toBeVisible()
|
||||||
|
await sessionEl.hover()
|
||||||
|
return sessionEl
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||||
|
const sessionEl = await hoverSessionItem(page, sessionID)
|
||||||
|
|
||||||
|
const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
|
||||||
|
await expect(menuTrigger).toBeVisible()
|
||||||
|
await menuTrigger.click()
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
|
||||||
|
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click({ force: options?.force })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
||||||
|
const dialog = page.getByRole("dialog").first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSharePopover(page: Page) {
|
||||||
|
const rightSection = page.locator(titlebarRightSelector)
|
||||||
|
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
||||||
|
await expect(shareButton).toBeVisible()
|
||||||
|
|
||||||
|
const popoverBody = page
|
||||||
|
.locator(popoverBodySelector)
|
||||||
|
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||||
|
.first()
|
||||||
|
|
||||||
|
const opened = await popoverBody
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
await shareButton.click()
|
||||||
|
await expect(popoverBody).toBeVisible()
|
||||||
|
}
|
||||||
|
return { rightSection, popoverBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
||||||
|
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickListItem(
|
||||||
|
container: Locator | Page,
|
||||||
|
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
|
||||||
|
): Promise<Locator> {
|
||||||
|
let item: Locator
|
||||||
|
|
||||||
|
if (typeof filter === "string" || filter instanceof RegExp) {
|
||||||
|
item = container.locator(listItemSelector).filter({ hasText: filter }).first()
|
||||||
|
} else if (filter.keyStartsWith) {
|
||||||
|
item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
|
||||||
|
} else if (filter.key) {
|
||||||
|
item = container.locator(listItemKeySelector(filter.key)).first()
|
||||||
|
} else if (filter.text) {
|
||||||
|
item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid filter provided to clickListItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withSession<T>(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
title: string,
|
||||||
|
callback: (session: { id: string; title: string }) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||||
|
if (!session?.id) throw new Error("Session create did not return an id")
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await callback(session)
|
||||||
|
} finally {
|
||||||
|
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openStatusPopover(page: Page) {
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const rightSection = page.locator(titlebarRightSelector)
|
||||||
|
const trigger = rightSection.getByRole("button", { name: /status/i }).first()
|
||||||
|
|
||||||
|
const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
|
||||||
|
|
||||||
|
const opened = await popoverBody
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
await expect(popoverBody).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rightSection, popoverBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||||
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||||
|
await expect(trigger).toHaveCount(1)
|
||||||
|
|
||||||
|
await trigger.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
const opened = await menu
|
||||||
|
.waitFor({ state: "visible", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (opened) {
|
||||||
|
const viewport = page.viewportSize()
|
||||||
|
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||||
|
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||||
|
await page.mouse.move(x, y)
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const viewport = page.viewportSize()
|
||||||
|
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||||
|
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||||
|
await page.mouse.move(x, y)
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
|
const current = await page
|
||||||
|
.getByRole("button", { name: "New workspace" })
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (current === enabled) return
|
||||||
|
|
||||||
|
await openProjectMenu(page, projectSlug)
|
||||||
|
|
||||||
|
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await toggle.click({ force: true })
|
||||||
|
|
||||||
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||||
|
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
|
const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { dirPath, promptSelector } from "../utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { dirPath } from "../utils"
|
||||||
|
|
||||||
test("project route redirects to /session", async ({ page, directory, slug }) => {
|
test("project route redirects to /session", async ({ page, directory, slug }) => {
|
||||||
await page.goto(dirPath(directory))
|
await page.goto(dirPath(directory))
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey } from "../utils"
|
import { openPalette } from "../actions"
|
||||||
|
|
||||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+P`)
|
const dialog = await openPalette(page)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { serverName, serverUrl } from "../utils"
|
import { serverName, serverUrl } from "../utils"
|
||||||
|
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||||
|
|
||||||
@@ -33,31 +34,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
|||||||
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
||||||
await expect(row).toBeVisible()
|
await expect(row).toBeVisible()
|
||||||
|
|
||||||
const menu = row.locator('[data-component="icon-button"]').last()
|
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||||
await menu.click()
|
await expect(menuTrigger).toBeVisible()
|
||||||
await page.getByRole("menuitem", { name: "Set as default" }).click()
|
await menuTrigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await clickMenuItem(menu, /set as default/i)
|
||||||
|
|
||||||
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
||||||
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await closeDialog(page, dialog)
|
||||||
const closed = await dialog
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!closed) {
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
const closedSecond = await dialog
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!closedSecond) {
|
|
||||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensurePopoverOpen()
|
await ensurePopoverOpen()
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
||||||
const title = `e2e smoke ${Date.now()}`
|
const title = `e2e smoke ${Date.now()}`
|
||||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
|
||||||
|
|
||||||
if (!created?.id) throw new Error("Session create did not return an id")
|
await withSession(sdk, title, async (session) => {
|
||||||
const sessionID = created.id
|
await gotoSession(session.id)
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoSession(sessionID)
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("hello from e2e")
|
await page.keyboard.type("hello from e2e")
|
||||||
await expect(prompt).toContainText("hello from e2e")
|
await expect(prompt).toContainText("hello from e2e")
|
||||||
} finally {
|
})
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,52 +1,42 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey, promptSelector } from "../utils"
|
import { openSidebar, withSession } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data)
|
|
||||||
const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data)
|
|
||||||
|
|
||||||
if (!one?.id) throw new Error("Session create did not return an id")
|
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
|
||||||
if (!two?.id) throw new Error("Session create did not return an id")
|
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
|
||||||
|
await gotoSession(one.id)
|
||||||
|
|
||||||
try {
|
await openSidebar(page)
|
||||||
await gotoSession(one.id)
|
|
||||||
|
|
||||||
const main = page.locator("main")
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
|
await expect(link).toBeVisible()
|
||||||
if (collapsed) {
|
await link.scrollIntoViewIfNeeded()
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
await link.click()
|
||||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
|
||||||
}
|
|
||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
await expect(link).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await link.scrollIntoViewIfNeeded()
|
|
||||||
await link.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
const back = page.getByRole("button", { name: "Back" })
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
const forward = page.getByRole("button", { name: "Forward" })
|
||||||
|
|
||||||
const back = page.getByRole("button", { name: "Back" })
|
await expect(back).toBeVisible()
|
||||||
const forward = page.getByRole("button", { name: "Forward" })
|
await expect(back).toBeEnabled()
|
||||||
|
await back.click()
|
||||||
|
|
||||||
await expect(back).toBeVisible()
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||||
await expect(back).toBeEnabled()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await back.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
await expect(forward).toBeVisible()
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(forward).toBeEnabled()
|
||||||
|
await forward.click()
|
||||||
|
|
||||||
await expect(forward).toBeVisible()
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
await expect(forward).toBeEnabled()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await forward.click()
|
})
|
||||||
|
})
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
} finally {
|
|
||||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
|
||||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey } from "../utils"
|
import { openPalette, clickListItem } from "../actions"
|
||||||
|
|
||||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+P`)
|
const dialog = await openPalette(page)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill("package.json")
|
await input.fill("package.json")
|
||||||
|
|
||||||
const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
await clickListItem(dialog, { keyStartsWith: "file:" })
|
||||||
await expect(fileItem).toBeVisible()
|
|
||||||
await fileItem.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey } from "../utils"
|
import { openPalette, clickListItem } from "../actions"
|
||||||
|
|
||||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -7,21 +7,12 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
|||||||
const sep = process.platform === "win32" ? "\\" : "/"
|
const sep = process.platform === "win32" ? "\\" : "/"
|
||||||
const file = ["packages", "app", "package.json"].join(sep)
|
const file = ["packages", "app", "package.json"].join(sep)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+P`)
|
const dialog = await openPalette(page)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill(file)
|
await input.fill(file)
|
||||||
|
|
||||||
const fileItem = dialog
|
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
||||||
.locator(
|
|
||||||
'[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]',
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
await expect(fileItem).toBeVisible()
|
|
||||||
await fileItem.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { test as base, expect } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils"
|
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
||||||
|
import { promptSelector } from "./selectors"
|
||||||
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
|
export const settingsKey = "settings.v3"
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
sdk: ReturnType<typeof createSdk>
|
sdk: ReturnType<typeof createSdk>
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
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 = {
|
type WorkerFixtures = {
|
||||||
@@ -29,54 +41,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
await use(createSdk(directory))
|
await use(createSdk(directory))
|
||||||
},
|
},
|
||||||
gotoSession: async ({ page, directory }, use) => {
|
gotoSession: async ({ page, directory }, use) => {
|
||||||
await page.addInitScript(
|
await seedStorage(page, { directory })
|
||||||
(input: { directory: string; serverUrl: string }) => {
|
|
||||||
const key = "opencode.global.dat:server"
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
const parsed = (() => {
|
|
||||||
if (!raw) return undefined
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as unknown
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
|
||||||
const list = Array.isArray(store.list) ? store.list : []
|
|
||||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
|
||||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
|
||||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
|
||||||
|
|
||||||
const add = (origin: string) => {
|
|
||||||
const current = nextProjects[origin]
|
|
||||||
const items = Array.isArray(current) ? current : []
|
|
||||||
const existing = items.filter(
|
|
||||||
(p): p is { worktree: string; expanded?: boolean } =>
|
|
||||||
!!p &&
|
|
||||||
typeof p === "object" &&
|
|
||||||
"worktree" in p &&
|
|
||||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existing.some((p) => p.worktree === input.directory)) return
|
|
||||||
nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing]
|
|
||||||
}
|
|
||||||
|
|
||||||
add("local")
|
|
||||||
add(input.serverUrl)
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
key,
|
|
||||||
JSON.stringify({
|
|
||||||
list,
|
|
||||||
projects: nextProjects,
|
|
||||||
lastProject,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, serverUrl },
|
|
||||||
)
|
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
@@ -84,6 +49,39 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
}
|
}
|
||||||
await use(gotoSession)
|
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 }
|
export { expect }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { clickListItem } from "../actions"
|
||||||
|
|
||||||
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -32,9 +33,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
|||||||
|
|
||||||
await input.fill(model)
|
await input.fill(model)
|
||||||
|
|
||||||
const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`)
|
await clickListItem(dialog, { key })
|
||||||
await expect(item).toBeVisible()
|
|
||||||
await item.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey, promptSelector } from "../utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { closeDialog, openSettings, clickListItem } from "../actions"
|
||||||
|
|
||||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -27,18 +28,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
|
|||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
await expect(picker).toHaveCount(0)
|
await expect(picker).toHaveCount(0)
|
||||||
|
|
||||||
const settings = page.getByRole("dialog")
|
const settings = await openSettings(page)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
|
||||||
const opened = await settings
|
|
||||||
.waitFor({ state: "visible", timeout: 3000 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!opened) {
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
|
||||||
await expect(settings).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
await settings.getByRole("tab", { name: "Models" }).click()
|
await settings.getByRole("tab", { name: "Models" }).click()
|
||||||
const search = settings.getByPlaceholder("Search models")
|
const search = settings.getByPlaceholder("Search models")
|
||||||
@@ -52,22 +42,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
|
|||||||
await toggle.locator('[data-slot="switch-control"]').click()
|
await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await closeDialog(page, settings)
|
||||||
const closed = await settings
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!closed) {
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
const closedSecond = await settings
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!closedSecond) {
|
|
||||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
|
||||||
await expect(settings).toHaveCount(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
await page.keyboard.type("/model")
|
await page.keyboard.type("/model")
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { openSidebar } from "../actions"
|
||||||
|
|
||||||
|
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await withProject(async () => {
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
const header = page.locator(".group\\/project").first()
|
||||||
|
await header.hover()
|
||||||
|
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||||
|
await expect(editItem).toBeVisible()
|
||||||
|
await editItem.click({ force: true })
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = `e2e project ${Date.now()}`
|
||||||
|
const startup = `echo e2e_${Date.now()}`
|
||||||
|
|
||||||
|
const dialog = await open()
|
||||||
|
|
||||||
|
const nameInput = dialog.getByLabel("Name")
|
||||||
|
await nameInput.fill(name)
|
||||||
|
|
||||||
|
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||||
|
await startupInput.fill(startup)
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Save" }).click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const header = page.locator(".group\\/project").first()
|
||||||
|
await expect(header).toContainText(name)
|
||||||
|
|
||||||
|
const reopened = await open()
|
||||||
|
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
||||||
|
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
||||||
|
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||||
|
await expect(reopened).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||||
|
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
||||||
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
|
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withProject(
|
||||||
|
async () => {
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.hover()
|
||||||
|
|
||||||
|
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
await close.click()
|
||||||
|
|
||||||
|
await expect(otherButton).toHaveCount(0)
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can close a project via project header more options menu", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherName = other.split("/").pop() ?? other
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withProject(
|
||||||
|
async () => {
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
|
const header = page
|
||||||
|
.locator(".group\\/project")
|
||||||
|
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
||||||
|
.first()
|
||||||
|
await expect(header).toContainText(otherName)
|
||||||
|
|
||||||
|
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||||
|
await expect(trigger).toHaveCount(1)
|
||||||
|
await trigger.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
|
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
await expect(otherButton).toHaveCount(0)
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
||||||
|
import { projectSwitchSelector } from "../selectors"
|
||||||
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withProject(
|
||||||
|
async ({ directory }) => {
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const currentSlug = dirSlug(directory)
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
|
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||||
|
await expect(currentButton).toBeVisible()
|
||||||
|
await currentButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" })
|
||||||
|
import {
|
||||||
|
cleanupTestProject,
|
||||||
|
clickMenuItem,
|
||||||
|
confirmDialog,
|
||||||
|
openSidebar,
|
||||||
|
openWorkspaceMenu,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
} from "../actions"
|
||||||
|
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
|
|
||||||
|
function slugFromUrl(url: string) {
|
||||||
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||||
|
const rootSlug = project.slug
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
return slug.length > 0 && slug !== rootSlug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
const dir = base64Decode(slug)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
return { rootSlug, slug, directory: dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await withProject(async ({ slug }) => {
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
||||||
|
|
||||||
|
await setWorkspacesEnabled(page, slug, false)
|
||||||
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can create a workspace", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await withProject(async ({ slug }) => {
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const currentSlug = slugFromUrl(page.url())
|
||||||
|
return currentSlug.length > 0 && currentSlug !== slug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const workspaceSlug = slugFromUrl(page.url())
|
||||||
|
const workspaceDir = base64Decode(workspaceSlug)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
||||||
|
|
||||||
|
await cleanupTestProject(workspaceDir)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can rename a workspace", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const { slug } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const rename = `e2e workspace ${Date.now()}`
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||||
|
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
const input = item.locator(inlineInputSelector).first()
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(rename)
|
||||||
|
await input.press("Enter")
|
||||||
|
await expect(item).toContainText(rename)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const readme = path.join(createdDir, "README.md")
|
||||||
|
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||||
|
const original = await fs.readFile(readme, "utf8")
|
||||||
|
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
||||||
|
await fs.writeFile(readme, dirty, "utf8")
|
||||||
|
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const files = await sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Reset workspace$/i)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const files = await sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
|
||||||
|
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can delete a workspace", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||||
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await withProject(async ({ slug: rootSlug }) => {
|
||||||
|
const workspaces = [] as { directory: string; slug: string }[]
|
||||||
|
|
||||||
|
const listSlugs = async () => {
|
||||||
|
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||||
|
const slugs = await nodes.evaluateAll((els) => {
|
||||||
|
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
||||||
|
})
|
||||||
|
return slugs
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitReady = async (slug: string) => {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = async (from: string, to: string) => {
|
||||||
|
const src = page.locator(workspaceItemSelector(from)).first()
|
||||||
|
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||||
|
|
||||||
|
await src.scrollIntoViewIfNeeded()
|
||||||
|
await dst.scrollIntoViewIfNeeded()
|
||||||
|
|
||||||
|
const a = await src.boundingBox()
|
||||||
|
const b = await dst.boundingBox()
|
||||||
|
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||||
|
|
||||||
|
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||||
|
await page.mouse.up()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
|
for (const _ of [0, 1]) {
|
||||||
|
const prev = slugFromUrl(page.url())
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
const dir = base64Decode(slug)
|
||||||
|
workspaces.push({ slug, directory: dir })
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
||||||
|
|
||||||
|
const a = workspaces[0].slug
|
||||||
|
const b = workspaces[1].slug
|
||||||
|
|
||||||
|
await waitReady(a)
|
||||||
|
await waitReady(b)
|
||||||
|
|
||||||
|
const list = async () => {
|
||||||
|
const slugs = await listSlugs()
|
||||||
|
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const slugs = await list()
|
||||||
|
return slugs.length === 2
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const before = await list()
|
||||||
|
const from = before[1]
|
||||||
|
const to = before[0]
|
||||||
|
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
||||||
|
|
||||||
|
await drag(from, to)
|
||||||
|
|
||||||
|
await expect.poll(async () => await list()).toEqual([from, to])
|
||||||
|
} finally {
|
||||||
|
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||||
const title = `e2e smoke context ${Date.now()}`
|
const title = `e2e smoke context ${Date.now()}`
|
||||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
|
||||||
|
|
||||||
if (!created?.id) throw new Error("Session create did not return an id")
|
await withSession(sdk, title, async (session) => {
|
||||||
const sessionID = created.id
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sdk.session.promptAsync({
|
await sdk.session.promptAsync({
|
||||||
sessionID,
|
sessionID: session.id,
|
||||||
noReply: true,
|
noReply: true,
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
@@ -22,12 +19,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
|||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
||||||
return messages.length
|
return messages.length
|
||||||
})
|
})
|
||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
|
|
||||||
await gotoSession(sessionID)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const contextButton = page
|
const contextButton = page
|
||||||
.locator('[data-component="button"]')
|
.locator('[data-component="button"]')
|
||||||
@@ -39,7 +36,5 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
|||||||
|
|
||||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
} finally {
|
})
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
|
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
|
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { sessionIDFromUrl, withSession } from "../actions"
|
||||||
function sessionIDFromUrl(url: string) {
|
|
||||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
|
||||||
return match?.[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
|
export const terminalSelector = '[data-component="terminal"]'
|
||||||
|
|
||||||
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
|
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||||
|
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
||||||
|
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
||||||
|
export const settingsFontSelector = '[data-action="settings-font"]'
|
||||||
|
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
||||||
|
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||||
|
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||||
|
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
||||||
|
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
||||||
|
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
||||||
|
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||||
|
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||||
|
|
||||||
|
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||||
|
|
||||||
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectMenuTriggerSelector = (slug: string) =>
|
||||||
|
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const titlebarRightSelector = "#opencode-titlebar-right"
|
||||||
|
|
||||||
|
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||||
|
|
||||||
|
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
||||||
|
|
||||||
|
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||||
|
|
||||||
|
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||||
|
|
||||||
|
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||||
|
|
||||||
|
export const workspaceItemSelector = (slug: string) =>
|
||||||
|
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
|
||||||
|
|
||||||
|
export const workspaceMenuTriggerSelector = (slug: string) =>
|
||||||
|
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
|
||||||
|
|
||||||
|
export const listItemSelector = '[data-slot="list-item"]'
|
||||||
|
|
||||||
|
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
||||||
|
|
||||||
|
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
|
||||||
|
|
||||||
|
export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import {
|
||||||
|
openSidebar,
|
||||||
|
openSessionMoreMenu,
|
||||||
|
clickMenuItem,
|
||||||
|
confirmDialog,
|
||||||
|
openSharePopover,
|
||||||
|
withSession,
|
||||||
|
} from "../actions"
|
||||||
|
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||||
|
|
||||||
|
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||||
|
|
||||||
|
test("sidebar session can be renamed", 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 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()
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(newTitle)
|
||||||
|
await input.press("Enter")
|
||||||
|
|
||||||
|
await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => {
|
||||||
|
const stamp = Date.now()
|
||||||
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => {
|
||||||
|
const stamp = Date.now()
|
||||||
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
|
const stamp = Date.now()
|
||||||
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||||
|
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeUndefined()
|
||||||
|
|
||||||
|
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
||||||
|
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const sharedPopover = await openSharePopover(page)
|
||||||
|
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||||
|
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||||
|
await unpublish.click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBeUndefined()
|
||||||
|
|
||||||
|
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const unsharedPopover = await openSharePopover(page)
|
||||||
|
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { openSettings, closeDialog, withSession } from "../actions"
|
||||||
|
import { keybindButtonSelector } from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
const initialKeybind = await keybindButton.textContent()
|
||||||
|
expect(initialKeybind).toContain("B")
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+KeyH`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newKeybind = await keybindButton.textContent()
|
||||||
|
expect(newKeybind).toContain("H")
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
|
const main = page.locator("main")
|
||||||
|
const initialClasses = (await main.getAttribute("class")) ?? ""
|
||||||
|
const initiallyClosed = initialClasses.includes("xl:border-l")
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
||||||
|
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
|
||||||
|
expect(afterToggleClosed).toBe(!initiallyClosed)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const finalClasses = (await main.getAttribute("class")) ?? ""
|
||||||
|
const finalClosed = finalClasses.includes("xl:border-l")
|
||||||
|
expect(finalClosed).toBe(initiallyClosed)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
const customKeybind = await keybindButton.textContent()
|
||||||
|
expect(customKeybind).toContain("X")
|
||||||
|
|
||||||
|
const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
|
||||||
|
await expect(resetButton).toBeVisible()
|
||||||
|
await expect(resetButton).toBeEnabled()
|
||||||
|
await resetButton.click()
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const restoredKeybind = await keybindButton.textContent()
|
||||||
|
expect(restoredKeybind).toContain("B")
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clearing a keybind works", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
const initialKeybind = await keybindButton.textContent()
|
||||||
|
expect(initialKeybind).toContain("B")
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
||||||
|
await page.keyboard.press("Delete")
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const clearedKeybind = await keybindButton.textContent()
|
||||||
|
expect(clearedKeybind).toMatch(/unassigned|press/i)
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+B`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const stillOnSession = page.url().includes("/session")
|
||||||
|
expect(stillOnSession).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing settings open keybind works", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
const initialKeybind = await keybindButton.textContent()
|
||||||
|
expect(initialKeybind).toContain(",")
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Slash`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newKeybind = await keybindButton.textContent()
|
||||||
|
expect(newKeybind).toContain("/")
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
|
const settingsDialog = page.getByRole("dialog")
|
||||||
|
await expect(settingsDialog).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Slash`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
await expect(settingsDialog).toBeVisible()
|
||||||
|
|
||||||
|
await closeDialog(page, settingsDialog)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, "test session for keybind", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const initialUrl = page.url()
|
||||||
|
expect(initialUrl).toContain(`/session/${session.id}`)
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+KeyN`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newKeybind = await keybindButton.textContent()
|
||||||
|
expect(newKeybind).toContain("N")
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+N`)
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
const newUrl = page.url()
|
||||||
|
expect(newUrl).toMatch(/\/session\/?$/)
|
||||||
|
expect(newUrl).not.toContain(session.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing file open keybind works", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
const initialKeybind = await keybindButton.textContent()
|
||||||
|
expect(initialKeybind).toContain("P")
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+KeyF`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newKeybind = await keybindButton.textContent()
|
||||||
|
expect(newKeybind).toContain("F")
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
|
const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
await expect(filePickerDialog).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+F`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
await expect(filePickerDialog).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(filePickerDialog).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+KeyY`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newKeybind = await keybindButton.textContent()
|
||||||
|
expect(newKeybind).toContain("Y")
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const pageStable = await page.evaluate(() => document.readyState === "complete")
|
||||||
|
expect(pageStable).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing command palette keybind works", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
const initialKeybind = await keybindButton.textContent()
|
||||||
|
expect(initialKeybind).toContain("P")
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+KeyK`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newKeybind = await keybindButton.textContent()
|
||||||
|
expect(newKeybind).toContain("K")
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
|
const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
|
||||||
|
await expect(palette).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+K`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
await expect(palette).toBeVisible()
|
||||||
|
await expect(palette.getByRole("textbox").first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(palette).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { modKey, settingsLanguageSelectSelector } from "../utils"
|
|
||||||
|
|
||||||
test("smoke changing language updates settings labels", async ({ page, gotoSession }) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
|
||||||
|
|
||||||
const opened = await dialog
|
|
||||||
.waitFor({ state: "visible", timeout: 3000 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!opened) {
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
const heading = dialog.getByRole("heading", { level: 2 })
|
|
||||||
await expect(heading).toHaveText("General")
|
|
||||||
|
|
||||||
const select = dialog.locator(settingsLanguageSelectSelector)
|
|
||||||
await expect(select).toBeVisible()
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
|
|
||||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
|
|
||||||
|
|
||||||
await expect(heading).toHaveText("Allgemein")
|
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
|
|
||||||
await expect(heading).toHaveText("General")
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { closeDialog, openSettings } from "../actions"
|
||||||
|
|
||||||
|
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/model")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="model.choose"]')
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const picker = page.getByRole("dialog")
|
||||||
|
await expect(picker).toBeVisible()
|
||||||
|
|
||||||
|
const target = picker.locator('[data-slot="list-item"]').first()
|
||||||
|
await expect(target).toBeVisible()
|
||||||
|
|
||||||
|
const key = await target.getAttribute("data-key")
|
||||||
|
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||||
|
|
||||||
|
const name = (await target.locator("span").first().innerText()).trim()
|
||||||
|
if (!name) throw new Error("Failed to resolve model name from list item")
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(picker).toHaveCount(0)
|
||||||
|
|
||||||
|
const settings = await openSettings(page)
|
||||||
|
|
||||||
|
await settings.getByRole("tab", { name: "Models" }).click()
|
||||||
|
const search = settings.getByPlaceholder("Search models")
|
||||||
|
await expect(search).toBeVisible()
|
||||||
|
await search.fill(name)
|
||||||
|
|
||||||
|
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
||||||
|
const input = toggle.locator('[data-slot="switch-input"]')
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||||
|
await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||||
|
|
||||||
|
await closeDialog(page, settings)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/model")
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const pickerAgain = page.getByRole("dialog")
|
||||||
|
await expect(pickerAgain).toBeVisible()
|
||||||
|
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
|
||||||
|
|
||||||
|
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(pickerAgain).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/model")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="model.choose"]')
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const picker = page.getByRole("dialog")
|
||||||
|
await expect(picker).toBeVisible()
|
||||||
|
|
||||||
|
const target = picker.locator('[data-slot="list-item"]').first()
|
||||||
|
await expect(target).toBeVisible()
|
||||||
|
|
||||||
|
const key = await target.getAttribute("data-key")
|
||||||
|
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||||
|
|
||||||
|
const name = (await target.locator("span").first().innerText()).trim()
|
||||||
|
if (!name) throw new Error("Failed to resolve model name from list item")
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(picker).toHaveCount(0)
|
||||||
|
|
||||||
|
const settings = await openSettings(page)
|
||||||
|
|
||||||
|
await settings.getByRole("tab", { name: "Models" }).click()
|
||||||
|
const search = settings.getByPlaceholder("Search models")
|
||||||
|
await expect(search).toBeVisible()
|
||||||
|
await search.fill(name)
|
||||||
|
|
||||||
|
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
||||||
|
const input = toggle.locator('[data-slot="switch-input"]')
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||||
|
|
||||||
|
await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||||
|
|
||||||
|
await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||||
|
|
||||||
|
await closeDialog(page, settings)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/model")
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const pickerAgain = page.getByRole("dialog")
|
||||||
|
await expect(pickerAgain).toBeVisible()
|
||||||
|
|
||||||
|
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(pickerAgain).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -1,56 +1,136 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey, promptSelector } from "../utils"
|
import { closeDialog, openSettings } from "../actions"
|
||||||
|
|
||||||
test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
|
test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const settings = await openSettings(page)
|
||||||
|
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||||
|
await expect(customProviderSection).toBeVisible()
|
||||||
|
|
||||||
const opened = await dialog
|
const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
|
||||||
.waitFor({ state: "visible", timeout: 3000 })
|
await connectButton.click()
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!opened) {
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
await dialog.getByRole("tab", { name: "Providers" }).click()
|
|
||||||
await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
|
|
||||||
await expect(dialog.getByText("Popular providers", { exact: true })).toBeVisible()
|
|
||||||
|
|
||||||
await dialog.getByRole("button", { name: "Show more providers" }).click()
|
|
||||||
|
|
||||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search providers") })
|
|
||||||
|
|
||||||
|
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||||
await expect(providerDialog).toBeVisible()
|
await expect(providerDialog).toBeVisible()
|
||||||
await expect(providerDialog.getByPlaceholder("Search providers")).toBeVisible()
|
|
||||||
await expect(providerDialog.locator('[data-slot="list-item"]').first()).toBeVisible()
|
await providerDialog.getByLabel("Provider ID").fill("test-provider")
|
||||||
|
await providerDialog.getByLabel("Display name").fill("Test Provider")
|
||||||
|
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
|
||||||
|
await providerDialog.getByLabel("API key").fill("fake-key")
|
||||||
|
|
||||||
|
await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
|
||||||
|
await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
|
||||||
|
|
||||||
|
await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
|
||||||
|
await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
|
||||||
|
await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
|
||||||
|
await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
|
||||||
|
await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
|
||||||
|
await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
await expect(providerDialog).toHaveCount(0)
|
await expect(providerDialog).toHaveCount(0)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
const stillOpen = await dialog.isVisible().catch(() => false)
|
await closeDialog(page, settings)
|
||||||
if (!stillOpen) return
|
})
|
||||||
|
|
||||||
|
test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const settings = await openSettings(page)
|
||||||
|
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||||
|
|
||||||
|
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||||
|
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
||||||
|
|
||||||
|
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||||
|
await expect(providerDialog).toBeVisible()
|
||||||
|
|
||||||
|
await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
|
||||||
|
await providerDialog.getByLabel("Base URL").fill("not-a-url")
|
||||||
|
|
||||||
|
await providerDialog.getByRole("button", { name: /submit|save/i }).click()
|
||||||
|
|
||||||
|
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
|
||||||
|
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(providerDialog).toHaveCount(0)
|
||||||
|
|
||||||
|
await closeDialog(page, settings)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const settings = await openSettings(page)
|
||||||
|
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||||
|
|
||||||
|
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||||
|
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
||||||
|
|
||||||
|
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||||
|
await expect(providerDialog).toBeVisible()
|
||||||
|
|
||||||
|
await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
|
||||||
|
await providerDialog.getByLabel("Display name").fill("Multi Model Test")
|
||||||
|
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
|
||||||
|
|
||||||
|
await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
|
||||||
|
await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
|
||||||
|
|
||||||
|
const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
|
||||||
|
await providerDialog.getByRole("button", { name: "Add model" }).click()
|
||||||
|
const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
|
||||||
|
expect(idInputsAfter).toBe(idInputsBefore + 1)
|
||||||
|
|
||||||
|
await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
|
||||||
|
await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
|
||||||
|
|
||||||
|
await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
|
||||||
|
await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
const closed = await dialog
|
await expect(providerDialog).toHaveCount(0)
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
await closeDialog(page, settings)
|
||||||
.catch(() => false)
|
})
|
||||||
if (closed) return
|
|
||||||
|
test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const settings = await openSettings(page)
|
||||||
|
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||||
|
|
||||||
|
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||||
|
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
||||||
|
|
||||||
|
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||||
|
await expect(providerDialog).toBeVisible()
|
||||||
|
|
||||||
|
await providerDialog.getByLabel("Provider ID").fill("header-test")
|
||||||
|
await providerDialog.getByLabel("Display name").fill("Header Test")
|
||||||
|
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
|
||||||
|
|
||||||
|
await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
|
||||||
|
await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
|
||||||
|
|
||||||
|
const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
|
||||||
|
await providerDialog.getByRole("button", { name: "Add header" }).click()
|
||||||
|
const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
|
||||||
|
expect(headerInputsAfter).toBe(headerInputsBefore + 1)
|
||||||
|
|
||||||
|
await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
|
||||||
|
await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
|
||||||
|
|
||||||
|
await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
|
||||||
|
await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
const closedSecond = await dialog
|
await expect(providerDialog).toHaveCount(0)
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
await closeDialog(page, settings)
|
||||||
.catch(() => false)
|
|
||||||
if (closedSecond) return
|
|
||||||
|
|
||||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,44 +1,292 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect, settingsKey } from "../fixtures"
|
||||||
import { modKey } from "../utils"
|
import { closeDialog, openSettings } from "../actions"
|
||||||
|
import {
|
||||||
|
settingsColorSchemeSelector,
|
||||||
|
settingsFontSelector,
|
||||||
|
settingsLanguageSelectSelector,
|
||||||
|
settingsNotificationsAgentSelector,
|
||||||
|
settingsNotificationsErrorsSelector,
|
||||||
|
settingsNotificationsPermissionsSelector,
|
||||||
|
settingsReleaseNotesSelector,
|
||||||
|
settingsSoundsAgentSelector,
|
||||||
|
settingsThemeSelector,
|
||||||
|
settingsUpdatesStartupSelector,
|
||||||
|
} from "../selectors"
|
||||||
|
|
||||||
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
|
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = await openSettings(page)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
|
||||||
|
|
||||||
const opened = await dialog
|
|
||||||
.waitFor({ state: "visible", timeout: 3000 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!opened) {
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
|
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
|
||||||
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
|
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing language updates settings labels", async ({ page, gotoSession }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
|
||||||
|
const heading = dialog.getByRole("heading", { level: 2 })
|
||||||
|
await expect(heading).toHaveText("General")
|
||||||
|
|
||||||
|
const select = dialog.locator(settingsLanguageSelectSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
|
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
|
||||||
|
|
||||||
|
await expect(heading).toHaveText("Allgemein")
|
||||||
|
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
|
||||||
|
await expect(heading).toHaveText("General")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const select = dialog.locator(settingsColorSchemeSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||||
|
|
||||||
|
const colorScheme = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute("data-color-scheme")
|
||||||
|
})
|
||||||
|
expect(colorScheme).toBe("dark")
|
||||||
|
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
|
||||||
|
|
||||||
|
const lightColorScheme = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute("data-color-scheme")
|
||||||
|
})
|
||||||
|
expect(lightColorScheme).toBe("light")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const select = dialog.locator(settingsThemeSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
|
const count = await items.count()
|
||||||
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
|
||||||
|
expect(firstTheme).toBeTruthy()
|
||||||
|
|
||||||
|
await items.nth(1).click()
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
|
const storedThemeId = await page.evaluate(() => {
|
||||||
|
return localStorage.getItem("opencode-theme-id")
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(storedThemeId).not.toBeNull()
|
||||||
|
expect(storedThemeId).not.toBe("oc-1")
|
||||||
|
|
||||||
|
const dataTheme = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute("data-theme")
|
||||||
|
})
|
||||||
|
expect(dataTheme).toBe(storedThemeId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const select = dialog.locator(settingsFontSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
|
||||||
|
const initialFontFamily = await page.evaluate(() => {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||||
|
})
|
||||||
|
expect(initialFontFamily).toContain("IBM Plex Mono")
|
||||||
|
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
|
await items.nth(2).click()
|
||||||
|
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
|
||||||
|
|
||||||
|
const newFontFamily = await page.evaluate(() => {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||||
|
})
|
||||||
|
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
|
||||||
|
await expect(switchContainer).toBeVisible()
|
||||||
|
|
||||||
|
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||||
|
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(initialState).toBe(true)
|
||||||
|
|
||||||
const closed = await dialog
|
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
await page.waitForTimeout(100)
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (closed) return
|
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(newState).toBe(false)
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
const stored = await page.evaluate((key) => {
|
||||||
const closedSecond = await dialog
|
const raw = localStorage.getItem(key)
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
return raw ? JSON.parse(raw) : null
|
||||||
.then(() => true)
|
}, settingsKey)
|
||||||
.catch(() => false)
|
|
||||||
|
expect(stored?.notifications?.agent).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
|
||||||
|
await expect(switchContainer).toBeVisible()
|
||||||
|
|
||||||
|
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||||
|
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(initialState).toBe(true)
|
||||||
|
|
||||||
|
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(newState).toBe(false)
|
||||||
|
|
||||||
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
expect(stored?.notifications?.permissions).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
|
||||||
|
await expect(switchContainer).toBeVisible()
|
||||||
|
|
||||||
|
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||||
|
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(initialState).toBe(false)
|
||||||
|
|
||||||
|
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(newState).toBe(true)
|
||||||
|
|
||||||
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
expect(stored?.notifications?.errors).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const select = dialog.locator(settingsSoundsAgentSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
|
await items.nth(2).click()
|
||||||
|
|
||||||
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
|
||||||
|
await expect(switchContainer).toBeVisible()
|
||||||
|
|
||||||
|
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||||
|
|
||||||
|
const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
|
||||||
|
if (isDisabled) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(initialState).toBe(true)
|
||||||
|
|
||||||
|
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(newState).toBe(false)
|
||||||
|
|
||||||
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
expect(stored?.updates?.startup).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const switchContainer = dialog.locator(settingsReleaseNotesSelector)
|
||||||
|
await expect(switchContainer).toBeVisible()
|
||||||
|
|
||||||
|
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||||
|
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(initialState).toBe(true)
|
||||||
|
|
||||||
|
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||||
|
expect(newState).toBe(false)
|
||||||
|
|
||||||
if (closedSecond) return
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
expect(stored?.general?.releaseNotes).toBe(false)
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey, promptSelector } from "../utils"
|
import { openSidebar, withSession } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
@@ -13,12 +14,7 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
|||||||
try {
|
try {
|
||||||
await gotoSession(one.id)
|
await gotoSession(one.id)
|
||||||
|
|
||||||
const main = page.locator("main")
|
await openSidebar(page)
|
||||||
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
|
|
||||||
if (collapsed) {
|
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
|
||||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(target).toBeVisible()
|
await expect(target).toBeVisible()
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey } from "../utils"
|
import { openSidebar, toggleSidebar } from "../actions"
|
||||||
|
|
||||||
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const main = page.locator("main")
|
await openSidebar(page)
|
||||||
const closedClass = /xl:border-l/
|
|
||||||
const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
|
|
||||||
|
|
||||||
if (isClosed) {
|
await toggleSidebar(page)
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
await expect(main).not.toHaveClass(closedClass)
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
await toggleSidebar(page)
|
||||||
await expect(main).toHaveClass(closedClass)
|
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
|
||||||
await expect(main).not.toHaveClass(closedClass)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { openStatusPopover, defocus } from "../actions"
|
||||||
|
|
||||||
|
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const { popoverBody } = await openStatusPopover(page)
|
||||||
|
|
||||||
|
await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
|
||||||
|
await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
|
||||||
|
await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
|
||||||
|
await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(popoverBody).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const { popoverBody } = await openStatusPopover(page)
|
||||||
|
|
||||||
|
const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
|
||||||
|
await expect(serversTab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
const serverList = popoverBody.locator('[role="tabpanel"]').first()
|
||||||
|
await expect(serverList.locator("button").first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const { popoverBody } = await openStatusPopover(page)
|
||||||
|
|
||||||
|
const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
|
||||||
|
await mcpTab.click()
|
||||||
|
|
||||||
|
const ariaSelected = await mcpTab.getAttribute("aria-selected")
|
||||||
|
expect(ariaSelected).toBe("true")
|
||||||
|
|
||||||
|
const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
||||||
|
await expect(mcpContent).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const { popoverBody } = await openStatusPopover(page)
|
||||||
|
|
||||||
|
const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
|
||||||
|
await lspTab.click()
|
||||||
|
|
||||||
|
const ariaSelected = await lspTab.getAttribute("aria-selected")
|
||||||
|
expect(ariaSelected).toBe("true")
|
||||||
|
|
||||||
|
const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
||||||
|
await expect(lspContent).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const { popoverBody } = await openStatusPopover(page)
|
||||||
|
|
||||||
|
const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
|
||||||
|
await pluginsTab.click()
|
||||||
|
|
||||||
|
const ariaSelected = await pluginsTab.getAttribute("aria-selected")
|
||||||
|
expect(ariaSelected).toBe("true")
|
||||||
|
|
||||||
|
const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
||||||
|
await expect(pluginsContent).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("status popover closes on escape", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const { popoverBody } = await openStatusPopover(page)
|
||||||
|
await expect(popoverBody).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(popoverBody).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const { popoverBody } = await openStatusPopover(page)
|
||||||
|
await expect(popoverBody).toBeVisible()
|
||||||
|
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
await expect(popoverBody).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector, terminalSelector, terminalToggleKey } from "../utils"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
|
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { terminalSelector, terminalToggleKey } from "../utils"
|
import { terminalSelector } from "../selectors"
|
||||||
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "./fixtures"
|
||||||
import { modelVariantCycleSelector } from "./utils"
|
import { modelVariantCycleSelector } from "./selectors"
|
||||||
|
|
||||||
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
|
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node"]
|
"types": ["node", "bun"]
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts"]
|
"include": ["./**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ export const serverName = `${serverHost}:${serverPort}`
|
|||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
export const promptSelector = '[data-component="prompt-input"]'
|
|
||||||
export const terminalSelector = '[data-component="terminal"]'
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
|
||||||
|
|
||||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
|
||||||
|
|
||||||
export function createSdk(directory?: string) {
|
export function createSdk(directory?: string) {
|
||||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.46",
|
"version": "1.1.51",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
|||||||
|
|
||||||
const serverEnv = {
|
const serverEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENCODE_DISABLE_SHARE: "true",
|
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||||
|
|||||||
@@ -241,19 +241,19 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
protected _rowEnd(row: number, isLastRow: boolean): void {
|
protected _rowEnd(row: number, isLastRow: boolean): void {
|
||||||
let rowSeparator = ""
|
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._currentRow += " ".repeat(this._nullCellCount)
|
||||||
this._nullCellCount = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLastRow) {
|
this._nullCellCount = 0
|
||||||
const nextLine = this._buffer.getLine(row + 1)
|
|
||||||
|
|
||||||
if (!nextLine?.isWrapped) {
|
if (!isLastRow && !wrapped) {
|
||||||
rowSeparator = "\r\n"
|
rowSeparator = "\r\n"
|
||||||
this._lastCursorRow = row + 1
|
this._lastCursorRow = row + 1
|
||||||
this._lastCursorCol = 0
|
this._lastCursorCol = 0
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._allRows[this._rowIndex] = this._currentRow
|
this._allRows[this._rowIndex] = this._currentRow
|
||||||
@@ -389,7 +389,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
|
|
||||||
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
|
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 (styleChanged) {
|
||||||
if (this._nullCellCount > 0) {
|
if (this._nullCellCount > 0) {
|
||||||
@@ -442,12 +442,24 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!excludeFinalCursorPosition) {
|
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 absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
|
||||||
const cursorCol = this._buffer.cursorX + 1
|
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
|
||||||
content += `\u001b[${cursorRow};${cursorCol}H`
|
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
|
return content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
if (store.authorization?.method === "code" && store.authorization?.url) {
|
||||||
platform.openLink(store.authorization.url)
|
void platform.openLink(store.authorization.url).catch(() => undefined)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -396,7 +396,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (store.authorization?.url) {
|
if (store.authorization?.url) {
|
||||||
platform.openLink(store.authorization.url)
|
void platform.openLink(store.authorization.url).catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
const result = await globalSDK.client.provider.oauth
|
||||||
|
|||||||
@@ -158,22 +158,22 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<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={{
|
classList={{
|
||||||
"opacity-100": store.iconHover && !store.iconUrl,
|
"opacity-100": store.iconHover && !store.iconUrl,
|
||||||
"opacity-0": !(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>
|
||||||
<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={{
|
classList={{
|
||||||
"opacity-100": store.iconHover && !!store.iconUrl,
|
"opacity-100": store.iconHover && !!store.iconUrl,
|
||||||
"opacity-0": !(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>
|
||||||
</div>
|
</div>
|
||||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import type { ListRef } from "@opencode-ai/ui/list"
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps {
|
|||||||
onSelect: (result: string | string[] | null) => void
|
onSelect: (result: string | string[] | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
absolute: string
|
||||||
|
search: string
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const sync = useGlobalSync()
|
const sync = useGlobalSync()
|
||||||
const sdk = useGlobalSDK()
|
const sdk = useGlobalSDK()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
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 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) {
|
function normalize(input: string) {
|
||||||
const v = input.replaceAll("\\", "/")
|
const v = input.replaceAll("\\", "/")
|
||||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||||
@@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
return ""
|
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)
|
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()
|
const h = home()
|
||||||
if (!h) return full
|
if (!h) return ""
|
||||||
|
|
||||||
const hn = trimTrailing(h)
|
const hn = trimTrailing(h)
|
||||||
const lc = full.toLowerCase()
|
const lc = full.toLowerCase()
|
||||||
const hc = hn.toLowerCase()
|
const hc = hn.toLowerCase()
|
||||||
if (lc === hc) return "~"
|
if (lc === hc) return "~"
|
||||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
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()
|
const base = start()
|
||||||
if (!base) return
|
if (!base) return
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(filter.trim())
|
const raw = normalizeDriveRoot(value)
|
||||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||||
|
|
||||||
const h = home()
|
const h = home()
|
||||||
@@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const directories = async (filter: string) => {
|
const directories = async (filter: string) => {
|
||||||
const input = scoped(filter)
|
const value = clean(filter)
|
||||||
if (!input) return [] as string[]
|
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 isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||||
|
|
||||||
const query = normalizeDriveRoot(input.path)
|
const query = normalizeDriveRoot(scopedInput.path)
|
||||||
|
|
||||||
if (!isPath) {
|
const find = () =>
|
||||||
const results = await sdk.client.find
|
sdk.client.find
|
||||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.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("/")
|
const segments = query.replace(/^\/+/, "").split("/")
|
||||||
@@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
|
|
||||||
const cap = 12
|
const cap = 12
|
||||||
const branch = 4
|
const branch = 4
|
||||||
let paths = [input.directory]
|
let paths = [scopedInput.directory]
|
||||||
for (const part of head) {
|
for (const part of head) {
|
||||||
if (part === "..") {
|
if (part === "..") {
|
||||||
paths = paths.map((p) => {
|
paths = paths.map(parentOf)
|
||||||
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)
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
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) {
|
function resolve(absolute: string) {
|
||||||
@@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
|
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
|
||||||
emptyMessage={language.t("dialog.directory.empty")}
|
emptyMessage={language.t("dialog.directory.empty")}
|
||||||
loadingMessage={language.t("common.loading")}
|
loadingMessage={language.t("common.loading")}
|
||||||
items={directories}
|
items={items}
|
||||||
key={(x) => x}
|
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) => {
|
onSelect={(path) => {
|
||||||
if (!path) return
|
if (!path) return
|
||||||
resolve(path)
|
resolve(path.absolute)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(absolute) => {
|
{(item) => {
|
||||||
const path = display(absolute)
|
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 (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<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">
|
<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">
|
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||||
{getDirectory(path)}
|
{getDirectory(path)}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
|
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
|
||||||
|
<span class="text-text-weak whitespace-nowrap">/</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { createMemo, createSignal, onCleanup, Show } from "solid-js"
|
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
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 { useLayout } from "@/context/layout"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
type EntryType = "command" | "file"
|
type EntryType = "command" | "file" | "session"
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,6 +27,9 @@ type Entry = {
|
|||||||
category: string
|
category: string
|
||||||
option?: CommandOption
|
option?: CommandOption
|
||||||
path?: string
|
path?: string
|
||||||
|
directory?: string
|
||||||
|
sessionID?: string
|
||||||
|
archived?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogSelectFileMode = "all" | "files"
|
type DialogSelectFileMode = "all" | "files"
|
||||||
@@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||||
@@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
path,
|
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
|
||||||
|
}): 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,
|
||||||
|
})
|
||||||
|
|
||||||
const list = createMemo(() => allowed().map(commandItem))
|
const list = createMemo(() => allowed().map(commandItem))
|
||||||
|
|
||||||
const picks = createMemo(() => {
|
const picks = createMemo(() => {
|
||||||
@@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
return out
|
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,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.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 items = async (text: string) => {
|
||||||
const query = text.trim()
|
const query = text.trim()
|
||||||
setGrouped(query.length > 0)
|
setGrouped(query.length > 0)
|
||||||
@@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const files = await file.searchFiles(query)
|
const files = await file.searchFiles(query)
|
||||||
return files.map(fileItem)
|
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)
|
const entries = files.map(fileItem)
|
||||||
return [...list(), ...entries]
|
return [...list(), ...nextSessions, ...entries]
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = (item: Entry | undefined) => {
|
const handleMove = (item: Entry | undefined) => {
|
||||||
@@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.type === "session") {
|
||||||
|
if (!item.directory || !item.sessionID) return
|
||||||
|
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!item.path) return
|
if (!item.path) return
|
||||||
open(item.path)
|
open(item.path)
|
||||||
}
|
}
|
||||||
@@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
items={items}
|
items={items}
|
||||||
key={(item) => item.id}
|
key={(item) => item.id}
|
||||||
filterKeys={["title", "description", "category"]}
|
filterKeys={["title", "description", "category"]}
|
||||||
groupBy={(item) => item.category}
|
groupBy={grouped() ? (item) => item.category : () => ""}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
>
|
>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<Show
|
<Switch
|
||||||
when={item.type === "command"}
|
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full flex items-center justify-between rounded-md pl-1">
|
<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">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="w-full flex items-center justify-between gap-4">
|
<Match when={item.type === "command"}>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="w-full flex items-center justify-between gap-4">
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<Show when={item.description}>
|
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
||||||
<span class="text-14-regular text-text-weak truncate">{item.description}</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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.keybind}>
|
</Match>
|
||||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
<Match when={item.type === "session"}>
|
||||||
</Show>
|
<div class="w-full flex items-center justify-between rounded-md pl-1">
|
||||||
</div>
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
</Show>
|
<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>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
forceMount={false}
|
|
||||||
value={
|
value={
|
||||||
<ModelTooltip
|
<ModelTooltip
|
||||||
model={item}
|
model={item}
|
||||||
@@ -90,7 +89,7 @@ const ModelList: Component<{
|
|||||||
|
|
||||||
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
children?: JSX.Element | ((open: boolean) => JSX.Element)
|
children?: JSX.Element
|
||||||
triggerAs?: T
|
triggerAs?: T
|
||||||
triggerProps?: ComponentProps<T>
|
triggerProps?: ComponentProps<T>
|
||||||
}) {
|
}) {
|
||||||
@@ -182,13 +181,12 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
|||||||
as={props.triggerAs ?? "div"}
|
as={props.triggerAs ?? "div"}
|
||||||
{...(props.triggerProps as any)}
|
{...(props.triggerProps as any)}
|
||||||
>
|
>
|
||||||
{typeof props.children === "function" ? props.children(store.open) : props.children}
|
{props.children}
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
|
||||||
data-component="model-popover-content"
|
|
||||||
ref={(el) => setStore("content", el)}
|
ref={(el) => setStore("content", el)}
|
||||||
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setStore("dismiss", "escape")
|
setStore("dismiss", "escape")
|
||||||
setStore("open", false)
|
setStore("open", false)
|
||||||
@@ -215,7 +213,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
|||||||
class="p-1"
|
class="p-1"
|
||||||
action={
|
action={
|
||||||
<div class="flex items-center gap-1">
|
<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
|
<IconButton
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -225,7 +223,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
|||||||
onClick={handleConnectProvider}
|
onClick={handleConnectProvider}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip placement="top" forceMount={false} value={language.t("dialog.model.manage")}>
|
<Tooltip placement="top" value={language.t("dialog.model.manage")}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="sliders"
|
icon="sliders"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -130,10 +130,57 @@ export default function FileTree(props: {
|
|||||||
const nodes = file.tree.children(props.path)
|
const nodes = file.tree.children(props.path)
|
||||||
const current = filter()
|
const current = filter()
|
||||||
if (!current) return nodes
|
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)
|
if (node.type === "file") return current.files.has(node.path)
|
||||||
return current.dirs.has(node.path)
|
return current.dirs.has(node.path)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const seen = new Set(out.map((node) => node.path))
|
||||||
|
|
||||||
|
for (const dir of current.dirs) {
|
||||||
|
if (parent(dir) !== props.path) continue
|
||||||
|
if (seen.has(dir)) continue
|
||||||
|
out.push({
|
||||||
|
name: leaf(dir),
|
||||||
|
path: dir,
|
||||||
|
absolute: dir,
|
||||||
|
type: "directory",
|
||||||
|
ignored: false,
|
||||||
|
})
|
||||||
|
seen.add(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of current.files) {
|
||||||
|
if (parent(item) !== props.path) continue
|
||||||
|
if (seen.has(item)) continue
|
||||||
|
out.push({
|
||||||
|
name: leaf(item),
|
||||||
|
path: item,
|
||||||
|
absolute: item,
|
||||||
|
type: "file",
|
||||||
|
ignored: false,
|
||||||
|
})
|
||||||
|
seen.add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.toSorted((a, b) => {
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === "directory" ? -1 : 1
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const Node = (
|
const Node = (
|
||||||
@@ -274,7 +321,6 @@ export default function FileTree(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
forceMount={false}
|
|
||||||
openDelay={2000}
|
openDelay={2000}
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export function Link(props: LinkProps) {
|
|||||||
const [local, rest] = splitProps(props, ["href", "children"])
|
const [local, rest] = splitProps(props, ["href", "children"])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
|
<button
|
||||||
|
class="text-text-strong underline"
|
||||||
|
onClick={() => void platform.openLink(local.href).catch(() => undefined)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{local.children}
|
{local.children}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,9 +32,7 @@ import { useNavigate, useParams } from "@solidjs/router"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { CycleLabel } from "@opencode-ai/ui/cycle-label"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
@@ -44,7 +42,6 @@ import { Select } from "@opencode-ai/ui/select"
|
|||||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
|
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
@@ -115,6 +112,7 @@ interface SlashCommand {
|
|||||||
description?: string
|
description?: string
|
||||||
keybind?: string
|
keybind?: string
|
||||||
type: "builtin" | "custom"
|
type: "builtin" | "custom"
|
||||||
|
source?: "command" | "mcp" | "skill"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
@@ -520,6 +518,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
title: cmd.name,
|
title: cmd.name,
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
type: "custom" as const,
|
type: "custom" as const,
|
||||||
|
source: cmd.source,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return [...custom, ...builtin]
|
return [...custom, ...builtin]
|
||||||
@@ -1133,7 +1132,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const images = imageAttachments().slice()
|
const images = imageAttachments().slice()
|
||||||
const mode = store.mode
|
const mode = store.mode
|
||||||
|
|
||||||
if (text.trim().length === 0 && images.length === 0) {
|
if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) {
|
||||||
if (working()) abort()
|
if (working()) abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1255,7 +1254,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
client.session
|
||||||
.shell({
|
.shell({
|
||||||
sessionID: session?.id || "",
|
sessionID: session.id,
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
command: text,
|
command: text,
|
||||||
@@ -1278,7 +1277,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
client.session
|
||||||
.command({
|
.command({
|
||||||
sessionID: session?.id || "",
|
sessionID: session.id,
|
||||||
command: commandName,
|
command: commandName,
|
||||||
arguments: args.join(" "),
|
arguments: args.join(" "),
|
||||||
agent,
|
agent,
|
||||||
@@ -1434,13 +1433,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const optimisticParts = requestParts.map((part) => ({
|
const optimisticParts = requestParts.map((part) => ({
|
||||||
...part,
|
...part,
|
||||||
sessionID: session?.id || "",
|
sessionID: session.id,
|
||||||
messageID,
|
messageID,
|
||||||
})) as unknown as Part[]
|
})) as unknown as Part[]
|
||||||
|
|
||||||
const optimisticMessage: Message = {
|
const optimisticMessage: Message = {
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sessionID: session?.id || "",
|
sessionID: session.id,
|
||||||
role: "user",
|
role: "user",
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent,
|
agent,
|
||||||
@@ -1451,9 +1450,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set(
|
sync.set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[session?.id || ""]
|
const messages = draft.message[session.id]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
draft.message[session?.id || ""] = [optimisticMessage]
|
draft.message[session.id] = [optimisticMessage]
|
||||||
} else {
|
} else {
|
||||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||||
messages.splice(result.index, 0, optimisticMessage)
|
messages.splice(result.index, 0, optimisticMessage)
|
||||||
@@ -1461,7 +1460,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
draft.part[messageID] = optimisticParts
|
draft.part[messageID] = optimisticParts
|
||||||
.filter((p) => !!p?.id)
|
.filter((p) => !!p?.id)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -1469,9 +1468,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
globalSync.child(sessionDirectory)[1](
|
globalSync.child(sessionDirectory)[1](
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[session?.id || ""]
|
const messages = draft.message[session.id]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
draft.message[session?.id || ""] = [optimisticMessage]
|
draft.message[session.id] = [optimisticMessage]
|
||||||
} else {
|
} else {
|
||||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||||
messages.splice(result.index, 0, optimisticMessage)
|
messages.splice(result.index, 0, optimisticMessage)
|
||||||
@@ -1479,7 +1478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
draft.part[messageID] = optimisticParts
|
draft.part[messageID] = optimisticParts
|
||||||
.filter((p) => !!p?.id)
|
.filter((p) => !!p?.id)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1488,7 +1487,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set(
|
sync.set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[session?.id || ""]
|
const messages = draft.message[session.id]
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||||
if (result.found) messages.splice(result.index, 1)
|
if (result.found) messages.splice(result.index, 1)
|
||||||
@@ -1501,7 +1500,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
globalSync.child(sessionDirectory)[1](
|
globalSync.child(sessionDirectory)[1](
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[session?.id || ""]
|
const messages = draft.message[session.id]
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||||
if (result.found) messages.splice(result.index, 1)
|
if (result.found) messages.splice(result.index, 1)
|
||||||
@@ -1522,15 +1521,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const worktree = WorktreeState.get(sessionDirectory)
|
const worktree = WorktreeState.get(sessionDirectory)
|
||||||
if (!worktree || worktree.status !== "pending") return true
|
if (!worktree || worktree.status !== "pending") return true
|
||||||
|
|
||||||
if (sessionDirectory === projectDirectory && session?.id) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set("session_status", session?.id, { type: "busy" })
|
sync.set("session_status", session.id, { type: "busy" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (sessionDirectory === projectDirectory && session?.id) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set("session_status", session?.id, { type: "idle" })
|
sync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
for (const item of commentItems) {
|
for (const item of commentItems) {
|
||||||
@@ -1547,7 +1546,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
restoreInput()
|
restoreInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.set(session?.id || "", { abort: controller, cleanup })
|
pending.set(session.id, { abort: controller, cleanup })
|
||||||
|
|
||||||
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
@@ -1575,7 +1574,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (timer.id === undefined) return
|
if (timer.id === undefined) return
|
||||||
clearTimeout(timer.id)
|
clearTimeout(timer.id)
|
||||||
})
|
})
|
||||||
pending.delete(session?.id || "")
|
pending.delete(session.id)
|
||||||
if (controller.signal.aborted) return false
|
if (controller.signal.aborted) return false
|
||||||
if (result.status === "failed") throw new Error(result.message)
|
if (result.status === "failed") throw new Error(result.message)
|
||||||
return true
|
return true
|
||||||
@@ -1585,7 +1584,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const ok = await waitForWorktree()
|
const ok = await waitForWorktree()
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
await client.session.prompt({
|
await client.session.prompt({
|
||||||
sessionID: session?.id || "",
|
sessionID: session.id,
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
messageID,
|
messageID,
|
||||||
@@ -1595,9 +1594,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void send().catch((err) => {
|
void send().catch((err) => {
|
||||||
pending.delete(session?.id || "")
|
pending.delete(session.id)
|
||||||
if (sessionDirectory === projectDirectory && session?.id) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set("session_status", session?.id, { type: "idle" })
|
sync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
@@ -1619,28 +1618,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const currrentModelVariant = createMemo(() => {
|
|
||||||
const modelVariant = local.model.variant.current() ?? ""
|
|
||||||
return modelVariant === "xhigh"
|
|
||||||
? "xHigh"
|
|
||||||
: modelVariant.length > 0
|
|
||||||
? modelVariant[0].toUpperCase() + modelVariant.slice(1)
|
|
||||||
: "Default"
|
|
||||||
})
|
|
||||||
|
|
||||||
const reasoningPercentage = createMemo(() => {
|
|
||||||
const variants = local.model.variant.list()
|
|
||||||
const current = local.model.variant.current()
|
|
||||||
const totalEntries = variants.length + 1
|
|
||||||
|
|
||||||
if (totalEntries <= 2 || current === "Default") {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndex = current ? variants.indexOf(current) + 1 : 0
|
|
||||||
return ((currentIndex + 1) / totalEntries) * 100
|
|
||||||
}, [local.model.variant])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
||||||
<Show when={store.popover}>
|
<Show when={store.popover}>
|
||||||
@@ -1693,7 +1670,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
|
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
||||||
@{(item as { type: "agent"; name: string }).name}
|
@{(item as { type: "agent"; name: string }).name}
|
||||||
</span>
|
</span>
|
||||||
@@ -1726,9 +1703,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<Show when={cmd.type === "custom"}>
|
<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">
|
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
||||||
{language.t("prompt.slash.badge.custom")}
|
{cmd.source === "skill"
|
||||||
|
? language.t("prompt.slash.badge.skill")
|
||||||
|
: cmd.source === "mcp"
|
||||||
|
? language.t("prompt.slash.badge.mcp")
|
||||||
|
: language.t("prompt.slash.badge.custom")}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={command.keybind(cmd.id)}>
|
<Show when={command.keybind(cmd.id)}>
|
||||||
@@ -1754,9 +1735,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={store.dragging}>
|
<Show when={store.dragging}>
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
|
<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">
|
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||||
<Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
|
<Icon name="photo" class="size-8" />
|
||||||
<span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
|
<span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1795,7 +1776,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
|
<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">
|
<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>
|
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
||||||
<Show when={item.selection}>
|
<Show when={item.selection}>
|
||||||
@@ -1812,7 +1793,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
icon="close-small"
|
icon="close-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
|
class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||||
@@ -1842,7 +1823,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
when={attachment.mime.startsWith("image/")}
|
when={attachment.mime.startsWith("image/")}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
||||||
<Icon name="folder" size="normal" class="size-6 text-text-base" />
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1915,8 +1896,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative p-3 flex items-center justify-between">
|
<div class="relative p-3 flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center justify-start gap-2">
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.mode === "shell"}>
|
<Match when={store.mode === "shell"}>
|
||||||
<div class="flex items-center gap-2 px-2 h-6">
|
<div class="flex items-center gap-2 px-2 h-6">
|
||||||
@@ -1928,6 +1909,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Match when={store.mode === "normal"}>
|
<Match when={store.mode === "normal"}>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
title={language.t("command.agent.cycle")}
|
title={language.t("command.agent.cycle")}
|
||||||
keybind={command.keybind("agent.cycle")}
|
keybind={command.keybind("agent.cycle")}
|
||||||
>
|
>
|
||||||
@@ -1935,7 +1917,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
options={local.agent.list().map((agent) => agent.name)}
|
options={local.agent.list().map((agent) => agent.name)}
|
||||||
current={local.agent.current()?.name ?? ""}
|
current={local.agent.current()?.name ?? ""}
|
||||||
onSelect={local.agent.set}
|
onSelect={local.agent.set}
|
||||||
class="capitalize"
|
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`}
|
||||||
|
valueClass="truncate"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
@@ -1944,64 +1927,68 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
fallback={
|
fallback={
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.choose")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.choose")}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
as="div"
|
as="div"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="px-2"
|
class="px-2 min-w-0 max-w-[240px]"
|
||||||
onClick={() => dialog.render(<DialogSelectModelUnpaid />, "select-model")}
|
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
<span class="truncate">
|
||||||
<MorphChevron expanded={dialog.isActive("select-model")} />
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.choose")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.choose")}
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}>
|
<ModelSelectorPopover
|
||||||
{(open) => (
|
triggerAs={Button}
|
||||||
<>
|
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
>
|
||||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
</Show>
|
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
</Show>
|
||||||
<MorphChevron expanded={open} class="text-text-weak" />
|
<span class="truncate">
|
||||||
</>
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
)}
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</ModelSelectorPopover>
|
</ModelSelectorPopover>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={local.model.variant.list().length > 0}>
|
<Show when={local.model.variant.list().length > 0}>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
title={language.t("command.model.variant.cycle")}
|
title={language.t("command.model.variant.cycle")}
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
data-action="model-variant-cycle"
|
data-action="model-variant-cycle"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="text-text-strong text-12-regular"
|
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
||||||
onClick={() => local.model.variant.cycle()}
|
onClick={() => local.model.variant.cycle()}
|
||||||
>
|
>
|
||||||
<Show when={local.model.variant.list().length > 1}>
|
{local.model.variant.current() ?? language.t("common.default")}
|
||||||
<ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
|
|
||||||
</Show>
|
|
||||||
<CycleLabel value={currrentModelVariant()} />
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={permission.permissionsEnabled() && params.id}>
|
<Show when={permission.permissionsEnabled() && params.id}>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
title={language.t("command.permissions.autoaccept.enable")}
|
||||||
keybind={command.keybind("permissions.autoaccept")}
|
keybind={command.keybind("permissions.autoaccept")}
|
||||||
>
|
>
|
||||||
@@ -2009,7 +1996,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||||
classList={{
|
classList={{
|
||||||
"_hidden group-hover/prompt-input:flex items-center justify-center": true,
|
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
}}
|
}}
|
||||||
@@ -2031,7 +2018,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 absolute right-3 bottom-3">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -2043,19 +2030,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-1.5 mr-1.5">
|
<div class="flex items-center gap-1 mr-1">
|
||||||
<SessionContextUsage />
|
<SessionContextUsage />
|
||||||
<Show when={store.mode === "normal"}>
|
<Show when={store.mode === "normal"}>
|
||||||
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
|
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="small"
|
class="size-6 px-1"
|
||||||
class="px-1"
|
|
||||||
onClick={() => fileInputRef.click()}
|
onClick={() => fileInputRef.click()}
|
||||||
aria-label={language.t("prompt.action.attachFile")}
|
aria-label={language.t("prompt.action.attachFile")}
|
||||||
>
|
>
|
||||||
<Icon name="photo" class="size-6 text-icon-base" />
|
<Icon name="photo" class="size-4.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -2074,7 +2060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span>{language.t("prompt.action.send")}</span>
|
<span>{language.t("prompt.action.send")}</span>
|
||||||
<Icon name="enter" size="normal" class="text-icon-base" />
|
<Icon name="enter" size="small" class="text-icon-base" />
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -2082,10 +2068,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!prompt.dirty() && !working()}
|
disabled={!prompt.dirty() && !working() && commentCount() === 0}
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="h-6 w-5.5"
|
class="h-6 w-4.5"
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const circle = () => (
|
const circle = () => (
|
||||||
<div class="text-icon-base">
|
<div class="flex items-center justify-center">
|
||||||
<ProgressCircle size={18} percentage={context()?.percentage ?? 0} />
|
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-7 text-icon-base"
|
class="size-6"
|
||||||
onClick={openContext}
|
onClick={openContext}
|
||||||
aria-label={language.t("context.usage.view")}
|
aria-label={language.t("context.usage.view")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Popover } from "@opencode-ai/ui/popover"
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||||
import { StatusPopover } from "../status-popover"
|
import { StatusPopover } from "../status-popover"
|
||||||
|
import { SessionOpenMenu } from "./session-open-menu"
|
||||||
|
|
||||||
export function SessionHeader() {
|
export function SessionHeader() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
@@ -117,7 +118,7 @@ export function SessionHeader() {
|
|||||||
function viewShare() {
|
function viewShare() {
|
||||||
const url = shareUrl()
|
const url = shareUrl()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
platform.openLink(url)
|
void platform.openLink(url).catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
||||||
@@ -150,6 +151,7 @@ export function SessionHeader() {
|
|||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<SessionOpenMenu dir={projectDirectory()} />
|
||||||
<StatusPopover />
|
<StatusPopover />
|
||||||
<Show when={showShare()}>
|
<Show when={showShare()}>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -167,7 +169,7 @@ export function SessionHeader() {
|
|||||||
triggerAs={Button}
|
triggerAs={Button}
|
||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "secondary",
|
variant: "secondary",
|
||||||
class: "rounded-sm w-[60px] h-[24px]",
|
class: "rounded-sm h-[24px] px-3",
|
||||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||||
style: { scale: 1 },
|
style: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { FileTypeIcon } from "@opencode-ai/ui/file-type-icon"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
|
||||||
|
export function SessionOpenMenu(props: { dir: string }) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const server = useServer()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const enabled = createMemo(
|
||||||
|
() => platform.platform === "desktop" && platform.os === "macos" && server.isLocal() && !!props.dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
const open = (app?: string) => {
|
||||||
|
if (!props.dir) return
|
||||||
|
void platform.openLink(props.dir, app).catch((error) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
if (!props.dir) return
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(props.dir)
|
||||||
|
.then(() => {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "check",
|
||||||
|
title: language.t("session.header.copyPath.copied"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("session.header.copyPath.copyFailed"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={Button}
|
||||||
|
variant="ghost"
|
||||||
|
icon="folder"
|
||||||
|
class="rounded-sm h-[24px] py-1.5 pr-2 pl-2 gap-1.5 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
|
||||||
|
aria-label={language.t("session.header.open")}
|
||||||
|
>
|
||||||
|
<span class="text-12-regular text-text-strong">{language.t("session.header.open")}</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="icon-base" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content class="mt-1 w-60">
|
||||||
|
<Show when={enabled()}>
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
|
||||||
|
<DropdownMenu.Item onSelect={() => open("Visual Studio Code")}>
|
||||||
|
<FileTypeIcon id="Vscode" class="size-5" />
|
||||||
|
<DropdownMenu.ItemLabel>VS Code</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => open("Cursor")}>
|
||||||
|
<FileTypeIcon id="Cursor" class="size-5" />
|
||||||
|
<DropdownMenu.ItemLabel>Cursor</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => open("Finder")}>
|
||||||
|
<Icon name="folder" size="small" class="icon-base shrink-0" />
|
||||||
|
<DropdownMenu.ItemLabel>Finder</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => open("Terminal")}>
|
||||||
|
<FileTypeIcon id="Console" class="size-5" />
|
||||||
|
<DropdownMenu.ItemLabel>Terminal</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => open("iTerm")}>
|
||||||
|
<FileTypeIcon id="Console" class="size-5" />
|
||||||
|
<DropdownMenu.ItemLabel>iTerm2</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => open("Ghostty")}>
|
||||||
|
<FileTypeIcon id="Console" class="size-5" />
|
||||||
|
<DropdownMenu.ItemLabel>Ghostty</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => open("Xcode")}>
|
||||||
|
<FileTypeIcon id="Swift" class="size-5" />
|
||||||
|
<DropdownMenu.ItemLabel>Xcode</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => open("Android Studio")}>
|
||||||
|
<FileTypeIcon id="Android" class="size-5" />
|
||||||
|
<DropdownMenu.ItemLabel>Android Studio</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu.Item onSelect={copy}>
|
||||||
|
<Icon name="copy" size="small" class="icon-base shrink-0" />
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("session.header.copyPath")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ import type { JSX } from "solid-js"
|
|||||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
|
||||||
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||||
return (
|
return (
|
||||||
@@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
|
|||||||
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
|
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const command = useCommand()
|
||||||
const sortable = createSortable(props.tab)
|
const sortable = createSortable(props.tab)
|
||||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
|||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value={props.tab}
|
value={props.tab}
|
||||||
closeButton={
|
closeButton={
|
||||||
<Tooltip value={language.t("common.closeTab")} placement="bottom">
|
<TooltipKeybind
|
||||||
|
title={language.t("common.closeTab")}
|
||||||
|
keybind={command.keybind("tab.close")}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="close-small"
|
icon="close-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -44,7 +50,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
|||||||
onClick={() => props.onTabClose(props.tab)}
|
onClick={() => props.onTabClose(props.tab)}
|
||||||
aria-label={language.t("common.closeTab")}
|
aria-label={language.t("common.closeTab")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</TooltipKeybind>
|
||||||
}
|
}
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "./link"
|
import { Link } from "./link"
|
||||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
cleanup: undefined as (() => void) | undefined,
|
cleanup: undefined as (() => void) | undefined,
|
||||||
@@ -131,12 +130,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const soundOptions = [...SOUND_OPTIONS]
|
const soundOptions = [...SOUND_OPTIONS]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollFade
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<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">
|
<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>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
||||||
@@ -171,6 +165,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={language.t("settings.general.row.appearance.description")}
|
description={language.t("settings.general.row.appearance.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
data-action="settings-color-scheme"
|
||||||
options={colorSchemeOptions()}
|
options={colorSchemeOptions()}
|
||||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||||
value={(o) => o.value}
|
value={(o) => o.value}
|
||||||
@@ -197,6 +192,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
data-action="settings-theme"
|
||||||
options={themeOptions()}
|
options={themeOptions()}
|
||||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||||
value={(o) => o.id}
|
value={(o) => o.id}
|
||||||
@@ -221,6 +217,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={language.t("settings.general.row.font.description")}
|
description={language.t("settings.general.row.font.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
data-action="settings-font"
|
||||||
options={fontOptionsList}
|
options={fontOptionsList}
|
||||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||||
value={(o) => o.value}
|
value={(o) => o.value}
|
||||||
@@ -250,30 +247,36 @@ export const SettingsGeneral: Component = () => {
|
|||||||
title={language.t("settings.general.notifications.agent.title")}
|
title={language.t("settings.general.notifications.agent.title")}
|
||||||
description={language.t("settings.general.notifications.agent.description")}
|
description={language.t("settings.general.notifications.agent.description")}
|
||||||
>
|
>
|
||||||
<Switch
|
<div data-action="settings-notifications-agent">
|
||||||
checked={settings.notifications.agent()}
|
<Switch
|
||||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
checked={settings.notifications.agent()}
|
||||||
/>
|
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.notifications.permissions.title")}
|
title={language.t("settings.general.notifications.permissions.title")}
|
||||||
description={language.t("settings.general.notifications.permissions.description")}
|
description={language.t("settings.general.notifications.permissions.description")}
|
||||||
>
|
>
|
||||||
<Switch
|
<div data-action="settings-notifications-permissions">
|
||||||
checked={settings.notifications.permissions()}
|
<Switch
|
||||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
checked={settings.notifications.permissions()}
|
||||||
/>
|
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.notifications.errors.title")}
|
title={language.t("settings.general.notifications.errors.title")}
|
||||||
description={language.t("settings.general.notifications.errors.description")}
|
description={language.t("settings.general.notifications.errors.description")}
|
||||||
>
|
>
|
||||||
<Switch
|
<div data-action="settings-notifications-errors">
|
||||||
checked={settings.notifications.errors()}
|
<Switch
|
||||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
checked={settings.notifications.errors()}
|
||||||
/>
|
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,6 +291,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={language.t("settings.general.sounds.agent.description")}
|
description={language.t("settings.general.sounds.agent.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
data-action="settings-sounds-agent"
|
||||||
options={soundOptions}
|
options={soundOptions}
|
||||||
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
|
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
|
||||||
value={(o) => o.id}
|
value={(o) => o.id}
|
||||||
@@ -312,6 +316,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={language.t("settings.general.sounds.permissions.description")}
|
description={language.t("settings.general.sounds.permissions.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
data-action="settings-sounds-permissions"
|
||||||
options={soundOptions}
|
options={soundOptions}
|
||||||
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
|
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
|
||||||
value={(o) => o.id}
|
value={(o) => o.id}
|
||||||
@@ -336,6 +341,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={language.t("settings.general.sounds.errors.description")}
|
description={language.t("settings.general.sounds.errors.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
data-action="settings-sounds-errors"
|
||||||
options={soundOptions}
|
options={soundOptions}
|
||||||
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
|
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
|
||||||
value={(o) => o.id}
|
value={(o) => o.id}
|
||||||
@@ -366,21 +372,25 @@ export const SettingsGeneral: Component = () => {
|
|||||||
title={language.t("settings.updates.row.startup.title")}
|
title={language.t("settings.updates.row.startup.title")}
|
||||||
description={language.t("settings.updates.row.startup.description")}
|
description={language.t("settings.updates.row.startup.description")}
|
||||||
>
|
>
|
||||||
<Switch
|
<div data-action="settings-updates-startup">
|
||||||
checked={settings.updates.startup()}
|
<Switch
|
||||||
disabled={!platform.checkUpdate}
|
checked={settings.updates.startup()}
|
||||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
disabled={!platform.checkUpdate}
|
||||||
/>
|
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.releaseNotes.title")}
|
title={language.t("settings.general.row.releaseNotes.title")}
|
||||||
description={language.t("settings.general.row.releaseNotes.description")}
|
description={language.t("settings.general.row.releaseNotes.description")}
|
||||||
>
|
>
|
||||||
<Switch
|
<div data-action="settings-release-notes">
|
||||||
checked={settings.general.releaseNotes()}
|
<Switch
|
||||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
checked={settings.general.releaseNotes()}
|
||||||
/>
|
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
@@ -401,7 +411,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollFade>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import fuzzysort from "fuzzysort"
|
|||||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
|
||||||
|
|
||||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||||
const PALETTE_ID = "command.palette"
|
const PALETTE_ID = "command.palette"
|
||||||
@@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollFade
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<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 flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
@@ -402,6 +396,7 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
<span class="text-14-regular text-text-strong">{title(id)}</span>
|
<span class="text-14-regular text-text-strong">{title(id)}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-keybind-id={id}
|
||||||
classList={{
|
classList={{
|
||||||
"h-8 px-3 rounded-md text-12-regular": true,
|
"h-8 px-3 rounded-md text-12-regular": true,
|
||||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||||
@@ -435,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</ScrollFade>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const SettingsProviders: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1" data-component="connected-providers-section">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||||
<Show
|
<Show
|
||||||
@@ -225,9 +225,12 @@ export const SettingsProviders: Component = () => {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
|
<div
|
||||||
|
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 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" />
|
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong">Custom provider</span>
|
<span class="text-14-medium text-text-strong">Custom provider</span>
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const once = { value: false }
|
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?directory=${encodeURIComponent(sdk.directory)}`)
|
||||||
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||||
if (window.__OPENCODE__?.serverPassword) {
|
if (window.__OPENCODE__?.serverPassword) {
|
||||||
url.username = "opencode"
|
url.username = "opencode"
|
||||||
url.password = window.__OPENCODE__?.serverPassword
|
url.password = window.__OPENCODE__?.serverPassword
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function Titlebar() {
|
|||||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||||
const web = createMemo(() => platform.platform === "web")
|
const web = createMemo(() => platform.platform === "web")
|
||||||
|
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||||
|
const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
|
||||||
|
|
||||||
const [history, setHistory] = createStore({
|
const [history, setHistory] = createStore({
|
||||||
stack: [] as string[],
|
stack: [] as string[],
|
||||||
@@ -134,7 +136,7 @@ export function Titlebar() {
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||||
data-tauri-drag-region
|
style={{ "min-height": minHeight() }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -142,10 +144,9 @@ export function Titlebar() {
|
|||||||
"pl-2": !mac(),
|
"pl-2": !mac(),
|
||||||
}}
|
}}
|
||||||
onMouseDown={drag}
|
onMouseDown={drag}
|
||||||
data-tauri-drag-region
|
|
||||||
>
|
>
|
||||||
<Show when={mac()}>
|
<Show when={mac()}>
|
||||||
<div class="w-[72px] h-full shrink-0" 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">
|
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="menu"
|
icon="menu"
|
||||||
@@ -219,13 +220,10 @@ export function Titlebar() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<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">
|
||||||
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 id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,9 +233,8 @@ export function Titlebar() {
|
|||||||
"pr-6": !windows(),
|
"pr-6": !windows(),
|
||||||
}}
|
}}
|
||||||
onMouseDown={drag}
|
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()}>
|
<Show when={windows()}>
|
||||||
<div class="w-6 shrink-0" />
|
<div class="w-6 shrink-0" />
|
||||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ type ChildOptions = {
|
|||||||
bootstrap?: boolean
|
bootstrap?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
@@ -297,7 +299,7 @@ function createGlobalSync() {
|
|||||||
const aUpdated = sessionUpdatedAt(a)
|
const aUpdated = sessionUpdatedAt(a)
|
||||||
const bUpdated = sessionUpdatedAt(b)
|
const bUpdated = sessionUpdatedAt(b)
|
||||||
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
||||||
return a.id.localeCompare(b.id)
|
return cmp(a.id, b.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
|
function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
|
||||||
@@ -325,7 +327,7 @@ function createGlobalSync() {
|
|||||||
const all = input
|
const all = input
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
.filter((s) => !s.time?.archived)
|
.filter((s) => !s.time?.archived)
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
|
||||||
const roots = all.filter((s) => !s.parentID)
|
const roots = all.filter((s) => !s.parentID)
|
||||||
const children = all.filter((s) => !!s.parentID)
|
const children = all.filter((s) => !!s.parentID)
|
||||||
@@ -342,7 +344,7 @@ function createGlobalSync() {
|
|||||||
return sessionUpdatedAt(s) > cutoff
|
return sessionUpdatedAt(s) > cutoff
|
||||||
})
|
})
|
||||||
|
|
||||||
return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id))
|
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureChild(directory: string) {
|
function ensureChild(directory: string) {
|
||||||
@@ -457,7 +459,7 @@ function createGlobalSync() {
|
|||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
.filter((s) => !s.time?.archived)
|
.filter((s) => !s.time?.archived)
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
|
||||||
// Read the current limit at resolve-time so callers that bump the limit while
|
// Read the current limit at resolve-time so callers that bump the limit while
|
||||||
// a request is in-flight still get the expanded result.
|
// a request is in-flight still get the expanded result.
|
||||||
@@ -559,7 +561,7 @@ function createGlobalSync() {
|
|||||||
"permission",
|
"permission",
|
||||||
sessionID,
|
sessionID,
|
||||||
reconcile(
|
reconcile(
|
||||||
permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
{ key: "id" },
|
{ key: "id" },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -588,7 +590,7 @@ function createGlobalSync() {
|
|||||||
"question",
|
"question",
|
||||||
sessionID,
|
sessionID,
|
||||||
reconcile(
|
reconcile(
|
||||||
questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
{ key: "id" },
|
{ key: "id" },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -986,7 +988,7 @@ function createGlobalSync() {
|
|||||||
.filter((p) => !!p?.id)
|
.filter((p) => !!p?.id)
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
setGlobalStore("project", projects)
|
setGlobalStore("project", projects)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -682,12 +682,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
if (!current) return
|
if (!current) return
|
||||||
|
|
||||||
const all = current.all.filter((x) => x !== tab)
|
const all = current.all.filter((x) => x !== tab)
|
||||||
|
if (current.active !== tab) {
|
||||||
|
setStore("sessionTabs", session, "all", all)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = current.all.findIndex((f) => f === tab)
|
||||||
|
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("sessionTabs", session, "all", all)
|
setStore("sessionTabs", session, "all", all)
|
||||||
if (current.active !== tab) return
|
|
||||||
|
|
||||||
const index = current.all.findIndex((f) => f === tab)
|
|
||||||
const next = all[index - 1] ?? all[0]
|
|
||||||
setStore("sessionTabs", session, "active", next)
|
setStore("sessionTabs", session, "active", next)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||||
|
import type { Accessor } from "solid-js"
|
||||||
|
|
||||||
export type Platform = {
|
export type Platform = {
|
||||||
/** Platform discriminator */
|
/** Platform discriminator */
|
||||||
@@ -11,8 +12,8 @@ export type Platform = {
|
|||||||
/** App version */
|
/** App version */
|
||||||
version?: string
|
version?: string
|
||||||
|
|
||||||
/** Open a URL in the default browser */
|
/** Open a URL/path using the OS (optionally with a specific app) */
|
||||||
openLink(url: string): void
|
openLink(url: string, openWith?: string): Promise<void>
|
||||||
|
|
||||||
/** Restart the app */
|
/** Restart the app */
|
||||||
restart(): Promise<void>
|
restart(): Promise<void>
|
||||||
@@ -55,6 +56,9 @@ export type Platform = {
|
|||||||
|
|
||||||
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||||
parseMarkdown?(markdown: string): Promise<string>
|
parseMarkdown?(markdown: string): Promise<string>
|
||||||
|
|
||||||
|
/** Webview zoom level (desktop only) */
|
||||||
|
webviewZoom?: Accessor<number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
|||||||
|
|
||||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||||
|
|
||||||
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -59,7 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const next = items
|
const next = items
|
||||||
.map((x) => x.info)
|
.map((x) => x.info)
|
||||||
.filter((m) => !!m?.id)
|
.filter((m) => !!m?.id)
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
|
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
|
||||||
@@ -69,7 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
"part",
|
"part",
|
||||||
message.info.id,
|
message.info.id,
|
||||||
reconcile(
|
reconcile(
|
||||||
message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
{ key: "id" },
|
{ key: "id" },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -129,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||||
messages.splice(result.index, 0, message)
|
messages.splice(result.index, 0, message)
|
||||||
}
|
}
|
||||||
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id))
|
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -271,7 +273,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
await client.session.list().then((x) => {
|
await client.session.list().then((x) => {
|
||||||
const sessions = (x.data ?? [])
|
const sessions = (x.data ?? [])
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
.slice(0, store.limit)
|
.slice(0, store.limit)
|
||||||
setStore("session", reconcile(sessions, { key: "id" }))
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
|||||||
const platform: Platform = {
|
const platform: Platform = {
|
||||||
platform: "web",
|
platform: "web",
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
openLink(url: string) {
|
async openLink(url: string, _openWith?: string) {
|
||||||
window.open(url, "_blank")
|
window.open(url, "_blank")
|
||||||
},
|
},
|
||||||
back() {
|
back() {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "فتح الإعدادات",
|
"command.settings.open": "فتح الإعدادات",
|
||||||
"command.session.previous": "الجلسة السابقة",
|
"command.session.previous": "الجلسة السابقة",
|
||||||
"command.session.next": "الجلسة التالية",
|
"command.session.next": "الجلسة التالية",
|
||||||
|
"command.session.previous.unseen": "Previous unread session",
|
||||||
|
"command.session.next.unseen": "Next unread session",
|
||||||
"command.session.archive": "أرشفة الجلسة",
|
"command.session.archive": "أرشفة الجلسة",
|
||||||
|
|
||||||
"command.palette": "لوحة الأوامر",
|
"command.palette": "لوحة الأوامر",
|
||||||
@@ -42,7 +44,6 @@ export const dict = {
|
|||||||
|
|
||||||
"command.session.new": "جلسة جديدة",
|
"command.session.new": "جلسة جديدة",
|
||||||
"command.file.open": "فتح ملف",
|
"command.file.open": "فتح ملف",
|
||||||
"command.file.open.description": "البحث في الملفات والأوامر",
|
|
||||||
"command.context.addSelection": "إضافة التحديد إلى السياق",
|
"command.context.addSelection": "إضافة التحديد إلى السياق",
|
||||||
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
|
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
|
||||||
"command.terminal.toggle": "تبديل المحطة الطرفية",
|
"command.terminal.toggle": "تبديل المحطة الطرفية",
|
||||||
@@ -68,6 +69,7 @@ export const dict = {
|
|||||||
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
||||||
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
|
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
|
||||||
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
|
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
|
||||||
|
"command.workspace.toggle": "تبديل مساحات العمل",
|
||||||
"command.session.undo": "تراجع",
|
"command.session.undo": "تراجع",
|
||||||
"command.session.undo.description": "تراجع عن الرسالة الأخيرة",
|
"command.session.undo.description": "تراجع عن الرسالة الأخيرة",
|
||||||
"command.session.redo": "إعادة",
|
"command.session.redo": "إعادة",
|
||||||
@@ -81,7 +83,7 @@ export const dict = {
|
|||||||
"command.session.unshare": "إلغاء مشاركة الجلسة",
|
"command.session.unshare": "إلغاء مشاركة الجلسة",
|
||||||
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
|
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
|
||||||
|
|
||||||
"palette.search.placeholder": "البحث في الملفات والأوامر",
|
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
|
||||||
"palette.empty": "لا توجد نتائج",
|
"palette.empty": "لا توجد نتائج",
|
||||||
"palette.group.commands": "الأوامر",
|
"palette.group.commands": "الأوامر",
|
||||||
"palette.group.files": "الملفات",
|
"palette.group.files": "الملفات",
|
||||||
@@ -210,6 +212,8 @@ export const dict = {
|
|||||||
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
|
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
|
||||||
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
|
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
|
||||||
"prompt.slash.badge.custom": "مخصص",
|
"prompt.slash.badge.custom": "مخصص",
|
||||||
|
"prompt.slash.badge.skill": "مهارة",
|
||||||
|
"prompt.slash.badge.mcp": "mcp",
|
||||||
"prompt.context.active": "نشط",
|
"prompt.context.active": "نشط",
|
||||||
"prompt.context.includeActiveFile": "تضمين الملف النشط",
|
"prompt.context.includeActiveFile": "تضمين الملف النشط",
|
||||||
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
|
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
|
||||||
@@ -344,6 +348,11 @@ export const dict = {
|
|||||||
"toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
|
"toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
|
||||||
"toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة",
|
"toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة",
|
||||||
|
|
||||||
|
"toast.workspace.enabled.title": "تم تمكين مساحات العمل",
|
||||||
|
"toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي",
|
||||||
|
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
|
||||||
|
"toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي",
|
||||||
|
|
||||||
"toast.model.none.title": "لم يتم تحديد نموذج",
|
"toast.model.none.title": "لم يتم تحديد نموذج",
|
||||||
"toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة",
|
"toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة",
|
||||||
|
|
||||||
@@ -432,6 +441,7 @@ export const dict = {
|
|||||||
"session.review.noChanges": "لا توجد تغييرات",
|
"session.review.noChanges": "لا توجد تغييرات",
|
||||||
"session.files.selectToOpen": "اختر ملفًا لفتحه",
|
"session.files.selectToOpen": "اختر ملفًا لفتحه",
|
||||||
"session.files.all": "كل الملفات",
|
"session.files.all": "كل الملفات",
|
||||||
|
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
|
||||||
"session.messages.renderEarlier": "عرض الرسائل السابقة",
|
"session.messages.renderEarlier": "عرض الرسائل السابقة",
|
||||||
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
|
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
|
||||||
"session.messages.loadEarlier": "تحميل الرسائل السابقة",
|
"session.messages.loadEarlier": "تحميل الرسائل السابقة",
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Abrir configurações",
|
"command.settings.open": "Abrir configurações",
|
||||||
"command.session.previous": "Sessão anterior",
|
"command.session.previous": "Sessão anterior",
|
||||||
"command.session.next": "Próxima sessão",
|
"command.session.next": "Próxima sessão",
|
||||||
|
"command.session.previous.unseen": "Previous unread session",
|
||||||
|
"command.session.next.unseen": "Next unread session",
|
||||||
"command.session.archive": "Arquivar sessão",
|
"command.session.archive": "Arquivar sessão",
|
||||||
|
|
||||||
"command.palette": "Paleta de comandos",
|
"command.palette": "Paleta de comandos",
|
||||||
@@ -42,7 +44,6 @@ export const dict = {
|
|||||||
|
|
||||||
"command.session.new": "Nova sessão",
|
"command.session.new": "Nova sessão",
|
||||||
"command.file.open": "Abrir arquivo",
|
"command.file.open": "Abrir arquivo",
|
||||||
"command.file.open.description": "Buscar arquivos e comandos",
|
|
||||||
"command.context.addSelection": "Adicionar seleção ao contexto",
|
"command.context.addSelection": "Adicionar seleção ao contexto",
|
||||||
"command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual",
|
"command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual",
|
||||||
"command.terminal.toggle": "Alternar terminal",
|
"command.terminal.toggle": "Alternar terminal",
|
||||||
@@ -68,6 +69,7 @@ export const dict = {
|
|||||||
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
|
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
|
||||||
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
|
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
|
||||||
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
|
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
|
||||||
|
"command.workspace.toggle": "Alternar espaços de trabalho",
|
||||||
"command.session.undo": "Desfazer",
|
"command.session.undo": "Desfazer",
|
||||||
"command.session.undo.description": "Desfazer a última mensagem",
|
"command.session.undo.description": "Desfazer a última mensagem",
|
||||||
"command.session.redo": "Refazer",
|
"command.session.redo": "Refazer",
|
||||||
@@ -81,7 +83,7 @@ export const dict = {
|
|||||||
"command.session.unshare": "Parar de compartilhar sessão",
|
"command.session.unshare": "Parar de compartilhar sessão",
|
||||||
"command.session.unshare.description": "Parar de compartilhar esta sessão",
|
"command.session.unshare.description": "Parar de compartilhar esta sessão",
|
||||||
|
|
||||||
"palette.search.placeholder": "Buscar arquivos e comandos",
|
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
|
||||||
"palette.empty": "Nenhum resultado encontrado",
|
"palette.empty": "Nenhum resultado encontrado",
|
||||||
"palette.group.commands": "Comandos",
|
"palette.group.commands": "Comandos",
|
||||||
"palette.group.files": "Arquivos",
|
"palette.group.files": "Arquivos",
|
||||||
@@ -210,6 +212,8 @@ export const dict = {
|
|||||||
"prompt.popover.emptyCommands": "Nenhum comando correspondente",
|
"prompt.popover.emptyCommands": "Nenhum comando correspondente",
|
||||||
"prompt.dropzone.label": "Solte imagens ou PDFs aqui",
|
"prompt.dropzone.label": "Solte imagens ou PDFs aqui",
|
||||||
"prompt.slash.badge.custom": "personalizado",
|
"prompt.slash.badge.custom": "personalizado",
|
||||||
|
"prompt.slash.badge.skill": "skill",
|
||||||
|
"prompt.slash.badge.mcp": "mcp",
|
||||||
"prompt.context.active": "ativo",
|
"prompt.context.active": "ativo",
|
||||||
"prompt.context.includeActiveFile": "Incluir arquivo ativo",
|
"prompt.context.includeActiveFile": "Incluir arquivo ativo",
|
||||||
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
|
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
|
||||||
@@ -343,6 +347,11 @@ export const dict = {
|
|||||||
"toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente",
|
"toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente",
|
||||||
"toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação",
|
"toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação",
|
||||||
|
|
||||||
|
"toast.workspace.enabled.title": "Espaços de trabalho ativados",
|
||||||
|
"toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral",
|
||||||
|
"toast.workspace.disabled.title": "Espaços de trabalho desativados",
|
||||||
|
"toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral",
|
||||||
|
|
||||||
"toast.model.none.title": "Nenhum modelo selecionado",
|
"toast.model.none.title": "Nenhum modelo selecionado",
|
||||||
"toast.model.none.description": "Conecte um provedor para resumir esta sessão",
|
"toast.model.none.description": "Conecte um provedor para resumir esta sessão",
|
||||||
|
|
||||||
@@ -433,6 +442,7 @@ export const dict = {
|
|||||||
"session.review.noChanges": "Sem alterações",
|
"session.review.noChanges": "Sem alterações",
|
||||||
"session.files.selectToOpen": "Selecione um arquivo para abrir",
|
"session.files.selectToOpen": "Selecione um arquivo para abrir",
|
||||||
"session.files.all": "Todos os arquivos",
|
"session.files.all": "Todos os arquivos",
|
||||||
|
"session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
|
||||||
"session.messages.renderEarlier": "Renderizar mensagens anteriores",
|
"session.messages.renderEarlier": "Renderizar mensagens anteriores",
|
||||||
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
|
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
|
||||||
"session.messages.loadEarlier": "Carregar mensagens anteriores",
|
"session.messages.loadEarlier": "Carregar mensagens anteriores",
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Åbn indstillinger",
|
"command.settings.open": "Åbn indstillinger",
|
||||||
"command.session.previous": "Forrige session",
|
"command.session.previous": "Forrige session",
|
||||||
"command.session.next": "Næste session",
|
"command.session.next": "Næste session",
|
||||||
|
"command.session.previous.unseen": "Previous unread session",
|
||||||
|
"command.session.next.unseen": "Next unread session",
|
||||||
"command.session.archive": "Arkivér session",
|
"command.session.archive": "Arkivér session",
|
||||||
|
|
||||||
"command.palette": "Kommandopalette",
|
"command.palette": "Kommandopalette",
|
||||||
@@ -42,7 +44,6 @@ export const dict = {
|
|||||||
|
|
||||||
"command.session.new": "Ny session",
|
"command.session.new": "Ny session",
|
||||||
"command.file.open": "Åbn fil",
|
"command.file.open": "Åbn fil",
|
||||||
"command.file.open.description": "Søg i filer og kommandoer",
|
|
||||||
"command.context.addSelection": "Tilføj markering til kontekst",
|
"command.context.addSelection": "Tilføj markering til kontekst",
|
||||||
"command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil",
|
"command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil",
|
||||||
"command.terminal.toggle": "Skift terminal",
|
"command.terminal.toggle": "Skift terminal",
|
||||||
@@ -68,6 +69,7 @@ export const dict = {
|
|||||||
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
|
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
|
||||||
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
|
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
|
||||||
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
|
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
|
||||||
|
"command.workspace.toggle": "Skift arbejdsområder",
|
||||||
"command.session.undo": "Fortryd",
|
"command.session.undo": "Fortryd",
|
||||||
"command.session.undo.description": "Fortryd den sidste besked",
|
"command.session.undo.description": "Fortryd den sidste besked",
|
||||||
"command.session.redo": "Omgør",
|
"command.session.redo": "Omgør",
|
||||||
@@ -81,7 +83,7 @@ export const dict = {
|
|||||||
"command.session.unshare": "Stop deling af session",
|
"command.session.unshare": "Stop deling af session",
|
||||||
"command.session.unshare.description": "Stop med at dele denne session",
|
"command.session.unshare.description": "Stop med at dele denne session",
|
||||||
|
|
||||||
"palette.search.placeholder": "Søg i filer og kommandoer",
|
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
|
||||||
"palette.empty": "Ingen resultater fundet",
|
"palette.empty": "Ingen resultater fundet",
|
||||||
"palette.group.commands": "Kommandoer",
|
"palette.group.commands": "Kommandoer",
|
||||||
"palette.group.files": "Filer",
|
"palette.group.files": "Filer",
|
||||||
@@ -210,6 +212,8 @@ export const dict = {
|
|||||||
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
|
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
|
||||||
"prompt.dropzone.label": "Slip billeder eller PDF'er her",
|
"prompt.dropzone.label": "Slip billeder eller PDF'er her",
|
||||||
"prompt.slash.badge.custom": "brugerdefineret",
|
"prompt.slash.badge.custom": "brugerdefineret",
|
||||||
|
"prompt.slash.badge.skill": "skill",
|
||||||
|
"prompt.slash.badge.mcp": "mcp",
|
||||||
"prompt.context.active": "aktiv",
|
"prompt.context.active": "aktiv",
|
||||||
"prompt.context.includeActiveFile": "Inkluder aktiv fil",
|
"prompt.context.includeActiveFile": "Inkluder aktiv fil",
|
||||||
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
|
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
|
||||||
@@ -345,6 +349,11 @@ export const dict = {
|
|||||||
"toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer",
|
"toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer",
|
||||||
"toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse",
|
"toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse",
|
||||||
|
|
||||||
|
"toast.workspace.enabled.title": "Arbejdsområder aktiveret",
|
||||||
|
"toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet",
|
||||||
|
"toast.workspace.disabled.title": "Arbejdsområder deaktiveret",
|
||||||
|
"toast.workspace.disabled.description": "Kun hoved-worktree vises i sidepanelet",
|
||||||
|
|
||||||
"toast.model.none.title": "Ingen model valgt",
|
"toast.model.none.title": "Ingen model valgt",
|
||||||
"toast.model.none.description": "Forbind en udbyder for at opsummere denne session",
|
"toast.model.none.description": "Forbind en udbyder for at opsummere denne session",
|
||||||
|
|
||||||
@@ -434,6 +443,7 @@ export const dict = {
|
|||||||
"session.review.noChanges": "Ingen ændringer",
|
"session.review.noChanges": "Ingen ændringer",
|
||||||
"session.files.selectToOpen": "Vælg en fil at åbne",
|
"session.files.selectToOpen": "Vælg en fil at åbne",
|
||||||
"session.files.all": "Alle filer",
|
"session.files.all": "Alle filer",
|
||||||
|
"session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
|
||||||
"session.messages.renderEarlier": "Vis tidligere beskeder",
|
"session.messages.renderEarlier": "Vis tidligere beskeder",
|
||||||
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
|
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
|
||||||
"session.messages.loadEarlier": "Indlæs tidligere beskeder",
|
"session.messages.loadEarlier": "Indlæs tidligere beskeder",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user