Merge branch 'dev' into sqlite2

This commit is contained in:
Dax 2026-02-02 23:36:00 -05:00 committed by GitHub
commit 949e69a9bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 3411 additions and 1926 deletions

View file

@ -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,6 +26,15 @@ 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"
@ -32,6 +42,42 @@ jobs:
core.info(`Dry run mode: ${dryRun}`) core.info(`Dry run mode: ${dryRun}`)
core.info(`Cutoff date: ${cutoff.toISOString()}`) core.info(`Cutoff date: ${cutoff.toISOString()}`)
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function withRetry(fn, description = 'API call') {
let lastError
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await fn()
return result
} catch (error) {
lastError = error
const isRateLimited = error.status === 403 &&
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
if (!isRateLimited) {
throw error
}
// Parse retry-after header, default to 60 seconds
const retryAfter = error.response?.headers?.['retry-after']
? parseInt(error.response.headers['retry-after'])
: 60
// Exponential backoff: retryAfter * 2^attempt
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
await sleep(backoffMs)
}
}
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
throw lastError
}
const query = ` const query = `
query($owner: String!, $repo: String!, $cursor: String) { query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) { repository(owner: $owner, name: $repo) {
@ -73,17 +119,27 @@ jobs:
const allPrs = [] const allPrs = []
let cursor = null let cursor = null
let hasNextPage = true let hasNextPage = true
let pageCount = 0
while (hasNextPage) { while (hasNextPage) {
const result = await github.graphql(query, { pageCount++
owner, core.info(`Fetching page ${pageCount} of open PRs...`)
repo,
cursor, const result = await withRetry(
}) () => github.graphql(query, { owner, repo, cursor }),
`GraphQL page ${pageCount}`
)
allPrs.push(...result.repository.pullRequests.nodes) allPrs.push(...result.repository.pullRequests.nodes)
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
cursor = result.repository.pullRequests.pageInfo.endCursor 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`) core.info(`Found ${allPrs.length} open pull requests`)
@ -114,28 +170,66 @@ jobs:
core.info(`Found ${stalePrs.length} stale pull requests`) 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.author.login}: ${pr.title}`) 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.author.login}: ${pr.title}`) closedCount++
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
// Delay before processing next PR
await sleep(requestDelayMs)
} catch (error) {
skippedCount++
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
}
} }
const elapsed = Math.round((Date.now() - startTime) / 1000)
core.info(`\n========== Summary ==========`)
core.info(`Total open PRs found: ${allPrs.length}`)
core.info(`Stale PRs identified: ${stalePrs.length}`)
core.info(`PRs closed: ${closedCount}`)
core.info(`PRs skipped (errors): ${skippedCount}`)
core.info(`Elapsed time: ${elapsed}s`)
core.info(`=============================`)

View file

@ -9,6 +9,7 @@
"options": {}, "options": {},
}, },
}, },
"mcp": {},
"tools": { "tools": {
"github-triage": false, "github-triage": false,
"github-pr-search": false, "github-pr-search": false,

View file

@ -1 +1,2 @@
sst-env.d.ts sst-env.d.ts
desktop/src/bindings.ts

View file

@ -23,7 +23,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.48", "version": "1.1.49",
"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.48", "version": "1.1.49",
"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.48", "version": "1.1.49",
"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.48", "version": "1.1.49",
"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.48", "version": "1.1.49",
"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.48", "version": "1.1.49",
"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.48", "version": "1.1.49",
"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.48", "version": "1.1.49",
"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.48", "version": "1.1.49",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -286,7 +286,7 @@
"@ai-sdk/vercel": "1.0.33", "@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.56", "@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",
@ -298,8 +298,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.76", "@opentui/core": "0.1.77",
"@opentui/solid": "0.1.76", "@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",
@ -365,7 +365,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.48", "version": "1.1.49",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@ -385,7 +385,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.48", "version": "1.1.49",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.90.10", "@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@ -396,7 +396,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.48", "version": "1.1.49",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@ -409,7 +409,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.48", "version": "1.1.49",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -451,7 +451,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.48", "version": "1.1.49",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@ -462,7 +462,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.1.48", "version": "1.1.49",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@ -940,7 +940,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=="],
@ -1246,21 +1246,21 @@
"@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.76", "", { "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.76", "@opentui/core-darwin-x64": "0.1.76", "@opentui/core-linux-arm64": "0.1.76", "@opentui/core-linux-x64": "0.1.76", "@opentui/core-win32-arm64": "0.1.76", "@opentui/core-win32-x64": "0.1.76", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Y4f4KH6Mbj0J6+MorcvtHSeT+Lbs3YDPEQcTRTWsPOqWz3A0F5/+OPtZKho1EtLWQqJflCWdf/JQj5A3We3qRg=="], "@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.76", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aRYNOPRKL6URovSPhRvXtBV7SqdmR7s6hmEBSdXiYo39AozTcvKviF8gJWXQATcKDEcOtRir6TsASzDq5Coheg=="], "@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.76", "", { "os": "darwin", "cpu": "x64" }, "sha512-KFaRvVQ0Wr1PgaexUkF3KYt41pYmxGJW3otENeE6WDa/nXe2AElibPFRjqSEH54YrY5Q84SDI77/wGP4LZ/Wyg=="], "@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.76", "", { "os": "linux", "cpu": "arm64" }, "sha512-s7v+GDwavfieZg8xZV4V07fXFrHfFq4UZ2JpYFDUgNs9vFp+++WUjh3pfbfE+2ldbhcG2iOtuiV9aG1tVCbTEg=="], "@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.76", "", { "os": "linux", "cpu": "x64" }, "sha512-ugwuHpmvdKRHXKVsrC3zRYY6bg2JxVCzAQ1NOiWRLq3N3N4ha6BHAkHMCeHgR/ZI4R8MSRB6vtJRVI1F9VHxjA=="], "@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.76", "", { "os": "win32", "cpu": "arm64" }, "sha512-wjpRWrerPItb5E1fP4SAcNMxQp1yEukbgvP4Azip836/ixxbghL6y0P57Ya/rv7QYLrkNZXoQ+tr9oXhPH5BVA=="], "@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.76", "", { "os": "win32", "cpu": "x64" }, "sha512-2YjtZJdd3iO+SY9NKocE4/Pm9VolzAthUOXjpK4Pv5pnR9hBpPvX7FFSXJTfASj7y2j1tATWrlQLocZCFP/oMA=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
"@opentui/solid": ["@opentui/solid@0.1.76", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.76", "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-PiD62FGoPoVLFpY4g08i4UYlx4sGR2OmHUPj6CuZZwy2UJD4fKn1RYV+kAPHfUW4qN/88I1k/w/Dniz1WvXrAQ=="], "@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=="],

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-aRFzPzgu32XgNSk8S2z4glTlgHqEmOLZHlBQSIYIMvY=", "x86_64-linux": "sha256-yIrljJgOR1GZCAXi5bx+YvrIAjSkTAMTSzlhLFY/ufE=",
"aarch64-linux": "sha256-aCZLkmRrCa0bli0jgsaLcC5GlZdjQPbb6xD6Fc03eX8=", "aarch64-linux": "sha256-Xa3BgqbuD5Cx5OpyVSN1v7Klge449hPqR1GY9E9cAX0=",
"aarch64-darwin": "sha256-oZOOR6k8MmabNVDQNY5ywR06rRycdnXZL+gUucKSQ+g=", "aarch64-darwin": "sha256-Q3FKm7+4Jr3PL+TnQngrTtv/xdek2st5HmgeoEOHUis=",
"x86_64-darwin": "sha256-LXIcLnjn+1eTFWIsQ9W0U2orGm59P/L470O0KFFkRHg=" "x86_64-darwin": "sha256-asJ8DBvIgkqh8HhrN48M/L4xj1kwv+uyQMy9bN2HxuM="
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.48", "version": "1.1.49",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {

View file

@ -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>

View file

@ -90,10 +90,9 @@ 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>
gutter?: number
}) { }) {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
open: boolean open: boolean
@ -176,14 +175,14 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
}} }}
modal={false} modal={false}
placement="top-start" placement="top-start"
gutter={props.gutter ?? 8} gutter={8}
> >
<Kobalte.Trigger <Kobalte.Trigger
ref={(el) => setStore("trigger", el)} ref={(el) => setStore("trigger", el)}
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

View file

@ -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 = (

View file

@ -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"
@ -1257,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,
@ -1280,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,
@ -1436,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,
@ -1453,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)
@ -1463,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
@ -1471,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)
@ -1481,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))
}), }),
) )
} }
@ -1490,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)
@ -1503,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)
@ -1524,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) {
@ -1549,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) {
@ -1577,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
@ -1587,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,
@ -1597,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"),
@ -1621,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}>
@ -1695,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>
@ -1760,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>
@ -1801,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}>
@ -1818,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)
@ -1848,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>
} }
> >
@ -1921,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">
@ -1934,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")}
> >
@ -1941,9 +1917,9 @@ 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"
gutter={12}
/> />
</TooltipKeybind> </TooltipKeybind>
<Show <Show
@ -1951,66 +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-[140px]"
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)} 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 max-w-[100px]">
<MorphChevron {local.model.current()?.name ?? language.t("dialog.model.select.title")}
expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")} </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" }} gutter={12}> <ModelSelectorPopover
{(open) => ( triggerAs={Button}
<> triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[140px]" }}
<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 max-w-[100px]">
</> {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")}
> >
@ -2018,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),
}} }}
@ -2040,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"
@ -2052,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>
@ -2083,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>
@ -2094,7 +2071,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled={!prompt.dirty() && !working()} disabled={!prompt.dirty() && !working()}
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>

View file

@ -64,7 +64,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
} }
const circle = () => ( const circle = () => (
<div class="p-1"> <div class="flex items-center justify-center">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} /> <ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
</div> </div>
) )

View file

@ -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)}

View file

@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings" import { useSettings, monoFontFamily } from "@/context/settings"
@ -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>
@ -232,7 +226,7 @@ export const SettingsGeneral: Component = () => {
variant="secondary" variant="secondary"
size="small" size="small"
triggerVariant="settings" triggerVariant="settings"
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }} triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
> >
{(option) => ( {(option) => (
<span style={{ "font-family": monoFontFamily(option?.value) }}> <span style={{ "font-family": monoFontFamily(option?.value) }}>
@ -417,7 +411,7 @@ export const SettingsGeneral: Component = () => {
</div> </div>
</div> </div>
</div> </div>
</ScrollFade> </div>
) )
} }

View file

@ -5,7 +5,6 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
import fuzzysort from "fuzzysort" 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"
@ -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">
@ -436,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
</div> </div>
</Show> </Show>
</div> </div>
</ScrollFade> </div>
) )
} }

View file

@ -9,7 +9,6 @@ import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number] type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@ -40,12 +39,7 @@ export const SettingsModels: 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]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
@ -131,6 +125,6 @@ export const SettingsModels: Component = () => {
</Show> </Show>
</Show> </Show>
</div> </div>
</ScrollFade> </div>
) )
} }

View file

@ -12,7 +12,6 @@ import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
type ProviderSource = "env" | "api" | "config" | "custom" type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderMeta = { source?: ProviderSource } type ProviderMeta = { source?: ProviderSource }
@ -116,12 +115,7 @@ export const SettingsProviders: 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-1 pt-6 pb-8 max-w-[720px]"> <div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
@ -267,6 +261,6 @@ export const SettingsProviders: Component = () => {
</Button> </Button>
</div> </div>
</div> </div>
</ScrollFade> </div>
) )
} }

View file

@ -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" />

View file

@ -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" },
), ),
) )
@ -1003,7 +1005,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)
}), }),
), ),

View file

@ -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 */
@ -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({

View file

@ -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" }))
}) })

View file

@ -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": "لوحة الأوامر",
@ -68,6 +70,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": "إعادة",
@ -346,6 +349,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": "قم بتوصيل موفر لتلخيص هذه الجلسة",

View file

@ -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",
@ -68,6 +70,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",
@ -345,6 +348,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",

View file

@ -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",
@ -68,6 +70,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",
@ -347,6 +350,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",

View file

@ -32,6 +32,8 @@ export const dict = {
"command.settings.open": "Einstellungen öffnen", "command.settings.open": "Einstellungen öffnen",
"command.session.previous": "Vorherige Sitzung", "command.session.previous": "Vorherige Sitzung",
"command.session.next": "Nächste Sitzung", "command.session.next": "Nächste Sitzung",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Sitzung archivieren", "command.session.archive": "Sitzung archivieren",
"command.palette": "Befehlspalette", "command.palette": "Befehlspalette",
@ -72,6 +74,7 @@ export const dict = {
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln", "command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren", "command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen", "command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
"command.workspace.toggle": "Arbeitsbereiche umschalten",
"command.session.undo": "Rückgängig", "command.session.undo": "Rückgängig",
"command.session.undo.description": "Letzte Nachricht rückgängig machen", "command.session.undo.description": "Letzte Nachricht rückgängig machen",
"command.session.redo": "Wiederherstellen", "command.session.redo": "Wiederherstellen",

View file

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Open settings", "command.settings.open": "Open settings",
"command.session.previous": "Previous session", "command.session.previous": "Previous session",
"command.session.next": "Next session", "command.session.next": "Next session",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archive session", "command.session.archive": "Archive session",
"command.palette": "Command palette", "command.palette": "Command palette",
@ -43,6 +45,7 @@ export const dict = {
"command.session.new": "New session", "command.session.new": "New session",
"command.file.open": "Open file", "command.file.open": "Open file",
"command.file.open.description": "Search files and commands", "command.file.open.description": "Search files and commands",
"command.tab.close": "Close tab",
"command.context.addSelection": "Add selection to context", "command.context.addSelection": "Add selection to context",
"command.context.addSelection.description": "Add selected lines from the current file", "command.context.addSelection.description": "Add selected lines from the current file",
"command.terminal.toggle": "Toggle terminal", "command.terminal.toggle": "Toggle terminal",
@ -68,6 +71,8 @@ export const dict = {
"command.model.variant.cycle.description": "Switch to the next effort level", "command.model.variant.cycle.description": "Switch to the next effort level",
"command.permissions.autoaccept.enable": "Auto-accept edits", "command.permissions.autoaccept.enable": "Auto-accept edits",
"command.permissions.autoaccept.disable": "Stop auto-accepting edits", "command.permissions.autoaccept.disable": "Stop auto-accepting edits",
"command.workspace.toggle": "Toggle workspaces",
"command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar",
"command.session.undo": "Undo", "command.session.undo": "Undo",
"command.session.undo.description": "Undo the last message", "command.session.undo.description": "Undo the last message",
"command.session.redo": "Redo", "command.session.redo": "Redo",
@ -347,6 +352,11 @@ export const dict = {
"toast.theme.title": "Theme switched", "toast.theme.title": "Theme switched",
"toast.scheme.title": "Color scheme", "toast.scheme.title": "Color scheme",
"toast.workspace.enabled.title": "Workspaces enabled",
"toast.workspace.enabled.description": "Multiple worktrees are now shown in the sidebar",
"toast.workspace.disabled.title": "Workspaces disabled",
"toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar",
"toast.permissions.autoaccept.on.title": "Auto-accepting edits", "toast.permissions.autoaccept.on.title": "Auto-accepting edits",
"toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved", "toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved",
"toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits", "toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits",

View file

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Abrir ajustes", "command.settings.open": "Abrir ajustes",
"command.session.previous": "Sesión anterior", "command.session.previous": "Sesión anterior",
"command.session.next": "Siguiente sesión", "command.session.next": "Siguiente sesión",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archivar sesión", "command.session.archive": "Archivar sesión",
"command.palette": "Paleta de comandos", "command.palette": "Paleta de comandos",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo", "command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente", "command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente", "command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
"command.workspace.toggle": "Alternar espacios de trabajo",
"command.session.undo": "Deshacer", "command.session.undo": "Deshacer",
"command.session.undo.description": "Deshacer el último mensaje", "command.session.undo.description": "Deshacer el último mensaje",
"command.session.redo": "Rehacer", "command.session.redo": "Rehacer",
@ -348,6 +351,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente", "toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente",
"toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación", "toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación",
"toast.workspace.enabled.title": "Espacios de trabajo habilitados",
"toast.workspace.enabled.description": "Ahora se muestran varios worktrees en la barra lateral",
"toast.workspace.disabled.title": "Espacios de trabajo deshabilitados",
"toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral",
"toast.model.none.title": "Ningún modelo seleccionado", "toast.model.none.title": "Ningún modelo seleccionado",
"toast.model.none.description": "Conecta un proveedor para resumir esta sesión", "toast.model.none.description": "Conecta un proveedor para resumir esta sesión",

View file

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Ouvrir les paramètres", "command.settings.open": "Ouvrir les paramètres",
"command.session.previous": "Session précédente", "command.session.previous": "Session précédente",
"command.session.next": "Session suivante", "command.session.next": "Session suivante",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archiver la session", "command.session.archive": "Archiver la session",
"command.palette": "Palette de commandes", "command.palette": "Palette de commandes",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant", "command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications", "command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications", "command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
"command.workspace.toggle": "Basculer les espaces de travail",
"command.session.undo": "Annuler", "command.session.undo": "Annuler",
"command.session.undo.description": "Annuler le dernier message", "command.session.undo.description": "Annuler le dernier message",
"command.session.redo": "Rétablir", "command.session.redo": "Rétablir",
@ -350,6 +353,11 @@ export const dict = {
"toast.permissions.autoaccept.off.description": "toast.permissions.autoaccept.off.description":
"Les permissions de modification et d'écriture nécessiteront une approbation", "Les permissions de modification et d'écriture nécessiteront une approbation",
"toast.workspace.enabled.title": "Espaces de travail activés",
"toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale",
"toast.workspace.disabled.title": "Espaces de travail désactivés",
"toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale",
"toast.model.none.title": "Aucun modèle sélectionné", "toast.model.none.title": "Aucun modèle sélectionné",
"toast.model.none.description": "Connectez un fournisseur pour résumer cette session", "toast.model.none.description": "Connectez un fournisseur pour résumer cette session",

View file

@ -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": "コマンドパレット",
@ -68,6 +70,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": "やり直す",

View file

@ -32,6 +32,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": "명령 팔레트",
@ -72,6 +74,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": "다시 실행",
@ -349,6 +352,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": "이제 사이드바에 여러 작업 트리가 표시됩니다",
"toast.workspace.disabled.title": "작업 공간 비활성화됨",
"toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다",
"toast.model.none.title": "선택된 모델 없음", "toast.model.none.title": "선택된 모델 없음",
"toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요", "toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요",

View file

@ -31,6 +31,8 @@ export const dict = {
"command.settings.open": "Åpne innstillinger", "command.settings.open": "Åpne innstillinger",
"command.session.previous": "Forrige sesjon", "command.session.previous": "Forrige sesjon",
"command.session.next": "Neste sesjon", "command.session.next": "Neste sesjon",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Arkiver sesjon", "command.session.archive": "Arkiver sesjon",
"command.palette": "Kommandopalett", "command.palette": "Kommandopalett",
@ -71,6 +73,7 @@ export const dict = {
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå", "command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
"command.permissions.autoaccept.enable": "Godta endringer automatisk", "command.permissions.autoaccept.enable": "Godta endringer automatisk",
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk", "command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
"command.workspace.toggle": "Veksle arbeidsområder",
"command.session.undo": "Angre", "command.session.undo": "Angre",
"command.session.undo.description": "Angre siste melding", "command.session.undo.description": "Angre siste melding",
"command.session.redo": "Gjør om", "command.session.redo": "Gjør om",
@ -349,6 +352,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk", "toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk",
"toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning", "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning",
"toast.workspace.enabled.title": "Arbeidsområder aktivert",
"toast.workspace.enabled.description": "Flere worktrees vises nå i sidefeltet",
"toast.workspace.disabled.title": "Arbeidsområder deaktivert",
"toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet",
"toast.model.none.title": "Ingen modell valgt", "toast.model.none.title": "Ingen modell valgt",
"toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen", "toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen",

View file

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Otwórz ustawienia", "command.settings.open": "Otwórz ustawienia",
"command.session.previous": "Poprzednia sesja", "command.session.previous": "Poprzednia sesja",
"command.session.next": "Następna sesja", "command.session.next": "Następna sesja",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Zarchiwizuj sesję", "command.session.archive": "Zarchiwizuj sesję",
"command.palette": "Paleta poleceń", "command.palette": "Paleta poleceń",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku", "command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji", "command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji", "command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
"command.workspace.toggle": "Przełącz przestrzenie robocze",
"command.session.undo": "Cofnij", "command.session.undo": "Cofnij",
"command.session.undo.description": "Cofnij ostatnią wiadomość", "command.session.undo.description": "Cofnij ostatnią wiadomość",
"command.session.redo": "Ponów", "command.session.redo": "Ponów",
@ -347,6 +350,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji", "toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji",
"toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia", "toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia",
"toast.workspace.enabled.title": "Przestrzenie robocze włączone",
"toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym",
"toast.workspace.disabled.title": "Przestrzenie robocze wyłączone",
"toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym",
"toast.model.none.title": "Nie wybrano modelu", "toast.model.none.title": "Nie wybrano modelu",
"toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję", "toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję",

View file

@ -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": "Палитра команд",
@ -68,6 +70,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": "Повторить",
@ -348,6 +351,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": "В боковой панели теперь отображаются несколько рабочих деревьев",
"toast.workspace.disabled.title": "Рабочие пространства отключены",
"toast.workspace.disabled.description": "В боковой панели отображается только главное рабочее дерево",
"toast.model.none.title": "Модель не выбрана", "toast.model.none.title": "Модель не выбрана",
"toast.model.none.description": "Подключите провайдера для суммаризации сессии", "toast.model.none.description": "Подключите провайдера для суммаризации сессии",

View file

@ -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": "คำสั่งค้นหา",
@ -68,6 +70,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": "ทำซ้ำ",
@ -347,10 +350,15 @@ export const dict = {
"toast.scheme.title": "โทนสี", "toast.scheme.title": "โทนสี",
"toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ", "toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ",
"toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและเขียนจะได้รับการอนุมัติโดยอัตโนมัติ", "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและจะได้รับเขียนการอนุมัติโดยอัตโนมัติ",
"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": "ตอนนี้จะแสดง worktree หลายรายการในแถบด้านข้าง",
"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": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้",

View file

@ -32,6 +32,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": "命令面板",
@ -72,6 +74,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": "重做",
@ -342,7 +345,12 @@ export const dict = {
"toast.language.description": "已切换到{{language}}", "toast.language.description": "已切换到{{language}}",
"toast.theme.title": "主题已切换", "toast.theme.title": "主题已切换",
"toast.scheme.title": "配色方案", "toast.scheme.title": "颜色方案",
"toast.workspace.enabled.title": "工作区已启用",
"toast.workspace.enabled.description": "侧边栏现在显示多个工作树",
"toast.workspace.disabled.title": "工作区已禁用",
"toast.workspace.disabled.description": "侧边栏只显示主工作树",
"toast.permissions.autoaccept.on.title": "自动接受编辑", "toast.permissions.autoaccept.on.title": "自动接受编辑",
"toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批", "toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批",

View file

@ -32,6 +32,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": "命令面板",
@ -72,6 +74,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": "重做",
@ -339,7 +342,12 @@ export const dict = {
"toast.language.description": "已切換到 {{language}}", "toast.language.description": "已切換到 {{language}}",
"toast.theme.title": "主題已切換", "toast.theme.title": "主題已切換",
"toast.scheme.title": "配色方案", "toast.scheme.title": "顏色方案",
"toast.workspace.enabled.title": "工作區已啟用",
"toast.workspace.enabled.description": "側邊欄現在顯示多個工作樹",
"toast.workspace.disabled.title": "工作區已停用",
"toast.workspace.disabled.description": "側邊欄只顯示主工作樹",
"toast.permissions.autoaccept.on.title": "自動接受編輯", "toast.permissions.autoaccept.on.title": "自動接受編輯",
"toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准", "toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准",

View file

@ -27,10 +27,12 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { List, type ListRef } from "@opencode-ai/ui/list"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card" import { HoverCard } from "@opencode-ai/ui/hover-card"
import { MessageNav } from "@opencode-ai/ui/message-nav" import { MessageNav } from "@opencode-ai/ui/message-nav"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { Collapsible } from "@opencode-ai/ui/collapsible" import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
@ -108,7 +110,7 @@ export default function Layout(props: ParentProps) {
const command = useCommand() const command = useCommand()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const initialDir = params.dir const initialDirectory = decode64(params.dir)
const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = { const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@ -119,7 +121,7 @@ export default function Layout(props: ParentProps) {
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !params.dir, autoselect: !initialDirectory,
busyWorkspaces: new Set<string>(), busyWorkspaces: new Set<string>(),
hoverSession: undefined as string | undefined, hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
@ -179,13 +181,21 @@ export default function Layout(props: ParentProps) {
const autoselecting = createMemo(() => { const autoselecting = createMemo(() => {
if (params.dir) return false if (params.dir) return false
if (initialDir) return false
if (!state.autoselect) return false if (!state.autoselect) return false
if (!pageReady()) return true if (!pageReady()) return true
if (!layoutReady()) return true if (!layoutReady()) return true
const list = layout.projects.list() const list = layout.projects.list()
if (list.length === 0) return false if (list.length > 0) return true
return true return !!server.projects.last()
})
createEffect(() => {
if (!state.autoselect) return
const dir = params.dir
if (!dir) return
const directory = decode64(dir)
if (!directory) return
setState("autoselect", false)
}) })
const editorOpen = (id: string) => editor.active === id const editorOpen = (id: string) => editor.active === id
@ -498,7 +508,7 @@ export default function Layout(props: ParentProps) {
const bUpdated = b.time.updated ?? b.time.created const bUpdated = b.time.updated ?? b.time.created
const aRecent = aUpdated > oneMinuteAgo const aRecent = aUpdated > oneMinuteAgo
const bRecent = bUpdated > oneMinuteAgo const bRecent = bUpdated > oneMinuteAgo
if (aRecent && bRecent) return a.id.localeCompare(b.id) if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
if (aRecent && !bRecent) return -1 if (aRecent && !bRecent) return -1
if (!aRecent && bRecent) return 1 if (!aRecent && bRecent) return 1
return bUpdated - aUpdated return bUpdated - aUpdated
@ -565,11 +575,18 @@ export default function Layout(props: ParentProps) {
if (!value.ready) return if (!value.ready) return
if (!value.layoutReady) return if (!value.layoutReady) return
if (!state.autoselect) return if (!state.autoselect) return
if (initialDir) return
if (value.dir) return if (value.dir) return
if (value.list.length === 0) return
const last = server.projects.last() const last = server.projects.last()
if (value.list.length === 0) {
if (!last) return
setState("autoselect", false)
openProject(last, false)
navigateToProject(last)
return
}
const next = value.list.find((project) => project.worktree === last) ?? value.list[0] const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
if (!next) return if (!next) return
setState("autoselect", false) setState("autoselect", false)
@ -738,7 +755,7 @@ export default function Layout(props: ParentProps) {
} }
async function prefetchMessages(directory: string, sessionID: string, token: number) { async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [, setStore] = globalSync.child(directory, { bootstrap: false }) const [store, setStore] = globalSync.child(directory, { bootstrap: false })
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => { .then((messages) => {
@ -749,23 +766,49 @@ export default function Layout(props: ParentProps) {
.map((x) => x.info) .map((x) => x.info)
.filter((m) => !!m?.id) .filter((m) => !!m?.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))
const current = store.message[sessionID] ?? []
const merged = (() => {
if (current.length === 0) return next
const map = new Map<string, Message>()
for (const item of current) {
if (!item?.id) continue
map.set(item.id, item)
}
for (const item of next) {
map.set(item.id, item)
}
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})()
batch(() => { batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" })) setStore("message", sessionID, reconcile(merged, { key: "id" }))
for (const message of items) { for (const message of items) {
setStore( const currentParts = store.part[message.info.id] ?? []
"part", const mergedParts = (() => {
message.info.id, if (currentParts.length === 0) {
reconcile( return message.parts
message.parts
.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))
{ key: "id" }, }
),
) const map = new Map<string, (typeof currentParts)[number]>()
for (const item of currentParts) {
if (!item?.id) continue
map.set(item.id, item)
}
for (const item of message.parts) {
if (!item?.id) continue
map.set(item.id, item)
}
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})()
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
} }
}) })
}) })
@ -886,6 +929,52 @@ export default function Layout(props: ParentProps) {
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
} }
function navigateSessionByUnseen(offset: number) {
const sessions = currentSessions()
if (sessions.length === 0) return
const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0)
if (!hasUnseen) return
const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex
for (let i = 1; i <= sessions.length; i++) {
const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
const session = sessions[index]
if (!session) continue
if (notification.session.unseen(session.id).length === 0) continue
prefetchSession(session, "high")
const next = sessions[(index + 1) % sessions.length]
const prev = sessions[(index - 1 + sessions.length) % sessions.length]
if (offset > 0) {
if (next) prefetchSession(next, "high")
if (prev) prefetchSession(prev)
}
if (offset < 0) {
if (prev) prefetchSession(prev, "high")
if (next) prefetchSession(next)
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup",
})
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return
}
}
async function archiveSession(session: Session) { async function archiveSession(session: Session) {
const [store, setStore] = globalSync.child(session.directory) const [store, setStore] = globalSync.child(session.directory)
const sessions = store.session ?? [] const sessions = store.session ?? []
@ -1024,6 +1113,20 @@ export default function Layout(props: ParentProps) {
keybind: "alt+arrowdown", keybind: "alt+arrowdown",
onSelect: () => navigateSessionByOffset(1), onSelect: () => navigateSessionByOffset(1),
}, },
{
id: "session.previous.unseen",
title: language.t("command.session.previous.unseen"),
category: language.t("command.category.session"),
keybind: "shift+alt+arrowup",
onSelect: () => navigateSessionByUnseen(-1),
},
{
id: "session.next.unseen",
title: language.t("command.session.next.unseen"),
category: language.t("command.category.session"),
keybind: "shift+alt+arrowdown",
onSelect: () => navigateSessionByUnseen(1),
},
{ {
id: "session.archive", id: "session.archive",
title: language.t("command.session.archive"), title: language.t("command.session.archive"),
@ -1035,6 +1138,29 @@ export default function Layout(props: ParentProps) {
if (session) archiveSession(session) if (session) archiveSession(session)
}, },
}, },
{
id: "workspace.toggle",
title: language.t("command.workspace.toggle"),
description: language.t("command.workspace.toggle.description"),
category: language.t("command.category.workspace"),
slash: "workspace",
disabled: !currentProject() || currentProject()?.vcs !== "git",
onSelect: () => {
const project = currentProject()
if (!project) return
if (project.vcs !== "git") return
const wasEnabled = layout.sidebar.workspaces(project.worktree)()
layout.sidebar.toggleWorkspaces(project.worktree)
showToast({
title: wasEnabled
? language.t("toast.workspace.disabled.title")
: language.t("toast.workspace.enabled.title"),
description: wasEnabled
? language.t("toast.workspace.disabled.description")
: language.t("toast.workspace.enabled.description"),
})
},
},
{ {
id: "theme.cycle", id: "theme.cycle",
title: language.t("command.theme.cycle"), title: language.t("command.theme.cycle"),
@ -2250,10 +2376,13 @@ export default function Layout(props: ParentProps) {
() => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(), () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
) )
const [open, setOpen] = createSignal(false) const [open, setOpen] = createSignal(false)
const [menu, setMenu] = createSignal(false)
const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree)) const active = createMemo(
() => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree),
)
createEffect(() => { createEffect(() => {
if (preview()) return if (preview()) return
@ -2291,50 +2420,95 @@ export default function Layout(props: ParentProps) {
} }
const projectName = () => props.project.name || getFilename(props.project.worktree) const projectName = () => props.project.name || getFilename(props.project.worktree)
const trigger = ( const Trigger = () => (
<button <ContextMenu
type="button" modal={!sidebarHovering()}
aria-label={projectName()} onOpenChange={(value) => {
data-action="project-switch" setMenu(value)
data-project={base64Encode(props.project.worktree)} if (value) setOpen(false)
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!selected() && !active(),
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
}} }}
onMouseEnter={() => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
}}
onFocus={() => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
}}
onClick={() => navigateToProject(props.project.worktree)}
onBlur={() => setOpen(false)}
> >
<ProjectIcon project={props.project} notify /> <ContextMenu.Trigger
</button> as="button"
type="button"
aria-label={projectName()}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!selected() && !active(),
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
}}
onMouseEnter={() => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
}}
onFocus={() => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
}}
onClick={() => navigateToProject(props.project.worktree)}
onBlur={() => setOpen(false)}
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? state.nav : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}>
<ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !layout.sidebar.workspaces(props.project.worktree)()}
onSelect={() => {
const enabled = layout.sidebar.workspaces(props.project.worktree)()
if (enabled) {
layout.sidebar.toggleWorkspaces(props.project.worktree)
return
}
if (props.project.vcs !== "git") return
layout.sidebar.toggleWorkspaces(props.project.worktree)
}}
>
<ContextMenu.ItemLabel>
{layout.sidebar.workspaces(props.project.worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
) )
return ( return (
// @ts-ignore // @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview()} fallback={trigger}> <Show when={preview()} fallback={<Trigger />}>
<HoverCard <HoverCard
open={open()} open={open() && !menu()}
openDelay={0} openDelay={0}
closeDelay={0} closeDelay={0}
placement="right-start" placement="right-start"
gutter={6} gutter={6}
trigger={trigger} trigger={<Trigger />}
onOpenChange={(value) => { onOpenChange={(value) => {
if (menu()) return
setOpen(value) setOpen(value)
if (value) setState("hoverSession", undefined) if (value) setState("hoverSession", undefined)
}} }}
@ -2532,6 +2706,14 @@ export default function Layout(props: ParentProps) {
} }
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
type SearchItem = {
id: string
title: string
directory: string
label: string
archived?: number
}
const projectName = createMemo(() => { const projectName = createMemo(() => {
const project = panelProps.project const project = panelProps.project
if (!project) return "" if (!project) return ""
@ -2547,6 +2729,107 @@ export default function Layout(props: ParentProps) {
}) })
const homedir = createMemo(() => globalSync.data.path.home) const homedir = createMemo(() => globalSync.data.path.home)
const [search, setSearch] = createStore({
value: "",
})
const searching = createMemo(() => search.value.trim().length > 0)
let searchRef: HTMLInputElement | undefined
let listRef: ListRef | undefined
const token = { value: 0 }
let inflight: Promise<SearchItem[]> | undefined
let all: SearchItem[] | undefined
const reset = () => {
token.value += 1
inflight = undefined
all = undefined
setSearch({ value: "" })
listRef = undefined
}
const open = (item: SearchItem | undefined) => {
if (!item) return
const href = `/${base64Encode(item.directory)}/session/${item.id}`
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
reset()
navigate(href)
layout.mobileSidebar.hide()
}
const items = (filter: string) => {
const query = filter.trim()
if (!query) {
token.value += 1
inflight = undefined
all = undefined
return [] as SearchItem[]
}
const project = panelProps.project
if (!project) return [] as SearchItem[]
if (all) return all
if (inflight) return inflight
const current = token.value
const dirs = workspaceIds(project)
inflight = Promise.all(
dirs.map((input) => {
const directory = workspaceKey(input)
const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
const kind =
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
const label = `${kind} : ${name}`
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"),
directory,
label,
archived: s.time?.archived,
})),
)
.catch(() => [] as SearchItem[])
}),
)
.then((results) => {
if (token.value !== current) return [] as SearchItem[]
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
})
all = next
return next
})
.catch(() => [] as SearchItem[])
.finally(() => {
inflight = undefined
})
return inflight
}
createEffect(
on(
() => panelProps.project?.worktree,
() => reset(),
{ defer: true },
),
)
return ( return (
<div <div
classList={{ classList={{
@ -2555,7 +2838,7 @@ export default function Layout(props: ParentProps) {
}} }}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }} style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
> >
<Show when={panelProps.project} keyed> <Show when={panelProps.project}>
{(p) => ( {(p) => (
<> <>
<div class="shrink-0 px-2 py-1"> <div class="shrink-0 px-2 py-1">
@ -2564,7 +2847,7 @@ export default function Layout(props: ParentProps) {
<InlineEditor <InlineEditor
id={`project:${projectId()}`} id={`project:${projectId()}`}
value={projectName} value={projectName}
onSave={(next) => renameProject(p, next)} onSave={(next) => renameProject(p(), next)}
class="text-16-medium text-text-strong truncate" class="text-16-medium text-text-strong truncate"
displayClass="text-16-medium text-text-strong truncate" displayClass="text-16-medium text-text-strong truncate"
stopPropagation stopPropagation
@ -2573,7 +2856,7 @@ export default function Layout(props: ParentProps) {
<Tooltip <Tooltip
placement="bottom" placement="bottom"
gutter={2} gutter={2}
value={p.worktree} value={p().worktree}
class="shrink-0" class="shrink-0"
contentStyle={{ contentStyle={{
"max-width": "640px", "max-width": "640px",
@ -2581,7 +2864,7 @@ export default function Layout(props: ParentProps) {
}} }}
> >
<span class="text-12-regular text-text-base truncate select-text"> <span class="text-12-regular text-text-base truncate select-text">
{p.worktree.replace(homedir(), "~")} {p().worktree.replace(homedir(), "~")}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
@ -2592,31 +2875,31 @@ export default function Layout(props: ParentProps) {
icon="dot-grid" icon="dot-grid"
variant="ghost" variant="ghost"
data-action="project-menu" data-action="project-menu"
data-project={base64Encode(p.worktree)} data-project={base64Encode(p().worktree)}
class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")} aria-label={language.t("common.moreOptions")}
/> />
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}> <DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1"> <DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}> <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
data-action="project-workspaces-toggle" data-action="project-workspaces-toggle"
data-project={base64Encode(p.worktree)} data-project={base64Encode(p().worktree)}
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()} disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => { onSelect={() => {
const enabled = layout.sidebar.workspaces(p.worktree)() const enabled = layout.sidebar.workspaces(p().worktree)()
if (enabled) { if (enabled) {
layout.sidebar.toggleWorkspaces(p.worktree) layout.sidebar.toggleWorkspaces(p().worktree)
return return
} }
if (p.vcs !== "git") return if (p().vcs !== "git") return
layout.sidebar.toggleWorkspaces(p.worktree) layout.sidebar.toggleWorkspaces(p().worktree)
}} }}
> >
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p.worktree)() {layout.sidebar.workspaces(p().worktree)()
? language.t("sidebar.workspaces.disable") ? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")} : language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
@ -2624,8 +2907,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
data-action="project-close-menu" data-action="project-close-menu"
data-project={base64Encode(p.worktree)} data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p.worktree)} onSelect={() => closeProject(p().worktree)}
> >
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
@ -2635,103 +2918,207 @@ export default function Layout(props: ParentProps) {
</div> </div>
</div> </div>
<Show <div class="shrink-0 px-2 pt-2">
when={workspacesEnabled()} <div
fallback={ class="flex items-center gap-2 p-2 rounded-md bg-surface-base shadow-xs-border-base focus-within:shadow-xs-border-select"
onPointerDown={(event) => {
const target = event.target
if (!(target instanceof Element)) return
if (target.closest("input, textarea, [contenteditable='true']")) return
searchRef?.focus()
}}
>
<Icon name="magnifying-glass" />
<InlineInput
ref={(el) => {
searchRef = el
}}
class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak"
style={{ "box-shadow": "none" }}
value={search.value}
onInput={(event) => setSearch("value", event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
setSearch("value", "")
queueMicrotask(() => searchRef?.focus())
return
}
if (!searching()) return
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") {
const ref = listRef
if (!ref) return
event.stopPropagation()
ref.onKeyDown(event)
return
}
if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
if (event.key === "n" || event.key === "p") {
const ref = listRef
if (!ref) return
event.stopPropagation()
ref.onKeyDown(event)
}
}
}}
placeholder={language.t("session.header.search.placeholder", { project: projectName() })}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
<Show when={search.value}>
<IconButton
icon="circle-x"
variant="ghost"
class="size-5"
aria-label={language.t("common.close")}
onClick={() => {
setSearch("value", "")
queueMicrotask(() => searchRef?.focus())
}}
/>
</Show>
</div>
</div>
<Show when={searching()}>
<List
class="flex-1 min-h-0 pb-2 pt-2 !px-2 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
items={items}
filter={search.value}
filterKeys={["title", "label", "id"]}
key={(item) => `${item.directory}:${item.id}`}
onSelect={open}
ref={(ref) => {
listRef = ref
}}
>
{(item) => (
<div class="flex flex-col gap-0.5 min-w-0 pr-2 text-left">
<span
class="text-14-medium text-text-strong truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.title}
</span>
<span
class="text-12-regular text-text-weak truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.label}
</span>
</div>
)}
</List>
</Show>
<div class="flex-1 min-h-0 flex flex-col" classList={{ hidden: searching() }}>
<Show
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
navigate(`/${base64Encode(p().worktree)}/session`)
layout.mobileSidebar.hide()
}}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace project={p()} mobile={panelProps.mobile} />
</div>
</>
}
>
<> <>
<div class="py-4 px-3"> <div class="shrink-0 py-4 px-3">
<TooltipKeybind <TooltipKeybind
title={language.t("command.session.new")} title={language.t("workspace.new")}
keybind={command.keybind("session.new")} keybind={command.keybind("workspace.new")}
placement="top" placement="top"
> >
<Button <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
size="large" {language.t("workspace.new")}
icon="plus-small"
class="w-full"
onClick={() => {
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
navigate(`/${base64Encode(p.worktree)}/session`)
layout.mobileSidebar.hide()
}}
>
{language.t("command.session.new")}
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
<div class="flex-1 min-h-0"> <div class="relative flex-1 min-h-0">
<LocalWorkspace project={p} mobile={panelProps.mobile} /> <DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace directory={directory} project={p()} mobile={panelProps.mobile} />
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay />
</DragOverlay>
</DragDropProvider>
</div> </div>
</> </>
} </Show>
> </div>
<>
<div class="py-4 px-3">
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace directory={directory} project={p} mobile={panelProps.mobile} />
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</> </>
)} )}
</Show> </Show>
<Show when={providers.all().length > 0 && providers.paid().length === 0}>
<div class="shrink-0 px-2 py-3 border-t border-border-weak-base"> <div
<div class="rounded-md bg-background-base shadow-xs-border-base"> class="shrink-0 px-2 py-3 border-t border-border-weak-base"
<div class="p-3 flex flex-col gap-2"> classList={{
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div> hidden: searching() || !(providers.all().length > 0 && providers.paid().length === 0),
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div> }}
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div> >
</div> <div class="rounded-md bg-background-base shadow-xs-border-base">
<Button <div class="p-3 flex flex-col gap-2">
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3" <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
size="large" <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
icon="plus" <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
onClick={connectProvider}
>
{language.t("command.provider.connect")}
</Button>
</div> </div>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
size="large"
icon="plus"
onClick={connectProvider}
>
{language.t("command.provider.connect")}
</Button>
</div> </div>
</Show> </div>
</div> </div>
) )
} }

View file

@ -500,9 +500,7 @@ export default function Page() {
const out = new Map<string, "add" | "del" | "mix">() const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) { for (const diff of diffs()) {
const file = normalize(diff.file) const file = normalize(diff.file)
const add = diff.additions > 0 const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
const del = diff.deletions > 0
const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix"
out.set(file, kind) out.set(file, kind)
@ -689,6 +687,18 @@ export default function Page() {
slash: "open", slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={() => showAllFiles()} />), onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={() => showAllFiles()} />),
}, },
{
id: "tab.close",
title: language.t("command.tab.close"),
category: language.t("command.category.file"),
keybind: "mod+w",
disabled: !tabs().active(),
onSelect: () => {
const active = tabs().active()
if (!active) return
tabs().close(active)
},
},
{ {
id: "context.addSelection", id: "context.addSelection",
title: language.t("command.context.addSelection"), title: language.t("command.context.addSelection"),
@ -1940,7 +1950,8 @@ export default function Page() {
"sticky top-0 z-30 bg-background-stronger": true, "sticky top-0 z-30 bg-background-stronger": true,
"w-full": true, "w-full": true,
"px-4 md:px-6": true, "px-4 md:px-6": true,
"md:max-w-200 md:mx-auto": centered(), "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto":
centered(),
}} }}
> >
<div class="h-10 flex items-center gap-1"> <div class="h-10 flex items-center gap-1">
@ -1968,7 +1979,8 @@ export default function Page() {
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{ classList={{
"w-full": true, "w-full": true,
"md:max-w-200 md:mx-auto": centered(), "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto":
centered(),
"mt-0.5": centered(), "mt-0.5": centered(),
"mt-0": !centered(), "mt-0": !centered(),
}} }}
@ -2021,7 +2033,7 @@ export default function Page() {
data-message-id={message.id} data-message-id={message.id}
classList={{ classList={{
"min-w-0 w-full max-w-full": true, "min-w-0 w-full max-w-full": true,
"md:max-w-200": centered(), "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
}} }}
> >
<SessionTurn <SessionTurn
@ -2078,7 +2090,7 @@ export default function Page() {
<div <div
classList={{ classList={{
"w-full px-4 pointer-events-auto": true, "w-full px-4 pointer-events-auto": true,
"md:max-w-200 md:mx-auto": centered(), "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
}} }}
> >
<Show when={request()} keyed> <Show when={request()} keyed>

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.48", "version": "1.1.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -9,8 +9,8 @@ export const config = {
github: { github: {
repoUrl: "https://github.com/anomalyco/opencode", repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: { starsFormatted: {
compact: "80K", compact: "95K",
full: "80,000", full: "95,000",
}, },
}, },
@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page) // Static stats (used on landing page)
stats: { stats: {
contributors: "600", contributors: "650",
commits: "7,500", commits: "8,500",
monthlyUsers: "1.5M", monthlyUsers: "2.5M",
}, },
} as const } as const

View file

@ -2,13 +2,17 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error" import { RateLimitError } from "./error"
import { logger } from "./logger" import { logger } from "./logger"
import { ZenData } from "@opencode-ai/console-core/model.js"
export function createRateLimiter(limit: number | undefined, rawIp: string) { export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string) {
if (!limit) return if (!limit) return
const ip = !rawIp.length ? "unknown" : rawIp const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now() const now = Date.now()
const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)] const intervals =
limit.period === "day"
? [buildYYYYMMDD(now)]
: [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
return { return {
track: async () => { track: async () => {
@ -28,11 +32,18 @@ export function createRateLimiter(limit: number | undefined, rawIp: string) {
) )
const total = rows.reduce((sum, r) => sum + r.count, 0) const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`) logger.debug(`rate limit total: ${total}`)
if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) if (total >= limit.value) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
}, },
} }
} }
function buildYYYYMMDD(timestamp: number) {
return new Date(timestamp)
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 8)
}
function buildYYYYMMDDHH(timestamp: number) { function buildYYYYMMDDHH(timestamp: number) {
return new Date(timestamp) return new Date(timestamp)
.toISOString() .toISOString()

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.48", "version": "1.1.49",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -18,8 +18,13 @@ export namespace ZenData {
}), }),
), ),
}) })
const RateLimitSchema = z.object({
period: z.enum(["day", "rolling"]),
value: z.number().int(),
})
export type Format = z.infer<typeof FormatSchema> export type Format = z.infer<typeof FormatSchema>
export type Trial = z.infer<typeof TrialSchema> export type Trial = z.infer<typeof TrialSchema>
export type RateLimit = z.infer<typeof RateLimitSchema>
const ModelCostSchema = z.object({ const ModelCostSchema = z.object({
input: z.number(), input: z.number(),
@ -37,7 +42,7 @@ export namespace ZenData {
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(), byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(), stickyProvider: z.enum(["strict", "prefer"]).optional(),
trial: TrialSchema.optional(), trial: TrialSchema.optional(),
rateLimit: z.number().optional(), rateLimit: RateLimitSchema.optional(),
fallbackProvider: z.string().optional(), fallbackProvider: z.string().optional(),
providers: z.array( providers: z.array(
z.object({ z.object({

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.48", "version": "1.1.49",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.48", "version": "1.1.49",
"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",

View file

@ -17,7 +17,7 @@
</head> </head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh p-px"></div> <div id="root" class="flex flex-col h-dvh"></div>
<div data-tauri-decorum-tb class="w-0 h-0 hidden" /> <div data-tauri-decorum-tb class="w-0 h-0 hidden" />
<script src="/src/index.tsx" type="module"></script> <script src="/src/index.tsx" type="module"></script>
</body> </body>

View file

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.1.48", "version": "1.1.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@ -1994,9 +2000,9 @@ dependencies = [
[[package]] [[package]]
name = "ico" name = "ico"
version = "0.4.0" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"png 0.17.16", "png 0.17.16",
@ -3065,12 +3071,14 @@ dependencies = [
"listeners", "listeners",
"objc2 0.6.3", "objc2 0.6.3",
"objc2-web-kit", "objc2-web-kit",
"reqwest", "reqwest 0.12.24",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
"specta",
"specta-typescript",
"tauri", "tauri",
"tauri-build", "tauri-build 2.5.2",
"tauri-plugin-clipboard-manager", "tauri-plugin-clipboard-manager",
"tauri-plugin-decorum", "tauri-plugin-decorum",
"tauri-plugin-deep-link", "tauri-plugin-deep-link",
@ -3085,6 +3093,7 @@ dependencies = [
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tauri-specta",
"tokio", "tokio",
"uuid", "uuid",
"webkit2gtk", "webkit2gtk",
@ -3221,6 +3230,12 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "pathdiff" name = "pathdiff"
version = "0.2.3" version = "0.2.3"
@ -3947,6 +3962,40 @@ dependencies = [
"webpki-roots", "webpki-roots",
] ]
[[package]]
name = "reqwest"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
]
[[package]] [[package]]
name = "rfd" name = "rfd"
version = "0.15.4" version = "0.15.4"
@ -4497,6 +4546,44 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "specta"
version = "2.0.0-rc.22"
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
dependencies = [
"paste",
"rustc_version",
"specta-macros",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.18"
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "specta-serde"
version = "0.0.9"
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
dependencies = [
"specta",
]
[[package]]
name = "specta-typescript"
version = "0.0.9"
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
dependencies = [
"specta",
"specta-serde",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -4712,9 +4799,8 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.9.3" version = "2.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
checksum = "9e492485dd390b35f7497401f67694f46161a2a00ffd800938d5dd3c898fb9d8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -4740,17 +4826,18 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.1",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
"serialize-to-javascript", "serialize-to-javascript",
"specta",
"swift-rs", "swift-rs",
"tauri-build", "tauri-build 2.5.3",
"tauri-macros", "tauri-macros",
"tauri-runtime", "tauri-runtime",
"tauri-runtime-wry", "tauri-runtime-wry",
"tauri-utils", "tauri-utils 2.8.1",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"tray-icon", "tray-icon",
@ -4777,7 +4864,28 @@ dependencies = [
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
"tauri-utils", "tauri-utils 2.8.0",
"tauri-winres",
"toml 0.9.8",
"walkdir",
]
[[package]]
name = "tauri-build"
version = "2.5.3"
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"glob",
"heck 0.5.0",
"json-patch",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"tauri-utils 2.8.1",
"tauri-winres", "tauri-winres",
"toml 0.9.8", "toml 0.9.8",
"walkdir", "walkdir",
@ -4785,9 +4893,8 @@ dependencies = [
[[package]] [[package]]
name = "tauri-codegen" name = "tauri-codegen"
version = "2.5.1" version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"brotli", "brotli",
@ -4802,7 +4909,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"syn 2.0.110", "syn 2.0.110",
"tauri-utils", "tauri-utils 2.8.1",
"thiserror 2.0.17", "thiserror 2.0.17",
"time", "time",
"url", "url",
@ -4812,16 +4919,15 @@ dependencies = [
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.5.1" version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.110", "syn 2.0.110",
"tauri-codegen", "tauri-codegen",
"tauri-utils", "tauri-utils 2.8.1",
] ]
[[package]] [[package]]
@ -4836,7 +4942,7 @@ dependencies = [
"schemars 0.8.22", "schemars 0.8.22",
"serde", "serde",
"serde_json", "serde_json",
"tauri-utils", "tauri-utils 2.8.0",
"toml 0.9.8", "toml 0.9.8",
"walkdir", "walkdir",
] ]
@ -4886,7 +4992,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"tauri-utils", "tauri-utils 2.8.0",
"thiserror 2.0.17", "thiserror 2.0.17",
"tracing", "tracing",
"url", "url",
@ -4928,7 +5034,7 @@ dependencies = [
"serde_repr", "serde_repr",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"tauri-utils", "tauri-utils 2.8.0",
"thiserror 2.0.17", "thiserror 2.0.17",
"toml 0.9.8", "toml 0.9.8",
"url", "url",
@ -4945,7 +5051,7 @@ dependencies = [
"data-url", "data-url",
"http", "http",
"regex", "regex",
"reqwest", "reqwest 0.12.24",
"schemars 0.8.22", "schemars 0.8.22",
"serde", "serde",
"serde_json", "serde_json",
@ -5096,7 +5202,7 @@ dependencies = [
"minisign-verify", "minisign-verify",
"osakit", "osakit",
"percent-encoding", "percent-encoding",
"reqwest", "reqwest 0.12.24",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
@ -5129,9 +5235,8 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.9.1" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926"
dependencies = [ dependencies = [
"cookie", "cookie",
"dpi", "dpi",
@ -5144,7 +5249,7 @@ dependencies = [
"raw-window-handle", "raw-window-handle",
"serde", "serde",
"serde_json", "serde_json",
"tauri-utils", "tauri-utils 2.8.1",
"thiserror 2.0.17", "thiserror 2.0.17",
"url", "url",
"webkit2gtk", "webkit2gtk",
@ -5154,9 +5259,8 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime-wry" name = "tauri-runtime-wry"
version = "2.9.1" version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
@ -5171,7 +5275,7 @@ dependencies = [
"softbuffer", "softbuffer",
"tao", "tao",
"tauri-runtime", "tauri-runtime",
"tauri-utils", "tauri-utils 2.8.1",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
@ -5179,11 +5283,74 @@ dependencies = [
"wry", "wry",
] ]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.21"
source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-specta-macros",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-specta-macros"
version = "2.0.0-rc.16"
source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b"
dependencies = [
"darling",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.8.0" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673" checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
dependencies = [
"anyhow",
"cargo_metadata",
"ctor",
"dunce",
"glob",
"html5ever",
"http",
"infer",
"json-patch",
"kuchikiki",
"log",
"memchr",
"phf 0.11.3",
"proc-macro2",
"quote",
"regex",
"schemars 0.8.22",
"semver",
"serde",
"serde-untagged",
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.17",
"toml 0.9.8",
"url",
"urlpattern",
"uuid",
"walkdir",
]
[[package]]
name = "tauri-utils"
version = "2.8.1"
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
@ -5547,9 +5714,9 @@ dependencies = [
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.6" version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"bytes", "bytes",
@ -6034,9 +6201,9 @@ dependencies = [
[[package]] [[package]]
name = "webkit2gtk" name = "webkit2gtk"
version = "2.0.1" version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"cairo-rs", "cairo-rs",
@ -6058,9 +6225,9 @@ dependencies = [
[[package]] [[package]]
name = "webkit2gtk-sys" name = "webkit2gtk-sys"
version = "2.0.1" version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"cairo-sys-rs", "cairo-sys-rs",
@ -6719,9 +6886,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]] [[package]]
name = "wry" name = "wry"
version = "0.53.5" version = "0.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"block2 0.6.2", "block2 0.6.2",

View file

@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["macos-private-api", "devtools"] } tauri = { version = "2.9.5", features = ["macos-private-api", "devtools"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2.4.6" tauri-plugin-deep-link = "2.4.6"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
@ -43,10 +43,13 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
uuid = { version = "1.19.0", features = ["v4"] } uuid = { version = "1.19.0", features = ["v4"] }
tauri-plugin-decorum = "1.1.1" tauri-plugin-decorum = "1.1.1"
comrak = { version = "0.50", default-features = false } comrak = { version = "0.50", default-features = false }
specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2" gtk = "0.18.2"
webkit2gtk = "=2.0.1" webkit2gtk = "=2.0.2"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6" objc2 = "0.6"
@ -59,3 +62,10 @@ windows = { version = "0.61", features = [
"Win32_System_Threading", "Win32_System_Threading",
"Win32_Security" "Win32_Security"
] } ] }
[patch.crates-io]
specta = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "6720b2848eff9a3e40af54c48d65f6d56b640c0b" }
# TODO: https://github.com/tauri-apps/tauri/pull/14812
tauri = { git = "https://github.com/tauri-apps/tauri", rev = "4d5d78daf636feaac20c5bc48a6071491c4291ee" }

View file

@ -51,6 +51,7 @@ fn is_cli_installed() -> bool {
const INSTALL_SCRIPT: &str = include_str!("../../../../install"); const INSTALL_SCRIPT: &str = include_str!("../../../../install");
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> { pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
if cfg!(not(unix)) { if cfg!(not(unix)) {
return Err("CLI installation is only supported on macOS & Linux".to_string()); return Err("CLI installation is only supported on macOS & Linux".to_string());

View file

@ -16,21 +16,26 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder}; use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder};
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
use tauri_plugin_deep_link::DeepLinkExt;
#[cfg(windows)] #[cfg(windows)]
use tauri_plugin_decorum::WebviewWindowExt; use tauri_plugin_decorum::WebviewWindowExt;
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot; use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::{mpsc, oneshot};
use crate::window_customizer::PinchZoomDisablePlugin; use crate::window_customizer::PinchZoomDisablePlugin;
const SETTINGS_STORE: &str = "opencode.settings.dat"; const SETTINGS_STORE: &str = "opencode.settings.dat";
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
#[derive(Clone, serde::Serialize)] fn window_state_flags() -> StateFlags {
StateFlags::all() - StateFlags::DECORATIONS
}
#[derive(Clone, serde::Serialize, specta::Type)]
struct ServerReadyData { struct ServerReadyData {
url: String, url: String,
password: Option<String>, password: Option<String>,
@ -64,6 +69,7 @@ struct LogState(Arc<Mutex<VecDeque<String>>>);
const MAX_LOG_ENTRIES: usize = 200; const MAX_LOG_ENTRIES: usize = 200;
#[tauri::command] #[tauri::command]
#[specta::specta]
fn kill_sidecar(app: AppHandle) { fn kill_sidecar(app: AppHandle) {
let Some(server_state) = app.try_state::<ServerState>() else { let Some(server_state) = app.try_state::<ServerState>() else {
println!("Server not running"); println!("Server not running");
@ -97,6 +103,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> { async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
state state
.status .status
@ -106,6 +113,7 @@ async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerRead
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> { fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
let store = app let store = app
.store(SETTINGS_STORE) .store(SETTINGS_STORE)
@ -119,6 +127,7 @@ fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> { async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
let store = app let store = app
.store(SETTINGS_STORE) .store(SETTINGS_STORE)
@ -252,6 +261,26 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
pub fn run() { pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
let builder = tauri_specta::Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(tauri_specta::collect_commands![
kill_sidecar,
install_cli,
ensure_server_ready,
get_default_server_url,
set_default_server_url,
markdown::parse_markdown_command
])
.error_handling(tauri_specta::ErrorHandlingMode::Throw);
#[cfg(debug_assertions)] // <- Only export on non-release builds
builder
.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
#[cfg(all(target_os = "macos", not(debug_assertions)))] #[cfg(all(target_os = "macos", not(debug_assertions)))]
let _ = std::process::Command::new("killall") let _ = std::process::Command::new("killall")
.arg("opencode-cli") .arg("opencode-cli")
@ -269,10 +298,7 @@ pub fn run() {
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin( .plugin(
tauri_plugin_window_state::Builder::new() tauri_plugin_window_state::Builder::new()
.with_state_flags( .with_state_flags(window_state_flags())
tauri_plugin_window_state::StateFlags::all()
- tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.build(), .build(),
) )
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
@ -285,15 +311,10 @@ pub fn run() {
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(PinchZoomDisablePlugin) .plugin(PinchZoomDisablePlugin)
.plugin(tauri_plugin_decorum::init()) .plugin(tauri_plugin_decorum::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(builder.invoke_handler())
kill_sidecar,
install_cli,
ensure_server_ready,
get_default_server_url,
set_default_server_url,
markdown::parse_markdown_command
])
.setup(move |app| { .setup(move |app| {
builder.mount_events(app);
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))] #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
app.deep_link().register_all().ok(); app.deep_link().register_all().ok();
@ -346,6 +367,8 @@ pub fn run() {
let window = window_builder.build().expect("Failed to create window"); let window = window_builder.build().expect("Failed to create window");
setup_window_state_listener(&app, &window);
#[cfg(windows)] #[cfg(windows)]
let _ = window.create_overlay_titlebar(); let _ = window.create_overlay_titlebar();
@ -526,6 +549,7 @@ async fn spawn_local_server(
let timestamp = Instant::now(); let timestamp = Instant::now();
loop { loop {
if timestamp.elapsed() > Duration::from_secs(30) { if timestamp.elapsed() > Duration::from_secs(30) {
let _ = child.kill();
break Err(format!( break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}", "Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap() get_logs(app.clone()).await.unwrap()
@ -540,3 +564,35 @@ async fn spawn_local_server(
} }
} }
} }
fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWindow) {
let (tx, mut rx) = mpsc::channel::<()>(1);
window.on_window_event(move |event| {
use tauri::WindowEvent;
if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) {
return;
}
let _ = tx.try_send(());
});
tauri::async_runtime::spawn({
let app = app.clone();
async move {
let save = || {
let handle = app.clone();
let app = app.clone();
let _ = handle.run_on_main_thread(move || {
let _ = app.save_window_state(window_state_flags());
});
};
while rx.recv().await.is_some() {
tokio::time::sleep(Duration::from_millis(200)).await;
save();
}
}
});
}

View file

@ -1,4 +1,6 @@
use comrak::{create_formatter, parse_document, Arena, Options, html::ChildRendering, nodes::NodeValue}; use comrak::{
Arena, Options, create_formatter, html::ChildRendering, nodes::NodeValue, parse_document,
};
use std::fmt::Write; use std::fmt::Write;
create_formatter!(ExternalLinkFormatter, { create_formatter!(ExternalLinkFormatter, {
@ -55,6 +57,7 @@ pub fn parse_markdown(input: &str) -> String {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn parse_markdown_command(markdown: String) -> Result<String, String> { pub async fn parse_markdown_command(markdown: String) -> Result<String, String> {
Ok(parse_markdown(&markdown)) Ok(parse_markdown(&markdown))
} }

View file

@ -1,4 +1,4 @@
use tauri::{plugin::Plugin, Manager, Runtime, Window}; use tauri::{Manager, Runtime, Window, plugin::Plugin};
pub struct PinchZoomDisablePlugin; pub struct PinchZoomDisablePlugin;
@ -21,8 +21,8 @@ impl<R: Runtime> Plugin<R> for PinchZoomDisablePlugin {
let _ = webview_window.with_webview(|_webview| { let _ = webview_window.with_webview(|_webview| {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
unsafe { unsafe {
use gtk::glib::ObjectExt;
use gtk::GestureZoom; use gtk::GestureZoom;
use gtk::glib::ObjectExt;
use webkit2gtk::glib::gobject_ffi; use webkit2gtk::glib::gobject_ffi;
if let Some(data) = _webview.inner().data::<GestureZoom>("wk-view-zoom-gesture") { if let Some(data) = _webview.inner().data::<GestureZoom>("wk-view-zoom-gesture") {

View file

@ -0,0 +1,19 @@
// This file has been generated by Tauri Specta. Do not edit this file manually.
import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"
/** Commands */
export const commands = {
killSidecar: () => __TAURI_INVOKE<void>("kill_sidecar"),
installCli: () => __TAURI_INVOKE<string>("install_cli"),
ensureServerReady: () => __TAURI_INVOKE<ServerReadyData>("ensure_server_ready"),
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
}
/* Types */
export type ServerReadyData = {
url: string
password: string | null
}

View file

@ -1,13 +1,13 @@
import { invoke } from "@tauri-apps/api/core"
import { message } from "@tauri-apps/plugin-dialog" import { message } from "@tauri-apps/plugin-dialog"
import { initI18n, t } from "./i18n" import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export async function installCli(): Promise<void> { export async function installCli(): Promise<void> {
await initI18n() await initI18n()
try { try {
const path = await invoke<string>("install_cli") const path = await commands.installCli()
await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") }) await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") })
} catch (e) { } catch (e) {
await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") }) await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") })

View file

@ -1,5 +1,5 @@
// @refresh reload // @refresh reload
import "./webview-zoom" import { webviewZoom } from "./webview-zoom"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app" import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog" import { open, save } from "@tauri-apps/plugin-dialog"
@ -7,7 +7,6 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { open as shellOpen } from "@tauri-apps/plugin-shell" import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os" import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater" import { check, Update } from "@tauri-apps/plugin-updater"
import { invoke } from "@tauri-apps/api/core"
import { getCurrentWindow } from "@tauri-apps/api/window" import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process" import { relaunch } from "@tauri-apps/plugin-process"
@ -22,6 +21,7 @@ import { createMenu } from "./menu"
import { initI18n, t } from "./i18n" import { initI18n, t } from "./i18n"
import pkg from "../package.json" import pkg from "../package.json"
import "./styles.css" import "./styles.css"
import { commands } from "./bindings"
const root = document.getElementById("root") const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) { if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@ -274,12 +274,12 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
update: async () => { update: async () => {
if (!UPDATER_ENABLED || !update) return if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined) if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
await update.install().catch(() => undefined) await update.install().catch(() => undefined)
}, },
restart: async () => { restart: async () => {
await invoke("kill_sidecar").catch(() => undefined) await commands.killSidecar().catch(() => undefined)
await relaunch() await relaunch()
}, },
@ -335,17 +335,17 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
}, },
getDefaultServerUrl: async () => { getDefaultServerUrl: async () => {
const result = await invoke<string | null>("get_default_server_url").catch(() => null) const result = await commands.getDefaultServerUrl().catch(() => null)
return result return result
}, },
setDefaultServerUrl: async (url: string | null) => { setDefaultServerUrl: async (url: string | null) => {
await invoke("set_default_server_url", { url }) await commands.setDefaultServerUrl(url)
}, },
parseMarkdown: async (markdown: string) => { parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
return invoke<string>("parse_markdown_command", { markdown })
}, webviewZoom,
}) })
createMenu() createMenu()
@ -391,11 +391,7 @@ type ServerReadyData = { url: string; password: string | null }
// Gate component that waits for the server to be ready // Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) { function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
const [serverData] = createResource<ServerReadyData>(() => const [serverData] = createResource(() => commands.ensureServerReady())
invoke("ensure_server_ready").then((v) => {
return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000))
}),
)
const errorMessage = () => { const errorMessage = () => {
const error = serverData.error const error = serverData.error
@ -406,7 +402,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
} }
const restartApp = async () => { const restartApp = async () => {
await invoke("kill_sidecar").catch(() => undefined) await commands.killSidecar().catch(() => undefined)
await relaunch().catch(() => undefined) await relaunch().catch(() => undefined)
} }

View file

@ -1,11 +1,11 @@
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu" import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
import { type as ostype } from "@tauri-apps/plugin-os" import { type as ostype } from "@tauri-apps/plugin-os"
import { invoke } from "@tauri-apps/api/core"
import { relaunch } from "@tauri-apps/plugin-process" import { relaunch } from "@tauri-apps/plugin-process"
import { runUpdater, UPDATER_ENABLED } from "./updater" import { runUpdater, UPDATER_ENABLED } from "./updater"
import { installCli } from "./cli" import { installCli } from "./cli"
import { initI18n, t } from "./i18n" import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export async function createMenu() { export async function createMenu() {
if (ostype() !== "macos") return if (ostype() !== "macos") return
@ -35,7 +35,7 @@ export async function createMenu() {
}), }),
await MenuItem.new({ await MenuItem.new({
action: async () => { action: async () => {
await invoke("kill_sidecar").catch(() => undefined) await commands.killSidecar().catch(() => undefined)
await relaunch().catch(() => undefined) await relaunch().catch(() => undefined)
}, },
text: t("desktop.menu.restart"), text: t("desktop.menu.restart"),

View file

@ -1,10 +1,10 @@
import { check } from "@tauri-apps/plugin-updater" import { check } from "@tauri-apps/plugin-updater"
import { relaunch } from "@tauri-apps/plugin-process" import { relaunch } from "@tauri-apps/plugin-process"
import { ask, message } from "@tauri-apps/plugin-dialog" import { ask, message } from "@tauri-apps/plugin-dialog"
import { invoke } from "@tauri-apps/api/core"
import { type as ostype } from "@tauri-apps/plugin-os" import { type as ostype } from "@tauri-apps/plugin-os"
import { initI18n, t } from "./i18n" import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
@ -39,13 +39,13 @@ export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
if (!shouldUpdate) return if (!shouldUpdate) return
try { try {
if (ostype() === "windows") await invoke("kill_sidecar") if (ostype() === "windows") await commands.killSidecar()
await update.install() await update.install()
} catch { } catch {
await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") }) await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") })
return return
} }
await invoke("kill_sidecar") await commands.killSidecar()
await relaunch() await relaunch()
} }

View file

@ -4,28 +4,34 @@
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { type as ostype } from "@tauri-apps/plugin-os" import { type as ostype } from "@tauri-apps/plugin-os"
import { createSignal } from "solid-js"
const OS_NAME = ostype() const OS_NAME = ostype()
let zoomLevel = 1 const [webviewZoom, setWebviewZoom] = createSignal(1)
const MAX_ZOOM_LEVEL = 10 const MAX_ZOOM_LEVEL = 10
const MIN_ZOOM_LEVEL = 0.2 const MIN_ZOOM_LEVEL = 0.2
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
const applyZoom = (next: number) => {
setWebviewZoom(next)
invoke("plugin:webview|set_webview_zoom", {
value: next,
})
}
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", (event) => {
if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) { if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
if (event.key === "-") {
zoomLevel -= 0.2 let newZoom = webviewZoom()
} else if (event.key === "=" || event.key === "+") {
zoomLevel += 0.2 if (event.key === "-") newZoom -= 0.2
} else if (event.key === "0") { if (event.key === "=" || event.key === "+") newZoom += 0.2
zoomLevel = 1 if (event.key === "0") newZoom = 1
} else {
return applyZoom(clamp(newZoom))
}
zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
invoke("plugin:webview|set_webview_zoom", {
value: zoomLevel,
})
}
}) })
export { webviewZoom }

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.48", "version": "1.1.49",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.1.48" version = "1.1.49"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.48", "version": "1.1.49",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.1.48", "version": "1.1.49",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -21,6 +21,7 @@
"bin": { "bin": {
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"randomField": "this-is-a-random-value-12345",
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
@ -73,7 +74,7 @@
"@ai-sdk/vercel": "1.0.33", "@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.56", "@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",
@ -85,8 +86,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.76", "@opentui/core": "0.1.77",
"@opentui/solid": "0.1.76", "@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",

View file

@ -18,6 +18,7 @@ const version = Object.values(binaries)[0]
await $`mkdir -p ./dist/${pkg.name}` await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin` await $`cp -r ./bin ./dist/${pkg.name}/bin`
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs` await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
await Bun.file(`./dist/${pkg.name}/package.json`).write( await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify( JSON.stringify(
@ -30,6 +31,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs", postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
}, },
version: version, version: version,
license: pkg.license,
optionalDependencies: binaries, optionalDependencies: binaries,
}, },
null, null,

View file

@ -55,7 +55,6 @@ export namespace Agent {
doom_loop: "ask", doom_loop: "ask",
external_directory: { external_directory: {
"*": "ask", "*": "ask",
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow", [Truncate.GLOB]: "allow",
}, },
question: "deny", question: "deny",
@ -140,7 +139,6 @@ export namespace Agent {
codesearch: "allow", codesearch: "allow",
read: "allow", read: "allow",
external_directory: { external_directory: {
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow", [Truncate.GLOB]: "allow",
}, },
}), }),
@ -229,19 +227,19 @@ export namespace Agent {
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
} }
// Ensure Truncate.DIR is allowed unless explicitly configured // Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in result) { for (const name in result) {
const agent = result[name] const agent = result[name]
const explicit = agent.permission.some((r) => { const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false if (r.action !== "deny") return false
return r.pattern === Truncate.DIR || r.pattern === Truncate.GLOB return r.pattern === Truncate.GLOB
}) })
if (explicit) continue if (explicit) continue
result[name].permission = PermissionNext.merge( result[name].permission = PermissionNext.merge(
result[name].permission, result[name].permission,
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow" } }), PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
) )
} }

View file

@ -5,12 +5,10 @@ import path from "path"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun" import { readableStreamToText } from "bun"
import { createRequire } from "module"
import { Lock } from "../util/lock" import { Lock } from "../util/lock"
export namespace BunProc { export namespace BunProc {
const log = Log.create({ service: "bun" }) const log = Log.create({ service: "bun" })
const req = createRequire(import.meta.url)
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) { export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", { log.info("running", {

View file

@ -4,25 +4,211 @@ import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Flag } from "../../flag/flag" import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { Command } from "../../command"
import { EOL } from "os" import { EOL } from "os"
import { select } from "@clack/prompts" import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission/next"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ListTool } from "../../tool/ls"
import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
const TOOL: Record<string, [string, string]> = { type ToolProps<T extends Tool.Info> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], input: Tool.InferParameters<T>
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], metadata: Tool.InferMetadata<T>
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], part: ToolPart
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], }
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD], function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
list: ["List", UI.Style.TEXT_INFO_BOLD], const state = part.state
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD], return {
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD], input: state.input as Tool.InferParameters<T>,
websearch: ["Search", UI.Style.TEXT_DIM_BOLD], metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
part,
}
}
type Inline = {
icon: string
title: string
description?: string
}
function inline(info: Inline) {
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
}
function block(info: Inline, output?: string) {
UI.empty()
inline(info)
if (!output?.trim()) return
UI.println(output)
UI.empty()
}
function fallback(part: ToolPart) {
const state = part.state
const input = "input" in state ? state.input : undefined
const title =
("title" in state && state.title ? state.title : undefined) ||
(input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
inline({
icon: "⚙",
title: `${part.tool} ${title}`,
})
}
function glob(info: ToolProps<typeof GlobTool>) {
const root = info.input.path ?? ""
const title = `Glob "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.count
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function grep(info: ToolProps<typeof GrepTool>) {
const root = info.input.path ?? ""
const title = `Grep "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.matches
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function list(info: ToolProps<typeof ListTool>) {
const dir = info.input.path ? normalizePath(info.input.path) : ""
inline({
icon: "→",
title: dir ? `List ${dir}` : "List",
})
}
function read(info: ToolProps<typeof ReadTool>) {
const file = normalizePath(info.input.filePath)
const pairs = Object.entries(info.input).filter(([key, value]) => {
if (key === "filePath") return false
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
})
const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined
inline({
icon: "→",
title: `Read ${file}`,
...(description && { description }),
})
}
function write(info: ToolProps<typeof WriteTool>) {
block(
{
icon: "←",
title: `Write ${normalizePath(info.input.filePath)}`,
},
info.part.state.status === "completed" ? info.part.state.output : undefined,
)
}
function webfetch(info: ToolProps<typeof WebFetchTool>) {
inline({
icon: "%",
title: `WebFetch ${info.input.url}`,
})
}
function edit(info: ToolProps<typeof EditTool>) {
const title = normalizePath(info.input.filePath)
const diff = info.metadata.diff
block(
{
icon: "←",
title: `Edit ${title}`,
},
diff,
)
}
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
inline({
icon: "◇",
title: `Exa Code Search "${info.input.query}"`,
})
}
function websearch(info: ToolProps<typeof WebSearchTool>) {
inline({
icon: "◈",
title: `Exa Web Search "${info.input.query}"`,
})
}
function task(info: ToolProps<typeof TaskTool>) {
const agent = Locale.titlecase(info.input.subagent_type)
const desc = info.input.description
const started = info.part.state.status === "running"
const name = desc ?? `${agent} Task`
inline({
icon: started ? "•" : "✓",
title: name,
description: desc ? `${agent} Agent` : undefined,
})
}
function skill(info: ToolProps<typeof SkillTool>) {
inline({
icon: "→",
title: `Skill "${info.input.name}"`,
})
}
function bash(info: ToolProps<typeof BashTool>) {
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
block(
{
icon: "$",
title: `${info.input.command}`,
},
output,
)
}
function todo(info: ToolProps<typeof TodoWriteTool>) {
block(
{
icon: "#",
title: "Todos",
},
info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"),
)
}
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
return input
} }
export const RunCommand = cmd({ export const RunCommand = cmd({
@ -97,11 +283,11 @@ export const RunCommand = cmd({
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ") .join(" ")
const fileParts: any[] = [] const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) { if (args.file) {
const files = Array.isArray(args.file) ? args.file : [args.file] const list = Array.isArray(args.file) ? args.file : [args.file]
for (const filePath of files) { for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath) const resolvedPath = path.resolve(process.cwd(), filePath)
const file = Bun.file(resolvedPath) const file = Bun.file(resolvedPath)
const stats = await file.stat().catch(() => {}) const stats = await file.stat().catch(() => {})
@ -117,7 +303,7 @@ export const RunCommand = cmd({
const stat = await file.stat() const stat = await file.stat()
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain" const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
fileParts.push({ files.push({
type: "file", type: "file",
url: `file://${resolvedPath}`, url: `file://${resolvedPath}`,
filename: path.basename(resolvedPath), filename: path.basename(resolvedPath),
@ -133,17 +319,75 @@ export const RunCommand = cmd({
process.exit(1) process.exit(1)
} }
const execute = async (sdk: OpencodeClient, sessionID: string) => { const rules: PermissionNext.Ruleset = [
const printEvent = (color: string, type: string, title: string) => { {
UI.println( permission: "question",
color + `|`, action: "deny",
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, pattern: "*",
"", },
UI.Style.TEXT_NORMAL + title, {
) permission: "plan_enter",
action: "deny",
pattern: "*",
},
{
permission: "plan_exit",
action: "deny",
pattern: "*",
},
]
function title() {
if (args.title === undefined) return
if (args.title !== "") return args.title
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient) {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
}
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!res.error && "data" in res && res.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
}
}
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "list") return list(props<typeof ListTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part))
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
return fallback(part)
} }
const outputJsonEvent = (type: string, data: any) => { function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") { if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true return true
@ -152,41 +396,77 @@ export const RunCommand = cmd({
} }
const events = await sdk.event.subscribe() const events = await sdk.event.subscribe()
let errorMsg: string | undefined let error: string | undefined
async function loop() {
const toggles = new Map<string, boolean>()
const eventProcessor = (async () => {
for await (const event of events.stream) { for await (const event of events.stream) {
if (
event.type === "message.updated" &&
event.properties.info.role === "assistant" &&
args.format !== "json" &&
toggles.get("start") !== true
) {
UI.empty()
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
UI.empty()
toggles.set("start", true)
}
if (event.type === "message.part.updated") { if (event.type === "message.part.updated") {
const part = event.properties.part const part = event.properties.part
if (part.sessionID !== sessionID) continue if (part.sessionID !== sessionID) continue
if (part.type === "tool" && part.state.status === "completed") { if (part.type === "tool" && part.state.status === "completed") {
if (outputJsonEvent("tool_use", { part })) continue if (emit("tool_use", { part })) continue
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] tool(part)
const title = }
part.state.title ||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown") if (
printEvent(color, tool, title) part.type === "tool" &&
if (part.tool === "bash" && part.state.output?.trim()) { part.tool === "task" &&
UI.println() part.state.status === "running" &&
UI.println(part.state.output) args.format !== "json"
} ) {
if (toggles.get(part.id) === true) continue
task(props<typeof TaskTool>(part))
toggles.set(part.id, true)
} }
if (part.type === "step-start") { if (part.type === "step-start") {
if (outputJsonEvent("step_start", { part })) continue if (emit("step_start", { part })) continue
} }
if (part.type === "step-finish") { if (part.type === "step-finish") {
if (outputJsonEvent("step_finish", { part })) continue if (emit("step_finish", { part })) continue
} }
if (part.type === "text" && part.time?.end) { if (part.type === "text" && part.time?.end) {
if (outputJsonEvent("text", { part })) continue if (emit("text", { part })) continue
const isPiped = !process.stdout.isTTY const text = part.text.trim()
if (!isPiped) UI.println() if (!text) continue
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL) if (!process.stdout.isTTY) {
if (!isPiped) UI.println() process.stdout.write(text + EOL)
continue
}
UI.empty()
UI.println(text)
UI.empty()
}
if (part.type === "reasoning" && part.time?.end) {
if (emit("reasoning", { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (process.stdout.isTTY) {
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
continue
}
process.stdout.write(line + EOL)
} }
} }
@ -197,42 +477,40 @@ export const RunCommand = cmd({
if ("data" in props.error && props.error.data && "message" in props.error.data) { if ("data" in props.error && props.error.data && "message" in props.error.data) {
err = String(props.error.data.message) err = String(props.error.data.message)
} }
errorMsg = errorMsg ? errorMsg + EOL + err : err error = error ? error + EOL + err : err
if (outputJsonEvent("error", { error: props.error })) continue if (emit("error", { error: props.error })) continue
UI.error(err) UI.error(err)
} }
if (event.type === "session.idle" && event.properties.sessionID === sessionID) { if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
event.properties.status.type === "idle"
) {
break break
} }
if (event.type === "permission.asked") { if (event.type === "permission.asked") {
const permission = event.properties const permission = event.properties
if (permission.sessionID !== sessionID) continue if (permission.sessionID !== sessionID) continue
const result = await select({ UI.println(
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, UI.Style.TEXT_WARNING_BOLD + "!",
options: [ UI.Style.TEXT_NORMAL +
{ value: "once", label: "Allow once" }, `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
{ value: "always", label: "Always allow: " + permission.always.join(", ") }, )
{ value: "reject", label: "Reject" }, await sdk.permission.reply({
], requestID: permission.id,
initialValue: "once", reply: "reject",
}).catch(() => "reject")
const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject"
await sdk.permission.respond({
sessionID,
permissionID: permission.id,
response,
}) })
} }
} }
})() }
// Validate agent if specified // Validate agent if specified
const resolvedAgent = await (async () => { const agent = await (async () => {
if (!args.agent) return undefined if (!args.agent) return undefined
const agent = await Agent.get(args.agent) const entry = await Agent.get(args.agent)
if (!agent) { if (!entry) {
UI.println( UI.println(
UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
@ -240,7 +518,7 @@ export const RunCommand = cmd({
) )
return undefined return undefined
} }
if (agent.mode === "subagent") { if (entry.mode === "subagent") {
UI.println( UI.println(
UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
@ -251,91 +529,42 @@ export const RunCommand = cmd({
return args.agent return args.agent
})() })()
const sessionID = await session(sdk)
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
await share(sdk, sessionID)
loop().catch((e) => {
console.error(e)
process.exit(1)
})
if (args.command) { if (args.command) {
await sdk.session.command({ await sdk.session.command({
sessionID, sessionID,
agent: resolvedAgent, agent,
model: args.model, model: args.model,
command: args.command, command: args.command,
arguments: message, arguments: message,
variant: args.variant, variant: args.variant,
}) })
} else { } else {
const modelParam = args.model ? Provider.parseModel(args.model) : undefined const model = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({ await sdk.session.prompt({
sessionID, sessionID,
agent: resolvedAgent, agent,
model: modelParam, model,
variant: args.variant, variant: args.variant,
parts: [...fileParts, { type: "text", text: message }], parts: [...files, { type: "text", text: message }],
}) })
} }
await eventProcessor
if (errorMsg) process.exit(1)
} }
if (args.attach) { if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach }) const sdk = createOpencodeClient({ baseUrl: args.attach })
return await execute(sdk)
const sessionID = await (async () => {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const title =
args.title !== undefined
? args.title === ""
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
: args.title
: undefined
const result = await sdk.session.create(
title
? {
title,
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}
: {
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
},
)
return result.data?.id
})()
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
const cfgResult = await sdk.config.get()
if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
const shareResult = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
return await execute(sdk, sessionID)
} }
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
@ -344,52 +573,7 @@ export const RunCommand = cmd({
return Server.App().fetch(request) return Server.App().fetch(request)
}) as typeof globalThis.fetch }) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
await execute(sdk)
if (args.command) {
const exists = await Command.get(args.command)
if (!exists) {
UI.error(`Command "${args.command}" not found`)
process.exit(1)
}
}
const sessionID = await (async () => {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const title =
args.title !== undefined
? args.title === ""
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
: args.title
: undefined
const result = await sdk.session.create(title ? { title } : {})
return result.data?.id
})()
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
const cfgResult = await sdk.config.get()
if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
const shareResult = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
await execute(sdk, sessionID)
}) })
}, },
}) })

View file

@ -169,6 +169,7 @@ export function tui(input: {
gatherStats: false, gatherStats: false,
exitOnCtrlC: false, exitOnCtrlC: false,
useKittyKeyboard: {}, useKittyKeyboard: {},
autoFocus: false,
consoleOptions: { consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => { onCopySelection: (text) => {

View file

@ -129,6 +129,16 @@ export function Autocomplete(props: {
return props.input().getTextRange(store.index + 1, props.input().cursorOffset) return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
}) })
// filter() reads reactive props.value plus non-reactive cursor/text state.
// On keypress those can be briefly out of sync, so filter() may return an empty/partial string.
// Copy it into search in an effect because effects run after reactive updates have been rendered and painted
// so the input has settled and all consumers read the same stable value.
const [search, setSearch] = createSignal("")
createEffect(() => {
const next = filter()
setSearch(next ? next : "")
})
// When the filter changes due to how TUI works, the mousemove might still be triggered // When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so // via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
// that the mouseover event doesn't trigger when filtering. // that the mouseover event doesn't trigger when filtering.
@ -208,7 +218,7 @@ export function Autocomplete(props: {
} }
const [files] = createResource( const [files] = createResource(
() => filter(), () => search(),
async (query) => { async (query) => {
if (!store.visible || store.visible === "/") return [] if (!store.visible || store.visible === "/") return []
@ -378,9 +388,9 @@ export function Autocomplete(props: {
const mixed: AutocompleteOption[] = const mixed: AutocompleteOption[] =
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
const currentFilter = filter() const searchValue = search()
if (!currentFilter) { if (!searchValue) {
return mixed return mixed
} }
@ -388,7 +398,7 @@ export function Autocomplete(props: {
return prev return prev
} }
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, { const result = fuzzysort.go(removeLineRange(searchValue), mixed, {
keys: [ keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()), (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description", "description",
@ -398,7 +408,7 @@ export function Autocomplete(props: {
scoreFn: (objResults) => { scoreFn: (objResults) => {
const displayResult = objResults[0] const displayResult = objResults[0]
let score = objResults.score let score = objResults.score
if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) { if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
score *= 2 score *= 2
} }
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0 const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0

View file

@ -1,23 +1,52 @@
import { useRenderer } from "@opentui/solid" import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
clear: () => void
get: () => string | undefined
}
}
export const { use: useExit, provider: ExitProvider } = createSimpleContext({ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit", name: "Exit",
init: (input: { onExit?: () => Promise<void> }) => { init: (input: { onExit?: () => Promise<void> }) => {
const renderer = useRenderer() const renderer = useRenderer()
return async (reason?: any) => { let message: string | undefined
// Reset window title before destroying renderer const store = {
renderer.setTerminalTitle("") set: (value?: string) => {
renderer.destroy() const prev = message
await input.onExit?.() message = value
if (reason) { return () => {
const formatted = FormatError(reason) ?? FormatUnknownError(reason) message = prev
if (formatted) {
process.stderr.write(formatted + "\n")
} }
} },
process.exit(0) clear: () => {
message = undefined
},
get: () => message,
} }
const exit: Exit = Object.assign(
async (reason?: unknown) => {
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
process.exit(0)
},
{
message: store,
},
)
return exit
}, },
}) })

View file

@ -41,7 +41,6 @@ import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { useSDK } from "./sdk"
type ThemeColors = { type ThemeColors = {
primary: RGBA primary: RGBA
@ -429,6 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromInts(0, 0, 0, 0)
const isDark = mode == "dark" const isDark = mode == "dark"
const col = (i: number) => { const col = (i: number) => {
@ -479,8 +479,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
textMuted, textMuted,
selectedListItemText: bg, selectedListItemText: bg,
// Background colors // Background colors - use transparent to respect terminal transparency
background: bg, background: transparent,
backgroundPanel: grays[2], backgroundPanel: grays[2],
backgroundElement: grays[3], backgroundElement: grays[3],
backgroundMenu: grays[3], backgroundMenu: grays[3],

View file

@ -77,6 +77,7 @@ import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question" import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript" import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@ -222,6 +223,19 @@ export function Session() {
// Allow exit when in child session (prompt is hidden) // Allow exit when in child session (prompt is hidden)
const exit = useExit() const exit = useExit()
createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50)
return exit.message.set(
[
``,
` █▀▀█ ${UI.Style.TEXT_DIM}${title}${UI.Style.TEXT_NORMAL}`,
` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`,
` ▀▀▀▀ `,
].join("\n"),
)
})
useKeyboard((evt) => { useKeyboard((evt) => {
if (!session()?.parentID) return if (!session()?.parentID) return
if (keybind.match("app_exit", evt)) { if (keybind.match("app_exit", evt)) {

View file

@ -63,7 +63,7 @@ export const WebCommand = cmd({
UI.println( UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
`opencode.local:${server.port}`, `${opts.mdnsDomain}:${server.port}`,
) )
} }

View file

@ -17,6 +17,11 @@ const options = {
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
default: false, default: false,
}, },
"mdns-domain": {
type: "string" as const,
describe: "custom domain name for mDNS service (default: opencode.local)",
default: "opencode.local",
},
cors: { cors: {
type: "string" as const, type: "string" as const,
array: true, array: true,
@ -36,9 +41,11 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const portExplicitlySet = process.argv.includes("--port") const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname") const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsExplicitlySet = process.argv.includes("--mdns")
const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
const corsExplicitlySet = process.argv.includes("--cors") const corsExplicitlySet = process.argv.includes("--cors")
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"])
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
const hostname = hostnameExplicitlySet const hostname = hostnameExplicitlySet
? args.hostname ? args.hostname
@ -49,5 +56,5 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
const cors = [...configCors, ...argsCors] const cors = [...configCors, ...argsCors]
return { hostname, port, mdns, cors } return { hostname, port, mdns, mdnsDomain, cors }
} }

View file

@ -62,8 +62,14 @@ export namespace Config {
export const state = Instance.state(async () => { export const state = Instance.state(async () => {
const auth = await Auth.all() const auth = await Auth.all()
// Load remote/well-known config first as the base layer (lowest precedence) // Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
// This allows organizations to provide default configs that users can override // 1) Remote .well-known/opencode (org defaults)
// 2) Global config (~/.config/opencode/opencode.json{,c})
// 3) Custom config (OPENCODE_CONFIG)
// 4) Project config (opencode.json{,c})
// 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c})
// 6) Inline config (OPENCODE_CONFIG_CONTENT)
// Managed config directory is enterprise-only and always overrides everything above.
let result: Info = {} let result: Info = {}
for (const [key, value] of Object.entries(auth)) { for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") { if (value.type === "wellknown") {
@ -85,16 +91,16 @@ export namespace Config {
} }
} }
// Global user config overrides remote config // Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global()) result = mergeConfigConcatArrays(result, await global())
// Custom config path overrides global // Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) { if (Flag.OPENCODE_CONFIG) {
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG)) result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
} }
// Project config has highest precedence (overrides global and remote) // Project config overrides global and remote config.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
@ -104,12 +110,6 @@ export namespace Config {
} }
} }
// Inline config content has highest precedence
if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
result.agent = result.agent || {} result.agent = result.agent || {}
result.mode = result.mode || {} result.mode = result.mode || {}
result.plugin = result.plugin || [] result.plugin = result.plugin || []
@ -136,6 +136,7 @@ export namespace Config {
)), )),
] ]
// .opencode directory config overrides (project and global) config sources.
if (Flag.OPENCODE_CONFIG_DIR) { if (Flag.OPENCODE_CONFIG_DIR) {
directories.push(Flag.OPENCODE_CONFIG_DIR) directories.push(Flag.OPENCODE_CONFIG_DIR)
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
@ -163,6 +164,12 @@ export namespace Config {
result.plugin.push(...(await loadPlugin(dir))) result.plugin.push(...(await loadPlugin(dir)))
} }
// Inline config content overrides all non-managed config sources.
if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
// Load managed config files last (highest priority) - enterprise admin-controlled // Load managed config files last (highest priority) - enterprise admin-controlled
// Kept separate from directories array to avoid write operations when installing plugins // Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions // which would fail on system directories requiring elevated permissions
@ -853,6 +860,7 @@ export namespace Config {
port: z.number().int().positive().optional().describe("Port to listen on"), port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"), hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"), mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"), cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
}) })
.strict() .strict()

View file

@ -355,3 +355,12 @@ export const pint: Info = {
return false return false
}, },
} }
export const ormolu: Info = {
name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return Bun.which("ormolu") !== null
},
}

View file

@ -1,4 +1,5 @@
import z from "zod" import z from "zod"
import os from "os"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { Config } from "../config/config" import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
@ -35,8 +36,9 @@ import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai" import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity" import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel" import { createVercel } from "@ai-sdk/vercel"
import { createGitLab } from "@gitlab/gitlab-ai-provider" import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
import { ProviderTransform } from "./transform" import { ProviderTransform } from "./transform"
import { Installation } from "../installation"
export namespace Provider { export namespace Provider {
const log = Log.create({ service: "provider" }) const log = Log.create({ service: "provider" })
@ -424,11 +426,17 @@ export namespace Provider {
const config = await Config.get() const config = await Config.get()
const providerConfig = config.provider?.["gitlab"] const providerConfig = config.provider?.["gitlab"]
const aiGatewayHeaders = {
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
...(providerConfig?.options?.aiGatewayHeaders || {}),
}
return { return {
autoload: !!apiKey, autoload: !!apiKey,
options: { options: {
instanceUrl, instanceUrl,
apiKey, apiKey,
aiGatewayHeaders,
featureFlags: { featureFlags: {
duo_agent_platform_agentic_chat: true, duo_agent_platform_agentic_chat: true,
duo_agent_platform: true, duo_agent_platform: true,
@ -437,6 +445,7 @@ export namespace Provider {
}, },
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) { async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, { return sdk.agenticChat(modelID, {
aiGatewayHeaders,
featureFlags: { featureFlags: {
duo_agent_platform_agentic_chat: true, duo_agent_platform_agentic_chat: true,
duo_agent_platform: true, duo_agent_platform: true,

View file

@ -18,12 +18,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
case "system": { case "system": {
messages.push({ messages.push({
role: "system", role: "system",
content: [ content: content,
{
type: "text",
text: content,
},
],
...metadata, ...metadata,
}) })
break break

View file

@ -658,11 +658,18 @@ export namespace ProviderTransform {
} }
export function smallOptions(model: Provider.Model) { export function smallOptions(model: Provider.Model) {
if (model.providerID === "openai" || model.api.id.includes("gpt-5")) { if (
if (model.api.id.includes("5.")) { model.providerID === "openai" ||
return { reasoningEffort: "low" } model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/github-copilot"
) {
if (model.api.id.includes("gpt-5")) {
if (model.api.id.includes("5.")) {
return { store: false, reasoningEffort: "low" }
}
return { store: false, reasoningEffort: "minimal" }
} }
return { reasoningEffort: "minimal" } return { store: false }
} }
if (model.providerID === "google") { if (model.providerID === "google") {
// gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget

View file

@ -7,17 +7,18 @@ export namespace MDNS {
let bonjour: Bonjour | undefined let bonjour: Bonjour | undefined
let currentPort: number | undefined let currentPort: number | undefined
export function publish(port: number) { export function publish(port: number, domain?: string) {
if (currentPort === port) return if (currentPort === port) return
if (bonjour) unpublish() if (bonjour) unpublish()
try { try {
const host = domain ?? "opencode.local"
const name = `opencode-${port}` const name = `opencode-${port}`
bonjour = new Bonjour() bonjour = new Bonjour()
const service = bonjour.publish({ const service = bonjour.publish({
name, name,
type: "http", type: "http",
host: "opencode.local", host,
port, port,
txt: { path: "/" }, txt: { path: "/" },
}) })

View file

@ -563,7 +563,13 @@ export namespace Server {
return result return result
} }
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { export function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}) {
_corsWhitelist = opts.cors ?? [] _corsWhitelist = opts.cors ?? []
const args = { const args = {
@ -591,7 +597,7 @@ export namespace Server {
opts.hostname !== "localhost" && opts.hostname !== "localhost" &&
opts.hostname !== "::1" opts.hostname !== "::1"
if (shouldPublishMDNS) { if (shouldPublishMDNS) {
MDNS.publish(server.port!) MDNS.publish(server.port!, opts.mdnsDomain)
} else if (opts.mdns) { } else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
} }

View file

@ -180,14 +180,6 @@ export namespace SessionProcessor {
case "tool-result": { case "tool-result": {
const match = toolcalls[value.toolCallId] const match = toolcalls[value.toolCallId]
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
const attachments = value.output.attachments?.map(
(attachment: Omit<MessageV2.FilePart, "id" | "messageID" | "sessionID">) => ({
...attachment,
id: Identifier.ascending("part"),
messageID: match.messageID,
sessionID: match.sessionID,
}),
)
await Session.updatePart({ await Session.updatePart({
...match, ...match,
state: { state: {
@ -200,7 +192,7 @@ export namespace SessionProcessor {
start: match.state.time.start, start: match.state.time.start,
end: Date.now(), end: Date.now(),
}, },
attachments, attachments: value.output.attachments,
}, },
}) })

View file

@ -185,17 +185,13 @@ export namespace SessionPrompt {
text: template, text: template,
}, },
] ]
const matches = ConfigMarkdown.files(template) const files = ConfigMarkdown.files(template)
const seen = new Set<string>() const seen = new Set<string>()
const names = matches await Promise.all(
.map((match) => match[1]) files.map(async (match) => {
.filter((name) => { const name = match[1]
if (seen.has(name)) return false if (seen.has(name)) return
seen.add(name) seen.add(name)
return true
})
const resolved = await Promise.all(
names.map(async (name) => {
const filepath = name.startsWith("~/") const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2)) ? path.join(os.homedir(), name.slice(2))
: path.resolve(Instance.worktree, name) : path.resolve(Instance.worktree, name)
@ -203,34 +199,33 @@ export namespace SessionPrompt {
const stats = await fs.stat(filepath).catch(() => undefined) const stats = await fs.stat(filepath).catch(() => undefined)
if (!stats) { if (!stats) {
const agent = await Agent.get(name) const agent = await Agent.get(name)
if (!agent) return undefined if (agent) {
return { parts.push({
type: "agent", type: "agent",
name: agent.name, name: agent.name,
} satisfies PromptInput["parts"][number] })
}
return
} }
if (stats.isDirectory()) { if (stats.isDirectory()) {
return { parts.push({
type: "file", type: "file",
url: `file://${filepath}`, url: `file://${filepath}`,
filename: name, filename: name,
mime: "application/x-directory", mime: "application/x-directory",
} satisfies PromptInput["parts"][number] })
return
} }
return { parts.push({
type: "file", type: "file",
url: `file://${filepath}`, url: `file://${filepath}`,
filename: name, filename: name,
mime: "text/plain", mime: "text/plain",
} satisfies PromptInput["parts"][number] })
}), }),
) )
for (const item of resolved) {
if (!item) continue
parts.push(item)
}
return parts return parts
} }
@ -430,12 +425,6 @@ export namespace SessionPrompt {
assistantMessage.time.completed = Date.now() assistantMessage.time.completed = Date.now()
await Session.updateMessage(assistantMessage) await Session.updateMessage(assistantMessage)
if (result && part.state.status === "running") { if (result && part.state.status === "running") {
const attachments = result.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
messageID: assistantMessage.id,
sessionID: assistantMessage.sessionID,
}))
await Session.updatePart({ await Session.updatePart({
...part, ...part,
state: { state: {
@ -444,7 +433,7 @@ export namespace SessionPrompt {
title: result.title, title: result.title,
metadata: result.metadata, metadata: result.metadata,
output: result.output, output: result.output,
attachments, attachments: result.attachments,
time: { time: {
...part.state.time, ...part.state.time,
end: Date.now(), end: Date.now(),
@ -783,13 +772,16 @@ export namespace SessionPrompt {
) )
const textParts: string[] = [] const textParts: string[] = []
const attachments: Omit<MessageV2.FilePart, "id" | "messageID" | "sessionID">[] = [] const attachments: MessageV2.FilePart[] = []
for (const contentItem of result.content) { for (const contentItem of result.content) {
if (contentItem.type === "text") { if (contentItem.type === "text") {
textParts.push(contentItem.text) textParts.push(contentItem.text)
} else if (contentItem.type === "image") { } else if (contentItem.type === "image") {
attachments.push({ attachments.push({
id: Identifier.ascending("part"),
sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file", type: "file",
mime: contentItem.mimeType, mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`, url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
@ -801,6 +793,9 @@ export namespace SessionPrompt {
} }
if (resource.blob) { if (resource.blob) {
attachments.push({ attachments.push({
id: Identifier.ascending("part"),
sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file", type: "file",
mime: resource.mimeType ?? "application/octet-stream", mime: resource.mimeType ?? "application/octet-stream",
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
@ -1049,7 +1044,6 @@ export namespace SessionPrompt {
pieces.push( pieces.push(
...result.attachments.map((attachment) => ({ ...result.attachments.map((attachment) => ({
...attachment, ...attachment,
id: Identifier.ascending("part"),
synthetic: true, synthetic: true,
filename: attachment.filename ?? part.filename, filename: attachment.filename ?? part.filename,
messageID: info.id, messageID: info.id,
@ -1187,18 +1181,7 @@ export namespace SessionPrompt {
}, },
] ]
}), }),
) ).then((x) => x.flat())
.then((x) => x.flat())
.then((drafts) =>
drafts.map(
(part): MessageV2.Part => ({
...part,
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
}),
),
)
await Plugin.trigger( await Plugin.trigger(
"chat.message", "chat.message",

View file

@ -188,6 +188,7 @@ export namespace Snapshot {
after: z.string(), after: z.string(),
additions: z.number(), additions: z.number(),
deletions: z.number(), deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
}) })
.meta({ .meta({
ref: "FileDiff", ref: "FileDiff",
@ -196,6 +197,23 @@ export namespace Snapshot {
export async function diffFull(from: string, to: string): Promise<FileDiff[]> { export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir() const git = gitdir()
const result: FileDiff[] = [] const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses =
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
for (const line of statuses.trim().split("\n")) {
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
status.set(file, kind)
}
for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
.quiet() .quiet()
.cwd(Instance.directory) .cwd(Instance.directory)
@ -224,6 +242,7 @@ export namespace Snapshot {
after, after,
additions: Number.isFinite(added) ? added : 0, additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0, deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file) ?? "modified",
}) })
} }
return result return result

View file

@ -128,7 +128,10 @@ export const BashTool = Tool.define("bash", async () => {
process.platform === "win32" && resolved.match(/^\/[a-z]\//) process.platform === "win32" && resolved.match(/^\/[a-z]\//)
? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\") ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
: resolved : resolved
if (!Instance.containsPath(normalized)) directories.add(normalized) if (!Instance.containsPath(normalized)) {
const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
directories.add(dir)
}
} }
} }
} }
@ -141,10 +144,11 @@ export const BashTool = Tool.define("bash", async () => {
} }
if (directories.size > 0) { if (directories.size > 0) {
const globs = Array.from(directories).map((dir) => path.join(dir, "*"))
await ctx.ask({ await ctx.ask({
permission: "external_directory", permission: "external_directory",
patterns: Array.from(directories), patterns: globs,
always: Array.from(directories).map((x) => path.dirname(x) + "*"), always: globs,
metadata: {}, metadata: {},
}) })
} }

View file

@ -77,12 +77,6 @@ export const BatchTool = Tool.define("batch", async () => {
}) })
const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
const attachments = result.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
messageID: ctx.messageID,
sessionID: ctx.sessionID,
}))
await Session.updatePart({ await Session.updatePart({
id: partID, id: partID,
@ -97,7 +91,7 @@ export const BatchTool = Tool.define("batch", async () => {
output: result.output, output: result.output,
title: result.title, title: result.title,
metadata: result.metadata, metadata: result.metadata,
attachments, attachments: result.attachments,
time: { time: {
start: callStartTime, start: callStartTime,
end: Date.now(), end: Date.now(),

View file

@ -6,6 +6,7 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt" import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction" import { InstructionPrompt } from "../session/instruction"
@ -78,6 +79,9 @@ export const ReadTool = Tool.define("read", {
}, },
attachments: [ attachments: [
{ {
id: Identifier.ascending("part"),
sessionID: ctx.sessionID,
messageID: ctx.messageID,
type: "file", type: "file",
mime, mime,
url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`,

View file

@ -110,7 +110,7 @@ export namespace ToolRegistry {
TaskTool, TaskTool,
WebFetchTool, WebFetchTool,
TodoWriteTool, TodoWriteTool,
TodoReadTool, // TodoReadTool,
WebSearchTool, WebSearchTool,
CodeSearchTool, CodeSearchTool,
SkillTool, SkillTool,

View file

@ -36,7 +36,7 @@ export namespace Tool {
title: string title: string
metadata: M metadata: M
output: string output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] attachments?: MessageV2.FilePart[]
}> }>
formatValidationError?(error: z.ZodError): string formatValidationError?(error: z.ZodError): string
}> }>

View file

@ -447,7 +447,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
}) })
}) })
test("Truncate.DIR is allowed even when user denies external_directory globally", async () => { test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
const { Truncate } = await import("../../src/tool/truncation") const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
@ -460,14 +460,14 @@ test("Truncate.DIR is allowed even when user denies external_directory globally"
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
}, },
}) })
}) })
test("Truncate.DIR is allowed even when user denies external_directory per-agent", async () => { test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
const { Truncate } = await import("../../src/tool/truncation") const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
@ -484,21 +484,21 @@ test("Truncate.DIR is allowed even when user denies external_directory per-agent
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
}, },
}) })
}) })
test("explicit Truncate.DIR deny is respected", async () => { test("explicit Truncate.GLOB deny is respected", async () => {
const { Truncate } = await import("../../src/tool/truncation") const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
permission: { permission: {
external_directory: { external_directory: {
"*": "deny", "*": "deny",
[Truncate.DIR]: "deny", [Truncate.GLOB]: "deny",
}, },
}, },
}, },
@ -507,8 +507,8 @@ test("explicit Truncate.DIR deny is respected", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
}, },
}) })
}) })

View file

@ -1,6 +1,24 @@
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages" import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages"
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
describe("system messages", () => {
test("should convert system message content to string", () => {
const result = convertToCopilotMessages([
{
role: "system",
content: "You are a helpful assistant with AGENTS.md instructions.",
},
])
expect(result).toEqual([
{
role: "system",
content: "You are a helpful assistant with AGENTS.md instructions.",
},
])
})
})
describe("user messages", () => { describe("user messages", () => {
test("should convert messages with only a text part to a string content", () => { test("should convert messages with only a text part to a string content", () => {
const result = convertToCopilotMessages([ const result = convertToCopilotMessages([

View file

@ -1,62 +0,0 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageV2 } from "../../src/session/message-v2"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("SessionPrompt ordering", () => {
test("keeps @file order with read output parts", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "a.txt"), "28\n")
await Bun.write(path.join(dir, "b.txt"), "42\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "What numbers are written in files @a.txt and @b.txt ?"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"])
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const items = stored.parts
const aPath = path.join(tmp.path, "a.txt")
const bPath = path.join(tmp.path, "b.txt")
const sequence = items.flatMap((part) => {
if (part.type === "text") {
if (part.text.includes(aPath)) return ["input:a"]
if (part.text.includes(bPath)) return ["input:b"]
if (part.text.includes("00001| 28")) return ["output:a"]
if (part.text.includes("00001| 42")) return ["output:b"]
return []
}
if (part.type === "file") {
if (part.filename === "a.txt") return ["file:a"]
if (part.filename === "b.txt") return ["file:b"]
}
return []
})
expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"])
await Session.remove(session.id)
},
})
})
})

View file

@ -749,6 +749,52 @@ test("revert preserves file that existed in snapshot when deleted then recreated
}) })
}) })
test("diffFull sets status based on git change type", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Bun.write(`${tmp.path}/grow.txt`, "one\n")
await Bun.write(`${tmp.path}/trim.txt`, "line1\nline2\n")
await Bun.write(`${tmp.path}/delete.txt`, "gone")
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.path}/grow.txt`, "one\ntwo\n")
await Bun.write(`${tmp.path}/trim.txt`, "line1\n")
await $`rm ${tmp.path}/delete.txt`.quiet()
await Bun.write(`${tmp.path}/added.txt`, "new")
const after = await Snapshot.track()
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
expect(diffs.length).toBe(4)
const added = diffs.find((d) => d.file === "added.txt")
expect(added).toBeDefined()
expect(added!.status).toBe("added")
const deleted = diffs.find((d) => d.file === "delete.txt")
expect(deleted).toBeDefined()
expect(deleted!.status).toBe("deleted")
const grow = diffs.find((d) => d.file === "grow.txt")
expect(grow).toBeDefined()
expect(grow!.status).toBe("modified")
expect(grow!.additions).toBeGreaterThan(0)
expect(grow!.deletions).toBe(0)
const trim = diffs.find((d) => d.file === "trim.txt")
expect(trim).toBeDefined()
expect(trim!.status).toBe("modified")
expect(trim!.additions).toBe(0)
expect(trim!.deletions).toBeGreaterThan(0)
},
})
})
test("diffFull with new file additions", async () => { test("diffFull with new file additions", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide({ await Instance.provide({

View file

@ -144,7 +144,42 @@ describe("tool.bash permissions", () => {
) )
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain("/tmp") expect(extDirReq!.patterns).toContain("/tmp/*")
},
})
})
test("asks for external_directory permission when file arg is outside project", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "outside.txt"), "x")
},
})
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
const filepath = path.join(outerTmp.path, "outside.txt")
await bash.execute(
{
command: `cat ${filepath}`,
description: "Read external file",
},
testCtx,
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
const expected = path.join(outerTmp.path, "*")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(expected)
expect(extDirReq!.always).toContain(expected)
}, },
}) })
}) })

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.48", "version": "1.1.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.48", "version": "1.1.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -96,6 +96,7 @@ export type FileDiff = {
after: string after: string
additions: number additions: number
deletions: number deletions: number
status?: "added" | "deleted" | "modified"
} }
export type UserMessage = { export type UserMessage = {
@ -1338,6 +1339,10 @@ export type ServerConfig = {
* Enable mDNS service discovery * Enable mDNS service discovery
*/ */
mdns?: boolean mdns?: boolean
/**
* Custom domain name for mDNS service (default: opencode.local)
*/
mdnsDomain?: string
/** /**
* Additional domains to allow for CORS * Additional domains to allow for CORS
*/ */

View file

@ -6087,6 +6087,10 @@
}, },
"deletions": { "deletions": {
"type": "number" "type": "number"
},
"status": {
"type": "string",
"enum": ["added", "deleted", "modified"]
} }
}, },
"required": ["file", "before", "after", "additions", "deletions"] "required": ["file", "before", "after", "additions", "deletions"]
@ -8933,6 +8937,10 @@
"description": "Enable mDNS service discovery", "description": "Enable mDNS service discovery",
"type": "boolean" "type": "boolean"
}, },
"mdnsDomain": {
"description": "Custom domain name for mDNS service (default: opencode.local)",
"type": "string"
},
"cors": { "cors": {
"description": "Additional domains to allow for CORS", "description": "Additional domains to allow for CORS",
"type": "array", "type": "array",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.48", "version": "1.1.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.48", "version": "1.1.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {

View file

@ -9,13 +9,7 @@
user-select: none; user-select: none;
cursor: default; cursor: default;
outline: none; outline: none;
padding: 4px 8px;
white-space: nowrap; white-space: nowrap;
transition-property: background-color, border-color, color, box-shadow, opacity;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-easing);
outline: none;
line-height: 20px;
&[data-variant="primary"] { &[data-variant="primary"] {
background-color: var(--button-primary-base); background-color: var(--button-primary-base);
@ -100,6 +94,7 @@
&:active:not(:disabled) { &:active:not(:disabled) {
background-color: var(--button-secondary-base); background-color: var(--button-secondary-base);
scale: 0.99; scale: 0.99;
transition: all 150ms ease-out;
} }
&:disabled { &:disabled {
border-color: var(--border-disabled); border-color: var(--border-disabled);
@ -114,31 +109,34 @@
} }
&[data-size="small"] { &[data-size="small"] {
padding: 4px 8px; height: 22px;
padding: 0 8px;
&[data-icon] { &[data-icon] {
padding: 4px 12px 4px 4px; padding: 0 12px 0 4px;
} }
font-size: var(--font-size-small);
line-height: var(--line-height-large);
gap: 4px; gap: 4px;
/* text-12-medium */ /* text-12-medium */
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); font-size: var(--font-size-small);
font-style: normal; font-style: normal;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
} }
&[data-size="normal"] { &[data-size="normal"] {
padding: 4px 6px; height: 24px;
line-height: 24px;
padding: 0 6px;
&[data-icon] { &[data-icon] {
padding: 4px 12px 4px 4px; padding: 0 12px 0 4px;
}
&[aria-haspopup] {
padding: 4px 6px 4px 8px;
} }
font-size: var(--font-size-small);
gap: 6px; gap: 6px;
/* text-12-medium */ /* text-12-medium */
@ -150,6 +148,7 @@
} }
&[data-size="large"] { &[data-size="large"] {
height: 32px;
padding: 6px 12px; padding: 6px 12px;
&[data-icon] { &[data-icon] {

Some files were not shown because too many files have changed in this diff Show more