Compare commits

...

79 Commits

Author SHA1 Message Date
Adam
f07e877204 fix(app): remove double-border in share button 2026-02-20 16:20:13 -06:00
Adam
58ad4359da chore: cleanup 2026-02-20 16:05:41 -06:00
Adam
ce2763720e fix(app): better sound effect disabling ux 2026-02-20 16:05:41 -06:00
Aiden Cline
950df3de19 ci: temporarily disable assigning of issues to rekram1-node (#14486) 2026-02-20 13:56:29 -06:00
Aiden Cline
1d9f05e4f5 cache platform binary in postinstall for faster startup (#14467) 2026-02-20 12:19:17 -06:00
Adam
46361cf35c fix(app): session review re-rendering too aggressively 2026-02-20 11:11:48 -06:00
Adam
c09d3dd5a7 chore: cleanup 2026-02-20 10:54:17 -06:00
Adam
fe89bedfcc wip(app): custom scroll view 2026-02-20 10:54:17 -06:00
Frank
1e48d7fe82 zen: gpt safety_identifier 2026-02-20 11:28:19 -05:00
Adam
2a904ec56f feat(app): show/hide reasoning summaries 2026-02-20 10:05:09 -06:00
Adam
0ce61c817b fix(app): stay pinned with auto-scroll on todos/questions/perms 2026-02-20 10:00:56 -06:00
Aiden Cline
1ffed2fa6c Revert "cache platform binary in postinstall for faster startup" (#14457) 2026-02-20 09:28:49 -06:00
Aiden Cline
c79f1a72d8 cache platform binary in postinstall for faster startup (#14396) 2026-02-20 09:26:13 -06:00
Adam
9c5bbba6ea fix(app): patch tool renders like edit tool 2026-02-20 09:13:17 -06:00
Brendan Allan
ce17f9dd94 desktop: publish betas to separate repo (#14376) 2026-02-20 22:33:21 +08:00
Brendan Allan
92ab4217c2 desktop: bring back -i in sidecar arguments
shell configs like .zshrc don't get loaded without it
2026-02-20 22:03:23 +08:00
opencode-agent[bot]
7867ba441f chore: generate 2026-02-20 13:46:03 +00:00
Ryan Vogel
7419ebc872 feat: add list sessions for all sessions (experimental) (#14038) 2026-02-20 08:45:12 -05:00
Adam
7e681b0bc0 fix(app): large text pasted into prompt-input causes main thread lock 2026-02-20 07:38:22 -06:00
Adam
4e9ef3ecc1 fix(app): terminal issues (#14435) 2026-02-20 07:34:36 -06:00
Adam
7e0e35af3f chore: update agent 2026-02-20 07:29:02 -06:00
Matt Silverlock
2410593023 fix(github): support variant in github action and opencode github run (#14431) 2026-02-20 13:20:54 +00:00
Shoubhit Dash
1de12604cf fix(ui): preserve url slashes for root workspace (#14294) 2026-02-20 07:02:48 -06:00
Shoubhit Dash
ac0b37a7b7 fix(snapshot): respect info exclude in snapshot staging (#13495) 2026-02-20 07:02:25 -06:00
Shoubhit Dash
7e1051af07 fix(ui): show full turn duration in assistant meta (#14378) 2026-02-20 07:01:13 -06:00
Matt Silverlock
93615bef28 fix(cli): missing plugin deps cause TUI to black screen (#14432) 2026-02-20 07:39:15 -05:00
Adam
a04e4e81fb chore: cleanup 2026-02-20 06:30:19 -06:00
opencode
296250f1b7 release: v1.2.10 2026-02-20 11:18:40 +00:00
Brendan Allan
443214871e sdk: build to dist/ instead of dist/src (#14383) 2026-02-20 14:10:38 +08:00
Brendan Allan
1c2416b6de desktop: don't spawn sidecar if default is localhost server 2026-02-20 12:18:39 +08:00
Rafi Khardalian
d86c10816d docs: clarify tool name collision precedence (#14313)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-19 22:00:06 -06:00
Aiden Cline
04a634a80d test: merge test files into a single file (#14366) 2026-02-19 20:32:01 -06:00
opencode
1eb6caa3c6 release: v1.2.9 2026-02-20 01:45:20 +00:00
Aiden Cline
1a329ba47d fix: issue from structuredClone addition by using unwrap (#14359) 2026-02-19 19:28:06 -06:00
David Hill
8d781b08ce tweak(ui): adjust session feed spacing 2026-02-20 01:22:12 +00:00
David Hill
8b99ac6513 tweak(ui): tone down reasoning emphasis 2026-02-20 01:17:48 +00:00
David Hill
63a469d0ce tweak(ui): refine session feed spacing 2026-02-20 01:17:48 +00:00
David Hill
ae98be83b3 fix(desktop): restore settings header mask 2026-02-19 18:37:56 -06:00
David Hill
a3181d5fbd tweak(ui): nudge edited files chevron 2026-02-19 18:37:56 -06:00
David Hill
998c8bf3a5 tweak(ui): stabilize collapsible chevron hover 2026-02-19 18:37:56 -06:00
NatChung
d2d7a37bca fix: add missing id/sessionID/messageID to MCP tool attachments (#14345) 2026-02-19 18:37:56 -06:00
Michael Hart
8ad60b1ec2 Use structuredClone instead of remeda's clone (#14351)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
2026-02-19 18:37:56 -06:00
Dax
01d518708a remove unnecessary deep clones from session loop and LLM stream (#14354) 2026-02-19 18:37:55 -06:00
Dax Raad
ae50f24c06 fix(web): correct config import path in Korean enterprise docs 2026-02-19 18:37:55 -06:00
Jay V
d32dd4d7fd docs: update providers layout and Windows sidebar label 2026-02-19 18:37:55 -06:00
Dax Raad
cb5a0de42f core: remove User-Agent header assertion from LLM test to fix failing test 2026-02-19 18:37:55 -06:00
opencode
f2090b26c1 release: v1.2.8 2026-02-19 22:38:42 +00:00
Adam
fca0166488 fix(app): black screen on launch with sidecar server 2026-02-19 16:23:33 -06:00
opencode-agent[bot]
686dd330a0 chore: generate 2026-02-19 22:19:09 +00:00
tctev
193013a44d feat(opencode): support adaptive thinking for claude sonnet 4.6 (#14283)
Co-authored-by: tctev <224793535+tctev@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-02-19 16:17:57 -06:00
Yanosh Kunsh
824ab4cecc feat(tui): add custom tool and mcp call responses visible and collapsable (#10649)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-19 15:36:40 -06:00
Adam
7a42ecdddb chore: cleanup 2026-02-19 21:27:39 +00:00
Adam
dd011e879c fix(app): clear todos on abort 2026-02-19 21:27:39 +00:00
opencode
04cf2b8268 release: v1.2.7 2026-02-19 21:27:31 +00:00
Matt Silverlock
1a1437e78b fix(github): action branch detection and 422 handling (#14322)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-19 15:11:59 -06:00
Adam
c76a81434d chore: cleanup 2026-02-19 15:09:24 -06:00
Adam
49cc872c44 chore: refactor composer/dock components (#14328) 2026-02-19 15:02:45 -06:00
Adam
f8dad0ae17 fix(app): terminal issues (#14329) 2026-02-19 14:54:09 -06:00
Adam
40a939f5f0 chore: cleanup 2026-02-19 14:34:49 -06:00
Adam
7729c6d895 chore: cleanup 2026-02-19 14:31:22 -06:00
Adam
7fb2081dce chore: cleanup 2026-02-19 14:25:12 -06:00
Shintaro Jokagi
3d9f6c0fe0 feat(i18n): update Japanese translations to WSL integration (#13160) 2026-02-19 14:18:52 -06:00
Shantur Rathore
190d2957eb fix(core): normalize file.status paths relative to instance dir (#14207) 2026-02-19 14:17:36 -06:00
Jun
b64d0768ba docs(ko): improve wording in ecosystem, enterprise, formatters, and github docs (#14220) 2026-02-19 14:17:15 -06:00
opencode-agent[bot]
1867f1acaa chore: generate 2026-02-19 20:12:16 +00:00
Aiden Cline
00c079868a test: fix discovery test to boot up server instead of relying on 3rd party (#14327) 2026-02-19 14:11:23 -06:00
opencode-agent[bot]
8b99648790 chore: update nix node_modules hashes 2026-02-19 18:56:40 +00:00
Dax
cb8b74d3f1 refactor: migrate from Bun.Glob to npm glob package (#14317) 2026-02-19 13:40:09 -05:00
Aiden Cline
7e35d0c610 core: bump ai sdk packages for google, google vertex, anthropic, bedrock, and provider utils (#14318) 2026-02-19 12:35:51 -06:00
Aiden Cline
5364ab74a2 tweak: add support for medium reasoning w/ gemini 3.1 (#14316) 2026-02-19 12:00:56 -06:00
opencode-agent[bot]
91f8dd5f57 chore: update nix node_modules hashes 2026-02-19 18:00:22 +00:00
opencode-agent[bot]
850402f093 chore: update nix node_modules hashes 2026-02-19 17:52:03 +00:00
Dax Raad
af72010e9f Revert "refactor: migrate from Bun.Glob to npm glob package"
This reverts commit 3c21735b35.
2026-02-19 12:48:43 -05:00
Brendan Allan
50883cc1e9 app: make localhost urls work in isLocal 2026-02-20 01:38:39 +08:00
Adam
f2858a42ba chore: cleanup 2026-02-19 11:36:37 -06:00
Dax Raad
3c21735b35 refactor: migrate from Bun.Glob to npm glob package
Replace Bun.Glob usage with a new Glob utility wrapper around the npm 'glob' package.
This moves us off Bun-specific APIs toward standard Node.js compatible solutions.

Changes:
- Add new src/util/glob.ts utility module with scan(), scanSync(), and match()
- Default include option is 'file' (only returns files, not directories)
- Add symlink option (default: false) to control symlink following
- Migrate all 12 files using Bun.Glob to use the new Glob utility
- Add comprehensive tests for the glob utility

Breaking changes:
- Removed support for include: 'dir' option (use include: 'all' and filter manually)
- symlink now defaults to false (was true in most Bun.Glob usages)

Files migrated:
- src/util/log.ts
- src/util/filesystem.ts
- src/tool/truncation.ts
- src/session/instruction.ts
- src/storage/json-migration.ts
- src/storage/storage.ts
- src/project/project.ts
- src/cli/cmd/tui/context/theme.tsx
- src/config/config.ts
- src/tool/registry.ts
- src/skill/skill.ts
- src/file/ignore.ts
2026-02-19 12:34:18 -05:00
Adam
56dda4c98c chore: cleanup 2026-02-19 11:12:56 -06:00
Adam
6b8902e8b9 fix(app): navigate to last session on project nav 2026-02-19 11:12:56 -06:00
Frank
08a2d002b8 zen: gemini 3.1 pro 2026-02-19 11:43:14 -05:00
168 changed files with 5028 additions and 1672 deletions

View File

@@ -41,6 +41,13 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
if: inputs.bump || inputs.version
run: bun i -g opencode-ai
@@ -49,14 +56,16 @@ jobs:
run: |
./script/version.ts
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GH_REPO: ${{ (github.ref_name == 'beta' && 'anomalyco/opencode-beta') || github.repository }}
outputs:
version: ${{ steps.version.outputs.version }}
release: ${{ steps.version.outputs.release }}
tag: ${{ steps.version.outputs.tag }}
repo: ${{ steps.version.outputs.repo }}
build-cli:
needs: version
@@ -69,6 +78,13 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build
id: build
run: |
@@ -76,7 +92,8 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@v4
with:
@@ -189,6 +206,13 @@ jobs:
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
timeout-minutes: 60
@@ -196,14 +220,16 @@ jobs:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.version.outputs.release }}
tagName: ${{ needs.version.outputs.tag }}
releaseDraft: true
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
releaseCommitish: ${{ github.sha }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -280,4 +306,5 @@ jobs:
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
AUR_KEY: ${{ secrets.AUR_KEY }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ target
opencode-dev
logs/
*.bun-build
tsconfig.tsbuildinfo

View File

@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3-pro
model: opencode/gemini-3.1-pro
---
You are a professional translator and localization specialist.

View File

@@ -5,8 +5,16 @@ import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
tui: [
"thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
@@ -42,10 +50,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
export default tool({
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
@@ -68,7 +73,8 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)")
}
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")

View File

@@ -4,3 +4,5 @@ Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)

112
bun.lock
View File

@@ -15,6 +15,7 @@
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
@@ -24,7 +25,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -74,7 +75,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -108,7 +109,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -135,7 +136,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -159,7 +160,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -183,7 +184,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -216,7 +217,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -245,7 +246,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -261,7 +262,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.6",
"version": "1.2.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -269,22 +270,22 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.79",
"@ai-sdk/anthropic": "2.0.62",
"@ai-sdk/amazon-bedrock": "3.0.82",
"@ai-sdk/anthropic": "2.0.65",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.103",
"@ai-sdk/google": "2.0.54",
"@ai-sdk/google-vertex": "3.0.106",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
"@ai-sdk/openai-compatible": "1.0.32",
"@ai-sdk/perplexity": "2.0.23",
"@ai-sdk/provider": "2.0.1",
"@ai-sdk/provider-utils": "3.0.20",
"@ai-sdk/provider-utils": "3.0.21",
"@ai-sdk/togetherai": "1.0.34",
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
@@ -321,6 +322,7 @@
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
@@ -374,7 +376,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -394,7 +396,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.6",
"version": "1.2.10",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -405,7 +407,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -418,7 +420,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -460,7 +462,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"zod": "catalog:",
},
@@ -471,7 +473,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.6",
"version": "1.2.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -576,7 +578,7 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.79", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.62", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfAQUb1GEmdTjLu5Ud1d5sieNHDpwoQdb4S14KmJlA5RsGREUZ1tfSKngFaiClxFtL0xPSZjePhTMV6Z65A7/g=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.82", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yb1EkRCMWex0tnpHPLGQxoJEiJvMGOizuxzlXFOpuGFiYgE679NsWE/F8pHwtoAWsqLlylgGAJvJDIJ8us8LEw=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
@@ -598,9 +600,9 @@
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VKguP0x/PUYpdQyuA/uy5pDGJy6reL0X/yDKxHfL207aCUXpFIBmyMhVs4US39dkEVhtmIFSwXauY0Pt170JRw=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.103", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.63", "@ai-sdk/google": "2.0.53", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MPZRSVOJFxYGHE4s6XjSWaiUPru7u2i/LUUA1Ih2nzNYZaei8c46Z56imOCD/KQjQX3afRA2iZh6P5McsmwhqA=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.106", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/google": "2.0.54", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-f9sA66bmhgJoTwa+pHWFSdYxPa0lgdQ/MgYNxZptzVyGptoziTf1a9EIXEL3jiCD0qIBAg+IhDAaYalbvZaDqQ=="],
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
@@ -614,7 +616,7 @@
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
@@ -2694,7 +2696,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -3074,7 +3076,7 @@
"lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
@@ -4198,9 +4200,9 @@
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
@@ -4208,27 +4210,25 @@
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.63", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ=="],
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
@@ -4238,12 +4238,20 @@
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
@@ -4630,6 +4638,10 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.79", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.62", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfAQUb1GEmdTjLu5Ud1d5sieNHDpwoQdb4S14KmJlA5RsGREUZ1tfSKngFaiClxFtL0xPSZjePhTMV6Z65A7/g=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.63", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA=="],
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ=="],
@@ -4640,8 +4652,6 @@
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
"ai-gateway-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -4768,7 +4778,7 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
@@ -4786,14 +4796,14 @@
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
@@ -4866,10 +4876,10 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"vitest/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
@@ -5202,6 +5212,8 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
@@ -5226,8 +5238,6 @@
"astro/unstorage/h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="],
"astro/unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"astro/unstorage/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"aws-sdk/xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
@@ -5266,7 +5276,9 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
@@ -5358,6 +5370,8 @@
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"vite-plugin-icons-spritesheet/glob/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],

View File

@@ -30,6 +30,10 @@ inputs:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
required: false
variant:
description: "Model variant for provider-specific reasoning effort (e.g., high, max, minimal)"
required: false
oidc_base_url:
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
required: false
@@ -71,4 +75,5 @@ runs:
PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-zs3o4OrLGqECnOxzbawP1UC+a7U3pZKr9QE+36qW+iA=",
"aarch64-linux": "sha256-bg0xtNJBbaZpDleCw+S6aay9Ntcil/h4HW7a1jGfc8Q=",
"aarch64-darwin": "sha256-alEZaFnNgd/7evGv+HLUieeRr8+YVN/FxhH2sNQBMcQ=",
"x86_64-darwin": "sha256-NMBZX6Y7JCUqK6ntCoaf7/a6tFArzDSV/TnBCTtwGMw="
"x86_64-linux": "sha256-fjrvCgQ2PHYxzw8NsiEHOcor46qN95/cfilFHFqCp/k=",
"aarch64-linux": "sha256-xWp4LLJrbrCPFL1F6SSbProq/t/az4CqhTcymPvjOBQ=",
"aarch64-darwin": "sha256-Wbfyy/bruFHKUWsyJ2aiPXAzLkk5MNBfN6QdGPQwZS0=",
"x86_64-darwin": "sha256-wDnMbiaBCRj5STkaLoVCZTdXVde+/YKfwWzwJZ1AJXQ="
}
}

View File

@@ -70,6 +70,7 @@
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",

View File

@@ -225,7 +225,7 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const scroller = page.locator(".session-scroller").first()
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
@@ -332,6 +332,163 @@ export async function withSession<T>(
}
}
const seedSystem = [
"You are seeding deterministic e2e UI state.",
"Follow the user's instruction exactly.",
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
"Do not call any extra tools.",
].join(" ")
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
const timeout = input.timeout ?? 30_000
const end = Date.now() + timeout
while (Date.now() < end) {
const value = await input.probe()
if (value !== undefined) return value
await new Promise((resolve) => setTimeout(resolve, 250))
}
}
const seed = async <T>(input: {
sessionID: string
prompt: string
sdk: ReturnType<typeof createSdk>
probe: () => Promise<T | undefined>
timeout?: number
attempts?: number
}) => {
for (let i = 0; i < (input.attempts ?? 2); i++) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
agent: "build",
system: seedSystem,
parts: [{ type: "text", text: input.prompt }],
})
const value = await wait({ probe: input.probe, timeout: input.timeout })
if (value !== undefined) return value
}
}
export async function seedSessionQuestion(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
questions: Array<{
header: string
question: string
options: Array<{ label: string; description: string }>
multiple?: boolean
custom?: boolean
}>
},
) {
const first = input.questions[0]
if (!first) throw new Error("Question seed requires at least one question")
const text = [
"Your only valid response is one question tool call.",
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
"Do not output plain text.",
"After calling the tool, wait for the user response.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.question.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
},
})
if (!result) throw new Error("Timed out seeding question request")
return { id: result.id }
}
export async function seedSessionPermission(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
permission: string
patterns: string[]
description?: string
},
) {
const text = [
"Your only valid response is one bash tool call.",
`Use this JSON input: ${JSON.stringify({
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
workdir: "/",
description: input.description ?? `seed ${input.permission} permission request`,
})}`,
"Do not output plain text.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.permission.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID)
},
})
if (!result) throw new Error("Timed out seeding permission request")
return { id: result.id }
}
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
todos: Array<{ content: string; status: string; priority: string }>
},
) {
const text = [
"Your only valid response is one todowrite tool call.",
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
"Do not output plain text.",
].join("\n")
const target = JSON.stringify(input.todos)
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
if (JSON.stringify(todos) !== target) return
return true
},
})
if (!result) throw new Error("Timed out seeding todos")
return true
}
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []),
sdk.permission.list().then((x) => x.data ?? []),
])
await Promise.all([
...questions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
...permissions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
])
return true
}
export async function openStatusPopover(page: Page) {
await defocus(page)

View File

@@ -1,7 +1,19 @@
import { base64Decode } from "@opencode-ai/util/encode"
import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject } from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
setWorkspacesEnabled,
sessionIDFromUrl,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -33,3 +45,94 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
await cleanupTestProject(other)
}
})
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
const stamp = Date.now()
let rootDir: string | undefined
let workspaceDir: string | undefined
let sessionID: string | undefined
try {
await withProject(
async ({ directory, slug }) => {
rootDir = directory
await defocus(page)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const next = slugFromUrl(page.url())
if (!next) return ""
if (next === slug) return ""
return next
},
{ timeout: 45_000 },
)
.not.toBe("")
const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(workspace).toBeVisible()
await workspace.hover()
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
await expect(newSession).toBeVisible()
await newSession.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(`project switch remembers workspace ${stamp}`)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
sessionID = created
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click()
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally {
if (sessionID) {
const id = sessionID
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
await Promise.all(
dirs.map((directory) =>
createSdk(directory)
.session.delete({ sessionID: id })
.catch(() => undefined),
),
)
}
if (workspaceDir) {
await cleanupTestProject(workspaceDir)
}
await cleanupTestProject(other)
}
})

View File

@@ -1,5 +1,15 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'

View File

@@ -0,0 +1,207 @@
import { test, expect } from "../fixtures"
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
questionDockSelector,
sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
const session = await sdk.session.create({ title }).then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
return fn(session)
}
test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
try {
return await fn()
} finally {
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
})
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["README.md"],
description: "Need permission for command",
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow once/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["REJECT.md"],
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["README.md"],
description: "Need permission for command",
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow always/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
})
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
})
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
})
})

View File

@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)

View File

@@ -6,6 +6,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await gotoSession()
const terminals = page.locator(terminalSelector)
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const opened = await terminals.first().isVisible()
if (!opened) {
@@ -21,6 +22,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.locator(promptSelector).click()
await page.keyboard.press("Control+Alt+T")
await expect(terminals).toHaveCount(2)
await expect(terminals.nth(1).locator("textarea")).toHaveCount(1)
await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1)
await expect(terminals.first().locator("textarea")).toHaveCount(1)
})

View File

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

View File

@@ -20,6 +20,7 @@ import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -88,6 +89,8 @@ const EXAMPLES = [
"prompt.example.25",
] as const
const NON_EMPTY_TEXT = /[^\s\u200B]/
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const sync = useSync()
@@ -635,7 +638,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let buffer = ""
const flushText = () => {
const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
let content = buffer
if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n")
if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
buffer = ""
if (!content) return
parts.push({ type: "text", content, start: position, end: position + content.length })
@@ -713,10 +718,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const trimmed = rawText.replace(/\u200B/g, "").trim()
const rawText =
rawParts.length === 1 && rawParts[0]?.type === "text"
? rawParts[0].content
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
if (shouldReset) {
closePopover()
@@ -756,19 +763,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const addPart = (part: ContentPart) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
if (part.type === "image") return false
const cursorPosition = getCursorPosition(editorRef)
const currentPrompt = prompt.current()
const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
const selection = window.getSelection()
if (!selection) return false
if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
editorRef.focus()
const cursor = prompt.cursor() ?? promptLength(prompt.current())
setCursorPosition(editorRef, cursor)
}
if (selection.rangeCount === 0) return false
const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return false
if (part.type === "file" || part.type === "agent") {
const cursorPosition = getCursorPosition(editorRef)
const rawText = prompt
.current()
.map((p) => ("content" in p ? p.content : ""))
.join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
const pill = createPill(part)
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
@@ -783,8 +802,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
} else if (part.type === "text") {
const range = selection.getRangeAt(0)
}
if (part.type === "text") {
const fragment = createTextFragment(part.content)
const last = fragment.lastChild
range.deleteContents()
@@ -820,6 +840,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
handleInput()
closePopover()
return true
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -1045,12 +1066,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
commandKeybind={command.keybind}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<form
<DockShellForm
onSubmit={handleSubmit}
classList={{
"group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true,
"rounded-[12px] overflow-clip focus-within:shadow-xs-border": true,
"focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
@@ -1243,10 +1263,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Show>
</div>
</form>
</DockShellForm>
<Show when={store.mode === "normal" || store.mode === "shell"}>
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
<div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<Show when={store.mode === "shell"}>
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
@@ -1254,7 +1274,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="size-4 shrink-0" />
</div>
</Show>
<Show when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
@@ -1354,7 +1373,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind>
</Show>
</div>
<div class="shrink-0" data-component="prompt-mode-toggle">
<div class="shrink-0">
<RadioGroup
options={["shell", "normal"] as const}
current={store.mode}
@@ -1385,7 +1404,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
</div>
</div>
</div>
</DockTray>
</Show>
</div>
)

View File

@@ -7,6 +7,19 @@ import { getCursorPosition } from "./editor-dom"
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined
@@ -14,7 +27,7 @@ type PromptAttachmentsInput = {
isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void
addPart: (part: ContentPart) => void
addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise<File | null>
}
@@ -89,6 +102,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
if (!plainText) return
if (largePaste(plainText)) {
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
input.focusEditor()
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
if (inserted) return

View File

@@ -24,6 +24,28 @@ describe("prompt-input editor dom", () => {
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
})
test("createTextFragment avoids break-node explosion for large multiline content", () => {
const content = Array.from({ length: 220 }, () => "line").join("\n")
const fragment = createTextFragment(content)
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(1)
expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
expect(container.textContent).toBe(content)
})
test("createTextFragment keeps terminal break in large multiline fallback", () => {
const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
const fragment = createTextFragment(content)
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
})
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("ab\u200B"))

View File

@@ -1,5 +1,20 @@
const MAX_BREAKS = 200
export function createTextFragment(content: string): DocumentFragment {
const fragment = document.createDocumentFragment()
let breaks = 0
for (const char of content) {
if (char !== "\n") continue
breaks += 1
if (breaks > MAX_BREAKS) {
const tail = content.endsWith("\n")
const text = tail ? content.slice(0, -1) : content
if (text) fragment.appendChild(document.createTextNode(text))
if (tail) fragment.appendChild(document.createElement("br"))
return fragment
}
}
const segments = content.split("\n")
segments.forEach((segment, index) => {
if (segment) {

View File

@@ -73,12 +73,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
globalSync.todo.set(sessionID, [])
const [, setStore] = globalSync.child(sdk.directory)
setStore("todo", sessionID, [])
const queued = pending.get(sessionID)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(sessionID)
globalSync.todo.set(sessionID, undefined)
return Promise.resolve()
}
return sdk.client.session
@@ -86,9 +90,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sessionID,
})
.catch(() => {})
.finally(() => {
globalSync.todo.set(sessionID, undefined)
})
}
const restoreCommentItems = (items: CommentItem[]) => {

View File

@@ -11,6 +11,7 @@ import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics"
@@ -268,9 +269,9 @@ export function SessionContextTab() {
})
return (
<div
class="@container h-full overflow-y-auto no-scrollbar pb-10"
ref={(el) => {
<ScrollView
class="@container h-full pb-10"
viewportRef={(el) => {
scroll = el
restoreScroll()
}}
@@ -336,6 +337,6 @@ export function SessionContextTab() {
</Accordion>
</div>
</div>
</div>
</ScrollView>
)
}

View File

@@ -452,7 +452,10 @@ export function SessionHeader() {
variant: "ghost",
class:
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
classList: { "rounded-r-none": share.shareUrl() !== undefined },
classList: {
"rounded-r-none": share.shareUrl() !== undefined,
"border-r-0": share.shareUrl() !== undefined,
},
style: { scale: 1 },
}}
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}

View File

@@ -20,12 +20,17 @@ let demoSoundState = {
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const playDemoSound = (src: string) => {
const stopDemoSound = () => {
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.cleanup = undefined
}
const playDemoSound = (src: string | undefined) => {
stopDemoSound()
if (!src) return
demoSoundState.timeout = setTimeout(() => {
demoSoundState.cleanup = playSound(src)
@@ -132,11 +137,17 @@ export const SettingsGeneral: Component = () => {
] as const
const fontOptionsList = [...fontOptions]
const soundOptions = [...SOUND_OPTIONS]
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const soundSelectProps = (current: () => string, set: (id: string) => void) => ({
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: soundOptions.find((o) => o.id === current()),
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
@@ -145,6 +156,12 @@ export const SettingsGeneral: Component = () => {
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.src)
},
@@ -250,6 +267,18 @@ export const SettingsGeneral: Component = () => {
)}
</Select>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
)
@@ -307,66 +336,45 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-agent-enabled">
<Switch
checked={settings.sounds.agentEnabled()}
onChange={(checked) => settings.sounds.setAgentEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.agentEnabled()}
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agent(),
(id) => settings.sounds.setAgent(id),
)}
/>
</div>
<Select
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-permissions-enabled">
<Switch
checked={settings.sounds.permissionsEnabled()}
onChange={(checked) => settings.sounds.setPermissionsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.permissionsEnabled()}
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissions(),
(id) => settings.sounds.setPermissions(id),
)}
/>
</div>
<Select
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-errors-enabled">
<Switch
checked={settings.sounds.errorsEnabled()}
onChange={(checked) => settings.sounds.setErrorsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.errorsEnabled()}
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errors(),
(id) => settings.sounds.setErrors(id),
)}
/>
</div>
<Select
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
/>
</SettingsRow>
</div>
</div>
@@ -418,7 +426,7 @@ export const SettingsGeneral: Component = () => {
return (
<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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-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>
</div>

View File

@@ -370,7 +370,7 @@ export const SettingsKeybinds: Component = () => {
return (
<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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-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">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>

View File

@@ -59,7 +59,7 @@ export const SettingsModels: Component = () => {
return (
<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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-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>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">

View File

@@ -177,7 +177,7 @@ export const SettingsPermissions: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>

View File

@@ -132,7 +132,7 @@ export const SettingsProviders: Component = () => {
return (
<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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-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>
</div>

View File

@@ -540,7 +540,7 @@ export const Terminal = (props: TerminalProps) => {
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })

View File

@@ -24,11 +24,15 @@ export function serverDisplayName(conn?: ServerConnection.Any) {
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
if (key === "sidecar") return "local"
const host = key.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
if (isLocalHost(key)) return "local"
return key
}
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
}
export namespace ServerConnection {
type Base = { displayName?: string }
@@ -197,7 +201,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
)
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" && c.variant === "base"
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
})
return {

View File

@@ -22,6 +22,7 @@ export interface Settings {
general: {
autoSave: boolean
releaseNotes: boolean
showReasoningSummaries: boolean
}
updates: {
startup: boolean
@@ -42,6 +43,7 @@ const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
showReasoningSummaries: false,
},
updates: {
startup: true,
@@ -120,6 +122,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries,
),
setShowReasoningSummaries(value: boolean) {
setStore("general", "showReasoningSummaries", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),

View File

@@ -565,6 +565,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "بلا",
"sound.option.alert01": "تنبيه 01",
"sound.option.alert02": "تنبيه 02",
"sound.option.alert03": "تنبيه 03",

View File

@@ -571,6 +571,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Nenhum",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
"sound.option.alert03": "Alerta 03",

View File

@@ -639,6 +639,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Nijedan",
"sound.option.alert01": "Upozorenje 01",
"sound.option.alert02": "Upozorenje 02",
"sound.option.alert03": "Upozorenje 03",

View File

@@ -635,6 +635,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Ingen",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
"sound.option.alert03": "Alarm 03",

View File

@@ -580,6 +580,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Keine",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
"sound.option.alert03": "Alarm 03",

View File

@@ -610,6 +610,8 @@ export const dict = {
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Customise the mono font used in code blocks",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
@@ -640,6 +642,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "None",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",

View File

@@ -643,6 +643,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Ninguno",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
"sound.option.alert03": "Alerta 03",

View File

@@ -579,6 +579,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Aucun",
"sound.option.alert01": "Alerte 01",
"sound.option.alert02": "Alerte 02",
"sound.option.alert03": "Alerte 03",

View File

@@ -527,8 +527,8 @@ export const dict = {
"settings.tab.general": "一般",
"settings.tab.shortcuts": "ショートカット",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL統合",
"settings.desktop.wsl.description": "WindowsのWSLでOpenCodeサーバーを実行します。",
"settings.desktop.wsl.title": "WSL連携",
"settings.desktop.wsl.description": "WindowsのWSL環境でOpenCodeサーバーを実行します。",
"settings.general.section.appearance": "外観",
"settings.general.section.notifications": "システム通知",
"settings.general.section.updates": "アップデート",
@@ -569,6 +569,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "なし",
"sound.option.alert01": "アラート 01",
"sound.option.alert02": "アラート 02",
"sound.option.alert03": "アラート 03",

View File

@@ -570,6 +570,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "없음",
"sound.option.alert01": "알림 01",
"sound.option.alert02": "알림 02",
"sound.option.alert03": "알림 03",

View File

@@ -642,6 +642,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Ingen",
"sound.option.alert01": "Varsel 01",
"sound.option.alert02": "Varsel 02",
"sound.option.alert03": "Varsel 03",

View File

@@ -570,6 +570,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Brak",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",

View File

@@ -640,6 +640,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Нет",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",

View File

@@ -634,6 +634,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "ไม่มี",
"sound.option.alert01": "เสียงเตือน 01",
"sound.option.alert02": "เสียงเตือน 02",
"sound.option.alert03": "เสียงเตือน 03",

View File

@@ -633,6 +633,7 @@ export const dict = {
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "无",
"sound.option.alert01": "警报 01",
"sound.option.alert02": "警报 02",
"sound.option.alert03": "警报 03",

View File

@@ -629,6 +629,7 @@ export const dict = {
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "無",
"sound.option.alert01": "警報 01",
"sound.option.alert02": "警報 02",
"sound.option.alert03": "警報 03",

View File

@@ -81,7 +81,7 @@ export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
createStore({
lastSession: {} as { [directory: string]: string },
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
activeProject: undefined as string | undefined,
activeWorkspace: undefined as string | undefined,
workspaceOrder: {} as Record<string, string[]>,
@@ -1074,11 +1074,37 @@ export default function Layout(props: ParentProps) {
dialog.show(() => <DialogSettings />)
}
function projectRoot(directory: string) {
const project = layout.projects
.list()
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find(
([root, dirs]) => root === directory || dirs.includes(directory),
)
if (known) return known[0]
const [child] = globalSync.child(directory, { bootstrap: false })
const id = child.project
if (!id) return directory
const meta = globalSync.data.project.find((item) => item.id === id)
return meta?.worktree ?? directory
}
function navigateToProject(directory: string | undefined) {
if (!directory) return
server.projects.touch(directory)
const lastSession = store.lastSession[directory]
navigateWithSidebarReset(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
const root = projectRoot(directory)
server.projects.touch(root)
const projectSession = store.lastProjectSession[root]
if (projectSession?.id) {
navigateWithSidebarReset(`/${base64Encode(projectSession.directory)}/session/${projectSession.id}`)
return
}
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
}
function navigateToSession(session: Session | undefined) {
@@ -1432,7 +1458,8 @@ export default function Layout(props: ParentProps) {
if (!dir || !id) return
const directory = decode64(dir)
if (!directory) return
setStore("lastSession", directory, id)
const at = Date.now()
setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory])
if (expanded === false) {

View File

@@ -166,7 +166,7 @@ const SessionHoverPreview = (props: {
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<div class="overflow-y-auto max-h-72 h-full">
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}

View File

@@ -27,7 +27,7 @@ import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
@@ -54,11 +54,7 @@ export default function Page() {
},
})
const blocked = createMemo(() => {
const sessionID = params.id
if (!sessionID) return false
return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
})
const composer = createSessionComposerState()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "")
@@ -401,7 +397,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (blocked()) return
if (composer.blocked()) return
inputRef?.focus()
}
}
@@ -947,15 +943,12 @@ export default function Page() {
if (next === dockHeight) return
const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
const delta = next - dockHeight
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
dockHeight = next
if (stick && el) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
})
}
if (stick) autoScroll.forceScrollToBottom()
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
@@ -1090,7 +1083,8 @@ export default function Page() {
</Switch>
</div>
<SessionPromptDock
<SessionComposerRegion
state={composer}
centered={centered()}
inputRef={(el) => {
inputRef = el
@@ -1101,6 +1095,7 @@ export default function Page() {
comments.clear()
resumeScroll()
}}
onResponseSubmit={resumeScroll}
setPromptDockRef={(el) => {
promptDock = el
}}

View File

@@ -0,0 +1,3 @@
export { SessionComposerRegion } from "./session-composer-region"
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
export type { SessionComposerState } from "./session-composer-state"

View File

@@ -0,0 +1,128 @@
import { Show, createEffect, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
export function SessionComposerRegion(props: {
state: SessionComposerState
centered: boolean
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeReset: () => void
onSubmit: () => void
onResponseSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const params = useParams()
const prompt = usePrompt()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
return (
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
>
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.state.questionRequest()} keyed>
{(request) => (
<div>
<SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
</div>
)}
</Show>
<Show when={props.state.permissionRequest()} keyed>
{(request) => (
<div>
<SessionPermissionDock
request={request}
responding={props.state.permissionResponding()}
onDecide={(response) => {
props.onResponseSubmit()
props.state.decide(response)
}}
/>
</div>
)}
</Show>
<Show when={!props.state.blocked()}>
<Show
when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoffPrompt() || language.t("prompt.loading")}
</div>
}
>
<Show when={props.state.dock()}>
<div
classList={{
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"max-h-[320px]": !props.state.closing(),
"max-h-0 pointer-events-none": props.state.closing(),
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
}}
>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
/>
</div>
</Show>
<div
classList={{
"relative z-10": true,
"transition-[margin] duration-[400ms] ease-out": true,
"-mt-9": props.state.dock() && !props.state.closing(),
"mt-0": !props.state.dock() || props.state.closing(),
}}
>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
onSubmit={props.onSubmit}
/>
</div>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -0,0 +1,158 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
export function createSessionComposerBlocked() {
const params = useParams()
const sync = useSync()
return createMemo(() => {
const id = params.id
if (!id) return false
return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
})
}
export function createSessionComposerState() {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const language = useLanguage()
const questionRequest = createMemo((): QuestionRequest | undefined => {
const id = params.id
if (!id) return
return sync.data.question[id]?.[0]
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
const id = params.id
if (!id) return
return sync.data.permission[id]?.[0]
})
const blocked = createSessionComposerBlocked()
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
const [store, setStore] = createStore({
responding: undefined as string | undefined,
dock: todos().length > 0,
closing: false,
opening: false,
})
const permissionResponding = createMemo(() => {
const perm = permissionRequest()
if (!perm) return false
return store.responding === perm.id
})
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (store.responding === perm.id) return
setStore("responding", perm.id)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
})
.finally(() => {
setStore("responding", (id) => (id === perm.id ? undefined : id))
})
}
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
let timer: number | undefined
let raf: number | undefined
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore({ dock: false, closing: false })
timer = undefined
}, 400)
}
createEffect(
on(
() => [todos().length, done()] as const,
([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
if (count === 0) {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ dock: false, closing: false, opening: false })
return
}
if (!complete) {
if (timer) window.clearTimeout(timer)
timer = undefined
const hidden = !store.dock || store.closing
setStore({ dock: true, closing: false })
if (hidden) {
setStore("opening", true)
raf = requestAnimationFrame(() => {
setStore("opening", false)
raf = undefined
})
return
}
setStore("opening", false)
return
}
if (prev && prev[1]) {
if (store.closing && !timer) scheduleClose()
return
}
setStore({ dock: true, opening: false, closing: true })
scheduleClose()
},
),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
onCleanup(() => {
if (!raf) return
cancelAnimationFrame(raf)
})
return {
blocked,
questionRequest,
permissionRequest,
permissionResponding,
decide,
todos,
dock: () => store.dock,
closing: () => store.closing,
opening: () => store.opening,
}
}
export type SessionComposerState = ReturnType<typeof createSessionComposerState>

View File

@@ -0,0 +1,74 @@
import { For, Show } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
export function SessionPermissionDock(props: {
request: PermissionRequest
responding: boolean
onDecide: (response: "once" | "always" | "reject") => void
}) {
const language = useLanguage()
const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.permission}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
}
return (
<DockPrompt
kind="permission"
header={
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
</>
}
>
<Show when={toolDescription()}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{toolDescription()}</div>
</div>
</Show>
<Show when={props.request.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={props.request.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
</div>
</Show>
</DockPrompt>
)
}

View File

@@ -8,7 +8,7 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK()
const language = useLanguage()
@@ -62,7 +62,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
const measure = () => {
if (!root) return
const scroller = document.querySelector(".session-scroller")
const scroller = document.querySelector(".scroll-view__viewport")
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
const top =
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
@@ -95,7 +95,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
window.addEventListener("resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".session-scroller")
const scroller = document.querySelector(".scroll-view__viewport")
const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
@@ -115,6 +115,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
const reply = async (answers: QuestionAnswer[]) => {
if (store.sending) return
props.onSubmit()
setStore("sending", true)
try {
await sdk.client.question.reply({ requestID: props.request.id, answers })
@@ -128,6 +129,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
const reject = async () => {
if (store.sending) return
props.onSubmit()
setStore("sending", true)
try {
await sdk.client.question.reject({ requestID: props.request.id })

View File

@@ -1,5 +1,6 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
@@ -54,13 +55,14 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
const preview = createMemo(() => active()?.content ?? "")
return (
<div
<DockTray
data-component="session-todo-dock"
classList={{
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
"h-[78px]": store.collapsed,
}}
>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
@@ -81,6 +83,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
</Show>
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
<IconButton
data-action="session-todo-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
@@ -98,10 +101,10 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
</div>
</div>
<div hidden={store.collapsed}>
<div data-slot="session-todo-list" hidden={store.collapsed}>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</div>
</DockTray>
)
}

View File

@@ -9,6 +9,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { useLayout } from "@/context/layout"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
@@ -509,51 +510,52 @@ export function FileTabContent(props: { tab: string }) {
)
return (
<Tabs.Content
value={props.tab}
class="mt-3 relative"
ref={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img
src={imageDataUrl()}
alt={path()}
class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)}
/>
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
{renderCode(svgContent() ?? "", "")}
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded && isBinary()}>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll as any}
>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img
src={imageDataUrl()}
alt={path()}
class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)}
/>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
{renderCode(svgContent() ?? "", "")}
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded && isBinary()}>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</Tabs.Content>
)
}

View File

@@ -8,12 +8,14 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -80,6 +82,7 @@ export function MessageTimeline(props: {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
@@ -320,8 +323,8 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
<div
ref={props.setScrollRef}
<ScrollView
viewportRef={props.setScrollRef}
onWheel={(e) => {
const root = e.currentTarget
const delta = normalizeWheelDelta({
@@ -365,10 +368,10 @@ export function MessageTimeline(props: {
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "64px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<Show when={showHeader()}>
@@ -535,6 +538,7 @@ export function MessageTimeline(props: {
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
showReasoningSummaries={settings.general.showReasoningSummaries()}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
@@ -545,7 +549,7 @@ export function MessageTimeline(props: {
)}
</For>
</div>
</div>
</ScrollView>
</div>
</Show>
)

View File

@@ -143,9 +143,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{
root: props.classes?.root ?? "pb-6",
root: props.classes?.root ?? "pb-6 pr-3",
header: props.classes?.header ?? "px-3",
container: props.classes?.container ?? "px-3",
container: props.classes?.container ?? "pl-3",
}}
diffs={props.diffs()}
diffStyle={props.diffStyle}

View File

@@ -1,318 +0,0 @@
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
export function SessionPromptDock(props: {
centered: boolean
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeReset: () => void
onSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const prompt = usePrompt()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
const questionRequest = createMemo((): QuestionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.permission[sessionID]?.[0]
})
const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const [responding, setResponding] = createSignal<string | undefined>()
const permissionResponding = () => {
const perm = permissionRequest()
if (!perm) return false
return responding() === perm.id
}
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (responding() === perm.id) return
setResponding(perm.id)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => {
setResponding((id) => (id === perm.id ? undefined : id))
})
}
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const [dock, setDock] = createSignal(todos().length > 0)
const [closing, setClosing] = createSignal(false)
const [opening, setOpening] = createSignal(false)
let timer: number | undefined
let raf: number | undefined
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setDock(false)
setClosing(false)
timer = undefined
}, 400)
}
createEffect(
on(
() => [todos().length, done()] as const,
([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
if (count === 0) {
if (timer) window.clearTimeout(timer)
timer = undefined
setDock(false)
setClosing(false)
setOpening(false)
return
}
if (!complete) {
if (timer) window.clearTimeout(timer)
timer = undefined
const wasHidden = !dock() || closing()
setDock(true)
setClosing(false)
if (wasHidden) {
setOpening(true)
raf = requestAnimationFrame(() => {
setOpening(false)
raf = undefined
})
return
}
setOpening(false)
return
}
if (prev && prev[1]) {
if (closing() && !timer) scheduleClose()
return
}
setDock(true)
setOpening(false)
setClosing(true)
scheduleClose()
},
),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
onCleanup(() => {
if (!raf) return
cancelAnimationFrame(raf)
})
return (
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
>
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={questionRequest()} keyed>
{(req) => {
return (
<div>
<QuestionDock request={req} />
</div>
)
}}
</Show>
<Show when={permissionRequest()} keyed>
{(perm) => {
const toolDescription = () => {
const key = `settings.permissions.tool.${perm.permission}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
}
return (
<div>
<DockPrompt
kind="permission"
header={
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
<Button
variant="ghost"
size="normal"
onClick={() => decide("reject")}
disabled={permissionResponding()}
>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => decide("always")}
disabled={permissionResponding()}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="normal"
onClick={() => decide("once")}
disabled={permissionResponding()}
>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
</>
}
>
<Show when={toolDescription()}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{toolDescription()}</div>
</div>
</Show>
<Show when={perm.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={perm.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
</div>
</Show>
</DockPrompt>
</div>
)
}}
</Show>
<Show when={!blocked()}>
<Show
when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoffPrompt() || language.t("prompt.loading")}
</div>
}
>
<Show when={dock()}>
<div
classList={{
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"max-h-[320px]": !closing(),
"max-h-0 pointer-events-none": closing(),
"opacity-0 translate-y-9": closing() || opening(),
"opacity-100 translate-y-0": !closing() && !opening(),
}}
>
<SessionTodoDock
todos={todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
/>
</div>
</Show>
<div
classList={{
"relative z-10": true,
"transition-[margin] duration-[400ms] ease-out": true,
"-mt-9": dock() && !closing(),
"mt-0": !dock() || closing(),
}}
>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
onSubmit={props.onSubmit}
/>
</div>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -38,34 +38,9 @@ export function TerminalPanel() {
const [store, setStore] = createStore({
autoCreated: false,
everOpened: false,
activeDraggable: undefined as string | undefined,
})
const rendered = createMemo(() => isDesktop() && (opened() || store.everOpened))
createEffect(
on(open, (isOpen, prev) => {
if (isOpen) {
if (!store.everOpened) setStore("everOpened", true)
const activeId = terminal.active()
if (!activeId) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
setTimeout(() => focusTerminalById(activeId), 0)
return
}
if (!prev) return
const panel = document.getElementById("terminal-panel")
const activeElement = document.activeElement
if (!panel || !(activeElement instanceof HTMLElement)) return
if (!panel.contains(activeElement)) return
activeElement.blur()
}),
)
createEffect(() => {
if (!opened()) {
setStore("autoCreated", false)
@@ -96,7 +71,7 @@ export function TerminalPanel() {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
setTimeout(() => focusTerminalById(activeId), 0)
},
),
)
@@ -158,32 +133,23 @@ export function TerminalPanel() {
}
return (
<Show when={rendered()}>
<Show when={open()}>
<div
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
classList={{
"relative w-full flex flex-col shrink-0 overflow-hidden": true,
"border-t border-border-weak-base": open(),
"pointer-events-none": !open(),
}}
style={{
height: `${height()}px`,
display: open() ? "flex" : "none",
}}
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${height()}px` }}
>
<Show when={open()}>
<ResizeHandle
direction="vertical"
size={height()}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={layout.terminal.resize}
onCollapse={close}
/>
</Show>
<ResizeHandle
direction="vertical"
size={height()}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={layout.terminal.resize}
onCollapse={close}
/>
<Show
when={terminal.ready()}
fallback={
@@ -243,21 +209,17 @@ export function TerminalPanel() {
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<For each={all()}>
{(pty) => (
<div
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: terminal.active() === pty.id ? "block" : "none",
}}
>
<Show when={pty.id} keyed>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Show>
</div>
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
</div>
)}
</Show>
)}
</For>
</Show>
</div>
</div>
<DragOverlay>

View File

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

View File

@@ -1,5 +1,5 @@
import { APIEvent } from "@solidjs/start"
import { DownloadPlatform } from "./types"
import type { APIEvent } from "@solidjs/start"
import type { DownloadPlatform } from "../types"
const assetNames: Record<string, string> = {
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
@@ -17,17 +17,20 @@ const downloadNames: Record<string, string> = {
"windows-x64-nsis": "OpenCode Desktop Installer.exe",
} satisfies { [K in DownloadPlatform]?: string }
export async function GET({ params: { platform } }: APIEvent) {
export async function GET({ params: { platform, channel } }: APIEvent) {
const assetName = assetNames[platform]
if (!assetName) return new Response("Not Found", { status: 404 })
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any)
const resp = await fetch(
`https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`,
{
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any,
)
const downloadName = downloadNames[platform]

View File

@@ -1,18 +1,18 @@
import "./index.css"
import { Title, Meta } from "@solidjs/meta"
import { A, createAsync, query } from "@solidjs/router"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { IconCopy, IconCheck } from "~/component/icon"
import { Meta, Title } from "@solidjs/meta"
import { A } from "@solidjs/router"
import { createSignal, type JSX, onMount, Show } from "solid-js"
import { Faq } from "~/component/faq"
import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png"
import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { IconCheck, IconCopy } from "~/component/icon"
import { Legal } from "~/component/legal"
import { LocaleLinks } from "~/component/locale-links"
import { config } from "~/config"
import { createSignal, onMount, Show, JSX } from "solid-js"
import { DownloadPlatform } from "./types"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { LocaleLinks } from "~/component/locale-links"
import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png"
import type { DownloadPlatform } from "./types"
type OS = "macOS" | "Windows" | "Linux" | null
@@ -40,8 +40,8 @@ function getDownloadPlatform(os: OS): DownloadPlatform {
}
}
function getDownloadHref(platform: DownloadPlatform) {
return `/download/${platform}`
function getDownloadHref(platform: DownloadPlatform, channel: "stable" | "beta" = "stable") {
return `/download/${channel}/${platform}`
}
function IconDownload(props: JSX.SvgSVGAttributes<SVGSVGElement>) {

View File

@@ -107,11 +107,14 @@ export async function handler(
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
const reqBody = JSON.stringify(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
}),
providerInfo.modifyBody(
{
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
},
authInfo?.workspaceID,
),
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")

View File

@@ -18,9 +18,10 @@ export const openaiHelper: ProviderHelper = () => ({
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>) => {
return body
},
modifyBody: (body: Record<string, any>, workspaceID?: string) => ({
...body,
...(workspaceID ? { safety_identifier: workspaceID } : {}),
}),
createBinaryStreamDecoder: () => undefined,
streamSeparator: "\n\n",
createUsageParser: () => {

View File

@@ -37,7 +37,7 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any>
modifyBody: (body: Record<string, any>, workspaceID?: string) => Record<string, any>
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
streamSeparator: string
createUsageParser: () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -320,7 +320,7 @@ pub fn spawn_command(
};
let mut cmd = Command::new(shell);
cmd.args(["-l", "-c", &line]);
cmd.args(["-il", "-c", &line]);
for (key, value) in envs {
cmd.env(key, value);

View File

@@ -40,7 +40,9 @@ use crate::windows::{LoadingWindow, MainWindow};
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
struct ServerReadyData {
url: String,
username: Option<String>,
password: Option<String>,
is_sidecar: bool
}
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
@@ -605,6 +607,7 @@ async fn initialize(app: AppHandle) {
child,
health_check,
url,
username,
password,
} => {
let app = app.clone();
@@ -631,7 +634,7 @@ async fn initialize(app: AppHandle) {
app.state::<ServerState>().set_child(Some(child));
Ok(ServerReadyData { url, password })
Ok(ServerReadyData { url, username,password, is_sidecar: true })
}
.map(move |res| {
let _ = server_ready_tx.send(res);
@@ -641,7 +644,9 @@ async fn initialize(app: AppHandle) {
ServerConnection::Existing { url } => {
let _ = server_ready_tx.send(Ok(ServerReadyData {
url: url.to_string(),
username: None,
password: None,
is_sidecar: false,
}));
None
}
@@ -719,6 +724,7 @@ enum ServerConnection {
},
CLI {
url: String,
username: Option<String>,
password: Option<String>,
child: CommandChild,
health_check: server::HealthCheck,
@@ -730,11 +736,15 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
tracing::info!(?custom_url, "Attempting server connection");
if let Some(url) = custom_url
&& server::check_health_or_ask_retry(&app, &url).await
if let Some(url) = &custom_url
&& server::check_health_or_ask_retry(&app, url).await
{
tracing::info!(%url, "Connected to custom server");
return ServerConnection::Existing { url: url.clone() };
// If the default server is already local, no need to also spawn a sidecar
if server::is_localhost_url(url) {
return ServerConnection::Existing { url: url.clone() };
}
// Remote default server: fall through and also spawn a local sidecar
}
let local_port = get_sidecar_port();
@@ -755,6 +765,7 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
ServerConnection::CLI {
url: local_url,
username: Some("opencode".to_string()),
password: Some(password),
child,
health_check,

View File

@@ -150,7 +150,7 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
return false;
};
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(3));
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7));
if url_is_localhost(&url) {
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
@@ -178,6 +178,10 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
.unwrap_or(false)
}
pub fn is_localhost_url(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| url_is_localhost(&u))
}
fn url_is_localhost(url: &reqwest::Url) -> bool {
url.host_str().is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenCode Beta",
"identifier": "ai.opencode.desktop.beta",
"bundle": {
"createUpdaterArtifacts": true,
"linux": {
"rpm": {
"compression": {
"type": "none"
}
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK",
"endpoints": ["https://github.com/anomalyco/opencode-beta/releases/latest/download/latest.json"]
}
}
}

View File

@@ -35,7 +35,9 @@ export type LoadingWindowComplete = null;
export type ServerReadyData = {
url: string,
username: string | null,
password: string | null,
is_sidecar: boolean,
};
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };

View File

@@ -23,7 +23,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json"
import { initI18n, t } from "./i18n"
@@ -31,7 +31,7 @@ import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Channel } from "@tauri-apps/api/core"
import { commands, type InitStep } from "./bindings"
import { commands, ServerReadyData, type InitStep } from "./bindings"
import { createMenu } from "./menu"
const root = document.getElementById("root")
@@ -452,16 +452,19 @@ render(() => {
<AppBaseProviders>
<ServerGate>
{(data) => {
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
const http = {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
}
const server: ServerConnection.Any = data.is_sidecar
? {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http,
}
: { type: "http", http }
function Inner() {
const cmd = useCommand()
@@ -472,12 +475,10 @@ render(() => {
}
return (
<Show when={defaultServer.loading ? false : defaultServer.latest}>
{(defaultServer) => (
<AppInterface defaultServer={defaultServer() ?? ServerConnection.key(server)} servers={[server]}>
<Inner />
</AppInterface>
)}
<Show when={!defaultServer.loading}>
<AppInterface defaultServer={defaultServer.latest ?? ServerConnection.key(server)} servers={[server]}>
<Inner />
</AppInterface>
</Show>
)
}}
@@ -487,10 +488,8 @@ render(() => {
)
}, root!)
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 }) {
function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) {
const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
if (serverData.state === "errored") throw serverData.error
@@ -504,7 +503,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
</div>
}
>
{(data) => props.children(data)}
{(data) => props.children(data())}
</Show>
)
}

View File

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

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.6"
version = "1.2.10"
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.2.6/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/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.2.6/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/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.2.6/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

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

View File

@@ -25,6 +25,12 @@ if (envPath) {
const scriptPath = fs.realpathSync(__filename)
const scriptDir = path.dirname(scriptPath)
//
const cached = path.join(scriptDir, ".opencode")
if (fs.existsSync(cached)) {
run(cached)
}
const platformMap = {
darwin: "darwin",
linux: "linux",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.6",
"version": "1.2.10",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -55,22 +55,22 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.79",
"@ai-sdk/anthropic": "2.0.62",
"@ai-sdk/amazon-bedrock": "3.0.82",
"@ai-sdk/anthropic": "2.0.65",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.103",
"@ai-sdk/google": "2.0.54",
"@ai-sdk/google-vertex": "3.0.106",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
"@ai-sdk/openai-compatible": "1.0.32",
"@ai-sdk/perplexity": "2.0.23",
"@ai-sdk/provider": "2.0.1",
"@ai-sdk/provider-utils": "3.0.20",
"@ai-sdk/provider-utils": "3.0.21",
"@ai-sdk/togetherai": "1.0.34",
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
@@ -107,6 +107,7 @@
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"hono": "catalog:",

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env bun
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"
import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -12,8 +12,9 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir)
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
@@ -26,7 +27,11 @@ await Bun.write(
console.log("Generated models-snapshot.ts")
// Load migrations from migration directories
const migrationDirs = (await fs.promises.readdir(path.join(dir, "migration"), { withFileTypes: true }))
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
@@ -171,7 +176,6 @@ for (const item of targets) {
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
//@ts-ignore (bun types aren't up to date)
autoloadTsconfig: true,
autoloadPackageJson: true,
target: name.replace(pkg.name, "bun") as any,
@@ -214,7 +218,7 @@ if (Script.release) {
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
}
}
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber`
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`
}
export { binaries }

View File

@@ -109,8 +109,14 @@ async function main() {
// On non-Windows platforms, just verify the binary package exists
// Don't replace the wrapper script - it handles binary execution
const { binaryPath } = findBinary()
console.log(`Platform binary verified at: ${binaryPath}`)
console.log("Wrapper script will handle binary execution")
const target = path.join(__dirname, "bin", ".opencode")
if (fs.existsSync(target)) fs.unlinkSync(target)
try {
fs.linkSync(binaryPath, target)
} catch {
fs.copyFileSync(binaryPath, target)
}
fs.chmodSync(target, 0o755)
} catch (error) {
console.error("Failed to setup opencode binary:", error.message)
process.exit(1)

View File

@@ -450,6 +450,7 @@ export const GithubRunCommand = cmd({
const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch"
const { providerID, modelID } = normalizeModel()
const variant = process.env["VARIANT"] || undefined
const runId = normalizeRunId()
const share = normalizeShare()
const oidcBaseUrl = normalizeOidcBaseUrl()
@@ -553,8 +554,12 @@ export const GithubRunCommand = cmd({
const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) {
// Agent switched branches (likely created its own branch/PR)
console.log("Agent managed its own branch, skipping infrastructure push/PR")
console.log("Response:", response)
} else if (dirty) {
const summary = await summarize(response)
// workflow_dispatch has an actor for co-author attribution, schedule does not
await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
@@ -565,7 +570,11 @@ export const GithubRunCommand = cmd({
summary,
`${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
)
console.log(`Created PR #${pr}`)
if (pr) {
console.log(`Created PR #${pr}`)
} else {
console.log("Skipped PR creation (no new commits)")
}
} else {
console.log("Response:", response)
}
@@ -580,8 +589,11 @@ export const GithubRunCommand = cmd({
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
if (switched) {
console.log("Agent managed its own branch, skipping infrastructure push")
}
if (dirty && !switched) {
const summary = await summarize(response)
await pushToLocalBranch(summary, uncommittedChanges)
}
@@ -591,12 +603,15 @@ export const GithubRunCommand = cmd({
}
// Fork PR
else {
await checkoutForkBranch(prData)
const forkBranch = await checkoutForkBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
if (switched) {
console.log("Agent managed its own branch, skipping infrastructure push")
}
if (dirty && !switched) {
const summary = await summarize(response)
await pushToForkBranch(summary, prData, uncommittedChanges)
}
@@ -612,8 +627,13 @@ export const GithubRunCommand = cmd({
const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) {
// Agent switched branches (likely created its own branch/PR).
// Don't push the stale infrastructure branch — just comment.
await createComment(`${response}${footer({ image: true })}`)
await removeReaction(commentType)
} else if (dirty) {
const summary = await summarize(response)
await pushToNewBranch(summary, branch, uncommittedChanges, false)
const pr = await createPR(
@@ -622,7 +642,11 @@ export const GithubRunCommand = cmd({
summary,
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
)
await createComment(`Created PR #${pr}${footer({ image: true })}`)
if (pr) {
await createComment(`Created PR #${pr}${footer({ image: true })}`)
} else {
await createComment(`${response}${footer({ image: true })}`)
}
await removeReaction(commentType)
} else {
await createComment(`${response}${footer({ image: true })}`)
@@ -889,6 +913,7 @@ export const GithubRunCommand = cmd({
const result = await SessionPrompt.prompt({
sessionID: session.id,
messageID: Identifier.ascending("message"),
variant,
model: {
providerID,
modelID,
@@ -942,6 +967,7 @@ export const GithubRunCommand = cmd({
const summary = await SessionPrompt.prompt({
sessionID: session.id,
messageID: Identifier.ascending("message"),
variant,
model: {
providerID,
modelID,
@@ -1068,6 +1094,7 @@ export const GithubRunCommand = cmd({
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
await $`git fetch fork --depth=${depth} ${remoteBranch}`
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
return localBranch
}
function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
@@ -1125,23 +1152,44 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await $`git push fork HEAD:${remoteBranch}`
}
async function branchIsDirty(originalHead: string) {
async function branchIsDirty(originalHead: string, expectedBranch: string) {
console.log("Checking if branch is dirty...")
// Detect if the agent switched branches during chat (e.g. created
// its own branch, committed, and possibly pushed/created a PR).
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
if (current !== expectedBranch) {
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
return { dirty: true, uncommittedChanges: false, switched: true }
}
const ret = await $`git status --porcelain`
const status = ret.stdout.toString().trim()
if (status.length > 0) {
return {
dirty: true,
uncommittedChanges: true,
}
return { dirty: true, uncommittedChanges: true, switched: false }
}
const head = await $`git rev-parse HEAD`
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
return {
dirty: head.stdout.toString().trim() !== originalHead,
dirty: head !== originalHead,
uncommittedChanges: false,
switched: false,
}
}
// Verify commits exist between base ref and a branch using rev-list.
// Falls back to fetching from origin when local refs are missing
// (common in shallow clones from actions/checkout).
async function hasNewCommits(base: string, head: string) {
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
if (result.exitCode !== 0) {
console.log(`rev-list failed, fetching origin/${base}...`)
await $`git fetch origin ${base} --depth=1`.nothrow()
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
return parseInt(retry.stdout.toString().trim()) > 0
}
return parseInt(result.stdout.toString().trim()) > 0
}
async function assertPermissions() {
// Only called for non-schedule events, so actor is defined
console.log(`Asserting permissions for user ${actor}...`)
@@ -1261,7 +1309,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
})
}
async function createPR(base: string, branch: string, title: string, body: string) {
async function createPR(base: string, branch: string, title: string, body: string): Promise<number | null> {
console.log("Creating pull request...")
// Check if an open PR already exists for this head→base combination
@@ -1286,17 +1334,36 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
console.log(`Failed to check for existing PR: ${e}`)
}
const pr = await withRetry(() =>
octoRest.rest.pulls.create({
owner,
repo,
head: branch,
base,
title,
body,
}),
)
return pr.data.number
// Verify there are commits between base and head before creating the PR.
// In shallow clones, the branch can appear dirty but share the same
// commit as the base, causing a 422 from GitHub.
if (!(await hasNewCommits(base, branch))) {
console.log(`No commits between ${base} and ${branch}, skipping PR creation`)
return null
}
try {
const pr = await withRetry(() =>
octoRest.rest.pulls.create({
owner,
repo,
head: branch,
base,
title,
body,
}),
)
return pr.data.number
} catch (e: unknown) {
// Handle "No commits between X and Y" validation error from GitHub.
// This can happen when the branch was pushed but has no new commits
// relative to the base (e.g. shallow clone edge cases).
if (e instanceof Error && e.message.includes("No commits between")) {
console.log(`GitHub rejected PR: ${e.message}`)
return null
}
throw e
}
}
async function withRetry<T>(fn: () => Promise<T>, retries = 1, delayMs = 5000): Promise<T> {

View File

@@ -2,8 +2,7 @@ import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
@@ -83,7 +82,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
return store.history.at(store.index)
},
append(item: PromptInfo) {
const entry = clone(item)
const entry = structuredClone(unwrap(item))
let trimmed = false
setStore(
produce((draft) => {

View File

@@ -2,8 +2,7 @@ import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
import type { PromptInfo } from "./history"
@@ -53,7 +52,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
return store.entries
},
push(entry: Omit<StashEntry, "timestamp">) {
const stash = clone({ ...entry, timestamp: Date.now() })
const stash = structuredClone(unwrap({ ...entry, timestamp: Date.now() }))
let trimmed = false
setStore(
produce((draft) => {

View File

@@ -3,6 +3,7 @@ import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
import ayu from "./theme/ayu.json" with { type: "json" }
import catppuccin from "./theme/catppuccin.json" with { type: "json" }
@@ -391,7 +392,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
},
})
const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.json")
async function getCustomThemes() {
const directories = [
Global.Path.config,
@@ -405,11 +405,11 @@ async function getCustomThemes() {
const result: Record<string, ThemeJson> = {}
for (const dir of directories) {
for await (const item of CUSTOM_THEME_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("themes/*.json", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const name = path.basename(item, ".json")
result[name] = await Filesystem.readJson(item)

View File

@@ -98,6 +98,7 @@ const context = createContext<{
showThinking: () => boolean
showTimestamps: () => boolean
showDetails: () => boolean
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
}>()
@@ -152,6 +153,7 @@ export function Session() {
const [showHeader, setShowHeader] = kv.signal("header_visible", true)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
@@ -600,6 +602,15 @@ export function Session() {
dialog.clear()
},
},
{
title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
value: "session.toggle.generic_tool_output",
category: "Session",
onSelect: (dialog) => {
setShowGenericToolOutput((prev) => !prev)
dialog.clear()
},
},
{
title: "Page up",
value: "session.page.up",
@@ -974,6 +985,7 @@ export function Session() {
showThinking,
showTimestamps,
showDetails,
showGenericToolOutput,
diffWrapMode,
sync,
}}
@@ -1508,10 +1520,40 @@ type ToolProps<T extends Tool.Info> = {
part: ToolPart
}
function GenericTool(props: ToolProps<any>) {
const { theme } = useTheme()
const ctx = use()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
})
return (
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
{props.tool} {input(props.input)}
</InlineTool>
<Show
when={props.output && ctx.showGenericToolOutput()}
fallback={
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
{props.tool} {input(props.input)}
</InlineTool>
}
>
<BlockTool
title={`# ${props.tool} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
</BlockTool>
</Show>
)
}

View File

@@ -28,6 +28,7 @@ import { constants, existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
@@ -291,7 +292,9 @@ export namespace Config {
...(proxied() ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch(() => {})
).catch((err) => {
log.warn("failed to install dependencies", { dir, error: err })
})
}
async function isWritable(dir: string) {
@@ -351,14 +354,13 @@ export namespace Config {
return ext.length ? file.slice(0, -ext.length) : file
}
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
for await (const item of COMMAND_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{command,commands}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -390,15 +392,14 @@ export namespace Config {
return result
}
const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
async function loadAgent(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of AGENT_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -430,14 +431,13 @@ export namespace Config {
return result
}
const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
async function loadMode(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of MODE_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{mode,modes}/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -467,15 +467,14 @@ export namespace Config {
return result
}
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
async function loadPlugin(dir: string) {
const plugins: string[] = []
for await (const item of PLUGIN_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
plugins.push(pathToFileURL(item).href)
}

View File

@@ -1,4 +1,5 @@
import { sep } from "node:path"
import { Glob } from "../util/glob"
export namespace FileIgnore {
const FOLDERS = new Set([
@@ -53,19 +54,17 @@ export namespace FileIgnore {
"**/.nyc_output/**",
]
const FILE_GLOBS = FILES.map((p) => new Bun.Glob(p))
export const PATTERNS = [...FILES, ...FOLDERS]
export function match(
filepath: string,
opts?: {
extra?: Bun.Glob[]
whitelist?: Bun.Glob[]
extra?: string[]
whitelist?: string[]
},
) {
for (const glob of opts?.whitelist || []) {
if (glob.match(filepath)) return false
for (const pattern of opts?.whitelist || []) {
if (Glob.match(pattern, filepath)) return false
}
const parts = filepath.split(sep)
@@ -74,8 +73,8 @@ export namespace FileIgnore {
}
const extra = opts?.extra || []
for (const glob of [...FILE_GLOBS, ...extra]) {
if (glob.match(filepath)) return true
for (const pattern of [...FILES, ...extra]) {
if (Glob.match(pattern, filepath)) return true
}
return false

View File

@@ -482,10 +482,13 @@ export namespace File {
}
}
return changedFiles.map((x) => ({
...x,
path: path.relative(Instance.directory, x.path),
}))
return changedFiles.map((x) => {
const full = path.isAbsolute(x.path) ? x.path : path.join(Instance.directory, x.path)
return {
...x,
path: path.relative(Instance.directory, full),
}
})
}
export async function read(file: string): Promise<Content> {

View File

@@ -41,8 +41,10 @@ export namespace Plugin {
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input)
hooks.push(init)
const init = await plugin(input).catch((err) => {
log.error("failed to load internal plugin", { name: plugin.name, error: err })
})
if (init) hooks.push(init)
}
let plugins = config.plugin ?? []
@@ -59,37 +61,40 @@ export namespace Plugin {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
const builtin = BUILTIN.some((x) => x.startsWith(pkg + "@"))
plugin = await BunProc.install(pkg, version).catch((err) => {
if (!builtin) throw err
const message = err instanceof Error ? err.message : String(err)
log.error("failed to install builtin plugin", {
pkg,
version,
error: message,
})
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg, version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install built-in plugin ${pkg}@${version}: ${message}`,
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!plugin) continue
}
const mod = await import(plugin)
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// Object.entries(mod) would return both entries pointing to the same function reference.
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
const init = await fn(input)
hooks.push(init)
}
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: plugin, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plugin}: ${message}`,
}).toObject(),
})
})
}
return {

View File

@@ -13,6 +13,7 @@ import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -262,16 +263,11 @@ export namespace Project {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
const matches = await Array.fromAsync(
glob.scan({
cwd: input.worktree,
absolute: true,
onlyFiles: true,
followSymlinks: false,
dot: false,
}),
)
const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const buffer = await Filesystem.readBytes(shortest)

View File

@@ -333,6 +333,10 @@ export namespace ProviderTransform {
if (!model.capabilities.reasoning) return {}
const id = model.id.toLowerCase()
const isAnthropicAdaptive = ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) =>
model.api.id.includes(v),
)
const adaptiveEfforts = ["low", "medium", "high", "max"]
if (
id.includes("deepseek") ||
id.includes("minimax") ||
@@ -366,6 +370,19 @@ export namespace ProviderTransform {
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) {
if (isAnthropicAdaptive) {
return Object.fromEntries(
adaptiveEfforts.map((effort) => [
effort,
{
thinking: {
type: "adaptive",
},
effort,
},
]),
)
}
return {
high: {
thinking: {
@@ -502,10 +519,9 @@ export namespace ProviderTransform {
case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
const efforts = ["low", "medium", "high", "max"]
if (isAnthropicAdaptive) {
return Object.fromEntries(
efforts.map((effort) => [
adaptiveEfforts.map((effort) => [
effort,
{
thinking: {
@@ -534,10 +550,9 @@ export namespace ProviderTransform {
case "@ai-sdk/amazon-bedrock":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
const efforts = ["low", "medium", "high", "max"]
if (isAnthropicAdaptive) {
return Object.fromEntries(
efforts.map((effort) => [
adaptiveEfforts.map((effort) => [
effort,
{
reasoningConfig: {
@@ -599,12 +614,19 @@ export namespace ProviderTransform {
},
}
}
let levels = ["low", "high"]
if (id.includes("3.1")) {
levels = ["low", "medium", "high"]
}
return Object.fromEntries(
["low", "high"].map((effort) => [
levels.map((effort) => [
effort,
{
includeThoughts: true,
thinkingLevel: effort,
thinkingConfig: {
includeThoughts: true,
thinkingLevel: effort,
},
},
]),
)
@@ -624,8 +646,7 @@ export namespace ProviderTransform {
groqEffort.map((effort) => [
effort,
{
includeThoughts: true,
thinkingLevel: effort,
reasoningEffort: effort,
},
]),
)

View File

@@ -18,12 +18,14 @@ export namespace Pty {
type Socket = {
readyState: number
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
type Subscriber = {
id: number
token: unknown
}
const sockets = new WeakMap<object, number>()
@@ -37,6 +39,44 @@ export namespace Pty {
return next
}
const token = (ws: Socket) => {
const data = ws.data
if (data === undefined) return
if (data === null) return
if (typeof data !== "object") return data
const id = (data as { connId?: unknown }).connId
if (typeof id === "number" || typeof id === "string") return id
const href = (data as { href?: unknown }).href
if (typeof href === "string") return href
const url = (data as { url?: unknown }).url
if (typeof url === "string") return url
if (url && typeof url === "object") {
const href = (url as { href?: unknown }).href
if (typeof href === "string") return href
return url
}
const events = (data as { events?: unknown }).events
if (typeof events === "number" || typeof events === "string") return events
if (events && typeof events === "object") {
const id = (events as { connId?: unknown }).connId
if (typeof id === "number" || typeof id === "string") return id
const id2 = (events as { connection?: unknown }).connection
if (typeof id2 === "number" || typeof id2 === "string") return id2
const id3 = (events as { id?: unknown }).id
if (typeof id3 === "number" || typeof id3 === "string") return id3
return events
}
return data
}
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
@@ -194,6 +234,12 @@ export namespace Pty {
session.subscribers.delete(ws)
continue
}
if (token(ws) !== sub.token) {
session.subscribers.delete(ws)
continue
}
try {
ws.send(chunk)
} catch {
@@ -291,7 +337,7 @@ export namespace Pty {
}
owners.set(ws, id)
session.subscribers.set(ws, { id: socketId })
session.subscribers.set(ws, { id: socketId, token: token(ws) })
const cleanup = () => {
session.subscribers.delete(ws)

View File

@@ -6,6 +6,7 @@ import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { Session } from "../../session"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() =>
return c.json(true)
},
)
.get(
"/session",
describeRoute({
summary: "List sessions",
description:
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
operationId: "experimental.session.list",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.GlobalInfo.array()),
},
},
},
},
}),
validator(
"query",
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
.optional()
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
cursor: z.coerce
.number()
.optional()
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
}),
),
async (c) => {
const query = c.req.valid("query")
const limit = query.limit ?? 100
const sessions: Session.GlobalInfo[] = []
for await (const session of Session.listGlobal({
directory: query.directory,
roots: query.roots,
start: query.start,
cursor: query.cursor,
search: query.search,
limit: limit + 1,
archived: query.archived,
})) {
sessions.push(session)
}
const hasMore = sessions.length > limit
const list = hasMore ? sessions.slice(0, limit) : sessions
if (hasMore && list.length > 0) {
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
}
return c.json(list)
},
)
.get(
"/resource",
describeRoute({

View File

@@ -10,8 +10,10 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import type { SQL } from "../storage/db"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
@@ -154,6 +156,24 @@ export namespace Session {
})
export type Info = z.output<typeof Info>
export const ProjectInfo = z
.object({
id: z.string(),
name: z.string().optional(),
worktree: z.string(),
})
.meta({
ref: "ProjectSummary",
})
export type ProjectInfo = z.output<typeof ProjectInfo>
export const GlobalInfo = Info.extend({
project: ProjectInfo.nullable(),
}).meta({
ref: "GlobalSession",
})
export type GlobalInfo = z.output<typeof GlobalInfo>
export const Event = {
Created: BusEvent.define(
"session.created",
@@ -544,6 +564,75 @@ export namespace Session {
}
}
export function* listGlobal(input?: {
directory?: string
roots?: boolean
start?: number
cursor?: number
search?: string
limit?: number
archived?: boolean
}) {
const conditions: SQL[] = []
if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
}
if (input?.start) {
conditions.push(gte(SessionTable.time_updated, input.start))
}
if (input?.cursor) {
conditions.push(lt(SessionTable.time_updated, input.cursor))
}
if (input?.search) {
conditions.push(like(SessionTable.title, `%${input.search}%`))
}
if (!input?.archived) {
conditions.push(isNull(SessionTable.time_archived))
}
const limit = input?.limit ?? 100
const rows = Database.use((db) => {
const query =
conditions.length > 0
? db
.select()
.from(SessionTable)
.where(and(...conditions))
: db.select().from(SessionTable)
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
})
const ids = [...new Set(rows.map((row) => row.project_id))]
const projects = new Map<string, ProjectInfo>()
if (ids.length > 0) {
const items = Database.use((db) =>
db
.select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
.from(ProjectTable)
.where(inArray(ProjectTable.id, ids))
.all(),
)
for (const item of items) {
projects.set(item.id, {
id: item.id,
name: item.name ?? undefined,
worktree: item.worktree,
})
}
}
for (const row of rows) {
const project = projects.get(row.project_id) ?? null
yield { ...fromRow(row), project }
}
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
const project = Instance.project
const rows = Database.use((db) =>

View File

@@ -6,6 +6,7 @@ import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "../util/log"
import { Glob } from "../util/glob"
import type { MessageV2 } from "./message-v2"
const log = Log.create({ service: "instruction" })
@@ -98,13 +99,11 @@ export namespace InstructionPrompt {
instruction = path.join(os.homedir(), instruction.slice(2))
}
const matches = path.isAbsolute(instruction)
? await Array.fromAsync(
new Bun.Glob(path.basename(instruction)).scan({
cwd: path.dirname(instruction),
absolute: true,
onlyFiles: true,
}),
).catch(() => [])
? await Glob.scan(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
}).catch(() => [])
: await resolveRelative(instruction)
matches.forEach((p) => {
paths.add(path.resolve(p))

View File

@@ -11,7 +11,7 @@ import {
tool,
jsonSchema,
} from "ai"
import { clone, mergeDeep, pipe } from "remeda"
import { mergeDeep, pipe } from "remeda"
import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
@@ -80,15 +80,11 @@ export namespace LLM {
)
const header = system[0]
const original = clone(system)
await Plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
if (system.length === 0) {
system.push(...original)
}
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)

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