Merge branch 'dev' into sqlite2

feature/workspace-domain
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 3409 additions and 1924 deletions

View File

@ -18,6 +18,7 @@ permissions:
jobs:
close-stale-prs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Close inactive PRs
uses: actions/github-script@v8
@ -25,6 +26,15 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const DAYS_INACTIVE = 60
const MAX_RETRIES = 3
// Adaptive delay: fast for small batches, slower for large to respect
// GitHub's 80 content-generating requests/minute limit
const SMALL_BATCH_THRESHOLD = 10
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
const startTime = Date.now()
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
const { owner, repo } = context.repo
const dryRun = context.payload.inputs?.dryRun === "true"
@ -32,6 +42,42 @@ jobs:
core.info(`Dry run mode: ${dryRun}`)
core.info(`Cutoff date: ${cutoff.toISOString()}`)
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function withRetry(fn, description = 'API call') {
let lastError
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await fn()
return result
} catch (error) {
lastError = error
const isRateLimited = error.status === 403 &&
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
if (!isRateLimited) {
throw error
}
// Parse retry-after header, default to 60 seconds
const retryAfter = error.response?.headers?.['retry-after']
? parseInt(error.response.headers['retry-after'])
: 60
// Exponential backoff: retryAfter * 2^attempt
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
await sleep(backoffMs)
}
}
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
throw lastError
}
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
@ -73,17 +119,27 @@ jobs:
const allPrs = []
let cursor = null
let hasNextPage = true
let pageCount = 0
while (hasNextPage) {
const result = await github.graphql(query, {
owner,
repo,
cursor,
})
pageCount++
core.info(`Fetching page ${pageCount} of open PRs...`)
const result = await withRetry(
() => github.graphql(query, { owner, repo, cursor }),
`GraphQL page ${pageCount}`
)
allPrs.push(...result.repository.pullRequests.nodes)
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
cursor = result.repository.pullRequests.pageInfo.endCursor
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
// Delay between pagination requests (use small batch delay for reads)
if (hasNextPage) {
await sleep(SMALL_BATCH_DELAY_MS)
}
}
core.info(`Found ${allPrs.length} open pull requests`)
@ -114,28 +170,66 @@ jobs:
core.info(`Found ${stalePrs.length} stale pull requests`)
// ============================================
// Close stale PRs
// ============================================
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
? LARGE_BATCH_DELAY_MS
: SMALL_BATCH_DELAY_MS
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
let closedCount = 0
let skippedCount = 0
for (const pr of stalePrs) {
const issue_number = pr.number
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
if (dryRun) {
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
continue
}
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: closeComment,
})
try {
// Add comment
await withRetry(
() => github.rest.issues.createComment({
owner,
repo,
issue_number,
body: closeComment,
}),
`Comment on PR #${issue_number}`
)
await github.rest.pulls.update({
owner,
repo,
pull_number: issue_number,
state: "closed",
})
// Close PR
await withRetry(
() => github.rest.pulls.update({
owner,
repo,
pull_number: issue_number,
state: "closed",
}),
`Close PR #${issue_number}`
)
core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
closedCount++
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
// Delay before processing next PR
await sleep(requestDelayMs)
} catch (error) {
skippedCount++
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
}
}
const elapsed = Math.round((Date.now() - startTime) / 1000)
core.info(`\n========== Summary ==========`)
core.info(`Total open PRs found: ${allPrs.length}`)
core.info(`Stale PRs identified: ${stalePrs.length}`)
core.info(`PRs closed: ${closedCount}`)
core.info(`PRs skipped (errors): ${skippedCount}`)
core.info(`Elapsed time: ${elapsed}s`)
core.info(`=============================`)

View File

@ -9,6 +9,7 @@
"options": {},
},
},
"mcp": {},
"tools": {
"github-triage": 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": {
"name": "@opencode-ai/app",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@ -213,7 +213,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@ -242,7 +242,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@ -258,7 +258,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.48",
"version": "1.1.49",
"bin": {
"opencode": "./bin/opencode",
},
@ -286,7 +286,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.56",
"@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/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@ -298,8 +298,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.76",
"@opentui/solid": "0.1.76",
"@opentui/core": "0.1.77",
"@opentui/solid": "0.1.77",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@ -365,7 +365,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@ -385,7 +385,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.48",
"version": "1.1.49",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@ -396,7 +396,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@ -409,7 +409,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@ -451,7 +451,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"zod": "catalog:",
},
@ -462,7 +462,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@ -940,7 +940,7 @@
"@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=="],
@ -1246,21 +1246,21 @@
"@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=="],

View File

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

View File

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

View File

@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort"
import { createMemo } from "solid-js"
import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import type { ListRef } from "@opencode-ai/ui/list"
interface DialogSelectDirectoryProps {
title?: string
@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps {
onSelect: (result: string | string[] | null) => void
}
type Row = {
absolute: string
search: string
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()
const home = createMemo(() => sync.data.path.home)
const [filter, setFilter] = createSignal("")
const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
const clean = (value: string) => {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
function normalize(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return ""
}
function display(path: string) {
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function display(path: string, input: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full) || full
}
function tildeOf(absolute: string) {
const full = trimTrailing(absolute)
const h = home()
if (!h) return full
if (!h) return ""
const hn = trimTrailing(h)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return full
return ""
}
function scoped(filter: string) {
function row(absolute: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search }
}
function scoped(value: string) {
const base = start()
if (!base) return
const raw = normalizeDriveRoot(filter.trim())
const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }
const h = home()
@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}
const directories = async (filter: string) => {
const input = scoped(filter)
if (!input) return [] as string[]
const value = clean(filter)
const scopedInput = scoped(value)
if (!scopedInput) return [] as string[]
const raw = normalizeDriveRoot(filter.trim())
const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(input.path)
const query = normalizeDriveRoot(scopedInput.path)
if (!isPath) {
const results = await sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
const find = () =>
sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
return results.map((rel) => join(input.directory, rel)).slice(0, 50)
if (!isPath) {
const results = await find()
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const cap = 12
const branch = 4
let paths = [input.directory]
let paths = [scopedInput.directory]
for (const part of head) {
if (part === "..") {
paths = paths.map((p) => {
const v = trimTrailing(p)
if (v === "/") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
return v.slice(0, i)
})
paths = paths.map(parentOf)
continue
}
@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
return Array.from(new Set(out)).slice(0, 50)
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/")
if (!expand || !tail) {
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
return items.slice(0, 50)
}
const needle = tail.toLowerCase()
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
const target = exact[0]
if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30)
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
}
const items = async (value: string) => {
const results = await directories(value)
return results.map(row)
}
function resolve(absolute: string) {
@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
items={directories}
key={(x) => x}
items={items}
key={(x) => x.absolute}
filterKeys={["search"]}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(clean(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
if (!item) return
e.preventDefault()
e.stopPropagation()
const value = display(item.absolute, filter())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
if (!path) return
resolve(path)
resolve(path.absolute)
}}
>
{(absolute) => {
const path = display(absolute)
{(item) => {
const path = display(item.absolute, filter())
if (path === "~") {
return (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">~</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}
return (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: absolute, type: "directory" }} class="shrink-0 size-4" />
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>

View File

@ -90,10 +90,9 @@ const ModelList: Component<{
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
provider?: string
children?: JSX.Element | ((open: boolean) => JSX.Element)
children?: JSX.Element
triggerAs?: T
triggerProps?: ComponentProps<T>
gutter?: number
}) {
const [store, setStore] = createStore<{
open: boolean
@ -176,14 +175,14 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
}}
modal={false}
placement="top-start"
gutter={props.gutter ?? 8}
gutter={8}
>
<Kobalte.Trigger
ref={(el) => setStore("trigger", el)}
as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)}
>
{typeof props.children === "function" ? props.children(store.open) : props.children}
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content

View File

@ -130,10 +130,57 @@ export default function FileTree(props: {
const nodes = file.tree.children(props.path)
const current = filter()
if (!current) return nodes
return nodes.filter((node) => {
const parent = (path: string) => {
const idx = path.lastIndexOf("/")
if (idx === -1) return ""
return path.slice(0, idx)
}
const leaf = (path: string) => {
const idx = path.lastIndexOf("/")
return idx === -1 ? path : path.slice(idx + 1)
}
const out = nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
const seen = new Set(out.map((node) => node.path))
for (const dir of current.dirs) {
if (parent(dir) !== props.path) continue
if (seen.has(dir)) continue
out.push({
name: leaf(dir),
path: dir,
absolute: dir,
type: "directory",
ignored: false,
})
seen.add(dir)
}
for (const item of current.files) {
if (parent(item) !== props.path) continue
if (seen.has(item)) continue
out.push({
name: leaf(item),
path: item,
absolute: item,
type: "file",
ignored: false,
})
seen.add(item)
}
return out.toSorted((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
})
const Node = (

View File

@ -32,9 +32,7 @@ import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
import { Button } from "@opencode-ai/ui/button"
import { CycleLabel } from "@opencode-ai/ui/cycle-label"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
@ -1257,7 +1254,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput()
client.session
.shell({
sessionID: session?.id || "",
sessionID: session.id,
agent,
model,
command: text,
@ -1280,7 +1277,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput()
client.session
.command({
sessionID: session?.id || "",
sessionID: session.id,
command: commandName,
arguments: args.join(" "),
agent,
@ -1436,13 +1433,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: session?.id || "",
sessionID: session.id,
messageID,
})) as unknown as Part[]
const optimisticMessage: Message = {
id: messageID,
sessionID: session?.id || "",
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent,
@ -1453,9 +1450,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
const messages = draft.message[session?.id || ""]
const messages = draft.message[session.id]
if (!messages) {
draft.message[session?.id || ""] = [optimisticMessage]
draft.message[session.id] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@ -1463,7 +1460,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draft.part[messageID] = optimisticParts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}),
)
return
@ -1471,9 +1468,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
const messages = draft.message[session?.id || ""]
const messages = draft.message[session.id]
if (!messages) {
draft.message[session?.id || ""] = [optimisticMessage]
draft.message[session.id] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@ -1481,7 +1478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draft.part[messageID] = optimisticParts
.filter((p) => !!p?.id)
.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) {
sync.set(
produce((draft) => {
const messages = draft.message[session?.id || ""]
const messages = draft.message[session.id]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
@ -1503,7 +1500,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
const messages = draft.message[session?.id || ""]
const messages = draft.message[session.id]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
@ -1524,15 +1521,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session?.id, { type: "busy" })
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session?.id, { type: "idle" })
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
for (const item of commentItems) {
@ -1549,7 +1546,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
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) => {
if (controller.signal.aborted) {
@ -1577,7 +1574,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(session?.id || "")
pending.delete(session.id)
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
@ -1587,7 +1584,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
sessionID: session?.id || "",
sessionID: session.id,
agent,
model,
messageID,
@ -1597,9 +1594,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
void send().catch((err) => {
pending.delete(session?.id || "")
if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session?.id, { type: "idle" })
pending.delete(session.id)
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}
showToast({
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 (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<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">
@{(item as { type: "agent"; name: string }).name}
</span>
@ -1760,9 +1735,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
>
<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">
<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>
</div>
</div>
@ -1801,7 +1776,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
>
<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">
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}>
@ -1818,7 +1793,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
icon="close-small"
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) => {
e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
@ -1848,7 +1823,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
when={attachment.mime.startsWith("image/")}
fallback={
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<Icon name="folder" size="normal" class="size-6 text-text-base" />
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
@ -1921,8 +1896,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Show>
</div>
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-2">
<div class="relative p-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Switch>
<Match when={store.mode === "shell"}>
<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"}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.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)}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class="capitalize"
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`}
valueClass="truncate"
variant="ghost"
gutter={12}
/>
</TooltipKeybind>
<Show
@ -1951,66 +1927,68 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
fallback={
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
as="div"
variant="ghost"
class="px-2"
class="px-2 min-w-0 max-w-[140px]"
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
<MorphChevron
expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")}
/>
<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" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }} gutter={12}>
{(open) => (
<>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
<MorphChevron expanded={open} class="text-text-weak" />
</>
)}
<ModelSelectorPopover
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>
<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>
</TooltipKeybind>
</Show>
<Show when={local.model.variant.list().length > 0}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Button
data-action="model-variant-cycle"
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()}
>
<Show when={local.model.variant.list().length > 1}>
<ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
</Show>
<CycleLabel value={currrentModelVariant()} />
{local.model.variant.current() ?? language.t("common.default")}
</Button>
</TooltipKeybind>
</Show>
<Show when={permission.permissionsEnabled() && params.id}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
@ -2018,7 +1996,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
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),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
@ -2040,7 +2018,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Match>
</Switch>
</div>
<div class="flex items-center gap-1 absolute right-3 bottom-3">
<div class="flex items-center gap-1 shrink-0">
<input
ref={fileInputRef}
type="file"
@ -2052,19 +2030,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1.5 mr-1.5">
<div class="flex items-center gap-1 mr-1">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
<Button
type="button"
variant="ghost"
size="small"
class="px-1"
class="size-6 px-1"
onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="photo" class="size-6 text-icon-base" />
<Icon name="photo" class="size-4.5" />
</Button>
</Tooltip>
</Show>
@ -2083,7 +2060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Match when={true}>
<div class="flex items-center gap-2">
<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>
</Match>
</Switch>
@ -2094,7 +2071,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"}
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")}
/>
</Tooltip>

View File

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

View File

@ -3,11 +3,12 @@ import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { FileIcon } from "@opencode-ai/ui/file-icon"
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 { getFilename } from "@opencode-ai/util/path"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
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 {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
return (
@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
<Tabs.Trigger
value={props.tab}
closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom">
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
>
<IconButton
icon="close-small"
variant="ghost"
@ -44,7 +50,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
</TooltipKeybind>
}
hideCloseButton
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 { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
@ -131,12 +130,7 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [...SOUND_OPTIONS]
return (
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
@ -232,7 +226,7 @@ export const SettingsGeneral: Component = () => {
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
>
{(option) => (
<span style={{ "font-family": monoFontFamily(option?.value) }}>
@ -417,7 +411,7 @@ export const SettingsGeneral: Component = () => {
</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 { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => {
})
return (
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
@ -436,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
</div>
</Show>
</div>
</ScrollFade>
</div>
)
}

View File

@ -9,7 +9,6 @@ import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@ -40,12 +39,7 @@ export const SettingsModels: Component = () => {
})
return (
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
@ -131,6 +125,6 @@ export const SettingsModels: Component = () => {
</Show>
</Show>
</div>
</ScrollFade>
</div>
)
}

View File

@ -12,7 +12,6 @@ import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderMeta = { source?: ProviderSource }
@ -116,12 +115,7 @@ export const SettingsProviders: Component = () => {
}
return (
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
@ -267,6 +261,6 @@ export const SettingsProviders: Component = () => {
</Button>
</div>
</div>
</ScrollFade>
</div>
)
}

View File

@ -24,6 +24,8 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1
const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
const [history, setHistory] = createStore({
stack: [] as string[],
@ -134,7 +136,7 @@ export function Titlebar() {
return (
<header
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
classList={{
@ -142,10 +144,9 @@ export function Titlebar() {
"pl-2": !mac(),
}}
onMouseDown={drag}
data-tauri-drag-region
>
<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">
<IconButton
icon="menu"
@ -219,13 +220,10 @@ export function Titlebar() {
</Tooltip>
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div>
<div
class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"
data-tauri-drag-region
>
<div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center">
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
</div>
@ -235,9 +233,8 @@ export function Titlebar() {
"pr-6": !windows(),
}}
onMouseDown={drag}
data-tauri-drag-region
>
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" data-tauri-drag-region />
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
<Show when={windows()}>
<div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" />

View File

@ -119,6 +119,8 @@ type ChildOptions = {
bootstrap?: boolean
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,
@ -297,7 +299,7 @@ function createGlobalSync() {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
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) {
@ -325,7 +327,7 @@ function createGlobalSync() {
const all = input
.filter((s) => !!s?.id)
.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 children = all.filter((s) => !!s.parentID)
@ -342,7 +344,7 @@ function createGlobalSync() {
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) {
@ -457,7 +459,7 @@ function createGlobalSync() {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.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
// a request is in-flight still get the expanded result.
@ -559,7 +561,7 @@ function createGlobalSync() {
"permission",
sessionID,
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" },
),
)
@ -588,7 +590,7 @@ function createGlobalSync() {
"question",
sessionID,
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" },
),
)
@ -1003,7 +1005,7 @@ function createGlobalSync() {
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => cmp(a.id, b.id))
setGlobalStore("project", projects)
}),
),

View File

@ -1,5 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
export type Platform = {
/** Platform discriminator */
@ -55,6 +56,9 @@ export type Platform = {
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
}
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 cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@ -59,7 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => cmp(a.id, b.id))
batch(() => {
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
@ -69,7 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
"part",
message.info.id,
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" },
),
)
@ -129,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
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) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})

View File

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "فتح الإعدادات",
"command.session.previous": "الجلسة السابقة",
"command.session.next": "الجلسة التالية",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "أرشفة الجلسة",
"command.palette": "لوحة الأوامر",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل",
"command.session.undo": "تراجع",
"command.session.undo.description": "تراجع عن الرسالة الأخيرة",
"command.session.redo": "إعادة",
@ -346,6 +349,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
"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.description": "قم بتوصيل موفر لتلخيص هذه الجلسة",

View File

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Abrir configurações",
"command.session.previous": "Sessão anterior",
"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.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.permissions.autoaccept.enable": "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.description": "Desfazer a última mensagem",
"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.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.description": "Conecte um provedor para resumir esta sessão",

View File

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Åbn indstillinger",
"command.session.previous": "Forrige 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.palette": "Kommandopalette",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
"command.workspace.toggle": "Skift arbejdsområder",
"command.session.undo": "Fortryd",
"command.session.undo.description": "Fortryd den sidste besked",
"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.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.description": "Forbind en udbyder for at opsummere denne session",

View File

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

View File

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Open settings",
"command.session.previous": "Previous 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.palette": "Command palette",
@ -43,6 +45,7 @@ export const dict = {
"command.session.new": "New session",
"command.file.open": "Open file",
"command.file.open.description": "Search files and commands",
"command.tab.close": "Close tab",
"command.context.addSelection": "Add selection to context",
"command.context.addSelection.description": "Add selected lines from the current file",
"command.terminal.toggle": "Toggle terminal",
@ -68,6 +71,8 @@ export const dict = {
"command.model.variant.cycle.description": "Switch to the next effort level",
"command.permissions.autoaccept.enable": "Auto-accept 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.description": "Undo the last message",
"command.session.redo": "Redo",
@ -347,6 +352,11 @@ export const dict = {
"toast.theme.title": "Theme switched",
"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.description": "Edit and write permissions will be automatically approved",
"toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits",

View File

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Abrir ajustes",
"command.session.previous": "Sesión anterior",
"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.palette": "Paleta de comandos",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
"command.permissions.autoaccept.enable": "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.description": "Deshacer el último mensaje",
"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.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.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.session.previous": "Session précédente",
"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.palette": "Palette de commandes",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
"command.permissions.autoaccept.enable": "Accepter automatiquement les 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.description": "Annuler le dernier message",
"command.session.redo": "Rétablir",
@ -350,6 +353,11 @@ export const dict = {
"toast.permissions.autoaccept.off.description":
"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.description": "Connectez un fournisseur pour résumer cette session",

View File

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "設定を開く",
"command.session.previous": "前のセッション",
"command.session.next": "次のセッション",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "セッションをアーカイブ",
"command.palette": "コマンドパレット",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "次の思考レベルに切り替え",
"command.permissions.autoaccept.enable": "編集を自動承認",
"command.permissions.autoaccept.disable": "編集の自動承認を停止",
"command.workspace.toggle": "ワークスペースを切り替え",
"command.session.undo": "元に戻す",
"command.session.undo.description": "最後のメッセージを元に戻す",
"command.session.redo": "やり直す",

View File

@ -32,6 +32,8 @@ export const dict = {
"command.settings.open": "설정 열기",
"command.session.previous": "이전 세션",
"command.session.next": "다음 세션",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "세션 보관",
"command.palette": "명령 팔레트",
@ -72,6 +74,7 @@ export const dict = {
"command.model.variant.cycle.description": "다음 생각 수준으로 전환",
"command.permissions.autoaccept.enable": "편집 자동 수락",
"command.permissions.autoaccept.disable": "편집 자동 수락 중지",
"command.workspace.toggle": "작업 공간 전환",
"command.session.undo": "실행 취소",
"command.session.undo.description": "마지막 메시지 실행 취소",
"command.session.redo": "다시 실행",
@ -349,6 +352,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "편집 자동 수락 중지됨",
"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.description": "이 세션을 요약하려면 공급자를 연결하세요",

View File

@ -31,6 +31,8 @@ export const dict = {
"command.settings.open": "Åpne innstillinger",
"command.session.previous": "Forrige 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.palette": "Kommandopalett",
@ -71,6 +73,7 @@ export const dict = {
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
"command.permissions.autoaccept.enable": "Godta endringer automatisk",
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
"command.workspace.toggle": "Veksle arbeidsområder",
"command.session.undo": "Angre",
"command.session.undo.description": "Angre siste melding",
"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.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.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.session.previous": "Poprzednia 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.palette": "Paleta poleceń",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
"command.permissions.autoaccept.enable": "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.description": "Cofnij ostatnią wiadomość",
"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.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.description": "Połącz dostawcę, aby podsumować tę sesję",

View File

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Открыть настройки",
"command.session.previous": "Предыдущая сессия",
"command.session.next": "Следующая сессия",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Архивировать сессию",
"command.palette": "Палитра команд",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
"command.permissions.autoaccept.enable": "Авто-принятие изменений",
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
"command.workspace.toggle": "Переключить рабочие пространства",
"command.session.undo": "Отменить",
"command.session.undo.description": "Отменить последнее сообщение",
"command.session.redo": "Повторить",
@ -348,6 +351,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Авто-принятие остановлено",
"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.description": "Подключите провайдера для суммаризации сессии",

View File

@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "เปิดการตั้งค่า",
"command.session.previous": "เซสชันก่อนหน้า",
"command.session.next": "เซสชันถัดไป",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "จัดเก็บเซสชัน",
"command.palette": "คำสั่งค้นหา",
@ -68,6 +70,7 @@ export const dict = {
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
"command.workspace.toggle": "สลับพื้นที่ทำงาน",
"command.session.undo": "ยกเลิก",
"command.session.undo.description": "ยกเลิกข้อความล่าสุด",
"command.session.redo": "ทำซ้ำ",
@ -347,10 +350,15 @@ export const dict = {
"toast.scheme.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.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.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้",

View File

@ -32,6 +32,8 @@ export const dict = {
"command.settings.open": "打开设置",
"command.session.previous": "上一个会话",
"command.session.next": "下一个会话",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "归档会话",
"command.palette": "命令面板",
@ -72,6 +74,7 @@ export const dict = {
"command.model.variant.cycle.description": "切换到下一个强度等级",
"command.permissions.autoaccept.enable": "自动接受编辑",
"command.permissions.autoaccept.disable": "停止自动接受编辑",
"command.workspace.toggle": "切换工作区",
"command.session.undo": "撤销",
"command.session.undo.description": "撤销上一条消息",
"command.session.redo": "重做",
@ -342,7 +345,12 @@ export const dict = {
"toast.language.description": "已切换到{{language}}",
"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.description": "编辑和写入权限将自动获批",

View File

@ -32,6 +32,8 @@ export const dict = {
"command.settings.open": "開啟設定",
"command.session.previous": "上一個工作階段",
"command.session.next": "下一個工作階段",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "封存工作階段",
"command.palette": "命令面板",
@ -72,6 +74,7 @@ export const dict = {
"command.model.variant.cycle.description": "切換到下一個強度等級",
"command.permissions.autoaccept.enable": "自動接受編輯",
"command.permissions.autoaccept.disable": "停止自動接受編輯",
"command.workspace.toggle": "切換工作區",
"command.session.undo": "復原",
"command.session.undo.description": "復原上一則訊息",
"command.session.redo": "重做",
@ -339,7 +342,12 @@ export const dict = {
"toast.language.description": "已切換到 {{language}}",
"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.description": "編輯和寫入權限將自動獲准",

View File

@ -27,10 +27,12 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
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 { HoverCard } from "@opencode-ai/ui/hover-card"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
@ -108,7 +110,7 @@ export default function Layout(props: ParentProps) {
const command = useCommand()
const theme = useTheme()
const language = useLanguage()
const initialDir = params.dir
const initialDirectory = decode64(params.dir)
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "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 [state, setState] = createStore({
autoselect: !params.dir,
autoselect: !initialDirectory,
busyWorkspaces: new Set<string>(),
hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined,
@ -179,13 +181,21 @@ export default function Layout(props: ParentProps) {
const autoselecting = createMemo(() => {
if (params.dir) return false
if (initialDir) return false
if (!state.autoselect) return false
if (!pageReady()) return true
if (!layoutReady()) return true
const list = layout.projects.list()
if (list.length === 0) return false
return true
if (list.length > 0) 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
@ -498,7 +508,7 @@ export default function Layout(props: ParentProps) {
const bUpdated = b.time.updated ?? b.time.created
const aRecent = aUpdated > 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
return bUpdated - aUpdated
@ -565,11 +575,18 @@ export default function Layout(props: ParentProps) {
if (!value.ready) return
if (!value.layoutReady) return
if (!state.autoselect) return
if (initialDir) return
if (value.dir) return
if (value.list.length === 0) return
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]
if (!next) return
setState("autoselect", false)
@ -738,7 +755,7 @@ export default function Layout(props: ParentProps) {
}
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 }))
.then((messages) => {
@ -749,23 +766,49 @@ export default function Layout(props: ParentProps) {
.map((x) => x.info)
.filter((m) => !!m?.id)
.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(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
setStore("message", sessionID, reconcile(merged, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
const currentParts = store.part[message.info.id] ?? []
const mergedParts = (() => {
if (currentParts.length === 0) {
return message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}
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}`))
}
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) {
const [store, setStore] = globalSync.child(session.directory)
const sessions = store.session ?? []
@ -1024,6 +1113,20 @@ export default function Layout(props: ParentProps) {
keybind: "alt+arrowdown",
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",
title: language.t("command.session.archive"),
@ -1035,6 +1138,29 @@ export default function Layout(props: ParentProps) {
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",
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)(),
)
const [open, setOpen] = createSignal(false)
const [menu, setMenu] = createSignal(false)
const preview = 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(() => {
if (preview()) return
@ -2291,50 +2420,95 @@ export default function Layout(props: ParentProps) {
}
const projectName = () => props.project.name || getFilename(props.project.worktree)
const trigger = (
<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(),
const Trigger = () => (
<ContextMenu
modal={!sidebarHovering()}
onOpenChange={(value) => {
setMenu(value)
if (value) setOpen(false)
}}
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 />
</button>
<ContextMenu.Trigger
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 (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview()} fallback={trigger}>
<Show when={preview()} fallback={<Trigger />}>
<HoverCard
open={open()}
open={open() && !menu()}
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
trigger={trigger}
trigger={<Trigger />}
onOpenChange={(value) => {
if (menu()) return
setOpen(value)
if (value) setState("hoverSession", undefined)
}}
@ -2532,6 +2706,14 @@ export default function Layout(props: ParentProps) {
}
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
type SearchItem = {
id: string
title: string
directory: string
label: string
archived?: number
}
const projectName = createMemo(() => {
const project = panelProps.project
if (!project) return ""
@ -2547,6 +2729,107 @@ export default function Layout(props: ParentProps) {
})
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 (
<div
classList={{
@ -2555,7 +2838,7 @@ export default function Layout(props: ParentProps) {
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
<Show when={panelProps.project} keyed>
<Show when={panelProps.project}>
{(p) => (
<>
<div class="shrink-0 px-2 py-1">
@ -2564,7 +2847,7 @@ export default function Layout(props: ParentProps) {
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => renameProject(p, next)}
onSave={(next) => renameProject(p(), next)}
class="text-16-medium text-text-strong truncate"
displayClass="text-16-medium text-text-strong truncate"
stopPropagation
@ -2573,7 +2856,7 @@ export default function Layout(props: ParentProps) {
<Tooltip
placement="bottom"
gutter={2}
value={p.worktree}
value={p().worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
@ -2581,7 +2864,7 @@ export default function Layout(props: ParentProps) {
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{p.worktree.replace(homedir(), "~")}
{p().worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
@ -2592,31 +2875,31 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
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"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<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.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p.worktree)}
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => {
const enabled = layout.sidebar.workspaces(p.worktree)()
const enabled = layout.sidebar.workspaces(p().worktree)()
if (enabled) {
layout.sidebar.toggleWorkspaces(p.worktree)
layout.sidebar.toggleWorkspaces(p().worktree)
return
}
if (p.vcs !== "git") return
layout.sidebar.toggleWorkspaces(p.worktree)
if (p().vcs !== "git") return
layout.sidebar.toggleWorkspaces(p().worktree)
}}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p.worktree)()
{layout.sidebar.workspaces(p().worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
@ -2624,8 +2907,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p.worktree)}
onSelect={() => closeProject(p.worktree)}
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@ -2635,103 +2918,207 @@ export default function Layout(props: ParentProps) {
</div>
</div>
<Show
when={workspacesEnabled()}
fallback={
<div class="shrink-0 px-2 pt-2">
<div
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
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
title={language.t("workspace.new")}
keybind={command.keybind("workspace.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 size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace project={p} mobile={panelProps.mobile} />
<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>
</>
}
>
<>
<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>
</div>
</>
)}
</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 class="rounded-md bg-background-base shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</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
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
classList={{
hidden: searching() || !(providers.all().length > 0 && providers.paid().length === 0),
}}
>
<div class="rounded-md bg-background-base shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</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>
</Show>
</div>
</div>
)
}

View File

@ -500,9 +500,7 @@ export default function Page() {
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const add = diff.additions > 0
const del = diff.deletions > 0
const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix"
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
@ -689,6 +687,18 @@ export default function Page() {
slash: "open",
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",
title: language.t("command.context.addSelection"),
@ -1940,7 +1950,8 @@ export default function Page() {
"sticky top-0 z-30 bg-background-stronger": true,
"w-full": 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">
@ -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]"
classList={{
"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": !centered(),
}}
@ -2021,7 +2033,7 @@ export default function Page() {
data-message-id={message.id}
classList={{
"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
@ -2078,7 +2090,7 @@ export default function Page() {
<div
classList={{
"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>

View File

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

View File

@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "80K",
full: "80,000",
compact: "95K",
full: "95,000",
},
},
@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "600",
commits: "7,500",
monthlyUsers: "1.5M",
contributors: "650",
commits: "8,500",
monthlyUsers: "2.5M",
},
} 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 { RateLimitError } from "./error"
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
const ip = !rawIp.length ? "unknown" : rawIp
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 {
track: async () => {
@ -28,11 +32,18 @@ export function createRateLimiter(limit: number | undefined, rawIp: string) {
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
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) {
return new Date(timestamp)
.toISOString()

View File

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.48",
"version": "1.1.49",
"private": true,
"type": "module",
"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 Trial = z.infer<typeof TrialSchema>
export type RateLimit = z.infer<typeof RateLimitSchema>
const ModelCostSchema = z.object({
input: z.number(),
@ -37,7 +42,7 @@ export namespace ZenData {
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(),
trial: TrialSchema.optional(),
rateLimit: z.number().optional(),
rateLimit: RateLimitSchema.optional(),
fallbackProvider: z.string().optional(),
providers: z.array(
z.object({

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.48",
"version": "1.1.49",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@ -17,7 +17,7 @@
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<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" />
<script src="/src/index.tsx" type="module"></script>
</body>

View File

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

View File

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

View File

@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[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-deep-link = "2.4.6"
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"] }
tauri-plugin-decorum = "1.1.1"
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]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"
webkit2gtk = "=2.0.2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
@ -59,3 +62,10 @@ windows = { version = "0.61", features = [
"Win32_System_Threading",
"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");
#[tauri::command]
#[specta::specta]
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
if cfg!(not(unix)) {
return Err("CLI installation is only supported on macOS & Linux".to_string());

View File

@ -16,21 +16,26 @@ use std::{
time::{Duration, Instant},
};
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)]
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_shell::process::{CommandChild, CommandEvent};
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;
const SETTINGS_STORE: &str = "opencode.settings.dat";
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 {
url: String,
password: Option<String>,
@ -64,6 +69,7 @@ struct LogState(Arc<Mutex<VecDeque<String>>>);
const MAX_LOG_ENTRIES: usize = 200;
#[tauri::command]
#[specta::specta]
fn kill_sidecar(app: AppHandle) {
let Some(server_state) = app.try_state::<ServerState>() else {
println!("Server not running");
@ -97,6 +103,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
#[specta::specta]
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
state
.status
@ -106,6 +113,7 @@ async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerRead
}
#[tauri::command]
#[specta::specta]
fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
let store = app
.store(SETTINGS_STORE)
@ -119,6 +127,7 @@ fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
}
#[tauri::command]
#[specta::specta]
async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
let store = app
.store(SETTINGS_STORE)
@ -252,6 +261,26 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
pub fn run() {
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)))]
let _ = std::process::Command::new("killall")
.arg("opencode-cli")
@ -269,10 +298,7 @@ pub fn run() {
.plugin(tauri_plugin_os::init())
.plugin(
tauri_plugin_window_state::Builder::new()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
- tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.with_state_flags(window_state_flags())
.build(),
)
.plugin(tauri_plugin_store::Builder::new().build())
@ -285,15 +311,10 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.plugin(PinchZoomDisablePlugin)
.plugin(tauri_plugin_decorum::init())
.invoke_handler(tauri::generate_handler![
kill_sidecar,
install_cli,
ensure_server_ready,
get_default_server_url,
set_default_server_url,
markdown::parse_markdown_command
])
.invoke_handler(builder.invoke_handler())
.setup(move |app| {
builder.mount_events(app);
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
app.deep_link().register_all().ok();
@ -346,6 +367,8 @@ pub fn run() {
let window = window_builder.build().expect("Failed to create window");
setup_window_state_listener(&app, &window);
#[cfg(windows)]
let _ = window.create_overlay_titlebar();
@ -526,6 +549,7 @@ async fn spawn_local_server(
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(30) {
let _ = child.kill();
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
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;
create_formatter!(ExternalLinkFormatter, {
@ -55,6 +57,7 @@ pub fn parse_markdown(input: &str) -> String {
}
#[tauri::command]
#[specta::specta]
pub async fn parse_markdown_command(markdown: String) -> Result<String, String> {
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;
@ -21,8 +21,8 @@ impl<R: Runtime> Plugin<R> for PinchZoomDisablePlugin {
let _ = webview_window.with_webview(|_webview| {
#[cfg(target_os = "linux")]
unsafe {
use gtk::glib::ObjectExt;
use gtk::GestureZoom;
use gtk::glib::ObjectExt;
use webkit2gtk::glib::gobject_ffi;
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 { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export async function installCli(): Promise<void> {
await initI18n()
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") })
} catch (e) {
await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.48"
version = "1.1.49"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[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"
args = ["acp"]
[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"
args = ["acp"]
[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"
args = ["acp"]
[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"
args = ["acp"]
[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"
args = ["acp"]

View File

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

View File

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

View File

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

View File

@ -55,7 +55,6 @@ export namespace Agent {
doom_loop: "ask",
external_directory: {
"*": "ask",
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow",
},
question: "deny",
@ -140,7 +139,6 @@ export namespace Agent {
codesearch: "allow",
read: "allow",
external_directory: {
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow",
},
}),
@ -229,19 +227,19 @@ export namespace Agent {
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) {
const agent = result[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") 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
result[name].permission = PermissionNext.merge(
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 { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { createRequire } from "module"
import { Lock } from "../util/lock"
export namespace BunProc {
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>) {
log.info("running", {

View File

@ -4,25 +4,211 @@ import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { Command } from "../../command"
import { EOL } from "os"
import { select } from "@clack/prompts"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
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]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
list: ["List", UI.Style.TEXT_INFO_BOLD],
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
type ToolProps<T extends Tool.Info> = {
input: Tool.InferParameters<T>
metadata: Tool.InferMetadata<T>
part: ToolPart
}
function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
const state = part.state
return {
input: state.input as Tool.InferParameters<T>,
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({
@ -97,11 +283,11 @@ export const RunCommand = cmd({
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
const fileParts: any[] = []
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
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 file = Bun.file(resolvedPath)
const stats = await file.stat().catch(() => {})
@ -117,7 +303,7 @@ export const RunCommand = cmd({
const stat = await file.stat()
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
fileParts.push({
files.push({
type: "file",
url: `file://${resolvedPath}`,
filename: path.basename(resolvedPath),
@ -133,17 +319,75 @@ export const RunCommand = cmd({
process.exit(1)
}
const execute = async (sdk: OpencodeClient, sessionID: string) => {
const printEvent = (color: string, type: string, title: string) => {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
const rules: PermissionNext.Ruleset = [
{
permission: "question",
action: "deny",
pattern: "*",
},
{
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") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true
@ -152,41 +396,77 @@ export const RunCommand = cmd({
}
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) {
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") {
const part = event.properties.part
if (part.sessionID !== sessionID) continue
if (part.type === "tool" && part.state.status === "completed") {
if (outputJsonEvent("tool_use", { part })) continue
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title ||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
printEvent(color, tool, title)
if (part.tool === "bash" && part.state.output?.trim()) {
UI.println()
UI.println(part.state.output)
}
if (emit("tool_use", { part })) continue
tool(part)
}
if (
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
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 (outputJsonEvent("step_start", { part })) continue
if (emit("step_start", { part })) continue
}
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 (outputJsonEvent("text", { part })) continue
const isPiped = !process.stdout.isTTY
if (!isPiped) UI.println()
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
if (!isPiped) UI.println()
if (emit("text", { part })) continue
const text = part.text.trim()
if (!text) continue
if (!process.stdout.isTTY) {
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) {
err = String(props.error.data.message)
}
errorMsg = errorMsg ? errorMsg + EOL + err : err
if (outputJsonEvent("error", { error: props.error })) continue
error = error ? error + EOL + err : err
if (emit("error", { error: props.error })) continue
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
}
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const result = await select({
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
options: [
{ value: "once", label: "Allow once" },
{ value: "always", label: "Always allow: " + permission.always.join(", ") },
{ value: "reject", label: "Reject" },
],
initialValue: "once",
}).catch(() => "reject")
const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject"
await sdk.permission.respond({
sessionID,
permissionID: permission.id,
response,
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
await sdk.permission.reply({
requestID: permission.id,
reply: "reject",
})
}
}
})()
}
// Validate agent if specified
const resolvedAgent = await (async () => {
const agent = await (async () => {
if (!args.agent) return undefined
const agent = await Agent.get(args.agent)
if (!agent) {
const entry = await Agent.get(args.agent)
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
@ -240,7 +518,7 @@ export const RunCommand = cmd({
)
return undefined
}
if (agent.mode === "subagent") {
if (entry.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
@ -251,91 +529,42 @@ export const RunCommand = cmd({
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) {
await sdk.session.command({
sessionID,
agent: resolvedAgent,
agent,
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
})
} else {
const modelParam = args.model ? Provider.parseModel(args.model) : undefined
const model = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({
sessionID,
agent: resolvedAgent,
model: modelParam,
agent,
model,
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) {
const sdk = createOpencodeClient({ baseUrl: args.attach })
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)
return await execute(sdk)
}
await bootstrap(process.cwd(), async () => {
@ -344,52 +573,7 @@ export const RunCommand = cmd({
return Server.App().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
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)
await execute(sdk)
})
},
})

View File

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

View File

@ -129,6 +129,16 @@ export function Autocomplete(props: {
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
// 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.
@ -208,7 +218,7 @@ export function Autocomplete(props: {
}
const [files] = createResource(
() => filter(),
() => search(),
async (query) => {
if (!store.visible || store.visible === "/") return []
@ -378,9 +388,9 @@ export function Autocomplete(props: {
const mixed: AutocompleteOption[] =
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
const currentFilter = filter()
const searchValue = search()
if (!currentFilter) {
if (!searchValue) {
return mixed
}
@ -388,7 +398,7 @@ export function Autocomplete(props: {
return prev
}
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
const result = fuzzysort.go(removeLineRange(searchValue), mixed, {
keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description",
@ -398,7 +408,7 @@ export function Autocomplete(props: {
scoreFn: (objResults) => {
const displayResult = objResults[0]
let score = objResults.score
if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
score *= 2
}
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0

View File

@ -1,23 +1,52 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
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({
name: "Exit",
init: (input: { onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
return async (reason?: any) => {
// 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")
let message: string | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
}
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 { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useSDK } from "./sdk"
type ThemeColors = {
primary: RGBA
@ -429,6 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromInts(0, 0, 0, 0)
const isDark = mode == "dark"
const col = (i: number) => {
@ -479,8 +479,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
textMuted,
selectedListItemText: bg,
// Background colors
background: bg,
// Background colors - use transparent to respect terminal transparency
background: transparent,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],

View File

@ -77,6 +77,7 @@ import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
addDefaultParsers(parsers.parsers)
@ -222,6 +223,19 @@ export function Session() {
// Allow exit when in child session (prompt is hidden)
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) => {
if (!session()?.parentID) return
if (keybind.match("app_exit", evt)) {

View File

@ -63,7 +63,7 @@ export const WebCommand = cmd({
UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
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)",
default: false,
},
"mdns-domain": {
type: "string" as const,
describe: "custom domain name for mDNS service (default: opencode.local)",
default: "opencode.local",
},
cors: {
type: "string" as const,
array: true,
@ -36,9 +41,11 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
const corsExplicitlySet = process.argv.includes("--cors")
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 hostname = hostnameExplicitlySet
? 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 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 () => {
const auth = await Auth.all()
// Load remote/well-known config first as the base layer (lowest precedence)
// This allows organizations to provide default configs that users can override
// Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
// 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 = {}
for (const [key, value] of Object.entries(auth)) {
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())
// Custom config path overrides global
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
result = mergeConfigConcatArrays(result, await loadFile(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) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
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.mode = result.mode || {}
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) {
directories.push(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)))
}
// 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
// Kept separate from directories array to avoid write operations when installing plugins
// 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"),
hostname: z.string().optional().describe("Hostname to listen on"),
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"),
})
.strict()

View File

@ -355,3 +355,12 @@ export const pint: Info = {
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 os from "os"
import fuzzysort from "fuzzysort"
import { Config } from "../config/config"
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 { createPerplexity } from "@ai-sdk/perplexity"
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 { Installation } from "../installation"
export namespace Provider {
const log = Log.create({ service: "provider" })
@ -424,11 +426,17 @@ export namespace Provider {
const config = await Config.get()
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 {
autoload: !!apiKey,
options: {
instanceUrl,
apiKey,
aiGatewayHeaders,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
@ -437,6 +445,7 @@ export namespace Provider {
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, {
aiGatewayHeaders,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,

View File

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

View File

@ -658,11 +658,18 @@ export namespace ProviderTransform {
}
export function smallOptions(model: Provider.Model) {
if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
if (model.api.id.includes("5.")) {
return { reasoningEffort: "low" }
if (
model.providerID === "openai" ||
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") {
// gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget

View File

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

View File

@ -563,7 +563,13 @@ export namespace Server {
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 ?? []
const args = {
@ -591,7 +597,7 @@ export namespace Server {
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (shouldPublishMDNS) {
MDNS.publish(server.port!)
MDNS.publish(server.port!, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}

View File

@ -180,14 +180,6 @@ export namespace SessionProcessor {
case "tool-result": {
const match = toolcalls[value.toolCallId]
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({
...match,
state: {
@ -200,7 +192,7 @@ export namespace SessionProcessor {
start: match.state.time.start,
end: Date.now(),
},
attachments,
attachments: value.output.attachments,
},
})

View File

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

View File

@ -188,6 +188,7 @@ export namespace Snapshot {
after: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
@ -196,6 +197,23 @@ export namespace Snapshot {
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir()
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} -- .`
.quiet()
.cwd(Instance.directory)
@ -224,6 +242,7 @@ export namespace Snapshot {
after,
additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file) ?? "modified",
})
}
return result

View File

@ -128,7 +128,10 @@ export const BashTool = Tool.define("bash", async () => {
process.platform === "win32" && resolved.match(/^\/[a-z]\//)
? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
: 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) {
const globs = Array.from(directories).map((dir) => path.join(dir, "*"))
await ctx.ask({
permission: "external_directory",
patterns: Array.from(directories),
always: Array.from(directories).map((x) => path.dirname(x) + "*"),
patterns: globs,
always: globs,
metadata: {},
})
}

View File

@ -77,12 +77,6 @@ export const BatchTool = Tool.define("batch", async () => {
})
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({
id: partID,
@ -97,7 +91,7 @@ export const BatchTool = Tool.define("batch", async () => {
output: result.output,
title: result.title,
metadata: result.metadata,
attachments,
attachments: result.attachments,
time: {
start: callStartTime,
end: Date.now(),

View File

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

View File

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

View File

@ -36,7 +36,7 @@ export namespace Tool {
title: string
metadata: M
output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
attachments?: MessageV2.FilePart[]
}>
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")
await using tmp = await tmpdir({
config: {
@ -460,14 +460,14 @@ test("Truncate.DIR is allowed even when user denies external_directory globally"
directory: tmp.path,
fn: async () => {
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.DIR, 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")
await using tmp = await tmpdir({
config: {
@ -484,21 +484,21 @@ test("Truncate.DIR is allowed even when user denies external_directory per-agent
directory: tmp.path,
fn: async () => {
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.DIR, 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")
await using tmp = await tmpdir({
config: {
permission: {
external_directory: {
"*": "deny",
[Truncate.DIR]: "deny",
[Truncate.GLOB]: "deny",
},
},
},
@ -507,8 +507,8 @@ test("explicit Truncate.DIR deny is respected", async () => {
directory: tmp.path,
fn: async () => {
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.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 { 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", () => {
test("should convert messages with only a text part to a string content", () => {
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 () => {
await using tmp = await bootstrap()
await Instance.provide({

View File

@ -144,7 +144,42 @@ describe("tool.bash permissions", () => {
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
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",
"name": "@opencode-ai/plugin",
"version": "1.1.48",
"version": "1.1.49",
"type": "module",
"license": "MIT",
"scripts": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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