Merge branch 'dev' into opencode-remote-voice

This commit is contained in:
Ryan Vogel
2026-03-31 13:59:13 -04:00
committed by GitHub
211 changed files with 78059 additions and 43600 deletions

View File

@@ -9,7 +9,8 @@ on:
jobs:
sync-locales:
if: github.actor != 'opencode-agent[bot]'
if: false
#if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
@@ -34,7 +35,7 @@ jobs:
- name: Compute changed English docs
id: changes
run: |
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
if [ -z "$FILES" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range"

View File

@@ -17,6 +17,10 @@ on:
- "patches/**"
- ".github/workflows/nix-hashes.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Native runners required: bun install cross-compilation flags (--os/--cpu)
# do not produce byte-identical node_modules as native installs.

View File

@@ -100,6 +100,9 @@ jobs:
run: bun --cwd packages/app test:e2e:local
env:
CI: true
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_E2E_MODEL: opencode/claude-haiku-4-5
OPENCODE_E2E_REQUIRE_PAID: "true"
timeout-minutes: 30
- name: Upload Playwright artifacts

1
.gitignore vendored
View File

@@ -25,6 +25,7 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
logs/
*.bun-build
tsconfig.tsbuildinfo

View File

@@ -1,23 +1,46 @@
---
model: opencode/kimi-k2.5
model: opencode/gpt-5.4
---
create UPCOMING_CHANGELOG.md
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
Do not preserve, merge, or reuse text from the existing file.
it should have sections
The input already contains the exact commit range since the last non-draft release.
The commits are already filtered to the release-relevant packages and grouped into
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
The input may also include a `## Community Contributors Input` section.
```
## TUI
Before writing any entry you keep, inspect the real diff with
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
understand the actual code changes and not just the commit message (they may be misleading).
Do not use `git log` or author metadata when deciding attribution.
## Desktop
Rules:
## Core
- Write the final file with sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter
- Prefer what changed for users over what code changed internally
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
- If an input bullet has no `(@username)` suffix, do not add one
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
- Do not derive the thank-you section from the main summary bullets
- Do not include the heading `## Community Contributors Input` in the final file
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
## Misc
```
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
fetch the latest github release for this repository to determine the last release version.
<changelog_input>
find each PR that was merged since the last release
!`bun script/raw-changelog.ts $ARGUMENTS`
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
</changelog_input>

View File

@@ -1,5 +1,5 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
@@ -615,7 +615,7 @@ const Modal = (props: {
)
}
const home = (input: Cfg): TuiSlotPlugin => ({
const home = (api: TuiPluginApi, input: Cfg) => ({
slots: {
home_logo(ctx) {
const map = ctx.theme.current
@@ -649,6 +649,36 @@ const home = (input: Cfg): TuiSlotPlugin => ({
</box>
)
},
home_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
hint?: JSX.Element
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
if (!("Prompt" in api.ui)) return null
const view = api.ui.Prompt
if (typeof view !== "function") return null
const Prompt = view as Prompt
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify api.ui.Prompt rendering",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const Hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
</text>
</box>
)
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)
const text = "extra content in the unified home bottom slot"
@@ -706,8 +736,8 @@ const block = (input: Cfg, order: number, title: string, text: string): TuiSlotP
},
})
const slot = (input: Cfg): TuiSlotPlugin[] => [
home(input),
const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
home(api, input),
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
@@ -848,7 +878,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
])
reg(api, value, keys)
for (const item of slot(value)) {
for (const item of slot(api, value)) {
api.slots.register(item)
}
}

View File

@@ -44,7 +44,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -97,7 +97,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -131,7 +131,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -158,7 +158,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -182,7 +182,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -206,7 +206,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -239,7 +239,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -270,7 +270,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -299,7 +299,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -367,7 +367,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.3",
"version": "1.3.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -393,7 +393,7 @@
"@ai-sdk/provider-utils": "4.0.21",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.74",
"@ai-sdk/xai": "3.0.75",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
@@ -408,8 +408,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.91",
"@opentui/solid": "0.1.91",
"@opentui/core": "0.1.93",
"@opentui/solid": "0.1.93",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -439,7 +439,7 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
@@ -449,6 +449,7 @@
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
@@ -494,22 +495,22 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "0.1.91",
"@opentui/solid": "0.1.91",
"@opentui/core": "0.1.93",
"@opentui/solid": "0.1.93",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.91",
"@opentui/solid": ">=0.1.91",
"@opentui/core": ">=0.1.93",
"@opentui/solid": ">=0.1.93",
},
"optionalPeers": [
"@opentui/core",
@@ -528,7 +529,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.3",
"version": "1.3.10",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -539,7 +540,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -574,7 +575,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -621,7 +622,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"zod": "catalog:",
},
@@ -632,7 +633,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.3",
"version": "1.3.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -665,8 +666,9 @@
},
},
"trustedDependencies": [
"electron",
"esbuild",
"tree-sitter-powershell",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
@@ -789,7 +791,7 @@
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.74", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HDDLsT+QrzE3c2QZLRV/HKAwMtXDb0PMDdk1PYUXLJ3r9Qv76zGKGyvJLX7Pu6c8TOHD1mwLrOVYrsTpC/eTMw=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.75", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V8UKK4fNpI9cnrtsZBvUp9O9J6Y9fTKBRoSLyEaNGPirACewixmLDbXsSgAeownPVWiWpK34bFysd+XouI5Ywg=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -1765,21 +1767,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.91", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.91", "@opentui/core-darwin-x64": "0.1.91", "@opentui/core-linux-arm64": "0.1.91", "@opentui/core-linux-x64": "0.1.91", "@opentui/core-win32-arm64": "0.1.91", "@opentui/core-win32-x64": "0.1.91", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-xkuBDChHix3lHESQZTWXnPi0c8aANtg0567te3Am2O9EB3V1afKYdOYRV7RrzC+VBNmkymD8dUN+jzLkEUnAEw=="],
"@opentui/core": ["@opentui/core@0.1.93", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.93", "@opentui/core-darwin-x64": "0.1.93", "@opentui/core-linux-arm64": "0.1.93", "@opentui/core-linux-x64": "0.1.93", "@opentui/core-win32-arm64": "0.1.93", "@opentui/core-win32-x64": "0.1.93", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-HlTM16ZiBKN0mPBNMHSILkSrbzNku6Pg/ovIpVVkEPqLeWeSC2bfZS4Uhc0Ej1sckVVVoU9HKBJanfHvpP+pMg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.91", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WlIMa832vyjHCJsteWtSDsTAOrOPw/LQjYXVPISwwKo5Puyyl9vWNsF+69eYEyFEh15u8JNNrOPK98nlXq8SOA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.93", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4I2mwhXLqRNUv7tu88hA6cBGaGpLZXkAa8W0VqBiGDV+Tx337x4T+vbQ7G57OwKXT787oTrEOF9rOOrGLov6qw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.91", "", { "os": "darwin", "cpu": "x64" }, "sha512-nFZgQrdGtEzf5GXg4YxtDzxHvSwAig2G4Qf6ySN6sU9f9eaB1NJNhOVYLNJHBVEs5qOamBee+nXYEtG6zInIFQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.93", "", { "os": "darwin", "cpu": "x64" }, "sha512-jvYMgcg47a5qLhSv1DnQiafEWBQ1UukGutmsYV1TvNuhWtuDXYLVy2AhKIHPzbB9JNrV0IpjbxUC8QnJaP3n8g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.91", "", { "os": "linux", "cpu": "arm64" }, "sha512-vXAcHZaS3QzEXYyvM9KoE0juSOMPPPdNrV5Fo4HAbI5BXGCkMNQJoN0j0EzoO9xwfsO+EulRSHCLVTNkvI4n8Q=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.93", "", { "os": "linux", "cpu": "arm64" }, "sha512-bvFqRcPftmg14iYmMc3d63XC9rhe4yF7pJRApH6klLBKp27WX/LU0iSO4mvyX7qhy65gcmyy4Sj9dl5jNJ+vlA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.91", "", { "os": "linux", "cpu": "x64" }, "sha512-rAJ9sOvvI9eoWHjVj6TLPDRqYPYISmfCm2TDxi67BO27+E7naJANHIIxMC7yhPAmwBof7plioL2lwl2UFXAoXw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.93", "", { "os": "linux", "cpu": "x64" }, "sha512-/wJXhwtNxdcpshrRl1KouyGE54ODAHxRQgBHtnlM/F4bB8cjzOlq2Yc+5cv5DxRz4Q0nQZFCPefwpg2U6ZwNdA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.91", "", { "os": "win32", "cpu": "arm64" }, "sha512-teLe7uHvPnD/lOwTwZp2lUFfeT27dk6ZSLWk8hrhsAJ/Y0MyoaCUHAsg3nZ/p+I3pie5aZUR1f0vrJfaZ8ukJw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.93", "", { "os": "win32", "cpu": "arm64" }, "sha512-g3PQobfM2yFPSzkBKRKFp8FgTG4ulWyJcU+GYXjyYmxQIT+ZbOU7UfR//ImRq3/FxUAfUC/MhC6WwjqccjEqBw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.91", "", { "os": "win32", "cpu": "x64" }, "sha512-Odx9S1NYp3I2jgy5aj5k3/wb3M+yChEK7k8UUxxFt4R37V1/um8n6Cxw4nfid6T2C45KDGJ/0BYe6lGugJlnSg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.93", "", { "os": "win32", "cpu": "x64" }, "sha512-Spllte2W7q+WfB1zVHgHilVJNp+jpp77PkkxTWyMQNvT7vJNt9LABMNjGTGiJBBMkAuKvO0GgFNKxrda7tFKrQ=="],
"@opentui/solid": ["@opentui/solid@0.1.91", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.91", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-phqiOcmTgNy7aG7s3P6zyatrBc1f6DkuLDJmGqy6R9QuoS4Mn9MKdNQe6Ick03xRAZuaS6ZdG3kueNxIlUMTCA=="],
"@opentui/solid": ["@opentui/solid@0.1.93", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.93", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-Qx+4qoLSjnRGoo/YY4sZJMyXj09Y5kaAMpVO+65Ax58MMj4TjABN4bOOiRT2KV7sKOMTjxiAgXAIaBuqBBJ0Qg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -4495,7 +4497,7 @@
"opencode": ["opencode@workspace:packages/opencode"],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.1", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-1EMZHdbADLMVaTVLQ6C/V8uVMDr6MP++osj2lmOecowtn46AafP/w6ADkV4AN/ddjA1rob5cWpMuf/iME6DI6A=="],
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
@@ -5283,6 +5285,8 @@
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -6309,6 +6313,8 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.74", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HDDLsT+QrzE3c2QZLRV/HKAwMtXDb0PMDdk1PYUXLJ3r9Qv76zGKGyvJLX7Pu6c8TOHD1mwLrOVYrsTpC/eTMw=="],
"ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"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=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-5VHEo9GCBP+MeLMoWqSuJLAX/qwGLdFjZe20yatgogM=",
"aarch64-linux": "sha256-hn+V2UpoCj1ddKcq1ySGOMRVvsd3T8sqgpd6nHYfUoA=",
"aarch64-darwin": "sha256-Qctkv6AHDrl+qdB1L+DeqLREeWm6BQqtVCK4tibIMCc=",
"x86_64-darwin": "sha256-YHHnow2dqtKOsjQvbyKk6HQmTo8cAv8frgOfD5aS3h8="
"x86_64-linux": "sha256-UuVbB5lTRB4bIcaKMc8CLSbQW7m9EjXgxYvxp/uO7Co=",
"aarch64-linux": "sha256-8D7ReLRVb7NDd5PQTVxFhRLmlLbfjK007XgIhhpNKoE=",
"aarch64-darwin": "sha256-M+z7C/eXfVqwDiGiiwKo/LT/m4dvCjL1Pblsr1kxoyI=",
"x86_64-darwin": "sha256-RzZS6GMwYVDPK0W+K/mlebixNMs2+JRkMG9n8OFhd0c="
}
}

View File

@@ -20,7 +20,7 @@ let
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = "${packageJson.version}-${rev}";
version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
src = lib.fileset.toSource {
root = ../.;
@@ -54,6 +54,7 @@ stdenvNoCC.mkDerivation {
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \
--ignore-scripts \
--no-progress

View File

@@ -3,6 +3,7 @@
stdenvNoCC,
callPackage,
bun,
nodejs,
sysctl,
makeBinaryWrapper,
models-dev,
@@ -19,6 +20,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nativeBuildInputs = [
bun
nodejs # for patchShebangs node_modules
installShellFiles
makeBinaryWrapper
models-dev
@@ -29,6 +31,8 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
patchShebangs node_modules
patchShebangs packages/*/node_modules
runHook postConfigure
'';

View File

@@ -104,6 +104,7 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],

View File

@@ -15,6 +15,16 @@ import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
const seedModel = (() => {
const [providerID = "opencode", modelID = "big-pickle"] = (
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
).split("/")
return {
providerID: providerID || "opencode",
modelID: modelID || "big-pickle",
}
})()
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
@@ -125,7 +135,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
await page.addInitScript((model: { providerID: string; modelID: string }) => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
@@ -143,12 +153,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
recent: [model],
user: [],
variant: {},
}),
)
})
}, seedModel)
}
export { expect }

View File

@@ -234,6 +234,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
@@ -283,6 +284,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
})
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`

View File

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

View File

@@ -71,7 +71,7 @@ const serverEnv = {
OPENCODE_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string>

View File

@@ -624,17 +624,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!cmd) return
promptProbe.select(cmd.id)
closePopover()
const images = imageAttachments()
if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
focusEditorEnd()
return
}
clearEditor()
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
prompt.set([...DEFAULT_PROMPT, ...images], 0)
command.trigger(cmd.id, "slash")
}

View File

@@ -11,6 +11,47 @@ import { useSDK } from "@/context/sdk"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
return (
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
)
}
function Option(props: {
multi: boolean
picked: boolean
label: string
description?: string
disabled: boolean
onClick: VoidFunction
}) {
return (
<button
type="button"
data-slot="question-option"
data-picked={props.picked}
role={props.multi ? "checkbox" : "radio"}
aria-checked={props.picked}
disabled={props.disabled}
onClick={props.onClick}
>
<Mark multi={props.multi} picked={props.picked} />
<span data-slot="question-option-main">
<span data-slot="option-label">{props.label}</span>
<Show when={props.description}>
<span data-slot="option-description">{props.description}</span>
</Show>
</span>
</button>
)
}
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK()
const language = useLanguage()
@@ -41,6 +82,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return language.t("session.question.progress", { current: n, total: total() })
})
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
const last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => {
@@ -164,6 +208,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
}
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer])
if (custom) setStore("custom", store.tab, answer)
@@ -230,6 +281,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customUpdate(input())
}
const resizeInput = (el: HTMLTextAreaElement) => {
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}
const focusCustom = (el: HTMLTextAreaElement) => {
setTimeout(() => {
el.focus()
resizeInput(el)
}, 0)
}
const toggleCustomMark = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
customToggle()
}
const next = () => {
if (sending()) return
if (store.editing) commitCustom()
@@ -270,10 +339,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
type="button"
data-slot="question-progress-segment"
data-active={i() === store.tab}
data-answered={
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
data-answered={answered(i())}
disabled={sending()}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
@@ -307,43 +373,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={sending()}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
onClick={() => selectOption(i())}
/>
)}
</For>
<Show
when={store.editing}
fallback={
<button
type="button"
data-slot="question-option"
data-custom="true"
data-picked={on()}
@@ -352,24 +398,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
disabled={sending()}
onClick={customOpen}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
<span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
@@ -394,33 +426,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-label">{customLabel()}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
ref={focusCustom}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
@@ -436,8 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
resizeInput(e.currentTarget)
}}
/>
</span>

View File

@@ -52,6 +52,132 @@ function FileCommentMenu(props: {
)
}
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: ScrollPos | undefined
let code: HTMLElement[] = []
const getCode = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const save = (next: ScrollPos) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
input.view().setScroll(input.tab(), out)
})
}
const onCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
save({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const sync = () => {
const next = getCode()
if (next.length === code.length && next.every((el, i) => el === code[i])) return
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
code = next
for (const item of code) {
item.addEventListener("scroll", onCodeScroll)
}
}
const restore = () => {
const el = scroll
if (!el) return
const pos = input.view().scroll(input.tab())
if (!pos) return
sync()
if (code.length > 0) {
for (const item of code) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
}
}
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
if (code.length > 0) return
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restore()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code.length === 0) sync()
save({
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const setViewport = (el: HTMLDivElement) => {
scroll = el
restore()
}
onCleanup(() => {
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
return {
handleScroll,
queueRestore,
setViewport,
}
}
export function FileTabContent(props: { tab: string }) {
const file = useFile()
const comments = useComments()
@@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null
const search = {
@@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, {
@@ -100,6 +225,12 @@ export function FileTabContent(props: { tab: string }) {
})
}
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
@@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview =
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const preview = input.preview ?? buildPreview(input.file, selection)
const saved = comments.add({
file: input.file,
@@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
comment: string
}) => {
comments.update(input.file, input.id, input.comment)
const preview =
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(preview ? { preview } : {}),
@@ -260,102 +383,6 @@ export function FileTabContent(props: { tab: string }) {
requestAnimationFrame(() => comments.clearFocus())
})
const getCodeScroll = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const queueScrollUpdate = (next: { x: number; y: number }) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
view().setScroll(props.tab, out)
})
}
const handleCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
queueScrollUpdate({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const syncCodeScroll = () => {
const next = getCodeScroll()
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
codeScroll = next
for (const item of codeScroll) {
item.addEventListener("scroll", handleCodeScroll)
}
}
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = view().scroll(props.tab)
if (!s) return
syncCodeScroll()
if (codeScroll.length > 0) {
for (const item of codeScroll) {
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
}
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (codeScroll.length > 0) return
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restoreScroll()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
queueScrollUpdate({
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const cancelCommenting = () => {
const p = path()
if (p) file.setSelectedLines(p, null)
@@ -375,16 +402,7 @@ export function FileTabContent(props: { tab: string }) {
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
queueRestore()
})
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
scrollSync.queueRestore()
})
const renderFile = (source: string) => (
@@ -402,7 +420,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
queueRestore()
scrollSync.queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
@@ -420,7 +438,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto",
path: path(),
current: state()?.content,
onLoad: queueRestore,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
@@ -435,14 +453,7 @@ export function FileTabContent(props: { tab: string }) {
return (
<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}
>
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>

View File

@@ -128,380 +128,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory)
}
command.register("session", () => {
const share =
sync.data.config.share === "disabled"
? []
: [
sessionCommand({
id: "session.share",
title: info()?.share?.url
? language.t("session.share.copy.copyLink")
: language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: async () => {
if (!params.id) return
const write = async (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return Promise.resolve(true)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return false
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return Promise.resolve(false)
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const copyShare = async (url: string, existing: boolean) => {
if (!(await write(url))) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
const copy = async (url: string, existing: boolean) => {
const ok = await write(url)
if (!ok) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
showToast({
title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
showToast({
title: existing
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
const share = async () => {
const sessionID = params.id
if (!sessionID) return
const existing = info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const existing = info()?.share?.url
if (existing) {
await copyShare(existing, true)
return
}
const url = await sdk.client.session
.share({ sessionID: params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
const url = await sdk.client.session
.share({ sessionID })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
await copy(url, false)
},
}),
sessionCommand({
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
},
}),
]
await copyShare(url, false)
}
const unshare = async () => {
const sessionID = params.id
if (!sessionID) return
await sdk.client.session
.unshare({ sessionID })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
}
const openFile = () => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
})
}
const closeTab = () => {
const tab = closableTab()
if (!tab) return
tabs().close(tab)
}
const addSelection = () => {
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
}
const openTerminal = () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
}
const chooseModel = () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
}
const chooseMcp = () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
}
const toggleAutoAccept = () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
}
const undo = async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const prev = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(prev)
}
const redo = async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const next = userMessages().find((x) => x.id > revertMessageID)
if (!next) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(last)
return
}
await sdk.client.session.revert({ sessionID, messageID: next.id })
const prev = findLast(userMessages(), (x) => x.id < next.id)
setActiveMessage(prev)
}
const compact = async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
}
const fork = () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
}
const shareCmds = () => {
if (sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: () => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
})
},
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: () => {
const tab = closableTab()
if (!tab) return
tabs().close(tab)
},
}),
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: () => {
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
},
}),
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: focusInput,
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}),
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+alt+[",
id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
onSelect: share,
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: unshare,
}),
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
},
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
},
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
onSelect: () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
},
}),
...share,
]
})
}
const sessionCmds = () => [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: redo,
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: compact,
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: fork,
}),
]
const fileCmds = () => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: openFile,
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: closeTab,
}),
]
const contextCmds = () => [
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: addSelection,
}),
]
const viewCmds = () => [
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: focusInput,
}),
]
const terminalCmds = () => [
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: openTerminal,
}),
]
const messageCmds = () => [
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+alt+[",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
]
const modelCmds = () => [
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: chooseModel,
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
]
const mcpCmds = () => [
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: chooseMcp,
}),
]
const agentCmds = () => [
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
]
const permissionsCmds = () => [
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
onSelect: toggleAutoAccept,
}),
]
command.register("session", () => [
...sessionCmds(),
...shareCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),
...terminalCmds(),
...messageCmds(),
...modelCmds(),
...mcpCmds(),
...agentCmds(),
...permissionsCmds(),
])
}

View File

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

View File

@@ -331,8 +331,7 @@ export const dict = {
"go.faq.a4.p3": "ألغِ في أي وقت.",
"go.faq.q5": "ماذا عن البيانات والخصوصية؟",
"go.faq.a5.body":
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر.",
"go.faq.a5.contactAfter": "إذا كان لديك أي أسئلة.",
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. يتبع مزودونا سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج.",
"go.faq.a5.beforeExceptions":
"تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
"go.faq.a5.exceptionsLink": "الاستثناءات التالية",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Cancele a qualquer momento.",
"go.faq.q5": "E sobre dados e privacidade?",
"go.faq.a5.body":
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável.",
"go.faq.a5.contactAfter": "se você tiver alguma dúvida.",
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos.",
"go.faq.a5.beforeExceptions":
"Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as",
"go.faq.a5.exceptionsLink": "seguintes exceções",

View File

@@ -336,8 +336,7 @@ export const dict = {
"go.faq.a4.p3": "Annuller til enhver tid.",
"go.faq.q5": "Hvad med data og privatliv?",
"go.faq.a5.body":
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang.",
"go.faq.a5.contactAfter": "hvis du har spørgsmål.",
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Vores udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning.",
"go.faq.a5.beforeExceptions":
"Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de",
"go.faq.a5.exceptionsLink": "følgende undtagelser",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Jederzeit kündbar.",
"go.faq.q5": "Was ist mit Daten und Privatsphäre?",
"go.faq.a5.body":
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang.",
"go.faq.a5.contactAfter": "wenn du Fragen hast.",
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang. Unsere Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen.",
"go.faq.a5.beforeExceptions":
"Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den",
"go.faq.a5.exceptionsLink": "folgenden Ausnahmen",

View File

@@ -331,8 +331,8 @@ export const dict = {
"go.faq.a4.p3": "Cancel any time.",
"go.faq.q5": "What about data and privacy?",
"go.faq.a5.body":
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
"go.faq.a5.contactAfter": "if you have any questions.",
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.",
"go.faq.a5.beforeExceptions":
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
"go.faq.a5.exceptionsLink": "following exceptions",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Cancela en cualquier momento.",
"go.faq.q5": "¿Qué pasa con los datos y la privacidad?",
"go.faq.a5.body":
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable.",
"go.faq.a5.contactAfter": "si tienes alguna pregunta.",
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos.",
"go.faq.a5.beforeExceptions":
"Los modelos de Go están alojados en EE. UU. Los proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos, con las",
"go.faq.a5.exceptionsLink": "siguientes excepciones",

View File

@@ -340,8 +340,7 @@ export const dict = {
"go.faq.a4.p3": "Annulez à tout moment.",
"go.faq.q5": "Et pour les données et la confidentialité ?",
"go.faq.a5.body":
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable.",
"go.faq.a5.contactAfter": "si vous avez des questions.",
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles.",
"go.faq.a5.beforeExceptions":
"Les modèles Go sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
"go.faq.a5.exceptionsLink": "exceptions suivantes",

View File

@@ -334,8 +334,7 @@ export const dict = {
"go.faq.a4.p3": "Annulla in qualsiasi momento.",
"go.faq.q5": "E per quanto riguarda dati e privacy?",
"go.faq.a5.body":
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile.",
"go.faq.a5.contactAfter": "se hai domande.",
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli.",
"go.faq.a5.beforeExceptions":
"I modelli Go sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
"go.faq.a5.exceptionsLink": "seguenti eccezioni",

View File

@@ -335,8 +335,7 @@ export const dict = {
"go.faq.a4.p3": "いつでもキャンセル可能です。",
"go.faq.q5": "データとプライバシーは?",
"go.faq.a5.body":
"このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。",
"go.faq.a5.contactAfter": "ご質問がございましたら。",
"このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。プロバイダーはゼロ保持ポリシーに従い、お客様のデータをモデルのトレーニングに使用しません。",
"go.faq.a5.beforeExceptions":
"Goのモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません",
"go.faq.a5.exceptionsLink": "以下の例外",

View File

@@ -331,8 +331,7 @@ export const dict = {
"go.faq.a4.p3": "언제든지 취소할 수 있습니다.",
"go.faq.q5": "데이터와 프라이버시는 어떤가요?",
"go.faq.a5.body":
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다.",
"go.faq.a5.contactAfter": "질문이 있으시면 언제든지 문의해 주세요.",
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다. 당사의 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다.",
"go.faq.a5.beforeExceptions":
"Go 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
"go.faq.a5.exceptionsLink": "다음 예외",

View File

@@ -335,8 +335,7 @@ export const dict = {
"go.faq.a4.p3": "Avslutt når som helst.",
"go.faq.q5": "Hva med data og personvern?",
"go.faq.a5.body":
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang.",
"go.faq.a5.contactAfter": "hvis du har spørsmål.",
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Våre leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening.",
"go.faq.a5.beforeExceptions":
"Go-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
"go.faq.a5.exceptionsLink": "følgende unntak",

View File

@@ -336,8 +336,7 @@ export const dict = {
"go.faq.a4.p3": "Anuluj w dowolnym momencie.",
"go.faq.q5": "A co z danymi i prywatnością?",
"go.faq.a5.body":
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp.",
"go.faq.a5.contactAfter": "jeśli masz jakiekolwiek pytania.",
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Nasi dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli.",
"go.faq.a5.beforeExceptions":
"Modele Go są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli, z",
"go.faq.a5.exceptionsLink": "następującymi wyjątkami",

View File

@@ -340,8 +340,7 @@ export const dict = {
"go.faq.a4.p3": "Отмена в любое время.",
"go.faq.q5": "Как насчет данных и приватности?",
"go.faq.a5.body":
"План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа.",
"go.faq.a5.contactAfter": "если у вас есть вопросы.",
"План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа. Наши провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей.",
"go.faq.a5.beforeExceptions":
"Модели Go размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
"go.faq.a5.exceptionsLink": "следующими исключениями",

View File

@@ -333,8 +333,7 @@ export const dict = {
"go.faq.a4.p3": "ยกเลิกได้ตลอดเวลา",
"go.faq.q5": "แล้วเรื่องข้อมูลและความเป็นส่วนตัวล่ะ?",
"go.faq.a5.body":
"แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร",
"go.faq.a5.contactAfter": "หากคุณมีคำถามใดๆ",
"แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร ผู้ให้บริการของเราปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูลและไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล",
"go.faq.a5.beforeExceptions":
"โมเดล Go โฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
"go.faq.a5.exceptionsLink": "ข้อยกเว้นดังนี้",

View File

@@ -339,8 +339,7 @@ export const dict = {
"go.faq.a4.p3": "yönetebilirsiniz. İstediğiniz zaman iptal edin.",
"go.faq.q5": "Veri ve gizlilik ne olacak?",
"go.faq.a5.body":
"Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır.",
"go.faq.a5.contactAfter": "herhangi bir sorunuz varsa.",
"Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır. Sağlayıcılarımız sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz.",
"go.faq.a5.beforeExceptions":
"Go modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
"go.faq.a5.exceptionsLink": "aşağıdaki istisnalar",

View File

@@ -319,8 +319,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "账户",
"go.faq.a4.p3": "中管理订阅。随时取消。",
"go.faq.q5": "数据和隐私如何?",
"go.faq.a5.body": "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。",
"go.faq.a5.contactAfter": "如果您有任何问题。",
"go.faq.a5.body":
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。我们的提供商遵循零留存政策,不使用您的数据进行模型训练。",
"go.faq.a5.beforeExceptions": "Go 模型托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
"go.faq.a5.exceptionsLink": "以下例外情况除外",
"go.faq.q6": "我可以充值余额吗?",

View File

@@ -319,8 +319,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "帳戶",
"go.faq.a4.p3": "中管理訂閱。隨時取消。",
"go.faq.q5": "資料與隱私怎麼辦?",
"go.faq.a5.body": "該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。",
"go.faq.a5.contactAfter": "如果你有任何問題。",
"go.faq.a5.body":
"該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。我們的供應商遵循零留存政策,不會將你的資料用於模型訓練。",
"go.faq.a5.beforeExceptions": "Go 模型託管在美國。供應商遵循零留存政策,不會將你的資料用於模型訓練,但有",
"go.faq.a5.exceptionsLink": "以下例外",
"go.faq.q6": "我可以儲值額度嗎?",

View File

@@ -432,10 +432,7 @@ export default function Home() {
</Faq>
</li>
<li>
<Faq question={i18n.t("go.faq.q5")}>
{i18n.t("go.faq.a5.body")} <a href="mailto:contact@anoma.ly">{i18n.t("common.contactUs")}</a>{" "}
{i18n.t("go.faq.a5.contactAfter")}
</Faq>
<Faq question={i18n.t("go.faq.q5")}>{i18n.t("go.faq.a5.body")}</Faq>
</li>
<li>
<Faq question={i18n.t("go.faq.q6")}>{i18n.t("go.faq.a6")}</Faq>

View File

@@ -139,19 +139,16 @@ 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 ?? {}),
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v),
),
},
authInfo?.workspaceID,
),
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v),
),
}),
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
@@ -470,15 +467,17 @@ export async function handler(
...(() => {
const providerProps = zenData.providers[modelProvider.id]
const format = providerProps.format
const providerModel = modelProvider.model
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
if (format === "google") return googleHelper({ reqModel, providerModel })
if (format === "openai") return openaiHelper({ reqModel, providerModel })
return oaCompatHelper({
const opts = {
reqModel,
providerModel,
providerModel: modelProvider.model,
adjustCacheUsage: providerProps.adjustCacheUsage,
})
safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
workspaceID: authInfo?.workspaceID,
}
if (format === "anthropic") return anthropicHelper(opts)
if (format === "google") return googleHelper(opts)
if (format === "openai") return openaiHelper(opts)
return oaCompatHelper(opts)
})(),
}
}

View File

@@ -21,17 +21,18 @@ type Usage = {
}
}
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
},
modifyBody: (body: Record<string, any>) => {
modifyBody: (body: Record<string, any>, workspaceID?: string) => {
return {
...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}),
...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}),
}
},
createBinaryStreamDecoder: () => undefined,

View File

@@ -12,13 +12,13 @@ type Usage = {
total_tokens?: number
}
export const openaiHelper: ProviderHelper = () => ({
export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
format: "openai",
modifyUrl: (providerApi: string) => providerApi + "/responses",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>, workspaceID?: string) => ({
modifyBody: (body: Record<string, any>) => ({
...body,
...(workspaceID ? { safety_identifier: workspaceID } : {}),
}),

View File

@@ -33,11 +33,17 @@ export type UsageInfo = {
cacheWrite1hTokens?: number
}
export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
export type ProviderHelper = (input: {
reqModel: string
providerModel: string
adjustCacheUsage?: boolean
safetyIdentifier?: string
workspaceID?: 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>, workspaceID?: string) => Record<string, any>
modifyBody: (body: Record<string, any>) => 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.3.3",
"version": "1.3.10",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -0,0 +1,39 @@
import { Billing } from "../src/billing.js"
import { Database, eq } from "../src/drizzle/index.js"
import { BillingTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { microCentsToCents } from "../src/util/price.js"
// get input from command line
const workspaceID = process.argv[2]
if (!workspaceID) {
console.error("Usage: bun freeze-workspace.ts <workspaceID>")
process.exit(1)
}
// check workspace exists
const workspace = await Database.use((tx) =>
tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0]),
)
if (!workspace) {
console.error("Error: Workspace not found")
process.exit(1)
}
const billing = await Database.use((tx) =>
tx
.select()
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
const amountInDollars = microCentsToCents(billing.balance) / 100
await Billing.grantCredit(workspaceID, 0 - amountInDollars)
console.log(`Removed payment of $${amountInDollars.toFixed(2)} from workspace ${workspaceID}`)

View File

@@ -18,8 +18,9 @@ import { ModelTable } from "../src/schema/model.sql.js"
// get input from command line
const identifier = process.argv[2]
const verbose = process.argv[process.argv.length - 1] === "-v"
if (!identifier) {
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey>")
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey> [-v]")
process.exit(1)
}
@@ -223,93 +224,68 @@ async function printWorkspace(workspaceID: string) {
),
)
await printTable("28-Day Usage", (tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
requests: sql<number>`COUNT(*)`.as("requests"),
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspace.id),
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
if (verbose) {
await printTable("28-Day Usage", (tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
requests: sql<number>`COUNT(*)`.as("requests"),
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspace.id),
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
.then((rows) => {
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
const mapped = rows.map((row) => ({
...row,
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
}))
if (mapped.length > 0) {
mapped.push({
date: "TOTAL",
requests: null as any,
inputTokens: null as any,
outputTokens: null as any,
reasoningTokens: null as any,
cacheReadTokens: null as any,
cacheWrite5mTokens: null as any,
cacheWrite1hTokens: null as any,
cost: `$${(totalCost / 100000000).toFixed(2)}`,
})
}
return mapped
}),
)
await printTable("Disabled Models", (tx) =>
tx
.select({
model: ModelTable.model,
timeCreated: ModelTable.timeCreated,
})
.from(ModelTable)
.where(eq(ModelTable.workspaceID, workspace.id))
.orderBy(sql`${ModelTable.timeCreated} DESC`)
.then((rows) =>
rows.map((row) => ({
model: row.model,
timeCreated: formatDate(row.timeCreated),
})),
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
.then((rows) => {
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
const mapped = rows.map((row) => ({
...row,
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
}))
if (mapped.length > 0) {
mapped.push({
date: "TOTAL",
requests: null as any,
inputTokens: null as any,
outputTokens: null as any,
reasoningTokens: null as any,
cacheReadTokens: null as any,
cacheWrite5mTokens: null as any,
cacheWrite1hTokens: null as any,
cost: `$${(totalCost / 100000000).toFixed(2)}`,
})
}
return mapped
}),
)
/*
await printTable("Usage", (tx) =>
tx
.select({
model: UsageTable.model,
provider: UsageTable.provider,
inputTokens: UsageTable.inputTokens,
outputTokens: UsageTable.outputTokens,
reasoningTokens: UsageTable.reasoningTokens,
cacheReadTokens: UsageTable.cacheReadTokens,
cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
cost: UsageTable.cost,
timeCreated: UsageTable.timeCreated,
})
.from(UsageTable)
.where(eq(UsageTable.workspaceID, workspace.id))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(10)
.then((rows) =>
rows.map((row) => ({
...row,
cost: `$${(row.cost / 100000000).toFixed(2)}`,
})),
),
)
await printTable("Disabled Models", (tx) =>
tx
.select({
model: ModelTable.model,
timeCreated: ModelTable.timeCreated,
})
.from(ModelTable)
.where(eq(ModelTable.workspaceID, workspace.id))
.orderBy(sql`${ModelTable.timeCreated} DESC`)
.then((rows) =>
rows.map((row) => ({
model: row.model,
timeCreated: formatDate(row.timeCreated),
})),
),
)
*/
)
}
}
function formatMicroCents(value: number | null | undefined) {

View File

@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
if (!value) throw new Error("ZEN_LIMITS not found")

View File

@@ -12,7 +12,7 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
const PARTS = 30
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
const lines = ret.split("\n")
const values = Array.from({ length: PARTS }, (_, i) => {
const value = lines

View File

@@ -6,7 +6,7 @@ import os from "os"
import { Subscription } from "../src/subscription"
const root = path.resolve(process.cwd(), "..", "..", "..")
const secrets = await $`bun sst secret list`.cwd(root).text()
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
// read value
const lines = secrets.split("\n")
@@ -25,4 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
Subscription.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_LIMITS ${newValue}`
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)

View File

@@ -6,7 +6,7 @@ import os from "os"
import { ZenData } from "../src/model"
const root = path.resolve(process.cwd(), "..", "..", "..")
const models = await $`bun sst secret list`.cwd(root).text()
const models = await $`bun sst secret list --stage frank`.cwd(root).text()
const PARTS = 30
// read the line starting with "ZEN_MODELS"
@@ -40,4 +40,4 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
await $`bun sst secret load ${envFile.name}`.cwd(root)
await $`bun sst secret load ${envFile.name} --stage frank`.cwd(root)

View File

@@ -37,6 +37,7 @@ export namespace ZenData {
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
safetyIdentifier: z.boolean().optional(),
}),
),
})

View File

@@ -1,3 +1,7 @@
export function centsToMicroCents(amount: number) {
return Math.round(amount * 1000000)
}
export function microCentsToCents(amount: number) {
return Math.round(amount / 1000000)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.3",
"version": "1.3.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.3.3",
"version": "1.3.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.3",
"version": "1.3.10",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.3",
"version": "1.3.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.3.3"
version = "1.3.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.3.3/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.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.3.3/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.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.3.3/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.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.3.3/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.3",
"version": "1.3.10",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -88,7 +88,7 @@
"@ai-sdk/provider-utils": "4.0.21",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.74",
"@ai-sdk/xai": "3.0.75",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
@@ -103,8 +103,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.91",
"@opentui/solid": "0.1.91",
"@opentui/core": "0.1.93",
"@opentui/solid": "0.1.93",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -134,7 +134,7 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
@@ -144,6 +144,7 @@
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",

View File

@@ -2,6 +2,7 @@ const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
const requirePaid = process.env.OPENCODE_E2E_REQUIRE_PAID === "true"
const parts = model.split("/")
const providerID = parts[0] ?? "opencode"
const modelID = parts[1] ?? "gpt-5-nano"
@@ -11,6 +12,7 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
const { Provider } = await import("../src/provider/provider")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project")
@@ -25,6 +27,19 @@ const seed = async () => {
await Config.waitForDependencies()
await ToolRegistry.ids()
if (requirePaid && providerID === "opencode" && !process.env.OPENCODE_API_KEY) {
throw new Error("OPENCODE_API_KEY is required when OPENCODE_E2E_REQUIRE_PAID=true")
}
const info = await Provider.getModel(ProviderID.make(providerID), ModelID.make(modelID))
if (requirePaid) {
const paid =
info.cost.input > 0 || info.cost.output > 0 || info.cost.cache.read > 0 || info.cost.cache.write > 0
if (!paid) {
throw new Error(`OPENCODE_E2E_MODEL must resolve to a paid model: ${providerID}/${modelID}`)
}
}
const session = await Session.create({ title })
const messageID = MessageID.ascending()
const partID = PartID.ascending()

View File

@@ -210,15 +210,13 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Vcs``project/vcs.ts`
- [x] `Worktree``worktree/index.ts`
Still open and likely worth migrating:
- [x] `Session``session/index.ts`
- [ ] `SessionProcessor`blocked by AI SDK v6 PR (#18433)
- [ ] `SessionPrompt`blocked by AI SDK v6 PR (#18433)
- [ ] `SessionCompaction`blocked by AI SDK v6 PR (#18433)
- [ ] `Provider`blocked by AI SDK v6 PR (#18433)
- [x] `SessionProcessor``session/processor.ts`
- [x] `SessionPrompt``session/prompt.ts`
- [x] `SessionCompaction``session/compaction.ts`
- [x] `Provider``provider/provider.ts`
Other services not yet migrated:
Still open:
- [ ] `SessionSummary``session/summary.ts`
- [ ] `SessionTodo``session/todo.ts`
@@ -235,7 +233,7 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
1. Migrate each tool to return Effects
2. Update `Tool.define()` factory to work with Effects
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
Individual tools, ordered by value:

View File

@@ -84,17 +84,27 @@ export default plugin
- TUI shape is `default export { id?, tui }`; including `server` is rejected.
- A single module cannot export both `server` and `tui`.
- `tui` signature is `(api, options, meta) => Promise<void>`.
- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
- If package `exports` contains `./tui`, the loader resolves that entrypoint.
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
- `package.json` `main` is only used for server plugin entrypoint resolution.
- If a configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure).
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
- File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used.
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
- If a path spec points at a directory, that directory must have `package.json` with `main`.
- If a path spec points at a directory, server loading can use `package.json` `main`.
- TUI path loading never uses `package.json` `main`.
- Legacy compatibility: path specs like `./plugin` can resolve to `./plugin/index.ts` (or `index.js`) when `package.json` is missing.
- The `./plugin -> ./plugin/index.*` fallback applies to both server and TUI v1 loading.
- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
## Package manifest and install
Package manifest is read from `package.json` field `oc-plugin`.
Install target detection is inferred from `package.json` entrypoints:
- `server` target when `exports["./server"]` exists or `main` is set.
- `tui` target when `exports["./tui"]` exists.
Example:
@@ -102,14 +112,20 @@ Example:
{
"name": "@acme/opencode-plugin",
"type": "module",
"main": "./dist/index.js",
"main": "./dist/server.js",
"exports": {
"./server": {
"import": "./dist/server.js",
"config": { "custom": true }
},
"./tui": {
"import": "./dist/tui.js",
"config": { "compact": true }
}
},
"engines": {
"opencode": "^1.0.0"
},
"oc-plugin": [
["server", { "custom": true }],
["tui", { "compact": true }]
]
}
}
```
@@ -138,10 +154,16 @@ npm plugins can declare a version compatibility range in `package.json` using th
- Local installs resolve target dir inside `patchPluginConfig`.
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install.
- Without `--force`, an already-configured npm package name is a no-op.
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
- Explicit npm specs with a version suffix (for example `pkg@1.2.3`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
- Bare npm specs (`pkg`) are treated as `latest` and can refresh when the cached version is stale.
- Tuple targets in `oc-plugin` provide default options written into config.
- A package can target `server`, `tui`, or both.
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
@@ -164,7 +186,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.app.version`
- `api.command.register(cb)` / `api.command.trigger(value)`
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog`
- `api.keybind.match`, `print`, `create`
- `api.tuiConfig`
- `api.kv.get`, `set`, `ready`
@@ -210,6 +232,7 @@ Command behavior:
- `ui.Dialog` is the base dialog wrapper.
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
- `ui.Prompt` renders the same prompt component used by the host app.
- `ui.toast(...)` shows a toast.
- `ui.dialog` exposes the host dialog stack:
- `replace(render, onClose?)`
@@ -266,7 +289,9 @@ Theme install behavior:
- Relative theme paths are resolved from the plugin root.
- Theme name is the JSON basename.
- Install is skipped if that theme name already exists.
- First install writes only when the destination file is missing.
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
- Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored.
@@ -277,6 +302,7 @@ Current host slot names:
- `app`
- `home_logo`
- `home_prompt` with props `{ workspace_id? }`
- `home_bottom`
- `sidebar_title` with props `{ session_id, title, share_url? }`
- `sidebar_content` with props `{ session_id }`
@@ -289,7 +315,7 @@ Slot notes:
- `api.slots.register(plugin)` does not return an unregister function.
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
- Plugin-provided `id` is not allowed.
- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
- Plugins cannot define new slot names in this branch.
### Plugin control and lifecycle
@@ -305,7 +331,6 @@ Slot notes:
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
- `api.lifecycle.signal` is aborted before cleanup runs.
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.

View File

@@ -393,7 +393,7 @@ export namespace Agent {
)
export const defaultLayer = layer.pipe(
Layer.provide(Auth.layer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)

View File

@@ -3,7 +3,7 @@ import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
@@ -53,17 +53,13 @@ export namespace Auth {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const all = Effect.fn("Auth.all")(function* () {
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
@@ -74,10 +70,9 @@ export namespace Auth {
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
@@ -85,17 +80,16 @@ export namespace Auth {
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
return Service.of({ get, all, set, remove })
}),
)
const { runPromise } = makeRuntime(Service, layer)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))

View File

@@ -50,7 +50,7 @@ export namespace BunProc {
}),
)
export async function install(pkg: string, version = "latest") {
export async function install(pkg: string, version = "latest", opts?: { ignoreScripts?: boolean }) {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
@@ -82,6 +82,7 @@ export namespace BunProc {
"add",
"--force",
"--exact",
...(opts?.ignoreScripts ? ["--ignore-scripts"] : []),
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",

View File

@@ -114,8 +114,8 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
if (manifest.code === "manifest_no_targets") {
inspect.stop("No plugin targets found", 1)
dep.log.error(`"${mod}" does not declare supported targets in package.json`)
dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
return false
}

View File

@@ -121,9 +121,11 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
}
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
return {
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
@@ -249,7 +251,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
renderer.disableStdoutInterception()
const dialog = useDialog()
const local = useLocal()
const kv = useKV()
@@ -588,6 +589,18 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
local.model.variant.cycle()
},
},
{
title: "Switch model variant",
value: "variant.list",
category: "Agent",
hidden: local.model.variant.list().length === 0,
slash: {
name: "variants",
},
onSelect: () => {
dialog.replace(() => <DialogVariant />)
},
},
{
title: "Agent cycle reverse",
value: "agent.cycle.reverse",

View File

@@ -136,7 +136,13 @@ export function DialogModel(props: { providerID?: string }) {
function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true })
if (local.model.variant.list().length > 0) {
const list = local.model.variant.list()
const cur = local.model.variant.selected()
if (cur === "default" || (cur && list.includes(cur))) {
dialog.clear()
return
}
if (list.length > 0) {
dialog.replace(() => <DialogVariant />)
return
}

View File

@@ -8,23 +8,32 @@ export function DialogVariant() {
const dialog = useDialog()
const options = createMemo(() => {
return local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
return [
{
value: "default",
title: "Default",
onSelect: () => {
dialog.clear()
local.model.variant.set(undefined)
},
},
}))
...local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
})),
]
})
return (
<DialogSelect<string>
options={options()}
title={"Select variant"}
current={local.model.variant.current()}
current={local.model.variant.selected()}
flat={true}
skipFilter={true}
/>
)
}

View File

@@ -45,6 +45,10 @@ export type PromptProps = {
ref?: (ref: PromptRef) => void
hint?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}
export type PromptRef = {
@@ -57,13 +61,16 @@ export type PromptRef = {
submit(): void
}
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"]
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
function randomIndex(count: number) {
if (count <= 0) return 0
return Math.floor(Math.random() * count)
}
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
@@ -83,6 +90,8 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
function promptModelWarning() {
toast.show({
@@ -152,7 +161,7 @@ export function Prompt(props: PromptProps) {
interrupt: number
placeholder: number
}>({
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
placeholder: randomIndex(list().length),
prompt: {
input: "",
parts: [],
@@ -166,7 +175,7 @@ export function Prompt(props: PromptProps) {
on(
() => props.sessionID,
() => {
setStore("placeholder", Math.floor(Math.random() * PLACEHOLDERS.length))
setStore("placeholder", randomIndex(list().length))
},
{ defer: true },
),
@@ -801,12 +810,14 @@ export function Prompt(props: PromptProps) {
})
const placeholderText = createMemo(() => {
if (props.sessionID) return undefined
if (props.showPlaceholder === false) return undefined
if (store.mode === "shell") {
const example = SHELL_PLACEHOLDERS[store.placeholder % SHELL_PLACEHOLDERS.length]
if (!shell().length) return undefined
const example = shell()[store.placeholder % shell().length]
return `Run a command... "${example}"`
}
return `Ask anything... "${PLACEHOLDERS[store.placeholder % PLACEHOLDERS.length]}"`
if (!list().length) return undefined
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const spinnerDef = createMemo(() => {
@@ -870,6 +881,7 @@ export function Prompt(props: PromptProps) {
>
<textarea
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={keybind.leader ? theme.textMuted : theme.text}
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
minHeight={1}
@@ -921,7 +933,7 @@ export function Prompt(props: PromptProps) {
}
}
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
e.preventDefault()
return
@@ -1096,7 +1108,7 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={<text />}>
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}

View File

@@ -321,12 +321,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
},
variant: {
current() {
selected() {
const m = currentModel()
if (!m) return undefined
const key = `${m.providerID}/${m.modelID}`
return modelStore.variant[key]
},
current() {
const v = this.selected()
if (!v) return undefined
if (!this.list().includes(v)) return undefined
return v
},
list() {
const m = currentModel()
if (!m) return []
@@ -339,7 +345,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const m = currentModel()
if (!m) return
const key = `${m.providerID}/${m.modelID}`
setModelStore("variant", key, value)
setModelStore("variant", key, value ?? "default")
save()
},
cycle() {

View File

@@ -183,6 +183,18 @@ export function addTheme(name: string, theme: unknown) {
return true
}
export function upsertTheme(name: string, theme: unknown) {
if (!name) return false
if (!isTheme(theme)) return false
if (customThemes[name] !== undefined) {
customThemes[name] = theme
} else {
pluginThemes[name] = theme
}
syncThemes()
return true
}
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {

View File

@@ -62,8 +62,8 @@
"light": "frappeText"
},
"textMuted": {
"dark": "frappeSubtext1",
"light": "frappeSubtext1"
"dark": "frappeOverlay2",
"light": "frappeOverlay2"
},
"background": {
"dark": "frappeBase",

View File

@@ -62,8 +62,8 @@
"light": "macText"
},
"textMuted": {
"dark": "macSubtext1",
"light": "macSubtext1"
"dark": "macOverlay2",
"light": "macOverlay2"
},
"background": {
"dark": "macBase",

View File

@@ -63,7 +63,7 @@
"success": { "dark": "darkGreen", "light": "lightGreen" },
"info": { "dark": "darkTeal", "light": "lightTeal" },
"text": { "dark": "darkText", "light": "lightText" },
"textMuted": { "dark": "darkSubtext1", "light": "lightSubtext1" },
"textMuted": { "dark": "darkOverlay2", "light": "lightOverlay2" },
"background": { "dark": "darkBase", "light": "lightBase" },
"backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" },
"backgroundElement": { "dark": "darkCrust", "light": "lightCrust" },

View File

@@ -0,0 +1,93 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { Global } from "@/global"
const id = "internal:home-footer"
function Directory(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const dir = createMemo(() => {
const dir = props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
const branch = props.api.state.vcs?.branch
if (branch) return out + ":" + branch
return out
})
return <text fg={theme().textMuted}>{dir()}</text>
}
function Mcp(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.mcp())
const has = createMemo(() => list().length > 0)
const err = createMemo(() => list().some((item) => item.status === "failed"))
const count = createMemo(() => list().filter((item) => item.status === "connected").length)
return (
<Show when={has()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={theme().text}>
<Switch>
<Match when={err()}>
<span style={{ fg: theme().error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: count() > 0 ? theme().success : theme().textMuted }}> </span>
</Match>
</Switch>
{count()} MCP
</text>
<text fg={theme().textMuted}>/status</text>
</box>
</Show>
)
}
function Version(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
return (
<box flexShrink={0}>
<text fg={theme().textMuted}>{props.api.app.version}</text>
</box>
)
}
function View(props: { api: TuiPluginApi }) {
return (
<box
width="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
>
<Directory api={props.api} />
<Mcp api={props.api} />
<box flexGrow={1} />
<Version api={props.api} />
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
home_footer() {
return <View api={api} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -14,6 +14,7 @@ import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
import { Prompt } from "../component/prompt"
import type { useToast } from "../ui/toast"
import { Installation } from "@/installation"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
@@ -287,6 +288,19 @@ export function createTuiApi(input: Input): TuiHostPluginApi {
/>
)
},
Prompt(props) {
return (
<Prompt
workspaceID={props.workspaceID}
visible={props.visible}
disabled={props.disabled}
onSubmit={props.onSubmit}
hint={props.hint}
showPlaceholder={props.showPlaceholder}
placeholders={props.placeholders}
/>
)
},
toast(inputToast) {
input.toast.show({
title: inputToast.title,

View File

@@ -1,3 +1,4 @@
import HomeFooter from "../feature-plugins/home/footer"
import HomeTips from "../feature-plugins/home/tips"
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
@@ -14,6 +15,7 @@ export type InternalTuiPlugin = TuiPluginModule & {
}
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
HomeFooter,
HomeTips,
SidebarContext,
SidebarMcp,

View File

@@ -18,38 +18,29 @@ import { Log } from "@/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import {
checkPluginCompatibility,
isDeprecatedPlugin,
pluginSource,
readPluginId,
readV1Plugin,
resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget,
type PluginSource,
} from "@/plugin/shared"
import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
import { PluginLoader } from "@/plugin/loader"
import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
import { addTheme, hasTheme } from "../context/theme"
import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
type PluginLoad = {
item?: Config.PluginSpec
options: Config.PluginOptions | undefined
spec: string
target: string
retry: boolean
source: PluginSource | "internal"
id: string
module: TuiPluginModule
install_theme: TuiTheme["install"]
theme_meta: TuiConfig.PluginMeta
theme_root: string
}
type Api = HostPluginApi
@@ -64,8 +55,8 @@ type PluginEntry = {
id: string
load: PluginLoad
meta: TuiPluginMeta
themes: Record<string, PluginMeta.Theme>
plugin: TuiPlugin
options: Config.PluginOptions | undefined
enabled: boolean
scope?: PluginScope
}
@@ -76,13 +67,7 @@ type RuntimeState = {
slots: HostSlots
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
pending: Map<
string,
{
item: Config.PluginSpec
meta: TuiConfig.PluginMeta
}
>
pending: Map<string, TuiConfig.PluginRecord>
}
const log = Log.create({ service: "tui.plugin" })
@@ -102,6 +87,11 @@ function fail(message: string, data: Record<string, unknown>) {
console.error(`[tui.plugin] ${text}`, next)
}
function warn(message: string, data: Record<string, unknown>) {
log.warn(message, data)
console.warn(`[tui.plugin] ${message}`, data)
}
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -143,12 +133,54 @@ function resolveRoot(root: string) {
return path.resolve(process.cwd(), root)
}
function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
function createThemeInstaller(
meta: TuiConfig.PluginMeta,
root: string,
spec: string,
plugin: PluginEntry,
): TuiTheme["install"] {
return async (file) => {
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
const theme = path.basename(src, path.extname(src))
if (hasTheme(theme)) return
const name = path.basename(src, path.extname(src))
const source_dir = path.dirname(meta.source)
const local_dir =
path.basename(source_dir) === ".opencode"
? path.join(source_dir, "themes")
: path.join(source_dir, ".opencode", "themes")
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
const dest = path.join(dest_dir, `${name}.json`)
const stat = await Filesystem.statAsync(src)
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
const exists = hasTheme(name)
const prev = plugin.themes[name]
if (exists) {
if (plugin.meta.state !== "updated") return
if (!prev) {
if (await Filesystem.exists(dest)) {
plugin.themes[name] = {
src,
dest,
mtime,
size,
}
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
})
})
}
return
}
if (prev.dest !== dest) return
if (prev.mtime === mtime && prev.size === size) return
}
const text = await Filesystem.readText(src).catch((error) => {
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
@@ -170,90 +202,110 @@ function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: st
return
}
const source_dir = path.dirname(meta.source)
const local_dir =
path.basename(source_dir) === ".opencode"
? path.join(source_dir, "themes")
: path.join(source_dir, ".opencode", "themes")
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
const dest = path.join(dest_dir, `${theme}.json`)
if (!(await Filesystem.exists(dest))) {
if (exists || !(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((error) => {
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
})
}
addTheme(theme, data)
upsertTheme(name, data)
plugin.themes[name] = {
src,
dest,
mtime,
size,
}
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
})
})
}
}
async function loadExternalPlugin(
item: Config.PluginSpec,
meta: TuiConfig.PluginMeta | undefined,
retry = false,
): Promise<PluginLoad | undefined> {
const spec = Config.pluginSpecifier(item)
if (isDeprecatedPlugin(spec)) return
log.info("loading tui plugin", { path: spec, retry })
const resolved = await resolvePluginTarget(spec).catch((error) => {
fail("failed to resolve tui plugin", { path: spec, retry, error })
return
})
if (!resolved) return
async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
const plan = PluginLoader.plan(cfg.item)
if (plan.deprecated) return
const source = pluginSource(spec)
if (source === "npm") {
const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => true)
.catch((error) => {
fail("tui plugin incompatible", { path: spec, retry, error })
return false
log.info("loading tui plugin", { path: plan.spec, retry })
const resolved = await PluginLoader.resolve(plan, "tui")
if (!resolved.ok) {
if (resolved.stage === "missing") {
warn("tui plugin has no entrypoint", {
path: plan.spec,
retry,
message: resolved.message,
})
if (!ok) return
return
}
if (resolved.stage === "install") {
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
return
}
if (resolved.stage === "compatibility") {
fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error })
return
}
fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error })
return
}
const target = resolved
if (!meta) {
fail("missing tui plugin metadata", {
path: spec,
const loaded = await PluginLoader.load(resolved.value)
if (!loaded.ok) {
fail("failed to load tui plugin", {
path: plan.spec,
target: resolved.value.entry,
retry,
error: loaded.error,
})
return
}
const root = resolveRoot(source === "file" ? spec : target)
const install_theme = createThemeInstaller(meta, root, spec)
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
return
})
if (!entry) return
const mod = await import(entry)
.then((raw) => {
return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
const mod = await Promise.resolve()
.then(() => {
return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
})
.catch((error) => {
fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
fail("failed to load tui plugin", {
path: plan.spec,
target: loaded.value.entry,
retry,
error,
})
return
})
if (!mod) return
const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => {
fail("failed to load tui plugin", { path: spec, target, retry, error })
const id = await resolvePluginId(
loaded.value.source,
plan.spec,
loaded.value.target,
readPluginId(mod.id, plan.spec),
loaded.value.pkg,
).catch((error) => {
fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error })
return
})
if (!id) return
return {
item,
spec,
target,
options: plan.options,
spec: plan.spec,
target: loaded.value.target,
retry,
source,
source: loaded.value.source,
id,
module: mod,
install_theme,
theme_meta: {
scope: cfg.scope,
source: cfg.source,
},
theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
}
}
@@ -291,20 +343,18 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
const target = spec
return {
options: undefined,
spec,
target,
retry: false,
source: "internal",
id: item.id,
module: item,
install_theme: createThemeInstaller(
{
scope: "global",
source: target,
},
process.cwd(),
spec,
),
theme_meta: {
scope: "global",
source: target,
},
theme_root: process.cwd(),
}
}
@@ -436,10 +486,10 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
if (plugin.scope) return true
const scope = createPluginScope(plugin.load, plugin.id)
const api = pluginApi(state, plugin.load, scope, plugin.id)
const api = pluginApi(state, plugin, scope, plugin.id)
const ok = await Promise.resolve()
.then(async () => {
await plugin.plugin(api, plugin.options, plugin.meta)
await plugin.plugin(api, plugin.load.options, plugin.meta)
return true
})
.catch((error) => {
@@ -479,9 +529,10 @@ async function deactivatePluginById(state: RuntimeState | undefined, id: string,
return deactivatePluginEntry(state, plugin, persist)
}
function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi {
function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScope, base: string): TuiPluginApi {
const api = runtime.api
const host = runtime.slots
const load = plugin.load
const command: TuiPluginApi["command"] = {
register(cb) {
return scope.track(api.command.register(cb))
@@ -504,7 +555,7 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
}
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
install: load.install_theme,
install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
})
const event: TuiPluginApi["event"] = {
@@ -563,20 +614,6 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
}
}
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
const options = load.item ? Config.pluginOptions(load.item) : undefined
return [
{
id: load.id,
load,
meta,
plugin: load.module.tui,
options,
enabled: true,
},
]
}
function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
if (state.plugins_by_id.has(plugin.id)) {
fail("duplicate tui plugin id", {
@@ -600,12 +637,8 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
}
}
async function resolveExternalPlugins(
list: Config.PluginSpec[],
wait: () => Promise<void>,
meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
) {
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item)))
const ready: PluginLoad[] = []
let deps: Promise<void> | undefined
@@ -614,13 +647,12 @@ async function resolveExternalPlugins(
if (!entry) {
const item = list[i]
if (!item) continue
const spec = Config.pluginSpecifier(item)
if (pluginSource(spec) !== "file") continue
if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
deps ??= wait().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
await deps
entry = await loadExternalPlugin(item, meta(item), true)
entry = await loadExternalPlugin(item, true)
}
if (!entry) continue
ready.push(entry)
@@ -661,20 +693,28 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
}
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
for (const plugin of collectPluginEntries(entry, row)) {
if (!addPluginEntry(state, plugin)) {
ok = false
continue
}
plugins.push(plugin)
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
const plugin: PluginEntry = {
id: entry.id,
load: entry,
meta: row,
themes,
plugin: entry.module.tui,
enabled: true,
}
if (!addPluginEntry(state, plugin)) {
ok = false
continue
}
plugins.push(plugin)
}
return { plugins, ok }
}
function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta {
function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
return {
item: spec,
scope: "local",
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
}
@@ -712,36 +752,27 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
const spec = raw.trim()
if (!spec) return false
const pending = state.pending.get(spec)
const item = pending?.item ?? spec
const nextSpec = Config.pluginSpecifier(item)
if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
const next = Config.pluginSpecifier(cfg.item)
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec)
return true
}
const meta = pending?.meta ?? defaultPluginMeta(state)
const ready = await Instance.provide({
directory: state.directory,
fn: () =>
resolveExternalPlugins(
[item],
() => TuiConfig.waitForDependencies(),
() => meta,
),
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
fail("failed to add tui plugin", { path: nextSpec, error })
fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[]
})
if (!ready.length) {
fail("failed to add tui plugin", { path: nextSpec })
return false
}
const first = ready[0]
if (!first) {
fail("failed to add tui plugin", { path: nextSpec })
fail("failed to add tui plugin", { path: next })
return false
}
if (state.plugins_by_id.has(first.id)) {
@@ -758,7 +789,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
if (ok) state.pending.delete(spec)
if (!ok) {
fail("failed to add tui plugin", { path: nextSpec })
fail("failed to add tui plugin", { path: next })
}
return ok
}
@@ -806,7 +837,7 @@ async function installPluginBySpec(
if (manifest.code === "manifest_no_targets") {
return {
ok: false,
message: `"${spec}" does not declare supported targets in package.json`,
message: `"${spec}" does not expose plugin entrypoints in package.json`,
}
}
@@ -841,12 +872,11 @@ async function installPluginBySpec(
const tui = manifest.targets.find((item) => item.kind === "tui")
if (tui) {
const file = patch.items.find((item) => item.kind === "tui")?.file
const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
state.pending.set(spec, {
item: tui.opts ? [spec, tui.opts] : spec,
meta: {
scope: global ? "global" : "local",
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
},
item,
scope: global ? "global" : "local",
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
})
}
@@ -929,25 +959,26 @@ export namespace TuiPluginRuntime {
directory: cwd,
fn: async () => {
const config = await TuiConfig.get()
const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
if (Flag.OPENCODE_PURE && config.plugin?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
for (const plugin of collectPluginEntries(entry, meta)) {
addPluginEntry(next, plugin)
}
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
const ready = await resolveExternalPlugins(
plugins,
() => TuiConfig.waitForDependencies(),
(item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
)
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config)

View File

@@ -1,59 +1,31 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { createEffect, on, onMount } from "solid-js"
import { Logo } from "../component/logo"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
import { useDirectory } from "../context/directory"
import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "../plugin"
// TODO: what is the best way to do this?
let once = false
const placeholder = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
shell: ["ls -la", "git status", "pwd"],
}
export function Home() {
const sync = useSync()
const { theme } = useTheme()
const route = useRouteData("home")
const promptRef = usePromptRef()
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
})
const connectedMcpCount = createMemo(() => {
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
})
const Hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<Show when={connectedMcpCount() > 0}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}></span> mcp errors{" "}
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}></span>{" "}
{Locale.pluralize(connectedMcpCount(), "{} mcp server", "{} mcp servers")}
</Match>
</Switch>
</text>
</Show>
</box>
)
let prompt: PromptRef
let prompt: PromptRef | undefined
const args = useArgs()
const local = useLocal()
onMount(() => {
if (once) return
if (!prompt) return
if (route.initialPrompt) {
prompt.set(route.initialPrompt)
once = true
@@ -69,13 +41,13 @@ export function Home() {
() => sync.ready && local.model.ready,
(ready) => {
if (!ready) return
if (!prompt) return
if (!args.prompt) return
if (prompt.current?.input !== args.prompt) return
prompt.submit()
},
),
)
const directory = useDirectory()
return (
<>
@@ -89,41 +61,23 @@ export function Home() {
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<Prompt
ref={(r) => {
prompt = r
promptRef.set(r)
}}
hint={Hint}
workspaceID={route.workspaceID}
/>
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID}>
<Prompt
ref={(r) => {
prompt = r
promptRef.set(r)
}}
workspaceID={route.workspaceID}
placeholders={placeholder}
/>
</TuiPluginRuntime.Slot>
</box>
<TuiPluginRuntime.Slot name="home_bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={1} flexDirection="row" flexShrink={0}>
<Show when={mcp()}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: connectedMcpCount() > 0 ? theme.success : theme.textMuted }}> </span>
</Match>
</Switch>
{connectedMcpCount()} MCP
</text>
<text fg={theme.textMuted}>/status</text>
</Show>
</box>
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={theme.textMuted}>{Installation.VERSION}</text>
</box>
<box width="100%" flexShrink={0}>
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
</box>
</>
)

View File

@@ -334,7 +334,7 @@ export function Session() {
if (children().length === 1) return
const sessions = children().filter((x) => !!x.parentID)
let next = sessions.findIndex((x) => x.id === session()?.id) + direction
let next = sessions.findIndex((x) => x.id === session()?.id) - direction
if (next >= sessions.length) next = 0
if (next < 0) next = sessions.length - 1
@@ -1228,7 +1228,6 @@ function UserMessage(props: {
const local = useLocal()
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const sync = useSync()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
@@ -1614,17 +1613,6 @@ function GenericTool(props: ToolProps<any>) {
)
}
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
const { theme } = useTheme()
return (
<text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}>
<Show fallback={<>~ {props.fallback}</>} when={props.when}>
<span style={{ bold: true }}>{props.icon}</span> {props.children}
</Show>
</text>
)
}
function InlineTool(props: {
icon: string
iconColor?: RGBA
@@ -1962,10 +1950,7 @@ function WebSearch(props: ToolProps<any>) {
}
function Task(props: ToolProps<typeof TaskTool>) {
const { theme } = useTheme()
const keybind = useKeybind()
const { navigate } = useRoute()
const local = useLocal()
const sync = useSync()
onMount(() => {
@@ -1996,7 +1981,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
const content = createMemo(() => {
if (!props.input.description) return ""
let content = [`Task ${props.input.description}`]
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task ${props.input.description}`]
if (isRunning() && tools().length > 0) {
// content[0] += ` · ${tools().length} toolcalls`

View File

@@ -387,6 +387,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}}
initialValue={input()}
placeholder="Type your own answer"
placeholderColor={theme.textMuted}
minHeight={1}
maxHeight={6}
textColor={theme.text}

View File

@@ -13,6 +13,23 @@ export function SubagentFooter() {
const route = useRouteData("session")
const sync = useSync()
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const session = createMemo(() => sync.session.get(route.sessionID))
const subagentInfo = createMemo(() => {
const s = session()
if (!s) return { label: "Subagent", index: 0, total: 0 }
const agentMatch = s.title.match(/@(\w+) subagent/)
const label = agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
if (!s.parentID) return { label, index: 0, total: 0 }
const siblings = sync.data.session
.filter((x) => x.parentID === s.parentID)
.toSorted((a, b) => a.time.created - b.time.created)
const index = siblings.findIndex((x) => x.id === s.id)
return { label, index: index + 1, total: siblings.length }
})
const usage = createMemo(() => {
const msg = messages()
@@ -58,10 +75,15 @@ export function SubagentFooter() {
backgroundColor={theme.backgroundPanel}
>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={2}>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>
<b>Subagent session</b>
<b>{subagentInfo().label}</b>
</text>
<Show when={subagentInfo().total > 0}>
<text style={{ fg: theme.textMuted }}>
({subagentInfo().index} of {subagentInfo().total})
</text>
</Show>
<Show when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">

View File

@@ -103,6 +103,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.defaultFilename}
placeholder="Enter filename"
placeholderColor={theme.textMuted}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}

View File

@@ -74,6 +74,7 @@ export function DialogPrompt(props: DialogPromptProps) {
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
placeholderColor={theme.textMuted}
textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text}

View File

@@ -260,6 +260,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
</box>

View File

@@ -1,7 +1,6 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL } from "url"
import { createRequire } from "module"
import os from "os"
import z from "zod"
import { ModelsDev } from "../provider/models"
@@ -122,7 +121,10 @@ export namespace Config {
const gitignore = path.join(dir, ".gitignore")
const ignore = await Filesystem.exists(gitignore)
if (!ignore) {
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
await Filesystem.write(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
// Bun can race cache writes on Windows when installs run in parallel across dirs.
@@ -366,33 +368,18 @@ export namespace Config {
export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
const spec = pluginSpecifier(plugin)
if (!isPathPluginSpec(spec)) return plugin
if (spec.startsWith("file://")) {
const resolved = await resolvePathPluginTarget(spec).catch(() => spec)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
}
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
const base = pathToFileURL(spec).href
const resolved = await resolvePathPluginTarget(base).catch(() => base)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
}
try {
const base = import.meta.resolve!(spec, configFilepath)
const resolved = await resolvePathPluginTarget(base).catch(() => base)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
try {
const require = createRequire(configFilepath)
const base = pathToFileURL(require.resolve(spec)).href
const resolved = await resolvePathPluginTarget(base).catch(() => base)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
return plugin
}
}
const base = path.dirname(configFilepath)
const file = (() => {
if (spec.startsWith("file://")) return spec
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
return pathToFileURL(path.resolve(base, spec)).href
})()
const resolved = await resolvePathPluginTarget(file).catch(() => file)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
}
/**
@@ -1556,7 +1543,7 @@ export namespace Config {
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Auth.layer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
)

View File

@@ -22,6 +22,12 @@ export namespace TuiConfig {
source: string
}
export type PluginRecord = {
item: Config.PluginSpec
scope: PluginMeta["scope"]
source: string
}
type PluginEntry = {
item: Config.PluginSpec
meta: PluginMeta
@@ -33,7 +39,8 @@ export namespace TuiConfig {
}
export type Info = z.output<typeof Info> & {
plugin_meta?: Record<string, PluginMeta>
// Internal resolved plugin list used by runtime loading.
plugin_records?: PluginRecord[]
}
function pluginScope(file: string): PluginMeta["scope"] {
@@ -149,10 +156,13 @@ export namespace TuiConfig {
const merged = dedupePlugins(acc.entries)
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
acc.result.plugin = merged.map((item) => item.item)
acc.result.plugin_meta = merged.length
? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta]))
: undefined
const list = merged.map((item) => ({
item: item.item,
scope: item.meta.scope,
source: item.meta.source,
}))
acc.result.plugin = list.map((item) => item.item)
acc.result.plugin_records = list.length ? list : undefined
const deps: Promise<void>[] = []
if (acc.result.plugin?.length) {

View File

@@ -9,6 +9,8 @@ export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: L
return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromiseExit(service.use(fn), options),
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromise(service.use(fn), options),
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),

View File

@@ -0,0 +1,216 @@
import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect"
export interface Runner<A, E = never> {
readonly state: Runner.State<A, E>
readonly busy: boolean
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly startShell: (work: (signal: AbortSignal) => Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly cancel: Effect.Effect<void>
}
export namespace Runner {
export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
interface RunHandle<A, E> {
id: number
done: Deferred.Deferred<A, E | Cancelled>
fiber: Fiber.Fiber<A, E>
}
interface ShellHandle<A, E> {
id: number
fiber: Fiber.Fiber<A, E>
abort: AbortController
}
interface PendingHandle<A, E> {
id: number
done: Deferred.Deferred<A, E | Cancelled>
work: Effect.Effect<A, E>
}
export type State<A, E> =
| { readonly _tag: "Idle" }
| { readonly _tag: "Running"; readonly run: RunHandle<A, E> }
| { readonly _tag: "Shell"; readonly shell: ShellHandle<A, E> }
| { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle<A, E>; readonly run: PendingHandle<A, E> }
export const make = <A, E = never>(
scope: Scope.Scope,
opts?: {
onIdle?: Effect.Effect<void>
onBusy?: Effect.Effect<void>
onInterrupt?: Effect.Effect<A, E>
busy?: () => never
},
): Runner<A, E> => {
const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
const idle = opts?.onIdle ?? Effect.void
const busy = opts?.onBusy ?? Effect.void
const onInterrupt = opts?.onInterrupt
let ids = 0
const state = () => SynchronizedRef.getUnsafe(ref)
const next = () => {
ids += 1
return ids
}
const complete = (done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid)
: Deferred.done(done, exit).pipe(Effect.asVoid)
const idleIfCurrent = () =>
SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten)
const finishRun = (id: number, done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
SynchronizedRef.modify(
ref,
(st) =>
[
Effect.gen(function* () {
if (st._tag === "Running" && st.run.id === id) yield* idle
yield* complete(done, exit)
}),
st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st,
] as const,
).pipe(Effect.flatten)
const startRun = (work: Effect.Effect<A, E>, done: Deferred.Deferred<A, E | Cancelled>) =>
Effect.gen(function* () {
const id = next()
const fiber = yield* work.pipe(
Effect.onExit((exit) => finishRun(id, done, exit)),
Effect.forkIn(scope),
)
return { id, done, fiber } satisfies RunHandle<A, E>
})
const finishShell = (id: number) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const
if (st._tag === "ShellThenRun" && st.shell.id === id) {
const run = yield* startRun(st.run.work, st.run.done)
return [Effect.void, { _tag: "Running", run }] as const
}
return [Effect.void, st] as const
}),
).pipe(Effect.flatten)
const stopShell = (shell: ShellHandle<A, E>) =>
Effect.gen(function* () {
shell.abort.abort()
const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis"))
if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber)
yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid)
})
const ensureRunning = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
switch (st._tag) {
case "Running":
case "ShellThenRun":
return [Deferred.await(st.run.done), st] as const
case "Shell": {
const run = {
id: next(),
done: yield* Deferred.make<A, E | Cancelled>(),
work,
} satisfies PendingHandle<A, E>
return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const
}
case "Idle": {
const done = yield* Deferred.make<A, E | Cancelled>()
const run = yield* startRun(work, done)
return [Deferred.await(done), { _tag: "Running", run }] as const
}
}
}),
).pipe(
Effect.flatten,
Effect.catch(
(e): Effect.Effect<A, E> => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)),
),
)
const startShell = (work: (signal: AbortSignal) => Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
if (st._tag !== "Idle") {
return [
Effect.sync(() => {
if (opts?.busy) opts.busy()
throw new Error("Runner is busy")
}),
st,
] as const
}
yield* busy
const id = next()
const abort = new AbortController()
const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
const shell = { id, fiber, abort } satisfies ShellHandle<A, E>
return [
Effect.gen(function* () {
const exit = yield* Fiber.await(fiber)
if (Exit.isSuccess(exit)) return exit.value
if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt
return yield* Effect.failCause(exit.cause)
}),
{ _tag: "Shell", shell },
] as const
}),
).pipe(Effect.flatten)
const cancel = SynchronizedRef.modify(ref, (st) => {
switch (st._tag) {
case "Idle":
return [Effect.void, st] as const
case "Running":
return [
Effect.gen(function* () {
yield* Fiber.interrupt(st.run.fiber)
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
] as const
case "Shell":
return [
Effect.gen(function* () {
yield* stopShell(st.shell)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
] as const
case "ShellThenRun":
return [
Effect.gen(function* () {
yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
yield* stopShell(st.shell)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
] as const
}
}).pipe(Effect.flatten)
return {
get state() {
return state()
},
get busy() {
return state()._tag !== "Idle"
},
ensureRunning,
startShell,
cancel,
}
}
}

View File

@@ -541,7 +541,7 @@ export namespace File {
const exists = yield* appFs.existsSafe(full)
if (!exists) return { type: "text" as const, content: "" }
const mimeType = Filesystem.mimeType(full)
const mimeType = AppFileSystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }

View File

@@ -1,9 +1,9 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
export namespace FileTime {
@@ -12,21 +12,9 @@ export namespace FileTime {
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
@@ -53,7 +41,17 @@ export namespace FileTime {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const stamp = Effect.fnUntraced(function* (file: string) {
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
return {
read: yield* DateTime.nowAsDate,
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
size: info ? Number(info.size) : undefined,
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() =>
Effect.succeed({
@@ -92,7 +90,7 @@ export namespace FileTime {
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
const changed = next.mtime !== time.mtime || next.size !== time.size
if (!changed) return
throw new Error(
@@ -108,7 +106,9 @@ export namespace FileTime {
}),
).pipe(Layer.orDie)
const { runPromise } = makeRuntime(Service, layer)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file))

View File

@@ -375,38 +375,6 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
delete provider.models[modelId]
}
if (!provider.models["gpt-5.3-codex"]) {
const model = {
id: ModelID.make("gpt-5.3-codex"),
providerID: ProviderID.openai,
api: {
id: "gpt-5.3-codex",
url: "https://chatgpt.com/backend-api/codex",
npm: "@ai-sdk/openai",
},
name: "GPT-5.3 Codex",
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 400_000, input: 272_000, output: 128_000 },
status: "active" as const,
options: {},
headers: {},
release_date: "2026-02-05",
variants: {} as Record<string, Record<string, any>>,
family: "gpt-codex",
}
model.variants = ProviderTransform.variants(model)
provider.models["gpt-5.3-codex"] = model
}
// Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) {
model.cost = {

View File

@@ -14,19 +14,8 @@ import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,
isDeprecatedPlugin,
parsePluginSpecifier,
pluginSource,
readPluginId,
readV1Plugin,
resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget,
type PluginSource,
} from "./shared"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -36,11 +25,7 @@ export namespace Plugin {
}
type Loaded = {
item: Config.PluginSpec
spec: string
target: string
source: PluginSource
mod: Record<string, unknown>
row: PluginLoader.Loaded
}
// Hook names that follow the (input, output) => Promise<void> trigger pattern
@@ -93,91 +78,22 @@ export namespace Plugin {
return result
}
async function resolvePlugin(spec: string) {
const parsed = parsePluginSpecifier(spec)
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = errorMessage(cause ?? err)
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!target) return
return target
}
async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
const spec = Config.pluginSpecifier(item)
if (isDeprecatedPlugin(spec)) return
log.info("loading plugin", { path: spec })
const resolved = await resolvePlugin(spec)
if (!resolved) return
const source = pluginSource(spec)
if (source === "npm") {
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => false)
.catch((err) => {
const message = errorMessage(err)
log.warn("plugin incompatible", { path: spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Plugin ${spec} skipped: ${message}`,
}).toObject(),
})
return true
})
if (incompatible) return
}
const target = resolved
const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
const message = errorMessage(err)
log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
}).toObject(),
})
return
})
if (!entry) return
const mod = await import(entry).catch((err) => {
const message = errorMessage(err)
log.error("failed to load plugin", { path: spec, target: entry, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
}).toObject(),
})
return
})
if (!mod) return
return {
item,
spec,
target,
source,
mod,
}
}
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect")
if (plugin) {
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec))
hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item)))
await resolvePluginId(
load.row.source,
load.row.spec,
load.row.target,
readPluginId(plugin.id, load.row.spec),
load.row.pkg,
)
hooks.push(await (plugin as PluginModule).server(input, load.row.options))
return
}
for (const server of getLegacyPlugins(load.mod)) {
hooks.push(await server(input, Config.pluginOptions(load.item)))
for (const server of getLegacyPlugins(load.row.mod)) {
hooks.push(await server(input, load.row.options))
}
}
@@ -232,7 +148,82 @@ export namespace Plugin {
}
if (plugins.length) yield* config.waitForDependencies()
const loaded = yield* Effect.promise(() => Promise.all(plugins.map((item) => prepPlugin(item))))
const loaded = yield* Effect.promise(() =>
Promise.all(
plugins.map(async (item) => {
const plan = PluginLoader.plan(item)
if (plan.deprecated) return
log.info("loading plugin", { path: plan.spec })
const resolved = await PluginLoader.resolve(plan, "server")
if (!resolved.ok) {
if (resolved.stage === "missing") {
log.warn("plugin has no server entrypoint", {
path: plan.spec,
message: resolved.message,
})
return
}
const cause =
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
const message = errorMessage(cause)
if (resolved.stage === "install") {
const parsed = parsePluginSpecifier(plan.spec)
log.error("failed to install plugin", {
pkg: parsed.pkg,
version: parsed.version,
error: message,
})
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`,
}).toObject(),
})
return
}
if (resolved.stage === "compatibility") {
log.warn("plugin incompatible", { path: plan.spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Plugin ${plan.spec} skipped: ${message}`,
}).toObject(),
})
return
}
log.error("failed to resolve plugin server entry", {
path: plan.spec,
error: message,
})
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plan.spec}: ${message}`,
}).toObject(),
})
return
}
const mod = await PluginLoader.load(resolved.value)
if (!mod.ok) {
const message = errorMessage(mod.error)
log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plan.spec}: ${message}`,
}).toObject(),
})
return
}
return {
row: mod.value,
}
}),
),
)
for (const load of loaded) {
if (!load) continue
@@ -242,14 +233,14 @@ export namespace Plugin {
try: () => applyPlugin(load, input, hooks),
catch: (err) => {
const message = errorMessage(err)
log.error("failed to load plugin", { path: load.spec, error: message })
log.error("failed to load plugin", { path: load.row.spec, error: message })
return message
},
}).pipe(
Effect.catch((message) =>
bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`,
message: `Failed to load plugin ${load.row.spec}: ${message}`,
}).toObject(),
}),
),
@@ -292,7 +283,7 @@ export namespace Plugin {
for (const hook of state.hooks) {
const fn = hook[name] as any
if (!fn) continue
yield* Effect.promise(() => fn(input, output))
yield* Effect.promise(async () => fn(input, output))
}
return output
})

View File

@@ -11,6 +11,7 @@ import { ConfigPaths } from "@/config/paths"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { isRecord } from "@/util/record"
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
@@ -94,33 +95,91 @@ function pluginSpec(item: unknown) {
return item[0]
}
function parseTarget(item: unknown): Target | undefined {
if (item === "server" || item === "tui") return { kind: item }
if (!Array.isArray(item)) return
if (item[0] !== "server" && item[0] !== "tui") return
if (item.length < 2) return { kind: item[0] }
const opt = item[1]
if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
return {
kind: item[0],
opts: opt,
}
function pluginList(data: unknown) {
if (!data || typeof data !== "object" || Array.isArray(data)) return
const item = data as { plugin?: unknown }
if (!Array.isArray(item.plugin)) return
return item.plugin
}
function parseTargets(raw: unknown) {
if (!Array.isArray(raw)) return []
const map = new Map<Kind, Target>()
for (const item of raw) {
const hit = parseTarget(item)
function exportValue(value: unknown): string | undefined {
if (typeof value === "string") {
const next = value.trim()
if (next) return next
return
}
if (!isRecord(value)) return
for (const key of ["import", "default"]) {
const next = value[key]
if (typeof next !== "string") continue
const hit = next.trim()
if (!hit) continue
map.set(hit.kind, hit)
return hit
}
return [...map.values()]
}
function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
function exportOptions(value: unknown): Record<string, unknown> | undefined {
if (!isRecord(value)) return
const config = value.config
if (!isRecord(config)) return
return config
}
function exportTarget(pkg: Record<string, unknown>, kind: Kind) {
const exports = pkg.exports
if (!isRecord(exports)) return
const value = exports[`./${kind}`]
const entry = exportValue(value)
if (!entry) return
return {
opts: exportOptions(value),
}
}
function hasMainTarget(pkg: Record<string, unknown>) {
const main = pkg.main
if (typeof main !== "string") return false
return Boolean(main.trim())
}
function packageTargets(pkg: Record<string, unknown>) {
const targets: Target[] = []
const server = exportTarget(pkg, "server")
if (server) {
targets.push({ kind: "server", opts: server.opts })
} else if (hasMainTarget(pkg)) {
targets.push({ kind: "server" })
}
const tui = exportTarget(pkg, "tui")
if (tui) {
targets.push({ kind: "tui", opts: tui.opts })
}
return targets
}
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
return applyEdits(
text,
modify(text, path, value, {
formattingOptions: {
tabSize: 2,
insertSpaces: true,
},
isArrayInsertion: insert,
}),
)
}
function patchPluginList(
text: string,
list: unknown[] | undefined,
spec: string,
next: unknown,
force = false,
): { mode: Mode; text: string } {
const pkg = parsePluginSpecifier(spec).pkg
const rows = list.map((item, i) => ({
const rows = (list ?? []).map((item, i) => ({
item,
i,
spec: pluginSpec(item),
@@ -133,16 +192,22 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
})
if (!dup.length) {
if (!list) {
return {
mode: "add",
text: patch(text, ["plugin"], [next]),
}
}
return {
mode: "add",
list: [...list, next],
text: patch(text, ["plugin", list.length], next, true),
}
}
if (!force) {
return {
mode: "noop",
list,
text,
}
}
@@ -150,29 +215,37 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
if (!keep) {
return {
mode: "noop",
list,
text,
}
}
if (dup.length === 1 && keep.spec === spec) {
return {
mode: "noop",
list,
text,
}
}
const idx = new Set(dup.map((item) => item.i))
let out = text
if (typeof keep.item === "string") {
out = patch(out, ["plugin", keep.i], next)
}
if (Array.isArray(keep.item) && typeof keep.item[0] === "string") {
out = patch(out, ["plugin", keep.i, 0], spec)
}
const del = dup
.map((item) => item.i)
.filter((i) => i !== keep.i)
.sort((a, b) => b - a)
for (const i of del) {
out = patch(out, ["plugin", i], undefined)
}
return {
mode: "replace",
list: rows.flatMap((row) => {
if (!idx.has(row.i)) return [row.item]
if (row.i !== keep.i) return []
if (typeof row.item === "string") return [next]
if (Array.isArray(row.item) && typeof row.item[0] === "string") {
return [[spec, ...row.item.slice(1)]]
}
return [row.item]
}),
text: out,
}
}
@@ -220,7 +293,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
}
}
const targets = parseTargets(pkg.item.json["oc-plugin"])
const targets = packageTargets(pkg.item.json)
if (!targets.length) {
return {
ok: false,
@@ -289,10 +362,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
}
}
const list: unknown[] =
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
const item = target.opts ? [spec, target.opts] : spec
const out = patchPluginList(list, spec, item, force)
const list = pluginList(data)
const item = target.opts ? ([spec, target.opts] as const) : spec
const out = patchPluginList(text, list, spec, item, force)
if (out.mode === "noop") {
return {
ok: true,
@@ -304,13 +376,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
}
}
const edits = modify(text, ["plugin"], out.list, {
formattingOptions: {
tabSize: 2,
insertSpaces: true,
},
})
const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
const write = await dep.write(cfg, out.text).catch((error: unknown) => error)
if (write instanceof Error) {
return {
ok: false,

View File

@@ -0,0 +1,137 @@
import { Config } from "@/config/config"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,
createPluginEntry,
isDeprecatedPlugin,
resolvePluginTarget,
type PluginKind,
type PluginPackage,
type PluginSource,
} from "./shared"
export namespace PluginLoader {
export type Plan = {
item: Config.PluginSpec
spec: string
options: Config.PluginOptions | undefined
deprecated: boolean
}
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
export function plan(item: Config.PluginSpec): Plan {
const spec = Config.pluginSpecifier(item)
return {
item,
spec,
options: Config.pluginOptions(item),
deprecated: isDeprecatedPlugin(spec),
}
}
export async function resolve(
plan: Plan,
kind: PluginKind,
): Promise<
| { ok: true; value: Resolved }
| { ok: false; stage: "missing"; message: string }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
} catch (error) {
return {
ok: false,
stage: "install",
error,
}
}
if (!target) {
return {
ok: false,
stage: "install",
error: new Error(`Plugin ${plan.spec} target is empty`),
}
}
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
} catch (error) {
return {
ok: false,
stage: "entry",
error,
}
}
if (!base.entry) {
return {
ok: false,
stage: "missing",
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
}
}
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
} catch (error) {
return {
ok: false,
stage: "compatibility",
error,
}
}
}
return {
ok: true,
value: {
...plan,
source: base.source,
target: base.target,
entry: base.entry,
pkg: base.pkg,
},
}
}
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod
try {
mod = await import(row.entry)
} catch (error) {
return {
ok: false,
error,
}
}
if (!mod) {
return {
ok: false,
error: new Error(`Plugin ${row.spec} module is empty`),
}
}
return {
ok: true,
value: {
...row,
mod,
},
}
}
}

View File

@@ -11,6 +11,13 @@ import { parsePluginSpecifier, pluginSource } from "./shared"
export namespace PluginMeta {
type Source = "file" | "npm"
export type Theme = {
src: string
dest: string
mtime?: number
size?: number
}
export type Entry = {
id: string
source: Source
@@ -24,6 +31,7 @@ export namespace PluginMeta {
time_changed: number
load_count: number
fingerprint: string
themes?: Record<string, Theme>
}
export type State = "first" | "updated" | "same"
@@ -35,7 +43,7 @@ export namespace PluginMeta {
}
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
type Row = Touch & { core: Core }
function storePath() {
@@ -52,11 +60,11 @@ export namespace PluginMeta {
return
}
function modifiedAt(file: string) {
const stat = Filesystem.stat(file)
async function modifiedAt(file: string) {
const stat = await Filesystem.statAsync(file)
if (!stat) return
const value = stat.mtimeMs
return Math.floor(typeof value === "bigint" ? Number(value) : value)
const mtime = stat.mtimeMs
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
}
function resolvedTarget(target: string) {
@@ -66,7 +74,7 @@ export namespace PluginMeta {
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = Filesystem.stat(resolved)
const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
@@ -84,7 +92,7 @@ export namespace PluginMeta {
source,
spec,
target,
modified: file ? modifiedAt(file) : undefined,
modified: file ? await modifiedAt(file) : undefined,
}
}
@@ -122,6 +130,7 @@ export namespace PluginMeta {
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
themes: prev?.themes,
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
@@ -158,6 +167,20 @@ export namespace PluginMeta {
})
}
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
const file = storePath()
await Flock.withLock(lock(file), async () => {
const store = await read(file)
const entry = store[id]
if (!entry) return
entry.themes = {
...(entry.themes ?? {}),
[name]: theme,
}
await Filesystem.writeJson(file, store)
})
}
export async function list(): Promise<Store> {
const file = storePath()
return Flock.withLock(lock(file), async () => read(file))

View File

@@ -23,19 +23,31 @@ export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui"
type PluginMode = "strict" | "detect"
export function pluginSource(spec: string): PluginSource {
return spec.startsWith("file://") ? "file" : "npm"
export type PluginPackage = {
dir: string
pkg: string
json: Record<string, unknown>
}
function hasEntrypoint(json: Record<string, unknown>, kind: PluginKind) {
if (!isRecord(json.exports)) return false
return `./${kind}` in json.exports
export type PluginEntry = {
spec: string
source: PluginSource
target: string
pkg?: PluginPackage
entry?: string
}
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
export function pluginSource(spec: string): PluginSource {
if (isPathPluginSpec(spec)) return "file"
return "npm"
}
function resolveExportPath(raw: string, dir: string) {
if (raw.startsWith("./") || raw.startsWith("../")) return path.resolve(dir, raw)
if (raw.startsWith("file://")) return fileURLToPath(raw)
return raw
if (path.isAbsolute(raw)) return raw
return path.resolve(dir, raw)
}
function extractExportValue(value: unknown): string | undefined {
@@ -48,26 +60,92 @@ function extractExportValue(value: unknown): string | undefined {
return undefined
}
export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) {
const pkg = await readPluginPackage(target).catch(() => undefined)
if (!pkg) return target
if (!hasEntrypoint(pkg.json, kind)) return target
const exports = pkg.json.exports
if (!isRecord(exports)) return target
const raw = extractExportValue(exports[`./${kind}`])
if (!raw) return target
function packageMain(pkg: PluginPackage) {
const value = pkg.json.main
if (typeof value !== "string") return
const next = value.trim()
if (!next) return
return next
}
function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
const resolved = resolveExportPath(raw, pkg.dir)
const root = Filesystem.resolve(pkg.dir)
const next = Filesystem.resolve(resolved)
if (!Filesystem.contains(root, next)) {
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
}
return pathToFileURL(next).href
}
function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
const exports = pkg.json.exports
if (isRecord(exports)) {
const raw = extractExportValue(exports[`./${kind}`])
if (raw) return resolvePackagePath(spec, raw, kind, pkg)
}
if (kind !== "server") return
const main = packageMain(pkg)
if (!main) return
return resolvePackagePath(spec, main, kind, pkg)
}
function targetPath(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
if (path.isAbsolute(target)) return target
}
async function resolveDirectoryIndex(dir: string) {
for (const name of INDEX_FILES) {
const file = path.join(dir, name)
if (await Filesystem.exists(file)) return file
}
}
async function resolveTargetDirectory(target: string) {
const file = targetPath(target)
if (!file) return
const stat = await Filesystem.stat(file)
if (!stat?.isDirectory()) return
return file
}
async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind, pkg?: PluginPackage) {
const source = pluginSource(spec)
const hit =
pkg ?? (source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined))
if (!hit) return target
const entry = resolvePackageEntrypoint(spec, kind, hit)
if (entry) return entry
const dir = await resolveTargetDirectory(target)
if (kind === "tui") {
if (source === "file" && dir) {
const index = await resolveDirectoryIndex(dir)
if (index) return pathToFileURL(index).href
}
if (source === "npm") return
if (dir) return
return target
}
if (dir && isRecord(hit.json.exports)) {
if (source === "file") {
const index = await resolveDirectoryIndex(dir)
if (index) return pathToFileURL(index).href
}
return
}
return target
}
export function isPathPluginSpec(spec: string) {
return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
}
@@ -81,19 +159,21 @@ export async function resolvePathPluginTarget(spec: string) {
return pathToFileURL(file).href
}
const pkg = await Filesystem.readJson<Record<string, unknown>>(path.join(file, "package.json")).catch(() => undefined)
if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`)
if (typeof pkg.main !== "string" || !pkg.main.trim()) {
throw new Error(`Plugin directory ${file} must define package.json main`)
if (await Filesystem.exists(path.join(file, "package.json"))) {
return pathToFileURL(file).href
}
return pathToFileURL(path.resolve(file, pkg.main)).href
const index = await resolveDirectoryIndex(file)
if (index) return pathToFileURL(index).href
throw new Error(`Plugin directory ${file} is missing package.json or index file`)
}
export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
export async function checkPluginCompatibility(target: string, opencodeVersion: string, pkg?: PluginPackage) {
if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
const pkg = await readPluginPackage(target).catch(() => undefined)
if (!pkg) return
const engines = pkg.json.engines
const hit = pkg ?? (await readPluginPackage(target).catch(() => undefined))
if (!hit) return
const engines = hit.json.engines
if (!isRecord(engines)) return
const range = engines.opencode
if (typeof range !== "string") return
@@ -104,10 +184,10 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
return BunProc.install(parsed.pkg, parsed.version)
return BunProc.install(parsed.pkg, parsed.version, { ignoreScripts: true })
}
export async function readPluginPackage(target: string) {
export async function readPluginPackage(target: string): Promise<PluginPackage> {
const file = target.startsWith("file://") ? fileURLToPath(target) : target
const stat = await Filesystem.stat(file)
const dir = stat?.isDirectory() ? file : path.dirname(file)
@@ -116,6 +196,20 @@ export async function readPluginPackage(target: string) {
return { dir, pkg, json }
}
export async function createPluginEntry(spec: string, target: string, kind: PluginKind): Promise<PluginEntry> {
const source = pluginSource(spec)
const pkg =
source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined)
const entry = await resolvePluginEntrypoint(spec, target, kind, pkg)
return {
spec,
source,
target,
pkg,
entry,
}
}
export function readPluginId(id: unknown, spec: string) {
if (id === undefined) return
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
@@ -158,15 +252,21 @@ export function readV1Plugin(
return value
}
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
export async function resolvePluginId(
source: PluginSource,
spec: string,
target: string,
id: string | undefined,
pkg?: PluginPackage,
) {
if (source === "file") {
if (id) return id
throw new TypeError(`Path plugin ${spec} must export id`)
}
if (id) return id
const pkg = await readPluginPackage(target)
if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) {
throw new TypeError(`Plugin package ${pkg.pkg} is missing name`)
const hit = pkg ?? (await readPluginPackage(target))
if (typeof hit.json.name !== "string" || !hit.json.name.trim()) {
throw new TypeError(`Plugin package ${hit.pkg} is missing name`)
}
return pkg.json.name.trim()
return hit.json.name.trim()
}

View File

@@ -230,7 +230,7 @@ export namespace ProviderAuth {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
export const defaultLayer = layer.pipe(Layer.provide(Auth.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

File diff suppressed because it is too large Load Diff

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